select.tsx 8.1 KB

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