select.tsx 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  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 { CaretUp, CaretDown, Check } from '@phosphor-icons/react'
  16. import { DropdownItem } from '@/shared/components/dropdown/dropdown-menu'
  17. import OLSpinner from './ol/ol-spinner'
  18. import DSFormLabel from '@/shared/components/ds/ds-form-label'
  19. import DSFormGroup from '@/shared/components/ds/ds-form-group'
  20. import DSFormControl from '@/shared/components/ds/ds-form-control'
  21. export type SelectProps<T> = {
  22. // The items rendered as dropdown options.
  23. items: T[]
  24. // Stringifies an item of type T. The resulting string is rendered as a dropdown option.
  25. itemToString: (item: T | null | undefined) => string
  26. // Caption for the dropdown.
  27. label?: ReactNode
  28. // Attribute used to identify the component inside a Form. This name is used to
  29. // retrieve FormData when the form is submitted. The value of the FormData entry
  30. // is the string returned by `itemToString(selectedItem)`.
  31. name?: string
  32. // Hint text displayed in the initial render.
  33. defaultText?: string
  34. // Initial selected item, displayed in the initial render. When both `defaultText`
  35. // and `defaultItem` are set the latter is ignored.
  36. defaultItem?: T | null
  37. // Stringifies an item. The resulting string is rendered as a subtitle in a dropdown option.
  38. itemToSubtitle?: (item: T | null | undefined) => string
  39. // Stringifies an item. The resulting string is rendered as a React `key` for each item.
  40. itemToKey: (item: T) => string
  41. // Callback invoked after the selected item is updated.
  42. onSelectedItemChanged?: (item: T | null | undefined) => void
  43. // Optionally directly control the selected item.
  44. selected?: T | null
  45. // When `true` item selection is disabled.
  46. disabled?: boolean
  47. // Determine which items should be disabled
  48. itemToDisabled?: (item: T | null | undefined) => boolean
  49. // When `true` displays an "Optional" subtext after the `label` caption.
  50. optionalLabel?: boolean
  51. // When `true` displays a spinner next to the `label` caption.
  52. loading?: boolean
  53. // Show a checkmark next to the selected item
  54. selectedIcon?: boolean
  55. // testId for the input element
  56. dataTestId?: string
  57. // CIAM-specific layout
  58. isCiam?: boolean
  59. }
  60. export const Select = <T,>({
  61. items,
  62. itemToString = item => (item === null ? '' : String(item)),
  63. label,
  64. name,
  65. defaultText = 'Items',
  66. defaultItem,
  67. itemToSubtitle,
  68. itemToKey,
  69. onSelectedItemChanged,
  70. selected,
  71. disabled = false,
  72. itemToDisabled,
  73. optionalLabel = false,
  74. loading = false,
  75. selectedIcon = false,
  76. dataTestId,
  77. isCiam,
  78. }: SelectProps<T>) => {
  79. const [selectedItem, setSelectedItem] = useState<T | undefined | null>(
  80. defaultItem
  81. )
  82. const { t } = useTranslation()
  83. const {
  84. isOpen,
  85. getToggleButtonProps,
  86. getLabelProps,
  87. getMenuProps,
  88. getItemProps,
  89. highlightedIndex,
  90. openMenu,
  91. closeMenu,
  92. } = useSelect({
  93. items: items ?? [],
  94. itemToString,
  95. isItemDisabled: item => itemToDisabled?.(item) || false,
  96. selectedItem: selected || defaultItem,
  97. onSelectedItemChange: changes => {
  98. if (onSelectedItemChanged) {
  99. onSelectedItemChanged(changes.selectedItem)
  100. }
  101. setSelectedItem(changes.selectedItem)
  102. },
  103. })
  104. useEffect(() => {
  105. setSelectedItem(selected)
  106. }, [selected])
  107. const rootRef = useRef<HTMLDivElement | null>(null)
  108. useEffect(() => {
  109. if (!name || !rootRef.current) return
  110. const parentForm: HTMLFormElement | null | undefined =
  111. rootRef.current?.closest('form')
  112. if (!parentForm) return
  113. function handleFormDataEvent(event: FormDataEvent) {
  114. const data = event.formData
  115. const key = name as string // can't be undefined due to early exit in the effect
  116. if (selectedItem || defaultItem) {
  117. data.append(key, itemToString(selectedItem || defaultItem))
  118. }
  119. }
  120. parentForm.addEventListener('formdata', handleFormDataEvent)
  121. return () => {
  122. parentForm.removeEventListener('formdata', handleFormDataEvent)
  123. }
  124. }, [name, itemToString, selectedItem, defaultItem])
  125. const onKeyDown: KeyboardEventHandler<HTMLInputElement> = useCallback(
  126. event => {
  127. if ((event.key === 'Enter' || event.key === ' ') && !isOpen) {
  128. event.preventDefault()
  129. ;(event.nativeEvent as any).preventDownshiftDefault = true
  130. openMenu()
  131. } else if (event.key === 'Escape' && isOpen) {
  132. event.stopPropagation()
  133. closeMenu()
  134. }
  135. },
  136. [closeMenu, isOpen, openMenu]
  137. )
  138. let value: string | undefined
  139. if (selectedItem || defaultItem) {
  140. value = itemToString(selectedItem || defaultItem)
  141. } else {
  142. value = defaultText
  143. }
  144. const tickIcon = function () {
  145. return isCiam ? <Check /> : 'check'
  146. }
  147. const dropdown = (
  148. <ul
  149. {...getMenuProps({ disabled })}
  150. className={classNames('dropdown-menu', {
  151. 'w-100': !isCiam,
  152. 'ciam-dropdown-menu': isCiam,
  153. show: isOpen,
  154. })}
  155. >
  156. {isOpen &&
  157. items?.map((item, index) => {
  158. // We're using an actual disabled button so we don't need the
  159. // aria-disabled prop
  160. const { 'aria-disabled': disabled, ...itemProps } = getItemProps({
  161. item,
  162. index,
  163. })
  164. return (
  165. <li role="none" key={itemToKey(item)}>
  166. <DropdownItem
  167. as="button"
  168. type="button"
  169. className={classNames({
  170. 'select-highlighted': highlightedIndex === index,
  171. })}
  172. active={selectedItem === item}
  173. trailingIcon={
  174. selectedIcon && selectedItem === item ? tickIcon() : undefined
  175. }
  176. description={itemToSubtitle ? itemToSubtitle(item) : undefined}
  177. {...itemProps}
  178. disabled={disabled}
  179. >
  180. {itemToString(item)}
  181. </DropdownItem>
  182. </li>
  183. )
  184. })}
  185. </ul>
  186. )
  187. if (isCiam) {
  188. return (
  189. <div className="select-wrapper" ref={rootRef}>
  190. <DSFormGroup>
  191. {label ? (
  192. <DSFormLabel {...getLabelProps()}>
  193. {label} {optionalLabel && <span>({t('optional')})</span>}{' '}
  194. {loading && <OLSpinner size="sm" />}
  195. </DSFormLabel>
  196. ) : null}
  197. <DSFormControl
  198. data-testid={dataTestId}
  199. {...getToggleButtonProps({
  200. disabled,
  201. onKeyDown,
  202. className: 'select-trigger',
  203. })}
  204. value={value}
  205. readOnly
  206. append={isOpen ? <CaretUp /> : <CaretDown />}
  207. />
  208. {dropdown}
  209. </DSFormGroup>
  210. </div>
  211. )
  212. }
  213. return (
  214. <div className="select-wrapper" ref={rootRef}>
  215. {label ? (
  216. <Form.Label {...getLabelProps()}>
  217. {label}{' '}
  218. {optionalLabel && (
  219. <span className="fw-normal">({t('optional')})</span>
  220. )}{' '}
  221. {loading && <OLSpinner size="sm" />}
  222. </Form.Label>
  223. ) : null}
  224. <FormControl
  225. data-testid={dataTestId}
  226. {...getToggleButtonProps({
  227. disabled,
  228. onKeyDown,
  229. className: 'select-trigger',
  230. })}
  231. value={value}
  232. readOnly
  233. append={
  234. <MaterialIcon
  235. type={isOpen ? 'keyboard_arrow_up' : 'keyboard_arrow_down'}
  236. className="align-text-bottom"
  237. />
  238. }
  239. />
  240. {dropdown}
  241. </div>
  242. )
  243. }