downshift-input.tsx 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  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 '@/shared/components/ol/ol-form-control'
  6. import { DropdownItem } from '@/shared/components/dropdown/dropdown-menu'
  7. import OLFormLabel from '@/shared/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. getItemProps,
  46. highlightedIndex,
  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. const shouldOpen = isOpen && inputItems.length
  73. return (
  74. <div className={classnames('dropdown', 'd-block')}>
  75. <div>
  76. <OLFormLabel
  77. {...getLabelProps()}
  78. className={showLabel ? '' : 'visually-hidden'}
  79. >
  80. {label}
  81. </OLFormLabel>
  82. <OLFormControl
  83. {...getInputProps({
  84. onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
  85. setValue(event.target.value)
  86. },
  87. ref: inputRef,
  88. })}
  89. placeholder={placeholder}
  90. disabled={disabled}
  91. />
  92. </div>
  93. <ul
  94. {...getMenuProps()}
  95. className={classnames('dropdown-menu', 'select-dropdown-menu', {
  96. show: shouldOpen,
  97. })}
  98. >
  99. {showSuggestedText && inputItems.length && (
  100. <li>
  101. <DropdownItem as="span" role={undefined} disabled>
  102. {itemsTitle}
  103. </DropdownItem>
  104. </li>
  105. )}
  106. {inputItems.map((item, index) => (
  107. // eslint-disable-next-line jsx-a11y/role-supports-aria-props
  108. <li
  109. key={`${item}${index}`}
  110. {...getItemProps({ item, index })}
  111. aria-selected={selectedItem === item}
  112. >
  113. <DropdownItem
  114. as="span"
  115. role={undefined}
  116. className={classnames({
  117. active: selectedItem === item,
  118. 'dropdown-item-highlighted': highlightedIndex === index,
  119. })}
  120. trailingIcon={selectedItem === item ? 'check' : undefined}
  121. >
  122. {highlightMatchedCharacters(item, inputValue)}
  123. </DropdownItem>
  124. </li>
  125. ))}
  126. </ul>
  127. </div>
  128. )
  129. }
  130. const DownshiftInput = forwardRef<
  131. HTMLInputElement,
  132. Omit<DownshiftInputProps, 'inputRef'>
  133. >((props, ref) => <Downshift {...props} inputRef={ref} />)
  134. DownshiftInput.displayName = 'DownshiftInput'
  135. export default DownshiftInput