diff --git a/package.json b/package.json index fb59e1b6..7d0f9fc8 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "bs58": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "emoji-mart": "^5.6.0", "framer-motion": "^12.33.0", "lucide-react": "^0.445.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88f463d3..f9aef00a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,6 +134,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) emoji-mart: specifier: ^5.6.0 version: 5.6.0 @@ -2179,6 +2182,12 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -5854,6 +5863,18 @@ snapshots: clsx@2.1.1: {} + cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + color-convert@2.0.1: dependencies: color-name: 1.1.4 diff --git a/src/components/selectors/AssetSelector.tsx b/src/components/selectors/AssetSelector.tsx index b2b5400c..1605a144 100644 --- a/src/components/selectors/AssetSelector.tsx +++ b/src/components/selectors/AssetSelector.tsx @@ -99,7 +99,6 @@ export function AssetSelector({ checkAssetsInOffers(); }, [offering, assets.nfts, assets.options]); - // Generate unique IDs for new items const generateId = useCallback(() => Date.now() + Math.random(), []); const addToken = () => { @@ -260,7 +259,7 @@ export function AssetSelector({
@@ -274,7 +273,7 @@ export function AssetSelector({ + + + + + + {onPageChange && ( +
+ + Page {(page ?? 0) + 1} + +
+ + +
+
+ )} + + + {isLoading ? ( +
+ Loading... +
+ ) : ( + <> + + {emptyMessage ?? defaultEmptyMessage} + + + {items.map((item) => { + const itemId = getItemId(item); + const isDisabled = disabled.includes(itemId); + const isSelected = value === itemId; + + return ( + + {isSelected && ( + + )} +
{renderItem(item)}
+
+ ); + })} +
+ + )} +
+
+
+ + ); +} diff --git a/src/components/selectors/TokenSelector.tsx b/src/components/selectors/TokenSelector.tsx index 8ed35525..2cb2fe98 100644 --- a/src/components/selectors/TokenSelector.tsx +++ b/src/components/selectors/TokenSelector.tsx @@ -2,10 +2,9 @@ import { TokenRecord, commands } from '@/bindings'; import { useErrors } from '@/hooks/useErrors'; import { getAssetDisplayName, isValidAssetId } from '@/lib/utils'; import { t } from '@lingui/core/macro'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { AssetIcon } from '../AssetIcon'; -import { Input } from '../ui/input'; -import { DropdownSelector } from './DropdownSelector'; +import { SearchableSelect } from './SearchableSelect'; export interface TokenSelectorProps { value: string | null | undefined; @@ -30,14 +29,6 @@ export function TokenSelector({ const [tokens, setTokens] = useState>({}); const [searchTerm, setSearchTerm] = useState(''); - const inputRef = useRef(null); - - // Restore focus after token list updates - useEffect(() => { - if (searchTerm && inputRef.current) { - inputRef.current.focus(); - } - }, [tokens, searchTerm]); useEffect(() => { const fetchTokens = async () => { @@ -74,87 +65,88 @@ export function TokenSelector({ fetchTokens(); }, [addError, includeXch, showAllCats]); - // Filter tokens based on search term or show all if it's a valid asset ID - const filteredTokenIds = useMemo( - () => - Object.values(tokens) - .filter((token) => { - if (!token.visible) return false; - if (hideZeroBalance && token.balance === 0) return false; - if (!searchTerm) return true; - - if (isValidAssetId(searchTerm)) { - return token.asset_id?.toLowerCase() === searchTerm.toLowerCase(); - } + // Filter tokens based on search term and visibility/balance settings + const filteredTokens = useMemo(() => { + return Object.values(tokens).filter((token) => { + if (!token.visible) return false; + if (hideZeroBalance && token.balance === 0) return false; + if (!searchTerm) return true; + if (isValidAssetId(searchTerm)) { + return token.asset_id?.toLowerCase() === searchTerm.toLowerCase(); + } - // Search by name and ticker - return ( - token.name?.toLowerCase().includes(searchTerm.toLowerCase()) || - token.ticker?.toLowerCase().includes(searchTerm.toLowerCase()) - ); - }) - .map((token) => token.asset_id ?? 'xch'), - [tokens, hideZeroBalance, searchTerm], + return ( + token.name?.toLowerCase().includes(searchTerm.toLowerCase()) || + token.ticker?.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }); + }, [tokens, hideZeroBalance, searchTerm]); + + const handleSelect = useCallback( + (assetId: string | null) => { + // Convert 'xch' sentinel back to null + onChange(assetId === 'xch' ? null : assetId); + }, + [onChange], ); - return ( - { - onChange(assetId === 'xch' ? null : assetId); - // Only clear search term if it's not a valid asset ID - if (!/^[a-fA-F0-9]{64}$/.test(searchTerm)) { - setSearchTerm(''); - } - }} - isDisabled={(token) => disabled.includes(token)} - className={className} - manualInput={ - { - const newValue = e.target.value; - setSearchTerm(newValue); - - if (/^[a-fA-F0-9]{64}$/.test(newValue)) { - onChange(newValue); - } + const handleManualInput = useCallback( + (assetId: string) => { + onChange(assetId); + }, + [onChange], + ); + + // Convert disabled array to handle null -> 'xch' conversion + const disabledIds = useMemo(() => { + return disabled.map((id) => (id === null ? 'xch' : id)); + }, [disabled]); + + const renderToken = useCallback( + (token: TokenRecord) => ( +
+ - } - renderItem={(assetId) => ( -
- -
- - {getAssetDisplayName( - tokens[assetId]?.name, - tokens[assetId]?.ticker, - 'token', - )} - {tokens[assetId]?.ticker && ` (${tokens[assetId]?.ticker})`}{' '} - - - {assetId === 'xch' ? null : assetId} - -
+
+ + {getAssetDisplayName(token.name, token.ticker, 'token')} + {token.ticker && ` (${token.ticker})`} + + + {token.asset_id === null ? null : token.asset_id} +
- )} +
+ ), + [], + ); + + return ( + token.asset_id ?? 'xch'} + renderItem={renderToken} + onSearchChange={setSearchTerm} + shouldFilter={false} + validateManualInput={isValidAssetId} + onManualInput={handleManualInput} + disabled={disabledIds} + className={className} + placeholder={t`Select asset`} + searchPlaceholder={t`Search or enter asset id`} + emptyMessage={t`No tokens found.`} /> ); } diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx new file mode 100644 index 00000000..1e428e8c --- /dev/null +++ b/src/components/ui/command.tsx @@ -0,0 +1,114 @@ +import { MagnifyingGlassIcon } from '@radix-ui/react-icons'; +import { Command as CommandPrimitive } from 'cmdk'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)); +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandItem.displayName = CommandPrimitive.Item.displayName; + +export { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +}; diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx index 3d5f5824..914eb53d 100644 --- a/src/components/ui/popover.tsx +++ b/src/components/ui/popover.tsx @@ -12,20 +12,32 @@ const PopoverAnchor = PopoverPrimitive.Anchor; const PopoverContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( - - - -)); +>( + ( + { + className, + align = 'center', + sideOffset = 4, + collisionPadding = 8, + ...props + }, + ref, + ) => ( + + + + ), +); PopoverContent.displayName = PopoverPrimitive.Content.displayName; export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger };