downshift-input.tsx 4.0 KB

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