downshift-input.tsx 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. import { useState, useEffect, forwardRef } from 'react'
  2. import { useCombobox } from 'downshift'
  3. import classnames from 'classnames'
  4. import { escapeRegExp } from 'lodash'
  5. import { bsVersion } from '@/features/utils/bootstrap-5'
  6. import OLFormControl from '@/features/ui/components/ol/ol-form-control'
  7. import { DropdownItem } from '@/features/ui/components/bootstrap-5/dropdown-menu'
  8. import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
  9. import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
  10. type DownshiftInputProps = {
  11. highlightMatches?: boolean
  12. items: string[]
  13. itemsTitle?: string
  14. inputValue: string
  15. label: string
  16. setValue: (value: string) => void
  17. inputRef?: React.ForwardedRef<HTMLInputElement>
  18. showLabel?: boolean
  19. showSuggestedText?: boolean
  20. } & React.InputHTMLAttributes<HTMLInputElement>
  21. const filterItemsByInputValue = (
  22. items: DownshiftInputProps['items'],
  23. inputValue: DownshiftInputProps['inputValue']
  24. ) => items.filter(item => item.toLowerCase().includes(inputValue.toLowerCase()))
  25. function Downshift({
  26. highlightMatches = false,
  27. items,
  28. itemsTitle,
  29. inputValue,
  30. placeholder,
  31. label,
  32. setValue,
  33. disabled,
  34. inputRef,
  35. showLabel = false,
  36. showSuggestedText = false,
  37. }: DownshiftInputProps) {
  38. const [inputItems, setInputItems] = useState(items)
  39. useEffect(() => {
  40. setInputItems(items)
  41. }, [items])
  42. const {
  43. isOpen,
  44. getLabelProps,
  45. getMenuProps,
  46. getInputProps,
  47. getComboboxProps,
  48. getItemProps,
  49. highlightedIndex,
  50. openMenu,
  51. selectedItem,
  52. } = useCombobox({
  53. inputValue,
  54. items: inputItems,
  55. initialSelectedItem: inputValue,
  56. onSelectedItemChange: ({ selectedItem }) => {
  57. setValue(selectedItem ?? '')
  58. },
  59. onInputValueChange: ({ inputValue = '' }) => {
  60. setInputItems(filterItemsByInputValue(items, inputValue))
  61. },
  62. onStateChange: ({ type }) => {
  63. if (type === useCombobox.stateChangeTypes.FunctionOpenMenu) {
  64. setInputItems(filterItemsByInputValue(items, inputValue))
  65. }
  66. },
  67. })
  68. const highlightMatchedCharacters = (item: string, query: string) => {
  69. if (!query || !highlightMatches) return item
  70. const regex = new RegExp(`(${escapeRegExp(query)})`, 'gi')
  71. const parts = item.split(regex)
  72. return parts.map((part, index) =>
  73. regex.test(part) ? <strong key={`${part}-${index}`}>{part}</strong> : part
  74. )
  75. }
  76. const shouldOpen = isOpen && inputItems.length
  77. return (
  78. <div
  79. className={classnames(
  80. 'dropdown',
  81. bsVersion({
  82. bs5: 'd-block',
  83. bs3: classnames('ui-select-container ui-select-bootstrap', {
  84. open: shouldOpen,
  85. }),
  86. })
  87. )}
  88. >
  89. <div {...getComboboxProps()}>
  90. {/* eslint-disable-next-line jsx-a11y/label-has-for */}
  91. <OLFormLabel
  92. {...getLabelProps()}
  93. className={
  94. showLabel
  95. ? ''
  96. : bsVersion({ bs5: 'visually-hidden', bs3: 'sr-only' })
  97. }
  98. >
  99. {label}
  100. </OLFormLabel>
  101. <OLFormControl
  102. {...getInputProps({
  103. onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
  104. setValue(event.target.value)
  105. },
  106. onFocus: () => {
  107. if (!isOpen) {
  108. openMenu()
  109. }
  110. },
  111. ref: inputRef,
  112. })}
  113. placeholder={placeholder}
  114. disabled={disabled}
  115. />
  116. </div>
  117. <ul
  118. {...getMenuProps()}
  119. className={classnames(
  120. 'dropdown-menu',
  121. bsVersion({
  122. bs5: classnames('select-dropdown-menu', { show: shouldOpen }),
  123. bs3: 'ui-select-choices ui-select-choices-content ui-select-dropdown',
  124. })
  125. )}
  126. >
  127. {showSuggestedText && inputItems.length && (
  128. <BootstrapVersionSwitcher
  129. bs3={<li className="ui-select-title">{itemsTitle}</li>}
  130. bs5={
  131. <li>
  132. <DropdownItem as="span" role={undefined} disabled>
  133. {itemsTitle}
  134. </DropdownItem>
  135. </li>
  136. }
  137. />
  138. )}
  139. {inputItems.map((item, index) => (
  140. // eslint-disable-next-line jsx-a11y/role-supports-aria-props
  141. <li
  142. className={bsVersion({ bs3: 'ui-select-choices-group' })}
  143. key={`${item}${index}`}
  144. {...getItemProps({ item, index })}
  145. aria-selected={selectedItem === item}
  146. >
  147. <BootstrapVersionSwitcher
  148. bs3={
  149. <div
  150. className={classnames('ui-select-choices-row', {
  151. active: selectedItem === item,
  152. 'ui-select-choices-row--highlighted':
  153. highlightedIndex === index,
  154. })}
  155. >
  156. <span className="ui-select-choices-row-inner">
  157. <span>{highlightMatchedCharacters(item, inputValue)}</span>
  158. </span>
  159. </div>
  160. }
  161. bs5={
  162. <DropdownItem
  163. as="span"
  164. role={undefined}
  165. className={classnames({
  166. active: selectedItem === item,
  167. 'dropdown-item-highlighted': highlightedIndex === index,
  168. })}
  169. trailingIcon={selectedItem === item ? 'check' : undefined}
  170. >
  171. {highlightMatchedCharacters(item, inputValue)}
  172. </DropdownItem>
  173. }
  174. />
  175. </li>
  176. ))}
  177. </ul>
  178. </div>
  179. )
  180. }
  181. const DownshiftInput = forwardRef<
  182. HTMLInputElement,
  183. Omit<DownshiftInputProps, 'inputRef'>
  184. >((props, ref) => <Downshift {...props} inputRef={ref} />)
  185. DownshiftInput.displayName = 'DownshiftInput'
  186. export default DownshiftInput