select.tsx 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  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 } from 'react-bootstrap'
  13. import FormControl from '@/shared/components/form/form-control'
  14. import MaterialIcon from '@/shared/components/material-icon'
  15. import { DropdownItem } from '@/shared/components/dropdown/dropdown-menu'
  16. import OLSpinner from './ol/ol-spinner'
  17. export type SelectProps<T> = {
  18. // The items rendered as dropdown options.
  19. items: T[]
  20. // Stringifies an item of type T. The resulting string is rendered as a dropdown option.
  21. itemToString: (item: T | null | undefined) => string
  22. // Caption for the dropdown.
  23. label?: ReactNode
  24. // Attribute used to identify the component inside a Form. This name is used to
  25. // retrieve FormData when the form is submitted. The value of the FormData entry
  26. // is the string returned by `itemToString(selectedItem)`.
  27. name?: string
  28. // Hint text displayed in the initial render.
  29. defaultText?: string
  30. // Initial selected item, displayed in the initial render. When both `defaultText`
  31. // and `defaultItem` are set the latter is ignored.
  32. defaultItem?: T | null
  33. // Stringifies an item. The resulting string is rendered as a subtitle in a dropdown option.
  34. itemToSubtitle?: (item: T | null | undefined) => string
  35. // Stringifies an item. The resulting string is rendered as a React `key` for each item.
  36. itemToKey: (item: T) => string
  37. // Callback invoked after the selected item is updated.
  38. onSelectedItemChanged?: (item: T | null | undefined) => void
  39. // Optionally directly control the selected item.
  40. selected?: T | null
  41. // When `true` item selection is disabled.
  42. disabled?: boolean
  43. // Determine which items should be disabled
  44. itemToDisabled?: (item: T | null | undefined) => boolean
  45. // When `true` displays an "Optional" subtext after the `label` caption.
  46. optionalLabel?: boolean
  47. // When `true` displays a spinner next to the `label` caption.
  48. loading?: boolean
  49. // Show a checkmark next to the selected item
  50. selectedIcon?: boolean
  51. // testId for the input element
  52. dataTestId?: string
  53. }
  54. export const Select = <T,>({
  55. items,
  56. itemToString = item => (item === null ? '' : String(item)),
  57. label,
  58. name,
  59. defaultText = 'Items',
  60. defaultItem,
  61. itemToSubtitle,
  62. itemToKey,
  63. onSelectedItemChanged,
  64. selected,
  65. disabled = false,
  66. itemToDisabled,
  67. optionalLabel = false,
  68. loading = false,
  69. selectedIcon = false,
  70. dataTestId,
  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. isItemDisabled: item => itemToDisabled?.(item) || false,
  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 onKeyDown: KeyboardEventHandler<HTMLInputElement> = useCallback(
  119. event => {
  120. if ((event.key === 'Enter' || event.key === ' ') && !isOpen) {
  121. event.preventDefault()
  122. ;(event.nativeEvent as any).preventDownshiftDefault = true
  123. openMenu()
  124. } else if (event.key === 'Escape' && isOpen) {
  125. event.stopPropagation()
  126. closeMenu()
  127. }
  128. },
  129. [closeMenu, isOpen, openMenu]
  130. )
  131. let value: string | undefined
  132. if (selectedItem || defaultItem) {
  133. value = itemToString(selectedItem || defaultItem)
  134. } else {
  135. value = defaultText
  136. }
  137. return (
  138. <div className="select-wrapper" ref={rootRef}>
  139. {label ? (
  140. <Form.Label {...getLabelProps()}>
  141. {label}{' '}
  142. {optionalLabel && (
  143. <span className="fw-normal">({t('optional')})</span>
  144. )}{' '}
  145. {loading && <OLSpinner size="sm" />}
  146. </Form.Label>
  147. ) : null}
  148. <FormControl
  149. data-testid={dataTestId}
  150. {...getToggleButtonProps({
  151. disabled,
  152. onKeyDown,
  153. className: 'select-trigger',
  154. })}
  155. value={value}
  156. readOnly
  157. append={
  158. <MaterialIcon
  159. type={isOpen ? 'keyboard_arrow_up' : 'keyboard_arrow_down'}
  160. className="align-text-bottom"
  161. />
  162. }
  163. />
  164. <ul
  165. {...getMenuProps({ disabled })}
  166. className={classNames('dropdown-menu w-100', { show: isOpen })}
  167. >
  168. {isOpen &&
  169. items?.map((item, index) => {
  170. // We're using an actual disabled button so we don't need the
  171. // aria-disabled prop
  172. const { 'aria-disabled': disabled, ...itemProps } = getItemProps({
  173. item,
  174. index,
  175. })
  176. return (
  177. <li role="none" key={itemToKey(item)}>
  178. <DropdownItem
  179. as="button"
  180. type="button"
  181. className={classNames({
  182. 'select-highlighted': highlightedIndex === index,
  183. })}
  184. active={selectedItem === item}
  185. trailingIcon={
  186. selectedIcon && selectedItem === item ? 'check' : undefined
  187. }
  188. description={
  189. itemToSubtitle ? itemToSubtitle(item) : undefined
  190. }
  191. {...itemProps}
  192. disabled={disabled}
  193. >
  194. {itemToString(item)}
  195. </DropdownItem>
  196. </li>
  197. )
  198. })}
  199. </ul>
  200. </div>
  201. )
  202. }