select.tsx 5.3 KB

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