select.tsx 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  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 Icon from './icon'
  14. import { useTranslation } from 'react-i18next'
  15. import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
  16. import { Form, Spinner } from 'react-bootstrap-5'
  17. import FormControl from '@/features/ui/components/bootstrap-5/form/form-control'
  18. import MaterialIcon from '@/shared/components/material-icon'
  19. import { DropdownItem } from '@/features/ui/components/bootstrap-5/dropdown-menu'
  20. export type SelectProps<T> = {
  21. // The items rendered as dropdown options.
  22. items: T[]
  23. // Stringifies an item of type T. The resulting string is rendered as a dropdown option.
  24. itemToString: (item: T | null | undefined) => string
  25. // Caption for the dropdown.
  26. label?: ReactNode
  27. // Attribute used to identify the component inside a Form. This name is used to
  28. // retrieve FormData when the form is submitted. The value of the FormData entry
  29. // is the string returned by `itemToString(selectedItem)`.
  30. name?: string
  31. // Hint text displayed in the initial render.
  32. defaultText?: string
  33. // Initial selected item, displayed in the initial render. When both `defaultText`
  34. // and `defaultItem` are set the latter is ignored.
  35. defaultItem?: T | null
  36. // Stringifies an item. The resulting string is rendered as a subtitle in a dropdown option.
  37. itemToSubtitle?: (item: T | null | undefined) => string
  38. // Stringifies an item. The resulting string is rendered as a React `key` for each item.
  39. itemToKey: (item: T) => string
  40. // Callback invoked after the selected item is updated.
  41. onSelectedItemChanged?: (item: T | null | undefined) => void
  42. // Optionally directly control the selected item.
  43. selected?: T | null
  44. // When `true` item selection is disabled.
  45. disabled?: boolean
  46. // Determine which items should be disabled
  47. itemToDisabled?: (item: T | null | undefined) => boolean
  48. // When `true` displays an "Optional" subtext after the `label` caption.
  49. optionalLabel?: boolean
  50. // When `true` displays a spinner next to the `label` caption.
  51. loading?: boolean
  52. // Show a checkmark next to the selected item
  53. selectedIcon?: boolean
  54. // testId for the input element
  55. dataTestId?: string
  56. }
  57. export const Select = <T,>({
  58. items,
  59. itemToString = item => (item === null ? '' : String(item)),
  60. label,
  61. name,
  62. defaultText = 'Items',
  63. defaultItem,
  64. itemToSubtitle,
  65. itemToKey,
  66. onSelectedItemChanged,
  67. selected,
  68. disabled = false,
  69. itemToDisabled,
  70. optionalLabel = false,
  71. loading = false,
  72. selectedIcon = false,
  73. dataTestId,
  74. }: SelectProps<T>) => {
  75. const [selectedItem, setSelectedItem] = useState<T | undefined | null>(
  76. defaultItem
  77. )
  78. const { t } = useTranslation()
  79. const {
  80. isOpen,
  81. getToggleButtonProps,
  82. getLabelProps,
  83. getMenuProps,
  84. getItemProps,
  85. highlightedIndex,
  86. openMenu,
  87. closeMenu,
  88. } = useSelect({
  89. items: items ?? [],
  90. itemToString,
  91. selectedItem: selected || defaultItem,
  92. onSelectedItemChange: changes => {
  93. if (onSelectedItemChanged) {
  94. onSelectedItemChanged(changes.selectedItem)
  95. }
  96. setSelectedItem(changes.selectedItem)
  97. },
  98. })
  99. useEffect(() => {
  100. setSelectedItem(selected)
  101. }, [selected])
  102. const rootRef = useRef<HTMLDivElement | null>(null)
  103. useEffect(() => {
  104. if (!name || !rootRef.current) return
  105. const parentForm: HTMLFormElement | null | undefined =
  106. rootRef.current?.closest('form')
  107. if (!parentForm) return
  108. function handleFormDataEvent(event: FormDataEvent) {
  109. const data = event.formData
  110. const key = name as string // can't be undefined due to early exit in the effect
  111. if (selectedItem || defaultItem) {
  112. data.append(key, itemToString(selectedItem || defaultItem))
  113. }
  114. }
  115. parentForm.addEventListener('formdata', handleFormDataEvent)
  116. return () => {
  117. parentForm.removeEventListener('formdata', handleFormDataEvent)
  118. }
  119. }, [name, itemToString, selectedItem, defaultItem])
  120. const handleMenuKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {
  121. if (event.key === 'Escape' && isOpen) {
  122. event.stopPropagation()
  123. closeMenu()
  124. }
  125. }
  126. const onKeyDown: KeyboardEventHandler<HTMLButtonElement> = useCallback(
  127. event => {
  128. if ((event.key === 'Enter' || event.key === ' ') && !isOpen) {
  129. event.preventDefault()
  130. ;(event.nativeEvent as any).preventDownshiftDefault = true
  131. openMenu()
  132. }
  133. },
  134. [isOpen, openMenu]
  135. )
  136. let value: string | undefined
  137. if (selectedItem || defaultItem) {
  138. value = itemToString(selectedItem || defaultItem)
  139. } else {
  140. value = defaultText
  141. }
  142. return (
  143. <BootstrapVersionSwitcher
  144. bs3={
  145. <div className="select-wrapper" ref={rootRef}>
  146. <div>
  147. {label ? (
  148. <label {...getLabelProps()}>
  149. {label}{' '}
  150. {optionalLabel && (
  151. <span className="select-optional-label text-muted">
  152. ({t('optional')})
  153. </span>
  154. )}{' '}
  155. {loading && (
  156. <Icon data-testid="spinner" fw type="spinner" spin />
  157. )}
  158. </label>
  159. ) : null}
  160. <div
  161. className={classNames({ disabled }, 'select-trigger')}
  162. // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
  163. tabIndex={0}
  164. {...getToggleButtonProps({
  165. disabled,
  166. onKeyDown,
  167. })}
  168. >
  169. <div>{value}</div>
  170. <div>
  171. {isOpen ? (
  172. <Icon type="chevron-up" fw />
  173. ) : (
  174. <Icon type="chevron-down" fw />
  175. )}
  176. </div>
  177. </div>
  178. </div>
  179. <ul
  180. className={classNames({ hidden: !isOpen }, 'select-items')}
  181. {...getMenuProps({ disabled, onKeyDown: handleMenuKeyDown })}
  182. >
  183. {isOpen &&
  184. items?.map((item, index) => {
  185. const isDisabled = itemToDisabled && itemToDisabled(item)
  186. return (
  187. <li
  188. className={classNames({
  189. 'select-highlighted': highlightedIndex === index,
  190. 'selected-active': selectedItem === item,
  191. 'select-icon': selectedIcon,
  192. 'select-disabled': isDisabled,
  193. })}
  194. key={itemToKey(item)}
  195. {...getItemProps({ item, index, disabled: isDisabled })}
  196. >
  197. <span className="select-item-title">
  198. {selectedIcon && (
  199. <div className="select-item-icon">
  200. {(selectedItem === item ||
  201. (!selectedItem && defaultItem === item)) && (
  202. <Icon type="check" fw />
  203. )}
  204. </div>
  205. )}
  206. {itemToString(item)}
  207. </span>
  208. {itemToSubtitle ? (
  209. <span className="text-muted select-item-subtitle">
  210. {itemToSubtitle(item)}
  211. </span>
  212. ) : null}
  213. </li>
  214. )
  215. })}
  216. </ul>
  217. </div>
  218. }
  219. bs5={
  220. <div className="select-wrapper" ref={rootRef}>
  221. {label ? (
  222. <Form.Label {...getLabelProps()}>
  223. {label}{' '}
  224. {optionalLabel && (
  225. <span className="fw-normal">({t('optional')})</span>
  226. )}{' '}
  227. {loading && (
  228. <span data-testid="spinner">
  229. <Spinner
  230. animation="border"
  231. aria-hidden="true"
  232. as="span"
  233. role="status"
  234. size="sm"
  235. />
  236. </span>
  237. )}
  238. </Form.Label>
  239. ) : null}
  240. <FormControl
  241. data-testid={dataTestId}
  242. {...getToggleButtonProps({
  243. disabled,
  244. onKeyDown,
  245. className: 'select-trigger',
  246. })}
  247. value={value}
  248. readOnly
  249. append={
  250. <MaterialIcon
  251. type={isOpen ? 'keyboard_arrow_up' : 'keyboard_arrow_down'}
  252. className="align-text-bottom"
  253. />
  254. }
  255. />
  256. <ul
  257. {...getMenuProps({ disabled, onKeyDown: handleMenuKeyDown })}
  258. className={classNames('dropdown-menu w-100', { show: isOpen })}
  259. >
  260. {isOpen &&
  261. items?.map((item, index) => {
  262. const isDisabled = itemToDisabled && itemToDisabled(item)
  263. return (
  264. <li role="none" key={itemToKey(item)}>
  265. <DropdownItem
  266. as="button"
  267. className={classNames({
  268. 'select-highlighted': highlightedIndex === index,
  269. })}
  270. active={selectedItem === item}
  271. trailingIcon={selectedItem === item ? 'check' : undefined}
  272. description={
  273. itemToSubtitle ? itemToSubtitle(item) : undefined
  274. }
  275. {...getItemProps({ item, index, disabled: isDisabled })}
  276. >
  277. {itemToString(item)}
  278. </DropdownItem>
  279. </li>
  280. )
  281. })}
  282. </ul>
  283. </div>
  284. }
  285. />
  286. )
  287. }