downshift-input.tsx 4.3 KB

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