downshift-input.tsx 5.0 KB

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