select.tsx 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. /* eslint-disable jsx-a11y/label-has-for */
  2. /* eslint-disable jsx-a11y/label-has-associated-control */
  3. import { useRef, useEffect } 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. } = useSelect({
  61. items: items ?? [],
  62. itemToString,
  63. onSelectedItemChange: changes => {
  64. if (onSelectedItemChanged) {
  65. onSelectedItemChanged(changes.selectedItem)
  66. }
  67. },
  68. })
  69. const rootRef = useRef<HTMLDivElement | null>(null)
  70. useEffect(() => {
  71. if (!name || !rootRef.current) return
  72. const parentForm: HTMLFormElement | null | undefined =
  73. rootRef.current?.closest('form')
  74. if (!parentForm) return
  75. function handleFormDataEvent(event: FormDataEvent) {
  76. const data = event.formData
  77. const key = name as string // can't be undefined due to early exit in the effect
  78. if (selectedItem || defaultItem) {
  79. data.append(key, itemToString(selectedItem || defaultItem))
  80. }
  81. }
  82. parentForm.addEventListener('formdata', handleFormDataEvent)
  83. return () => {
  84. parentForm.removeEventListener('formdata', handleFormDataEvent)
  85. }
  86. }, [name, itemToString, selectedItem, defaultItem])
  87. let value: string | undefined
  88. if (selectedItem || defaultItem) {
  89. value = itemToString(selectedItem || defaultItem)
  90. } else {
  91. value = defaultText
  92. }
  93. return (
  94. <div className="select-wrapper" ref={rootRef}>
  95. <div>
  96. {label ? (
  97. <label {...getLabelProps()}>
  98. {label}{' '}
  99. {optionalLabel && (
  100. <span className="select-optional-label text-muted">
  101. ({t('optional')})
  102. </span>
  103. )}{' '}
  104. {loading && <Icon data-testid="spinner" fw type="spinner" spin />}
  105. </label>
  106. ) : null}
  107. <div
  108. className={classNames({ disabled }, 'select-trigger')}
  109. {...getToggleButtonProps({ disabled })}
  110. >
  111. <div>{value}</div>
  112. <div>
  113. {isOpen ? (
  114. <Icon type="chevron-up" fw />
  115. ) : (
  116. <Icon type="chevron-down" fw />
  117. )}
  118. </div>
  119. </div>
  120. </div>
  121. <ul
  122. className={classNames({ hidden: !isOpen }, 'select-items')}
  123. {...getMenuProps({ disabled })}
  124. >
  125. {isOpen &&
  126. items?.map((item, index) => (
  127. <li
  128. className={classNames({
  129. 'select-highlighted': highlightedIndex === index,
  130. 'selected-active': selectedItem === item,
  131. })}
  132. key={itemToKey(item)}
  133. {...getItemProps({ item, index })}
  134. >
  135. <span className="select-item-title">{itemToString(item)}</span>
  136. {itemToSubtitle ? (
  137. <span className="text-muted select-item-subtitle">
  138. {itemToSubtitle(item)}
  139. </span>
  140. ) : null}
  141. </li>
  142. ))}
  143. </ul>
  144. </div>
  145. )
  146. }