select.tsx 5.3 KB

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