From 75ae56f1475c2c190f9c86c5c0f115f312fee784 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Tue, 6 Jan 2026 19:00:56 -0600 Subject: [PATCH 1/3] rewrite asset selectors with shadcn Command component --- package.json | 1 + pnpm-lock.yaml | 21 ++ src/components/selectors/AssetSelector.tsx | 1 - src/components/selectors/DropdownSelector.tsx | 140 ----------- src/components/selectors/NftSelector.tsx | 141 ++++++----- src/components/selectors/OptionSelector.tsx | 196 +++++++-------- src/components/selectors/SearchableSelect.tsx | 223 ++++++++++++++++++ src/components/selectors/TokenSelector.tsx | 164 ++++++------- src/components/ui/command.tsx | 114 +++++++++ 9 files changed, 619 insertions(+), 382 deletions(-) delete mode 100644 src/components/selectors/DropdownSelector.tsx create mode 100644 src/components/selectors/SearchableSelect.tsx create mode 100644 src/components/ui/command.tsx 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..4ba4aa74 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 = () => { diff --git a/src/components/selectors/DropdownSelector.tsx b/src/components/selectors/DropdownSelector.tsx deleted file mode 100644 index c18c2545..00000000 --- a/src/components/selectors/DropdownSelector.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { t } from '@lingui/core/macro'; -import { ChevronLeft, ChevronRight } from 'lucide-react'; -import { Button } from '../ui/button'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '../ui/select'; - -export interface DropdownSelectorProps { - loadedItems: string[]; - page: number; - setPage?: (page: number) => void; - renderItem: (item: string) => React.ReactNode; - value: string | undefined; - setValue: (item: string) => void; - isDisabled?: (item: string) => boolean; - pageSize?: number; - className?: string; - manualInput?: React.ReactNode; -} - -export function DropdownSelector({ - loadedItems, - page, - setPage, - renderItem, - value, - setValue, - isDisabled, - pageSize = 8, - className, - manualInput, -}: DropdownSelectorProps) { - return ( - - ); -} diff --git a/src/components/selectors/NftSelector.tsx b/src/components/selectors/NftSelector.tsx index 615d3e67..092e78ea 100644 --- a/src/components/selectors/NftSelector.tsx +++ b/src/components/selectors/NftSelector.tsx @@ -3,9 +3,8 @@ import { useErrors } from '@/hooks/useErrors'; import { nftUri } from '@/lib/nftUri'; import { isValidAddress } from '@/lib/utils'; import { t } from '@lingui/core/macro'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Input } from '../ui/input'; -import { DropdownSelector } from './DropdownSelector'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { SearchableSelect } from './SearchableSelect'; export interface NftSelectorProps { value: string | null; @@ -29,17 +28,9 @@ export function NftSelector({ {}, ); const [searchTerm, setSearchTerm] = useState(''); - const inputRef = useRef(null); const pageSize = 8; - // Restore focus after NFT list updates - useEffect(() => { - if (searchTerm && inputRef.current) { - inputRef.current.focus(); - } - }, [nfts, searchTerm]); - const isValidNftId = useMemo(() => { return isValidAddress(searchTerm, 'nft'); }, [searchTerm]); @@ -129,58 +120,88 @@ export function NftSelector({ const defaultNftImage = nftUri(null, null); - return ( - { + const handleSelect = useCallback( + (nftId: string | null) => { + if (nftId) { onChange(nftId); - // Only clear search term if it's not a valid NFT ID (i.e., user clicked on an item from the list) - if (!isValidAddress(searchTerm, 'nft')) { - setSearchTerm(''); - } - }} - isDisabled={(nft) => disabled.includes(nft)} - className={className} - manualInput={ - { - const newValue = e.target.value; - setSearchTerm(newValue); - - if (isValidAddress(newValue, 'nft')) { - onChange(newValue); - } - }} - /> } - renderItem={(nftId) => ( -
- -
- - {nfts[nftId]?.name ?? 'Unknown NFT'} - - - {nftId} - -
+ }, + [onChange], + ); + + const handleManualInput = useCallback( + (nftId: string) => { + onChange(nftId); + }, + [onChange], + ); + + const handleSearchChange = useCallback( + (search: string) => { + setSearchTerm(search); + // Reset to first page when search changes + if (page !== 0) { + setPage(0); + } + }, + [page], + ); + + // Get the NFT records for the current page + const nftItems = useMemo(() => { + return pageNftIds.map((id) => nfts[id]).filter(Boolean) as NftRecord[]; + }, [pageNftIds, nfts]); + + const renderNft = useCallback( + (nft: NftRecord) => ( +
+ +
+ + {nft.name ?? 'Unknown NFT'} + + + {nft.launcher_id} +
- )} +
+ ), + [nftThumbnails, defaultNftImage], + ); + + const validateNftId = useCallback((value: string) => { + return isValidAddress(value, 'nft'); + }, []); + + return ( + nft.launcher_id} + renderItem={renderNft} + onSearchChange={handleSearchChange} + shouldFilter={false} + validateManualInput={validateNftId} + onManualInput={handleManualInput} + page={page} + onPageChange={setPage} + pageSize={pageSize} + hasMorePages={pageNftIds.length >= pageSize} + disabled={disabled} + className={className} + placeholder={t`Select NFT`} + searchPlaceholder={t`Search by name or enter NFT ID`} + emptyMessage={t`No NFTs found.`} /> ); } diff --git a/src/components/selectors/OptionSelector.tsx b/src/components/selectors/OptionSelector.tsx index 414eb49b..ece03e7b 100644 --- a/src/components/selectors/OptionSelector.tsx +++ b/src/components/selectors/OptionSelector.tsx @@ -2,9 +2,8 @@ import { commands, OptionRecord } from '@/bindings'; import { useErrors } from '@/hooks/useErrors'; import { isValidAddress } from '@/lib/utils'; import { t } from '@lingui/core/macro'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { Input } from '../ui/input'; -import { DropdownSelector } from './DropdownSelector'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { SearchableSelect } from './SearchableSelect'; export interface OptionSelectorProps { value: string | null; @@ -23,19 +22,10 @@ export function OptionSelector({ const [page, setPage] = useState(0); const [options, setOptions] = useState>({}); - const [pageOptionIds, setPageOptionIds] = useState([]); const [searchTerm, setSearchTerm] = useState(''); - const inputRef = useRef(null); const pageSize = 8; - // Restore focus after option list updates - useEffect(() => { - if (searchTerm && inputRef.current) { - inputRef.current.focus(); - } - }, [options, searchTerm]); - const isValidOptionId = useMemo(() => { return isValidAddress(searchTerm, 'option'); }, [searchTerm]); @@ -56,7 +46,6 @@ export function OptionSelector({ .then(({ option }) => { if (option) { options[option.launcher_id] = option; - setPageOptionIds([option.launcher_id]); } }); } else { @@ -71,17 +60,11 @@ export function OptionSelector({ for (const option of data.options) { options[option.launcher_id] = option; } - setPageOptionIds( - data.options - .filter( - (option) => option.expiration_seconds * 1000 >= Date.now(), - ) - .map((option) => option.launcher_id), - ); }) .catch(addError); } + // Filter out expired options setOptions( Object.fromEntries( Object.entries(options).filter( @@ -92,42 +75,38 @@ export function OptionSelector({ }; fetchOptions(); - }, [addError, page, searchTerm, isValidOptionId, value]); + }, [addError, searchTerm, isValidOptionId, value]); - const filteredOptionIds = useMemo(() => { - return pageOptionIds - .filter((optionId) => { - const option = options[optionId]; + // Filter and sort options based on search term + const filteredOptions = useMemo(() => { + return Object.values(options) + .filter((option) => { + // Filter out expired options + if (option.expiration_seconds * 1000 < Date.now()) return false; - if (!option) return false; + if (!searchTerm) return true; - // Filter out expired options + // Match by launcher ID, name, underlying asset, or strike asset return ( - option.expiration_seconds * 1000 >= Date.now() && - (option.launcher_id === searchTerm || - option.name?.toLowerCase().includes(searchTerm.toLowerCase()) || - option.underlying_asset.name - ?.toLowerCase() - .includes(searchTerm.toLowerCase()) || - option.underlying_asset.ticker - ?.toLowerCase() - .includes(searchTerm.toLowerCase()) || - option.underlying_asset.asset_id === searchTerm || - option.strike_asset.name - ?.toLowerCase() - .includes(searchTerm.toLowerCase()) || - option.strike_asset.ticker - ?.toLowerCase() - .includes(searchTerm.toLowerCase()) || - option.strike_asset.asset_id === searchTerm) + option.launcher_id === searchTerm || + option.name?.toLowerCase().includes(searchTerm.toLowerCase()) || + option.underlying_asset.name + ?.toLowerCase() + .includes(searchTerm.toLowerCase()) || + option.underlying_asset.ticker + ?.toLowerCase() + .includes(searchTerm.toLowerCase()) || + option.underlying_asset.asset_id === searchTerm || + option.strike_asset.name + ?.toLowerCase() + .includes(searchTerm.toLowerCase()) || + option.strike_asset.ticker + ?.toLowerCase() + .includes(searchTerm.toLowerCase()) || + option.strike_asset.asset_id === searchTerm ); }) - .sort((aId, bId) => { - const a = options[aId]; - const b = options[bId]; - - if (!a || !b) return 0; - + .sort((a, b) => { return ( (a.name ?? '').localeCompare(b.name ?? '') || (a.underlying_asset.name ?? '').localeCompare( @@ -135,56 +114,83 @@ export function OptionSelector({ ) || (a.strike_asset.name ?? '').localeCompare(b.strike_asset.name ?? '') ); - }) + }); + }, [options, searchTerm]); - .slice(page * pageSize, (page + 1) * pageSize); - }, [options, searchTerm, page, pageOptionIds]); + const paginatedOptions = useMemo(() => { + const start = page * pageSize; + return filteredOptions.slice(start, start + pageSize); + }, [filteredOptions, page, pageSize]); - return ( - { + const handleSelect = useCallback( + (optionId: string | null) => { + if (optionId) { onChange(optionId); - // Only clear search term if it's not a valid Option ID (i.e., user clicked on an item from the list) - if (!isValidAddress(searchTerm, 'option')) { - setSearchTerm(''); - } - }} - isDisabled={(optionId) => disabled.includes(optionId)} - className={className} - manualInput={ - { - const newValue = e.target.value; - setSearchTerm(newValue); - - if (isValidAddress(newValue, 'option')) { - onChange(newValue); - } - }} - /> } - renderItem={(optionId) => ( -
-
- - {options[optionId]?.name ?? 'Unknown Option'} - - - {optionId} - -
+ }, + [onChange], + ); + + const handleManualInput = useCallback( + (optionId: string) => { + onChange(optionId); + }, + [onChange], + ); + + const handleSearchChange = useCallback( + (search: string) => { + setSearchTerm(search); + if (page !== 0) { + setPage(0); + } + }, + [page], + ); + + const validateOptionId = useCallback((value: string) => { + return isValidAddress(value, 'option'); + }, []); + + const renderOption = useCallback( + (option: OptionRecord) => ( +
+
+ + {option.name ?? 'Unknown Option'} + + + {option.launcher_id} +
- )} +
+ ), + [], + ); + + return ( + opt.launcher_id} + renderItem={renderOption} + onSearchChange={handleSearchChange} + shouldFilter={false} + validateManualInput={validateOptionId} + onManualInput={handleManualInput} + page={page} + onPageChange={setPage} + pageSize={pageSize} + hasMorePages={filteredOptions.length > (page + 1) * pageSize} + disabled={disabled} + className={className} + placeholder={t`Select option`} + searchPlaceholder={t`Search by name or enter Option ID`} + emptyMessage={t`No options found.`} /> ); } diff --git a/src/components/selectors/SearchableSelect.tsx b/src/components/selectors/SearchableSelect.tsx new file mode 100644 index 00000000..c8935b95 --- /dev/null +++ b/src/components/selectors/SearchableSelect.tsx @@ -0,0 +1,223 @@ +import { t } from '@lingui/core/macro'; +import { CaretSortIcon, CheckIcon } from '@radix-ui/react-icons'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { useCallback, useState } from 'react'; + +import { cn } from '@/lib/utils'; +import { Button } from '../ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '../ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; + +export interface SearchableSelectProps { + value: string | null | undefined; + onSelect: (value: string | null) => void; + items: T[]; + getItemId: (item: T) => string; + renderItem: (item: T) => React.ReactNode; + renderSelectedItem?: (item: T | undefined) => React.ReactNode; + onSearchChange?: (search: string) => void; + shouldFilter?: boolean; + onManualInput?: (value: string) => void; + validateManualInput?: (value: string) => boolean; + page?: number; + onPageChange?: (page: number) => void; + pageSize?: number; + hasMorePages?: boolean; + disabled?: (string | null)[]; + isLoading?: boolean; + className?: string; + placeholder?: string; + searchPlaceholder?: string; + emptyMessage?: string; +} + +export function SearchableSelect({ + value, + onSelect, + items, + getItemId, + renderItem, + renderSelectedItem, + onSearchChange, + shouldFilter = true, + onManualInput, + validateManualInput, + page, + onPageChange, + hasMorePages, + disabled = [], + isLoading = false, + className, + placeholder, + searchPlaceholder, + emptyMessage, +}: SearchableSelectProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + + const selectedItem = items.find((item) => getItemId(item) === value); + + const handleSelect = useCallback( + (itemId: string) => { + onSelect(itemId); + setOpen(false); + // Only clear search if it's not a valid manual input + if (!validateManualInput || !validateManualInput(search)) { + setSearch(''); + } + }, + [onSelect, search, validateManualInput], + ); + + const handleSearchChange = useCallback( + (newSearch: string) => { + setSearch(newSearch); + onSearchChange?.(newSearch); + + // Check for manual ID input + if (validateManualInput && validateManualInput(newSearch)) { + onManualInput?.(newSearch); + } + + if (onPageChange && page !== 0) { + onPageChange(0); + } + }, + [validateManualInput, onManualInput, onSearchChange, onPageChange, page], + ); + + const handleOpenChange = useCallback( + (newOpen: boolean) => { + setOpen(newOpen); + if (!newOpen) { + // Clear search when closing, unless it's a valid manual input + if (!validateManualInput || !validateManualInput(search)) { + setSearch(''); + } + } + }, + [search, validateManualInput], + ); + + const defaultPlaceholder = t`Select item`; + const defaultSearchPlaceholder = t`Search...`; + const defaultEmptyMessage = t`No items found.`; + + const triggerContent = renderSelectedItem + ? renderSelectedItem(selectedItem) + : selectedItem + ? renderItem(selectedItem) + : (placeholder ?? defaultPlaceholder); + + return ( + + + + + + + + + {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, +}; From 0a037ddd5bff8dd2aac1d1067e1d1f235646ce46 Mon Sep 17 00:00:00 2001 From: Rigidity Date: Sun, 8 Feb 2026 14:24:52 -0500 Subject: [PATCH 2/3] Fix stickiness --- src/components/selectors/AssetSelector.tsx | 4 ++-- src/components/selectors/SearchableSelect.tsx | 6 +++++- src/components/ui/popover.tsx | 3 ++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/components/selectors/AssetSelector.tsx b/src/components/selectors/AssetSelector.tsx index 4ba4aa74..1605a144 100644 --- a/src/components/selectors/AssetSelector.tsx +++ b/src/components/selectors/AssetSelector.tsx @@ -259,7 +259,7 @@ export function AssetSelector({
@@ -273,7 +273,7 @@ export function AssetSelector({
)} - + {isLoading ? (
Loading... diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx index 3d5f5824..e0599885 100644 --- a/src/components/ui/popover.tsx +++ b/src/components/ui/popover.tsx @@ -12,12 +12,13 @@ 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) => ( Date: Sun, 8 Feb 2026 14:28:18 -0500 Subject: [PATCH 3/3] fmt --- src/components/ui/popover.tsx | 41 ++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx index e0599885..914eb53d 100644 --- a/src/components/ui/popover.tsx +++ b/src/components/ui/popover.tsx @@ -12,21 +12,32 @@ const PopoverAnchor = PopoverPrimitive.Anchor; const PopoverContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, align = 'center', sideOffset = 4, collisionPadding = 8, ...props }, ref) => ( - - - -)); +>( + ( + { + className, + align = 'center', + sideOffset = 4, + collisionPadding = 8, + ...props + }, + ref, + ) => ( + + + + ), +); PopoverContent.displayName = PopoverPrimitive.Content.displayName; export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger };