select.tsx 6.5 KB

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