downshift-input.tsx 4.2 KB

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