| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213 |
- import {
- useRef,
- useEffect,
- KeyboardEventHandler,
- useCallback,
- ReactNode,
- useState,
- } from 'react'
- import classNames from 'classnames'
- import { useSelect } from 'downshift'
- import { useTranslation } from 'react-i18next'
- import { Form } from 'react-bootstrap'
- import FormControl from '@/shared/components/form/form-control'
- import MaterialIcon from '@/shared/components/material-icon'
- import { DropdownItem } from '@/shared/components/dropdown/dropdown-menu'
- import OLSpinner from './ol/ol-spinner'
- export type SelectProps<T> = {
- // 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 | null
- // 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 | null
- // 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
- // testId for the input element
- dataTestId?: string
- }
- export const Select = <T,>({
- 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,
- dataTestId,
- }: SelectProps<T>) => {
- const [selectedItem, setSelectedItem] = useState<T | undefined | null>(
- defaultItem
- )
- const { t } = useTranslation()
- const {
- isOpen,
- getToggleButtonProps,
- getLabelProps,
- getMenuProps,
- getItemProps,
- highlightedIndex,
- openMenu,
- closeMenu,
- } = useSelect({
- items: items ?? [],
- itemToString,
- isItemDisabled: item => itemToDisabled?.(item) || false,
- selectedItem: selected || defaultItem,
- onSelectedItemChange: changes => {
- if (onSelectedItemChanged) {
- onSelectedItemChanged(changes.selectedItem)
- }
- setSelectedItem(changes.selectedItem)
- },
- })
- useEffect(() => {
- setSelectedItem(selected)
- }, [selected])
- const rootRef = useRef<HTMLDivElement | null>(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<HTMLInputElement> = useCallback(
- event => {
- if ((event.key === 'Enter' || event.key === ' ') && !isOpen) {
- event.preventDefault()
- ;(event.nativeEvent as any).preventDownshiftDefault = true
- openMenu()
- } else if (event.key === 'Escape' && isOpen) {
- event.stopPropagation()
- closeMenu()
- }
- },
- [closeMenu, isOpen, openMenu]
- )
- let value: string | undefined
- if (selectedItem || defaultItem) {
- value = itemToString(selectedItem || defaultItem)
- } else {
- value = defaultText
- }
- return (
- <div className="select-wrapper" ref={rootRef}>
- {label ? (
- <Form.Label {...getLabelProps()}>
- {label}{' '}
- {optionalLabel && (
- <span className="fw-normal">({t('optional')})</span>
- )}{' '}
- {loading && <OLSpinner size="sm" />}
- </Form.Label>
- ) : null}
- <FormControl
- data-testid={dataTestId}
- {...getToggleButtonProps({
- disabled,
- onKeyDown,
- className: 'select-trigger',
- })}
- value={value}
- readOnly
- append={
- <MaterialIcon
- type={isOpen ? 'keyboard_arrow_up' : 'keyboard_arrow_down'}
- className="align-text-bottom"
- />
- }
- />
- <ul
- {...getMenuProps({ disabled })}
- className={classNames('dropdown-menu w-100', { show: isOpen })}
- >
- {isOpen &&
- items?.map((item, index) => {
- // We're using an actual disabled button so we don't need the
- // aria-disabled prop
- const { 'aria-disabled': disabled, ...itemProps } = getItemProps({
- item,
- index,
- })
- return (
- <li role="none" key={itemToKey(item)}>
- <DropdownItem
- as="button"
- type="button"
- className={classNames({
- 'select-highlighted': highlightedIndex === index,
- })}
- active={selectedItem === item}
- trailingIcon={
- selectedIcon && selectedItem === item ? 'check' : undefined
- }
- description={
- itemToSubtitle ? itemToSubtitle(item) : undefined
- }
- {...itemProps}
- disabled={disabled}
- >
- {itemToString(item)}
- </DropdownItem>
- </li>
- )
- })}
- </ul>
- </div>
- )
- }
|