select.tsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. import {
  2. useRef,
  3. useEffect,
  4. KeyboardEventHandler,
  5. useCallback,
  6. ReactNode,
  7. useState,
  8. useId,
  9. } from 'react'
  10. import classNames from 'classnames'
  11. import { useSelect } from 'downshift'
  12. import { useTranslation } from 'react-i18next'
  13. import { Form } from 'react-bootstrap'
  14. import FormControl from '@/shared/components/form/form-control'
  15. import MaterialIcon from '@/shared/components/material-icon'
  16. import { CaretUp, CaretDown, Check } from '@phosphor-icons/react'
  17. import { DropdownItem } from '@/shared/components/dropdown/dropdown-menu'
  18. import OLOverlay from '@/shared/components/ol/ol-overlay'
  19. import OLSpinner from './ol/ol-spinner'
  20. import DSFormLabel from '@/shared/components/ds/ds-form-label'
  21. import DSFormGroup from '@/shared/components/ds/ds-form-group'
  22. import DSFormControl from '@/shared/components/ds/ds-form-control'
  23. import { DropdownItemProps } from '@/shared/components/types/dropdown-menu-props'
  24. function SelectMenuPopover({
  25. show,
  26. target,
  27. onHide,
  28. children,
  29. }: {
  30. show: boolean
  31. target: HTMLElement | null
  32. onHide: () => void
  33. children: ReactNode
  34. }) {
  35. const id = useId()
  36. return (
  37. <OLOverlay
  38. show={show}
  39. target={target}
  40. placement="bottom-start"
  41. rootClose
  42. onHide={onHide}
  43. >
  44. {({ ref, style }) => (
  45. <div
  46. id={`select-popover-${id}`}
  47. ref={ref}
  48. style={{ ...style, width: target?.offsetWidth }}
  49. className="select-portal-popover"
  50. >
  51. {children}
  52. </div>
  53. )}
  54. </OLOverlay>
  55. )
  56. }
  57. export type SelectProps<T> = {
  58. // The items rendered as dropdown options.
  59. items: T[]
  60. // Stringifies an item of type T. The resulting string is rendered as a dropdown option.
  61. itemToString: (item: T | null | undefined) => string
  62. // Caption for the dropdown.
  63. label?: ReactNode
  64. // Attribute used to identify the component inside a Form. This name is used to
  65. // retrieve FormData when the form is submitted. The value of the FormData entry
  66. // is the string returned by `itemToString(selectedItem)`.
  67. name?: string
  68. // Hint text displayed in the initial render.
  69. defaultText?: string
  70. // Initial selected item, displayed in the initial render. When both `defaultText`
  71. // and `defaultItem` are set the latter is ignored.
  72. defaultItem?: T | null
  73. // Stringifies an item. The resulting string is rendered as a subtitle in a dropdown option.
  74. itemToSubtitle?: (item: T | null | undefined) => string
  75. // Stringifies an item. The resulting string is rendered as a React `key` for each item.
  76. itemToKey: (item: T) => string
  77. // Maps an item to a leading icon.
  78. itemToLeadingIcon?: (
  79. item: T | null | undefined
  80. ) => DropdownItemProps['leadingIcon']
  81. // Callback invoked after the selected item is updated.
  82. onSelectedItemChanged?: (item: T | null | undefined) => void
  83. // Optionally directly control the selected item.
  84. selected?: T | null
  85. // When `true` item selection is disabled.
  86. disabled?: boolean
  87. // Determine which items should be disabled
  88. itemToDisabled?: (item: T | null | undefined) => boolean
  89. // When `true` displays an "Optional" subtext after the `label` caption.
  90. optionalLabel?: boolean
  91. // When `true` displays a spinner next to the `label` caption.
  92. loading?: boolean
  93. // Show a checkmark next to the selected item
  94. selectedIcon?: boolean
  95. // testId for the input element
  96. dataTestId?: string
  97. // CIAM-specific layout
  98. isCiam?: boolean
  99. size?: React.ComponentProps<typeof FormControl>['size']
  100. // Renders the menu in a portal so it escapes overflow-clipping ancestors.
  101. portal?: boolean
  102. // Optional id for the toggle button element. When provided, enables association
  103. // with an external <label htmlFor="...">
  104. id?: string
  105. }
  106. export const Select = <T,>({
  107. items,
  108. itemToString = item => (item === null ? '' : String(item)),
  109. label,
  110. name,
  111. defaultText = 'Items',
  112. defaultItem,
  113. itemToSubtitle,
  114. itemToKey,
  115. itemToLeadingIcon,
  116. onSelectedItemChanged,
  117. selected,
  118. disabled = false,
  119. itemToDisabled,
  120. optionalLabel = false,
  121. loading = false,
  122. selectedIcon = false,
  123. dataTestId,
  124. isCiam,
  125. size,
  126. portal = false,
  127. id,
  128. }: SelectProps<T>) => {
  129. const toggleButtonId = id ? { id } : {}
  130. const [selectedItem, setSelectedItem] = useState<T | undefined | null>(
  131. defaultItem
  132. )
  133. const { t } = useTranslation()
  134. const {
  135. isOpen,
  136. getToggleButtonProps,
  137. getLabelProps,
  138. getMenuProps,
  139. getItemProps,
  140. highlightedIndex,
  141. openMenu,
  142. closeMenu,
  143. } = useSelect({
  144. items: items ?? [],
  145. itemToString,
  146. isItemDisabled: item => itemToDisabled?.(item) || false,
  147. selectedItem: selected || defaultItem,
  148. onSelectedItemChange: changes => {
  149. if (onSelectedItemChanged) {
  150. onSelectedItemChanged(changes.selectedItem)
  151. }
  152. setSelectedItem(changes.selectedItem)
  153. },
  154. })
  155. useEffect(() => {
  156. setSelectedItem(selected)
  157. }, [selected])
  158. const rootRef = useRef<HTMLDivElement | null>(null)
  159. useEffect(() => {
  160. if (!name || !rootRef.current) return
  161. const parentForm: HTMLFormElement | null | undefined =
  162. rootRef.current?.closest('form')
  163. if (!parentForm) return
  164. function handleFormDataEvent(event: FormDataEvent) {
  165. const data = event.formData
  166. const key = name as string // can't be undefined due to early exit in the effect
  167. if (selectedItem || defaultItem) {
  168. data.append(key, itemToString(selectedItem || defaultItem))
  169. }
  170. }
  171. parentForm.addEventListener('formdata', handleFormDataEvent)
  172. return () => {
  173. parentForm.removeEventListener('formdata', handleFormDataEvent)
  174. }
  175. }, [name, itemToString, selectedItem, defaultItem])
  176. const onKeyDown: KeyboardEventHandler<HTMLInputElement> = useCallback(
  177. event => {
  178. if ((event.key === 'Enter' || event.key === ' ') && !isOpen) {
  179. event.preventDefault()
  180. ;(event.nativeEvent as any).preventDownshiftDefault = true
  181. openMenu()
  182. } else if (event.key === 'Escape' && isOpen) {
  183. event.stopPropagation()
  184. closeMenu()
  185. }
  186. },
  187. [closeMenu, isOpen, openMenu]
  188. )
  189. let value: string | undefined
  190. if (selectedItem || defaultItem) {
  191. value = itemToString(selectedItem || defaultItem)
  192. } else {
  193. value = defaultText
  194. }
  195. const tickIcon = function () {
  196. return isCiam ? <Check /> : 'check'
  197. }
  198. const menu = (
  199. <ul
  200. {...getMenuProps({ disabled }, { suppressRefError: portal })}
  201. className={classNames('dropdown-menu', {
  202. 'w-100': !isCiam,
  203. 'ciam-dropdown-menu': isCiam,
  204. show: isOpen,
  205. 'select-portal-menu': portal,
  206. })}
  207. >
  208. {isOpen &&
  209. items?.map((item, index) => {
  210. // We're using an actual disabled button so we don't need the
  211. // aria-disabled prop
  212. const { 'aria-disabled': disabled, ...itemProps } = getItemProps({
  213. item,
  214. index,
  215. })
  216. return (
  217. <li role="none" key={itemToKey(item)}>
  218. <DropdownItem
  219. as="button"
  220. type="button"
  221. className={classNames({
  222. 'select-highlighted': highlightedIndex === index,
  223. })}
  224. active={selectedItem === item}
  225. trailingIcon={
  226. selectedIcon && selectedItem === item ? tickIcon() : undefined
  227. }
  228. leadingIcon={
  229. itemToLeadingIcon ? itemToLeadingIcon(item) : undefined
  230. }
  231. description={itemToSubtitle ? itemToSubtitle(item) : undefined}
  232. {...itemProps}
  233. disabled={disabled}
  234. >
  235. {itemToString(item)}
  236. </DropdownItem>
  237. </li>
  238. )
  239. })}
  240. </ul>
  241. )
  242. const dropdown = portal ? (
  243. <SelectMenuPopover
  244. show={isOpen}
  245. target={rootRef.current}
  246. onHide={closeMenu}
  247. >
  248. {menu}
  249. </SelectMenuPopover>
  250. ) : (
  251. menu
  252. )
  253. if (isCiam) {
  254. return (
  255. <div className="select-wrapper" ref={rootRef}>
  256. <DSFormGroup>
  257. {label ? (
  258. <DSFormLabel {...getLabelProps()}>
  259. {label} {optionalLabel && <span>({t('optional')})</span>}{' '}
  260. {loading && <OLSpinner size="sm" />}
  261. </DSFormLabel>
  262. ) : null}
  263. <DSFormControl
  264. data-testid={dataTestId}
  265. {...getToggleButtonProps({
  266. disabled,
  267. onKeyDown,
  268. className: 'select-trigger',
  269. ...toggleButtonId,
  270. })}
  271. value={value}
  272. readOnly
  273. append={isOpen ? <CaretUp /> : <CaretDown />}
  274. />
  275. {dropdown}
  276. </DSFormGroup>
  277. </div>
  278. )
  279. }
  280. return (
  281. <div className="select-wrapper" ref={rootRef}>
  282. {label ? (
  283. <Form.Label {...getLabelProps()}>
  284. {label}{' '}
  285. {optionalLabel && (
  286. <span className="fw-normal">({t('optional')})</span>
  287. )}{' '}
  288. {loading && <OLSpinner size="sm" />}
  289. </Form.Label>
  290. ) : null}
  291. <FormControl
  292. data-testid={dataTestId}
  293. {...getToggleButtonProps({
  294. disabled,
  295. onKeyDown,
  296. className: 'select-trigger',
  297. ...toggleButtonId,
  298. })}
  299. value={value}
  300. readOnly
  301. append={
  302. <MaterialIcon
  303. type={isOpen ? 'keyboard_arrow_up' : 'keyboard_arrow_down'}
  304. className="align-text-bottom"
  305. />
  306. }
  307. size={size}
  308. />
  309. {dropdown}
  310. </div>
  311. )
  312. }