select.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. /* eslint-disable jsx-a11y/label-has-for */
  2. /* eslint-disable jsx-a11y/label-has-associated-control */
  3. import {
  4. useRef,
  5. useEffect,
  6. KeyboardEventHandler,
  7. useCallback,
  8. ReactNode,
  9. useState,
  10. } from 'react'
  11. import classNames from 'classnames'
  12. import { useSelect } from 'downshift'
  13. import { useTranslation } from 'react-i18next'
  14. import { Form, Spinner } from 'react-bootstrap-5'
  15. import FormControl from '@/features/ui/components/bootstrap-5/form/form-control'
  16. import MaterialIcon from '@/shared/components/material-icon'
  17. import { DropdownItem } from '@/features/ui/components/bootstrap-5/dropdown-menu'
  18. export type SelectProps<T> = {
  19. // The items rendered as dropdown options.
  20. items: T[]
  21. // Stringifies an item of type T. The resulting string is rendered as a dropdown option.
  22. itemToString: (item: T | null | undefined) => string
  23. // Caption for the dropdown.
  24. label?: ReactNode
  25. // Attribute used to identify the component inside a Form. This name is used to
  26. // retrieve FormData when the form is submitted. The value of the FormData entry
  27. // is the string returned by `itemToString(selectedItem)`.
  28. name?: string
  29. // Hint text displayed in the initial render.
  30. defaultText?: string
  31. // Initial selected item, displayed in the initial render. When both `defaultText`
  32. // and `defaultItem` are set the latter is ignored.
  33. defaultItem?: T | null
  34. // Stringifies an item. The resulting string is rendered as a subtitle in a dropdown option.
  35. itemToSubtitle?: (item: T | null | undefined) => string
  36. // Stringifies an item. The resulting string is rendered as a React `key` for each item.
  37. itemToKey: (item: T) => string
  38. // Callback invoked after the selected item is updated.
  39. onSelectedItemChanged?: (item: T | null | undefined) => void
  40. // Optionally directly control the selected item.
  41. selected?: T | null
  42. // When `true` item selection is disabled.
  43. disabled?: boolean
  44. // Determine which items should be disabled
  45. itemToDisabled?: (item: T | null | undefined) => boolean
  46. // When `true` displays an "Optional" subtext after the `label` caption.
  47. optionalLabel?: boolean
  48. // When `true` displays a spinner next to the `label` caption.
  49. loading?: boolean
  50. // Show a checkmark next to the selected item
  51. selectedIcon?: boolean
  52. // testId for the input element
  53. dataTestId?: string
  54. }
  55. export const Select = <T,>({
  56. items,
  57. itemToString = item => (item === null ? '' : String(item)),
  58. label,
  59. name,
  60. defaultText = 'Items',
  61. defaultItem,
  62. itemToSubtitle,
  63. itemToKey,
  64. onSelectedItemChanged,
  65. selected,
  66. disabled = false,
  67. itemToDisabled,
  68. optionalLabel = false,
  69. loading = false,
  70. selectedIcon = false,
  71. dataTestId,
  72. }: SelectProps<T>) => {
  73. const [selectedItem, setSelectedItem] = useState<T | undefined | null>(
  74. defaultItem
  75. )
  76. const { t } = useTranslation()
  77. const {
  78. isOpen,
  79. getToggleButtonProps,
  80. getLabelProps,
  81. getMenuProps,
  82. getItemProps,
  83. highlightedIndex,
  84. openMenu,
  85. closeMenu,
  86. } = useSelect({
  87. items: items ?? [],
  88. itemToString,
  89. selectedItem: selected || defaultItem,
  90. onSelectedItemChange: changes => {
  91. if (onSelectedItemChanged) {
  92. onSelectedItemChanged(changes.selectedItem)
  93. }
  94. setSelectedItem(changes.selectedItem)
  95. },
  96. })
  97. useEffect(() => {
  98. setSelectedItem(selected)
  99. }, [selected])
  100. const rootRef = useRef<HTMLDivElement | null>(null)
  101. useEffect(() => {
  102. if (!name || !rootRef.current) return
  103. const parentForm: HTMLFormElement | null | undefined =
  104. rootRef.current?.closest('form')
  105. if (!parentForm) return
  106. function handleFormDataEvent(event: FormDataEvent) {
  107. const data = event.formData
  108. const key = name as string // can't be undefined due to early exit in the effect
  109. if (selectedItem || defaultItem) {
  110. data.append(key, itemToString(selectedItem || defaultItem))
  111. }
  112. }
  113. parentForm.addEventListener('formdata', handleFormDataEvent)
  114. return () => {
  115. parentForm.removeEventListener('formdata', handleFormDataEvent)
  116. }
  117. }, [name, itemToString, selectedItem, defaultItem])
  118. const handleMenuKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {
  119. if (event.key === 'Escape' && isOpen) {
  120. event.stopPropagation()
  121. closeMenu()
  122. }
  123. }
  124. const onKeyDown: KeyboardEventHandler<HTMLButtonElement> = useCallback(
  125. event => {
  126. if ((event.key === 'Enter' || event.key === ' ') && !isOpen) {
  127. event.preventDefault()
  128. ;(event.nativeEvent as any).preventDownshiftDefault = true
  129. openMenu()
  130. }
  131. },
  132. [isOpen, openMenu]
  133. )
  134. let value: string | undefined
  135. if (selectedItem || defaultItem) {
  136. value = itemToString(selectedItem || defaultItem)
  137. } else {
  138. value = defaultText
  139. }
  140. return (
  141. <div className="select-wrapper" ref={rootRef}>
  142. {label ? (
  143. <Form.Label {...getLabelProps()}>
  144. {label}{' '}
  145. {optionalLabel && (
  146. <span className="fw-normal">({t('optional')})</span>
  147. )}{' '}
  148. {loading && (
  149. <span data-testid="spinner">
  150. <Spinner
  151. animation="border"
  152. aria-hidden="true"
  153. as="span"
  154. role="status"
  155. size="sm"
  156. />
  157. </span>
  158. )}
  159. </Form.Label>
  160. ) : null}
  161. <FormControl
  162. data-testid={dataTestId}
  163. {...getToggleButtonProps({
  164. disabled,
  165. onKeyDown,
  166. className: 'select-trigger',
  167. })}
  168. value={value}
  169. readOnly
  170. append={
  171. <MaterialIcon
  172. type={isOpen ? 'keyboard_arrow_up' : 'keyboard_arrow_down'}
  173. className="align-text-bottom"
  174. />
  175. }
  176. />
  177. <ul
  178. {...getMenuProps({ disabled, onKeyDown: handleMenuKeyDown })}
  179. className={classNames('dropdown-menu w-100', { show: isOpen })}
  180. >
  181. {isOpen &&
  182. items?.map((item, index) => {
  183. const isDisabled = itemToDisabled && itemToDisabled(item)
  184. return (
  185. <li role="none" key={itemToKey(item)}>
  186. <DropdownItem
  187. as="button"
  188. className={classNames({
  189. 'select-highlighted': highlightedIndex === index,
  190. })}
  191. active={selectedItem === item}
  192. trailingIcon={
  193. selectedIcon && selectedItem === item ? 'check' : undefined
  194. }
  195. description={
  196. itemToSubtitle ? itemToSubtitle(item) : undefined
  197. }
  198. {...getItemProps({ item, index, disabled: isDisabled })}
  199. >
  200. {itemToString(item)}
  201. </DropdownItem>
  202. </li>
  203. )
  204. })}
  205. </ul>
  206. </div>
  207. )
  208. }