/* eslint-disable jsx-a11y/label-has-for */ /* eslint-disable jsx-a11y/label-has-associated-control */ import { useRef, useEffect, KeyboardEventHandler, useCallback, ReactNode, useState, } from 'react' import classNames from 'classnames' import { useSelect } from 'downshift' import Icon from './icon' import { useTranslation } from 'react-i18next' export type SelectProps = { // The items rendered as dropdown options. items: T[] // Stringifies an item of type T. The resulting string is rendered as a dropdown option. itemToString: (item: T | null | undefined) => string // Caption for the dropdown. label?: ReactNode // Attribute used to identify the component inside a Form. This name is used to // retrieve FormData when the form is submitted. The value of the FormData entry // is the string returned by `itemToString(selectedItem)`. name?: string // Hint text displayed in the initial render. defaultText?: string // Initial selected item, displayed in the initial render. When both `defaultText` // and `defaultItem` are set the latter is ignored. defaultItem?: T // Stringifies an item. The resulting string is rendered as a subtitle in a dropdown option. itemToSubtitle?: (item: T | null | undefined) => string // Stringifies an item. The resulting string is rendered as a React `key` for each item. itemToKey: (item: T) => string // Callback invoked after the selected item is updated. onSelectedItemChanged?: (item: T | null | undefined) => void // Optionally directly control the selected item. selected?: T // When `true` item selection is disabled. disabled?: boolean // Determine which items should be disabled itemToDisabled?: (item: T | null | undefined) => boolean // When `true` displays an "Optional" subtext after the `label` caption. optionalLabel?: boolean // When `true` displays a spinner next to the `label` caption. loading?: boolean // Show a checkmark next to the selected item selectedIcon?: boolean } export const Select = ({ items, itemToString = item => (item === null ? '' : String(item)), label, name, defaultText = 'Items', defaultItem, itemToSubtitle, itemToKey, onSelectedItemChanged, selected, disabled = false, itemToDisabled, optionalLabel = false, loading = false, selectedIcon = false, }: SelectProps) => { const [selectedItem, setSelectedItem] = useState( defaultItem ) const { t } = useTranslation() const { isOpen, getToggleButtonProps, getLabelProps, getMenuProps, getItemProps, highlightedIndex, openMenu, } = useSelect({ items: items ?? [], itemToString, selectedItem: selected || defaultItem, onSelectedItemChange: changes => { if (onSelectedItemChanged) { onSelectedItemChanged(changes.selectedItem) } setSelectedItem(changes.selectedItem) }, }) useEffect(() => { setSelectedItem(selected) }, [selected]) const rootRef = useRef(null) useEffect(() => { if (!name || !rootRef.current) return const parentForm: HTMLFormElement | null | undefined = rootRef.current?.closest('form') if (!parentForm) return function handleFormDataEvent(event: FormDataEvent) { const data = event.formData const key = name as string // can't be undefined due to early exit in the effect if (selectedItem || defaultItem) { data.append(key, itemToString(selectedItem || defaultItem)) } } parentForm.addEventListener('formdata', handleFormDataEvent) return () => { parentForm.removeEventListener('formdata', handleFormDataEvent) } }, [name, itemToString, selectedItem, defaultItem]) const onKeyDown: KeyboardEventHandler = useCallback( event => { if (event.key === 'Enter' && !isOpen) { event.preventDefault() ;(event.nativeEvent as any).preventDownshiftDefault = true openMenu() } }, [isOpen, openMenu] ) let value: string | undefined if (selectedItem || defaultItem) { value = itemToString(selectedItem || defaultItem) } else { value = defaultText } return (
{label ? ( ) : null}
{value}
{isOpen ? ( ) : ( )}
    {isOpen && items?.map((item, index) => { const isDisabled = itemToDisabled && itemToDisabled(item) return (
  • {selectedIcon && (
    {(selectedItem === item || (!selectedItem && defaultItem === item)) && ( )}
    )} {itemToString(item)}
    {itemToSubtitle ? ( {itemToSubtitle(item)} ) : null}
  • ) })}
) }