| import * as Ariakit from '@ariakit/react'; |
| import { matchSorter } from 'match-sorter'; |
| import { Search, ChevronDown } from 'lucide-react'; |
| import { useMemo, useState, useRef, memo, useEffect } from 'react'; |
| import { SelectRenderer } from '@ariakit/react-core/select/select-renderer'; |
| import type { OptionWithIcon } from '~/common'; |
| import './AnimatePopover.css'; |
| import { cn } from '~/utils'; |
|
|
| interface ControlComboboxProps { |
| selectedValue: string; |
| displayValue?: string; |
| items: OptionWithIcon[]; |
| setValue: (value: string) => void; |
| ariaLabel: string; |
| searchPlaceholder?: string; |
| selectPlaceholder?: string; |
| isCollapsed: boolean; |
| SelectIcon?: React.ReactNode; |
| containerClassName?: string; |
| iconClassName?: string; |
| showCarat?: boolean; |
| className?: string; |
| disabled?: boolean; |
| iconSide?: 'left' | 'right'; |
| selectId?: string; |
| } |
|
|
| const ROW_HEIGHT = 36; |
|
|
| function ControlCombobox({ |
| selectedValue, |
| displayValue, |
| items, |
| setValue, |
| ariaLabel, |
| searchPlaceholder, |
| selectPlaceholder, |
| containerClassName, |
| isCollapsed, |
| SelectIcon, |
| showCarat, |
| className, |
| disabled, |
| iconClassName, |
| iconSide = 'left', |
| selectId, |
| }: ControlComboboxProps) { |
| const [searchValue, setSearchValue] = useState(''); |
| const buttonRef = useRef<HTMLButtonElement>(null); |
| const [buttonWidth, setButtonWidth] = useState<number | null>(null); |
|
|
| const getItem = (option: OptionWithIcon) => ({ |
| id: `item-${option.value}`, |
| value: option.value as string | undefined, |
| label: option.label, |
| icon: option.icon, |
| }); |
|
|
| const combobox = Ariakit.useComboboxStore({ |
| defaultItems: items.map(getItem), |
| resetValueOnHide: true, |
| value: searchValue, |
| setValue: setSearchValue, |
| }); |
|
|
| const select = Ariakit.useSelectStore({ |
| combobox, |
| defaultItems: items.map(getItem), |
| value: selectedValue, |
| setValue, |
| }); |
|
|
| const matches = useMemo(() => { |
| const filteredItems = matchSorter(items, searchValue, { |
| keys: ['value', 'label'], |
| baseSort: (a, b) => (a.index < b.index ? -1 : 1), |
| }); |
| return filteredItems.map(getItem); |
| }, [searchValue, items]); |
|
|
| useEffect(() => { |
| if (buttonRef.current && !isCollapsed) { |
| setButtonWidth(buttonRef.current.offsetWidth); |
| } |
| }, [isCollapsed]); |
|
|
| const selectIconClassName = cn( |
| 'flex h-5 w-5 items-center justify-center overflow-hidden rounded-full', |
| iconClassName, |
| ); |
| const optionIconClassName = cn( |
| 'mr-2 flex h-5 w-5 items-center justify-center overflow-hidden rounded-full', |
| iconClassName, |
| ); |
|
|
| return ( |
| <div className={cn('flex w-full items-center justify-center px-1', containerClassName)}> |
| <Ariakit.SelectLabel store={select} className="sr-only"> |
| {ariaLabel} |
| </Ariakit.SelectLabel> |
| <Ariakit.Select |
| ref={buttonRef} |
| store={select} |
| id={selectId} |
| disabled={disabled} |
| className={cn( |
| 'flex items-center justify-center gap-2 rounded-full bg-surface-secondary', |
| 'text-text-primary hover:bg-surface-tertiary', |
| 'border border-border-light', |
| isCollapsed ? 'h-10 w-10' : 'h-10 w-full rounded-xl px-3 py-2 text-sm', |
| className, |
| )} |
| > |
| {SelectIcon != null && iconSide === 'left' && ( |
| <div className={selectIconClassName}>{SelectIcon}</div> |
| )} |
| {!isCollapsed && ( |
| <> |
| <span className="flex-grow truncate text-left"> |
| {displayValue != null |
| ? displayValue || selectPlaceholder |
| : selectedValue || selectPlaceholder} |
| </span> |
| {SelectIcon != null && iconSide === 'right' && ( |
| <div className={selectIconClassName}>{SelectIcon}</div> |
| )} |
| {showCarat && <ChevronDown className="h-4 w-4 text-text-secondary" />} |
| </> |
| )} |
| </Ariakit.Select> |
| <Ariakit.SelectPopover |
| store={select} |
| gutter={4} |
| portal |
| className={cn( |
| 'animate-popover z-50 overflow-hidden rounded-xl border border-border-light bg-surface-secondary shadow-lg', |
| )} |
| style={{ width: isCollapsed ? '300px' : (buttonWidth ?? '300px') }} |
| > |
| <div className="py-1.5"> |
| <div className="relative"> |
| <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-primary" /> |
| <Ariakit.Combobox |
| store={combobox} |
| autoSelect |
| placeholder={searchPlaceholder} |
| className="w-full rounded-md bg-surface-secondary py-2 pl-9 pr-3 text-sm text-text-primary focus:outline-none" |
| /> |
| </div> |
| </div> |
| <div className="max-h-[300px] overflow-auto"> |
| <Ariakit.ComboboxList store={combobox}> |
| <SelectRenderer store={select} items={matches} itemSize={ROW_HEIGHT} overscan={5}> |
| {({ value, icon, label, ...item }) => ( |
| <Ariakit.ComboboxItem |
| key={item.id} |
| {...item} |
| className={cn( |
| 'flex w-full cursor-pointer items-center px-3 text-sm', |
| 'text-text-primary hover:bg-surface-tertiary', |
| 'data-[active-item]:bg-surface-tertiary', |
| )} |
| render={<Ariakit.SelectItem value={value} />} |
| > |
| {icon != null && iconSide === 'left' && ( |
| <div className={optionIconClassName}>{icon}</div> |
| )} |
| <span className="flex-grow truncate text-left">{label}</span> |
| {icon != null && iconSide === 'right' && ( |
| <div className={optionIconClassName}>{icon}</div> |
| )} |
| </Ariakit.ComboboxItem> |
| )} |
| </SelectRenderer> |
| </Ariakit.ComboboxList> |
| </div> |
| </Ariakit.SelectPopover> |
| </div> |
| ); |
| } |
|
|
| export default memo(ControlCombobox); |
|
|