downshift-input.tsx 4.3 KB

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