| import React, { useRef } from 'react'; |
| import { |
| Label, |
| Listbox, |
| Transition, |
| ListboxButton, |
| ListboxOption, |
| ListboxOptions, |
| } from '@headlessui/react'; |
| import type { Option, OptionWithIcon, DropdownValueSetter } from '~/common'; |
| import { useMultiSearch } from './MultiSearch'; |
| import { CheckMark } from '~/svgs'; |
| import { cn } from '~/utils'; |
|
|
| type SelectDropDownProps = { |
| id?: string; |
| title?: string; |
| disabled?: boolean; |
| value: string | null | Option | OptionWithIcon; |
| setValue: DropdownValueSetter | ((value: string) => void); |
| tabIndex?: number; |
| availableValues?: string[] | Option[] | OptionWithIcon[]; |
| emptyTitle?: boolean; |
| showAbove?: boolean; |
| showLabel?: boolean; |
| iconSide?: 'left' | 'right'; |
| optionIconSide?: 'left' | 'right'; |
| renderOption?: () => React.ReactNode; |
| containerClassName?: string; |
| currentValueClass?: string; |
| optionsListClass?: string; |
| optionsClass?: string; |
| subContainerClassName?: string; |
| className?: string; |
| placeholder?: string; |
| searchClassName?: string; |
| searchPlaceholder?: string; |
| showOptionIcon?: boolean; |
| }; |
|
|
| function getOptionText(option: string | Option | OptionWithIcon): string { |
| if (typeof option === 'string') { |
| return option; |
| } |
| if ('label' in option) { |
| return option.label ?? ''; |
| } |
| if ('value' in option) { |
| return (option.value ?? '') + ''; |
| } |
| return ''; |
| } |
|
|
| function SelectDropDown({ |
| title: _title, |
| value, |
| disabled, |
| setValue, |
| availableValues, |
| showAbove = false, |
| showLabel = true, |
| emptyTitle = false, |
| iconSide = 'right', |
| optionIconSide = 'left', |
| placeholder, |
| containerClassName, |
| optionsListClass, |
| optionsClass, |
| currentValueClass, |
| subContainerClassName, |
| className, |
| renderOption, |
| searchClassName, |
| searchPlaceholder, |
| showOptionIcon = false, |
| }: SelectDropDownProps) { |
| const transitionProps = { className: 'top-full mt-3' }; |
| if (showAbove) { |
| transitionProps.className = 'bottom-full mb-3'; |
| } |
|
|
| let title = _title; |
| if (emptyTitle) { |
| title = ''; |
| } |
|
|
| const values = availableValues ?? []; |
|
|
| |
| const [filteredValues, searchRender] = useMultiSearch<string[] | Option[]>({ |
| availableOptions: values, |
| placeholder: searchPlaceholder, |
| getTextKeyOverride: (option) => getOptionText(option).toUpperCase(), |
| className: searchClassName, |
| disabled, |
| }); |
| const hasSearchRender = searchRender != null; |
| const options = hasSearchRender ? filteredValues : values; |
| const renderIcon = showOptionIcon && value != null && (value as OptionWithIcon).icon != null; |
|
|
| const buttonRef = useRef<HTMLButtonElement>(null); |
|
|
| return ( |
| <div className={cn('flex items-center justify-center gap-2', containerClassName ?? '')}> |
| <div className={cn('relative w-full', subContainerClassName ?? '')}> |
| <Listbox value={value} onChange={setValue} disabled={disabled}> |
| {({ open }) => ( |
| <> |
| <ListboxButton |
| ref={buttonRef} |
| data-testid="select-dropdown-button" |
| onKeyDown={(e) => { |
| if (e.key === 'Enter') { |
| e.preventDefault(); |
| if (!open && buttonRef.current) { |
| buttonRef.current.click(); |
| } |
| } |
| }} |
| className={cn( |
| 'relative flex w-full cursor-default flex-col rounded-md border border-black/10 bg-white py-2 pl-3 pr-10 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:bg-white dark:border-gray-600 dark:bg-gray-700 sm:text-sm', |
| className ?? '', |
| )} |
| > |
| {showLabel && ( |
| <Label |
| className="block text-xs text-gray-700 dark:text-gray-500" |
| id="headlessui-listbox-label-:r1:" |
| data-headlessui-state="" |
| > |
| {title} |
| </Label> |
| )} |
| <span className="inline-flex w-full truncate"> |
| <span |
| className={cn( |
| 'flex h-6 items-center gap-1 truncate text-sm text-gray-800 dark:text-white', |
| !showLabel ? 'text-xs' : '', |
| currentValueClass ?? '', |
| )} |
| > |
| {!showLabel && !emptyTitle && ( |
| <span className="text-xs text-gray-700 dark:text-gray-500">{title}:</span> |
| )} |
| {renderIcon && optionIconSide !== 'right' && ( |
| <span className="icon-md flex items-center"> |
| {(value as OptionWithIcon).icon} |
| </span> |
| )} |
| {renderIcon && ( |
| <span className="icon-md absolute right-0 mr-8 flex items-center"> |
| {(value as OptionWithIcon).icon} |
| </span> |
| )} |
| {(() => { |
| if (!value) { |
| return <span className="text-text-secondary">{placeholder}</span>; |
| } |
| if (typeof value !== 'string') { |
| return value.label ?? ''; |
| } |
| return value; |
| })()} |
| </span> |
| </span> |
| <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> |
| <svg |
| stroke="currentColor" |
| fill="none" |
| strokeWidth="2" |
| viewBox="0 0 24 24" |
| strokeLinecap="round" |
| strokeLinejoin="round" |
| className="h-4 w-4 text-gray-400" |
| height="1em" |
| width="1em" |
| xmlns="http://www.w3.org/2000/svg" |
| style={showAbove ? { transform: 'scaleY(-1)' } : {}} |
| > |
| <polyline points="6 9 12 15 18 9"></polyline> |
| </svg> |
| </span> |
| </ListboxButton> |
| <Transition |
| show={open} |
| as="div" |
| leave="transition ease-in duration-100" |
| leaveFrom="opacity-100" |
| leaveTo="opacity-0" |
| {...transitionProps} |
| > |
| <ListboxOptions |
| className={cn( |
| 'absolute z-10 mt-2 max-h-60 w-full overflow-auto rounded border bg-white text-xs ring-black/10 dark:border-gray-600 dark:bg-gray-700 dark:ring-white/20 md:w-[100%]', |
| optionsListClass ?? '', |
| )} |
| > |
| {renderOption && ( |
| <ListboxOption |
| key={'listbox-render-option'} |
| value={null} |
| className={cn( |
| 'group relative flex h-[42px] cursor-pointer select-none items-center overflow-hidden pl-3 pr-9 text-gray-800 hover:bg-gray-20 dark:text-white dark:hover:bg-gray-700', |
| optionsClass ?? '', |
| )} |
| > |
| {renderOption() as React.JSX.Element} |
| </ListboxOption> |
| )} |
| {searchRender as React.JSX.Element} |
| {options.map((option: string | Option, i: number) => { |
| if (!option) { |
| return null; |
| } |
| const currentLabel = |
| typeof option === 'string' ? option : (option.label ?? option.value ?? ''); |
| const currentValue = typeof option === 'string' ? option : (option.value ?? ''); |
| const currentIcon = |
| typeof option === 'string' |
| ? null |
| : ((option.icon as React.ReactNode) ?? null); |
| let activeValue: string | number | null | Option = value; |
| if (typeof activeValue !== 'string') { |
| activeValue = activeValue?.value ?? ''; |
| } |
| return ( |
| <ListboxOption |
| key={i} |
| value={option} |
| className={({ active }) => |
| cn( |
| 'group relative flex h-[42px] cursor-pointer select-none items-center overflow-hidden pl-3 pr-9 text-gray-800 hover:bg-gray-20 dark:text-white dark:hover:bg-gray-600', |
| active ? 'bg-surface-active text-text-primary' : '', |
| optionsClass ?? '', |
| ) |
| } |
| > |
| <span className="flex items-center gap-1.5 truncate"> |
| <span |
| className={cn( |
| 'flex h-6 items-center gap-1 text-gray-800 dark:text-gray-200', |
| option === value ? 'font-semibold' : '', |
| iconSide === 'left' ? 'ml-4' : '', |
| )} |
| > |
| {currentIcon != null && ( |
| <span |
| className={cn( |
| 'mr-1', |
| optionIconSide === 'right' ? 'absolute right-0 pr-2' : '', |
| )} |
| > |
| {currentIcon} |
| </span> |
| )} |
| {currentLabel} |
| </span> |
| {currentValue === activeValue && ( |
| <span |
| className={cn( |
| 'absolute inset-y-0 flex items-center text-gray-800 dark:text-gray-200', |
| iconSide === 'left' ? 'left-0 pl-2' : 'right-0 pr-3', |
| )} |
| > |
| <CheckMark /> |
| </span> |
| )} |
| </span> |
| </ListboxOption> |
| ); |
| })} |
| </ListboxOptions> |
| </Transition> |
| </> |
| )} |
| </Listbox> |
| </div> |
| </div> |
| ); |
| } |
|
|
| export default SelectDropDown; |
|
|