| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153 |
- /* eslint-disable jsx-a11y/label-has-for */
- /* eslint-disable jsx-a11y/label-has-associated-control */
- import { useRef, useEffect } from 'react'
- import classNames from 'classnames'
- import { useSelect } from 'downshift'
- import Icon from './icon'
- import { useTranslation } from 'react-i18next'
- 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?: string
- // 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
- // When `true` item selection is disabled.
- disabled?: 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
- }
- export const Select = <T,>({
- items,
- itemToString = item => (item === null ? '' : String(item)),
- label,
- name,
- defaultText = 'Items',
- defaultItem,
- itemToSubtitle,
- itemToKey,
- onSelectedItemChanged,
- disabled = false,
- optionalLabel = false,
- loading = false,
- }: SelectProps<T>) => {
- const { t } = useTranslation()
- const {
- isOpen,
- selectedItem,
- getToggleButtonProps,
- getLabelProps,
- getMenuProps,
- getItemProps,
- highlightedIndex,
- } = useSelect({
- items: items ?? [],
- itemToString,
- onSelectedItemChange: changes => {
- if (onSelectedItemChanged) {
- onSelectedItemChanged(changes.selectedItem)
- }
- },
- })
- 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])
- let value: string | undefined
- if (selectedItem || defaultItem) {
- value = itemToString(selectedItem || defaultItem)
- } else {
- value = defaultText
- }
- return (
- <div className="select-wrapper" ref={rootRef}>
- <div>
- {label ? (
- <label {...getLabelProps()}>
- {label}{' '}
- {optionalLabel && (
- <span className="select-optional-label text-muted">
- ({t('optional')})
- </span>
- )}{' '}
- {loading && <Icon data-testid="spinner" fw type="spinner" spin />}
- </label>
- ) : null}
- <div
- className={classNames({ disabled }, 'select-trigger')}
- {...getToggleButtonProps({ disabled })}
- >
- <div>{value}</div>
- <div>
- {isOpen ? (
- <Icon type="chevron-up" fw />
- ) : (
- <Icon type="chevron-down" fw />
- )}
- </div>
- </div>
- </div>
- <ul
- className={classNames({ hidden: !isOpen }, 'select-items')}
- {...getMenuProps({ disabled })}
- >
- {isOpen &&
- items?.map((item, index) => (
- <li
- className={classNames({
- 'select-highlighted': highlightedIndex === index,
- 'selected-active': selectedItem === item,
- })}
- key={itemToKey(item)}
- {...getItemProps({ item, index })}
- >
- <span className="select-item-title">{itemToString(item)}</span>
- {itemToSubtitle ? (
- <span className="text-muted select-item-subtitle">
- {itemToSubtitle(item)}
- </span>
- ) : null}
- </li>
- ))}
- </ul>
- </div>
- )
- }
|