select.tsx 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  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
  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
  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. }
  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. }: SelectProps<T>) => {
  72. const [selectedItem, setSelectedItem] = useState<T | undefined | null>(
  73. defaultItem
  74. )
  75. const { t } = useTranslation()
  76. const {
  77. isOpen,
  78. getToggleButtonProps,
  79. getLabelProps,
  80. getMenuProps,
  81. getItemProps,
  82. highlightedIndex,
  83. openMenu,
  84. closeMenu,
  85. } = useSelect({
  86. items: items ?? [],
  87. itemToString,
  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 handleMenuKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {
  118. if (event.key === 'Escape' && isOpen) {
  119. event.stopPropagation()
  120. closeMenu()
  121. }
  122. }
  123. const onKeyDown: KeyboardEventHandler<HTMLButtonElement> = useCallback(
  124. event => {
  125. if ((event.key === 'Enter' || event.key === ' ') && !isOpen) {
  126. event.preventDefault()
  127. ;(event.nativeEvent as any).preventDownshiftDefault = true
  128. openMenu()
  129. }
  130. },
  131. [isOpen, openMenu]
  132. )
  133. let value: string | undefined
  134. if (selectedItem || defaultItem) {
  135. value = itemToString(selectedItem || defaultItem)
  136. } else {
  137. value = defaultText
  138. }
  139. return (
  140. <BootstrapVersionSwitcher
  141. bs3={
  142. <div className="select-wrapper" ref={rootRef}>
  143. <div>
  144. {label ? (
  145. <label {...getLabelProps()}>
  146. {label}{' '}
  147. {optionalLabel && (
  148. <span className="select-optional-label text-muted">
  149. ({t('optional')})
  150. </span>
  151. )}{' '}
  152. {loading && (
  153. <Icon data-testid="spinner" fw type="spinner" spin />
  154. )}
  155. </label>
  156. ) : null}
  157. <div
  158. className={classNames({ disabled }, 'select-trigger')}
  159. // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
  160. tabIndex={0}
  161. {...getToggleButtonProps({
  162. disabled,
  163. onKeyDown,
  164. })}
  165. >
  166. <div>{value}</div>
  167. <div>
  168. {isOpen ? (
  169. <Icon type="chevron-up" fw />
  170. ) : (
  171. <Icon type="chevron-down" fw />
  172. )}
  173. </div>
  174. </div>
  175. </div>
  176. <ul
  177. className={classNames({ hidden: !isOpen }, 'select-items')}
  178. {...getMenuProps({ disabled, onKeyDown: handleMenuKeyDown })}
  179. >
  180. {isOpen &&
  181. items?.map((item, index) => {
  182. const isDisabled = itemToDisabled && itemToDisabled(item)
  183. return (
  184. <li
  185. className={classNames({
  186. 'select-highlighted': highlightedIndex === index,
  187. 'selected-active': selectedItem === item,
  188. 'select-icon': selectedIcon,
  189. 'select-disabled': isDisabled,
  190. })}
  191. key={itemToKey(item)}
  192. {...getItemProps({ item, index, disabled: isDisabled })}
  193. >
  194. <span className="select-item-title">
  195. {selectedIcon && (
  196. <div className="select-item-icon">
  197. {(selectedItem === item ||
  198. (!selectedItem && defaultItem === item)) && (
  199. <Icon type="check" fw />
  200. )}
  201. </div>
  202. )}
  203. {itemToString(item)}
  204. </span>
  205. {itemToSubtitle ? (
  206. <span className="text-muted select-item-subtitle">
  207. {itemToSubtitle(item)}
  208. </span>
  209. ) : null}
  210. </li>
  211. )
  212. })}
  213. </ul>
  214. </div>
  215. }
  216. bs5={
  217. <div className="select-wrapper" ref={rootRef}>
  218. {label ? (
  219. <Form.Label {...getLabelProps()}>
  220. {label}{' '}
  221. {optionalLabel && (
  222. <span className="fw-normal">({t('optional')})</span>
  223. )}{' '}
  224. {loading && (
  225. <span data-testid="spinner">
  226. <Spinner
  227. animation="border"
  228. aria-hidden="true"
  229. as="span"
  230. role="status"
  231. size="sm"
  232. />
  233. </span>
  234. )}
  235. </Form.Label>
  236. ) : null}
  237. <FormControl
  238. {...getToggleButtonProps({
  239. disabled,
  240. onKeyDown,
  241. className: 'select-trigger',
  242. })}
  243. value={value}
  244. readOnly
  245. append={
  246. <MaterialIcon
  247. type={isOpen ? 'keyboard_arrow_up' : 'keyboard_arrow_down'}
  248. className="align-text-bottom"
  249. />
  250. }
  251. />
  252. <ul
  253. {...getMenuProps({ disabled, onKeyDown: handleMenuKeyDown })}
  254. className={classNames('dropdown-menu w-100', { show: isOpen })}
  255. >
  256. {isOpen &&
  257. items?.map((item, index) => {
  258. const isDisabled = itemToDisabled && itemToDisabled(item)
  259. return (
  260. <li role="none" key={itemToKey(item)}>
  261. <DropdownItem
  262. as="button"
  263. className={classNames({
  264. 'select-highlighted': highlightedIndex === index,
  265. })}
  266. active={selectedItem === item}
  267. trailingIcon={selectedItem === item ? 'check' : undefined}
  268. description={
  269. itemToSubtitle ? itemToSubtitle(item) : undefined
  270. }
  271. {...getItemProps({ item, index, disabled: isDisabled })}
  272. >
  273. {itemToString(item)}
  274. </DropdownItem>
  275. </li>
  276. )
  277. })}
  278. </ul>
  279. </div>
  280. }
  281. />
  282. )
  283. }