import mergeProps from 'merge-props'; import { useEffect, useRef, useState, useDeferredValue } from 'react'; import { Listbox as ListboxBase } from '@headlessui/react'; import { useScreenSize } from '../../common/hooks/useScreenSize'; import { Breakpoint } from '../../common/propsValues/breakpoint'; import { useEffectEvent } from '../../common/hooks/useEffectEvent'; import { useInputAttributes } from '../contexts'; import { SelectInputBottomSheet } from './BottomSheet'; import { SelectInputPopover } from './Popover'; import { SelectInputOptions } from './Options'; import { DefaultRenderTrigger } from './DefaultRenderTrigger'; import { SelectInputOptionContentWithinTriggerContext, SelectInputTriggerButtonPropsContext, } from './SelectInput.contexts'; import { searchableString, sortByRelevance } from './SelectInput.utils'; import { SelectInputProps } from './SelectInput.types'; const noop = () => {}; /** * SelectInput component allows users to select an option from a dropdown list. * Supports filtering, multiple selection, and customization. */ export function SelectInput({ id: idProp, parentId, name, multiple, placeholder, autocomplete, items, defaultValue, value: controlledValue, compareValues, renderValue = String, renderFooter, renderTrigger = DefaultRenderTrigger, filterable, filterPlaceholder, sortFilteredOptions, disabled, size = 'md', className, UNSAFE_triggerButtonProps, triggerRef: externalTriggerRef, onFilterChange = noop, onChange, onOpen, onClose, onClear, }: SelectInputProps) { const inputAttributes = useInputAttributes({ nonLabelable: true }); const id = idProp ?? inputAttributes.id; const [open, setOpen] = useState(false); const initialized = useRef(false); const handleClose = useEffectEvent(onClose ?? (() => {})); const handleOpen = useEffectEvent(onOpen ?? (() => {})); useEffect(() => { if (initialized.current) { if (open) { handleOpen?.(); } else { handleClose?.(); } } else { initialized.current = true; } }, [handleClose, handleOpen, open]); const [filterQuery, _setFilterQuery] = useState(''); const deferredFilterQuery = useDeferredValue(filterQuery); const setFilterQuery = useEffectEvent((query: string) => { _setFilterQuery(query); if (query !== filterQuery) { onFilterChange({ query, queryNormalized: query ? searchableString(query) : null, }); } }); const internalTriggerRef = useRef(null); const screenSm = useScreenSize(Breakpoint.SMALL); const OptionsOverlay = screenSm ? SelectInputPopover : SelectInputBottomSheet; const searchInputRef = useRef(null); const listboxRef = useRef(null); const controllerRef = filterable ? searchInputRef : listboxRef; /** * Attempts to resolve the `listbox` label * @see https://storybook.wise.design/?path=/docs/forms-selectinput-accessibility--docs#labelling */ const getListBoxLabelProps = (): { listBoxLabel?: string; listBoxLabelledBy?: string; } => { if (UNSAFE_triggerButtonProps?.['aria-label']) { return { listBoxLabel: UNSAFE_triggerButtonProps['aria-label'], }; } if (UNSAFE_triggerButtonProps?.['aria-labelledby']) { return { listBoxLabelledBy: UNSAFE_triggerButtonProps['aria-labelledby'], }; } if (inputAttributes['aria-labelledby']) { return { listBoxLabelledBy: inputAttributes['aria-labelledby'], }; } return {}; }; return ( { if (!multiple) { setOpen(false); } onChange?.(value); }) satisfies SelectInputProps['onChange'] } > {({ disabled: uiDisabled, value }) => { const placeholderShown = multiple && Array.isArray(value) ? value.length === 0 : value == null; return ( ( { ref(node); if (externalTriggerRef) { // eslint-disable-next-line no-param-reassign externalTriggerRef.current = node; } else { internalTriggerRef.current = node; } }, size, ...inputAttributes, ...UNSAFE_triggerButtonProps, id, ...mergeProps( { onClick: () => { setOpen((prev) => !prev); }, onKeyDown: (event: React.KeyboardEvent) => { if ( event.key === ' ' || event.key === 'Enter' || event.key === 'ArrowDown' || event.key === 'ArrowUp' ) { setOpen((prev) => !prev); } }, }, getInteractionProps(), ), }} > {renderTrigger({ content: !placeholderShown ? ( {multiple && Array.isArray(value) ? (value as readonly NonNullable[]) .map((option) => renderValue(option, true)) .filter((node) => node != null) .join(', ') : renderValue(value as NonNullable, true)} ) : ( placeholder ), placeholderShown, clear: onClear != null ? () => { onClear(); (externalTriggerRef?.current ?? internalTriggerRef.current)?.focus({ preventScroll: true, }); } : undefined, disabled: uiDisabled, size, className, })} )} initialFocusRef={controllerRef} size={filterable ? 'lg' : 'md'} padding="none" onClose={() => { setOpen(false); }} onCloseEnd={() => { setFilterQuery(''); }} > { onChange?.(matchedValue as M extends true ? T[] : T); if (!multiple) { setOpen(false); } }} {...getListBoxLabelProps()} /> ); }} ); } // Attach sortByRelevance to the component for convenience SelectInput.sortByRelevance = sortByRelevance;