select.tsx 6.8 KB

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