downshift-input.tsx 5.7 KB

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