ol-autocomplete.tsx 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. import { useCombobox } from 'downshift'
  2. import { forwardRef, useState, useMemo, Fragment } from 'react'
  3. import { useTranslation } from 'react-i18next'
  4. import classnames from 'classnames'
  5. import MiniSearch from 'minisearch'
  6. import OLButton from '@/shared/components/ol/ol-button'
  7. import OLFormControl from '@/shared/components/ol/ol-form-control'
  8. import OLFormLabel from '@/shared/components/ol/ol-form-label'
  9. import {
  10. DropdownHeader,
  11. DropdownItem,
  12. } from '@/shared/components/dropdown/dropdown-menu'
  13. import MaterialIcon from '@/shared/components/material-icon'
  14. const FUZZY_SEARCH_THRESHOLD = 0.5
  15. export type OLAutocompleteItem = {
  16. value: string
  17. label: string
  18. group?: string
  19. }
  20. export type OLAutocompleteProps = {
  21. items: OLAutocompleteItem[]
  22. onChange: (value: string) => void
  23. placeholder?: string
  24. label: string
  25. showLabel?: boolean
  26. allowCreate?: boolean | ((value: string) => boolean)
  27. disabled?: boolean
  28. createOptionPrefix?: string
  29. useFuzzySearch?: boolean
  30. inputRef?: React.ForwardedRef<HTMLInputElement>
  31. }
  32. type OLAutocompleteDisplayItem =
  33. | {
  34. type: 'item'
  35. value: string
  36. label: string
  37. }
  38. | {
  39. type: 'create'
  40. inputValue: string
  41. }
  42. function OLAutocompleteInternal({
  43. items,
  44. onChange,
  45. placeholder,
  46. label,
  47. showLabel = false,
  48. allowCreate = true,
  49. disabled = false,
  50. createOptionPrefix = '+ Create',
  51. useFuzzySearch = false,
  52. inputRef,
  53. }: OLAutocompleteProps) {
  54. const { t } = useTranslation()
  55. const searchIndex = useMemo(() => {
  56. if (!useFuzzySearch) return null
  57. const searchIndex = new MiniSearch({
  58. fields: ['label'],
  59. storeFields: ['value', 'label', 'group'],
  60. idField: 'value',
  61. })
  62. searchIndex.addAll(items)
  63. return searchIndex
  64. }, [items, useFuzzySearch])
  65. const [internalInputValue, setInternalInputValue] = useState('')
  66. const inputItems = useMemo(() => {
  67. if (!internalInputValue) {
  68. return items
  69. }
  70. if (useFuzzySearch && searchIndex) {
  71. const results = searchIndex.search(internalInputValue, {
  72. fuzzy: FUZZY_SEARCH_THRESHOLD,
  73. prefix: true,
  74. })
  75. return results.map(result => ({
  76. value: result.value,
  77. label: result.label,
  78. group: result.group,
  79. }))
  80. }
  81. return items.filter(item =>
  82. item.label.toLowerCase().includes(internalInputValue.toLowerCase())
  83. )
  84. }, [items, internalInputValue, searchIndex, useFuzzySearch])
  85. const exactMatch = inputItems.some(
  86. item => item.label.toLowerCase() === internalInputValue.toLowerCase()
  87. )
  88. const allowCreateForInput =
  89. typeof allowCreate === 'function'
  90. ? allowCreate(internalInputValue)
  91. : allowCreate
  92. const showCreateOption =
  93. allowCreateForInput && internalInputValue && !exactMatch
  94. const displayItems: OLAutocompleteDisplayItem[] = [
  95. ...inputItems.map(item => ({
  96. type: 'item' as const,
  97. value: item.value,
  98. label: item.label,
  99. })),
  100. ...(showCreateOption
  101. ? [
  102. {
  103. type: 'create' as const,
  104. inputValue: internalInputValue,
  105. },
  106. ]
  107. : []),
  108. ]
  109. const hasGroupedItems = inputItems.some(item => Boolean(item.group))
  110. const {
  111. isOpen,
  112. getLabelProps,
  113. getMenuProps,
  114. getInputProps,
  115. getItemProps,
  116. highlightedIndex,
  117. selectItem,
  118. } = useCombobox<OLAutocompleteDisplayItem>({
  119. inputValue: internalInputValue,
  120. items: displayItems,
  121. defaultHighlightedIndex: 0,
  122. itemToString: item => {
  123. if (!item) return ''
  124. return item.type === 'create' ? item.inputValue : item.label
  125. },
  126. onSelectedItemChange: ({ selectedItem }) => {
  127. if (selectedItem) {
  128. if (selectedItem.type === 'create') {
  129. onChange(selectedItem.inputValue)
  130. setInternalInputValue(selectedItem.inputValue)
  131. } else {
  132. onChange(selectedItem.value)
  133. setInternalInputValue(selectedItem.label)
  134. }
  135. }
  136. },
  137. onInputValueChange: ({ inputValue = '' }) => {
  138. setInternalInputValue(inputValue)
  139. },
  140. })
  141. const shouldShowDropdown = isOpen && displayItems.length > 0
  142. const handleClear = () => {
  143. selectItem(null)
  144. setInternalInputValue('')
  145. onChange('')
  146. }
  147. return (
  148. <div className={classnames('dropdown', 'd-block')}>
  149. <OLFormLabel
  150. {...getLabelProps()}
  151. className={showLabel ? '' : 'visually-hidden'}
  152. >
  153. {label}
  154. </OLFormLabel>
  155. <div className="position-relative">
  156. <OLFormControl
  157. {...getInputProps({
  158. ref: inputRef,
  159. })}
  160. placeholder={placeholder}
  161. disabled={disabled}
  162. />
  163. {internalInputValue && !disabled && (
  164. <OLButton
  165. variant="ghost"
  166. size="sm"
  167. className="position-absolute top-50 end-0 translate-middle-y me-1"
  168. onClick={handleClear}
  169. aria-label={t('delete')}
  170. >
  171. <MaterialIcon type="close" />
  172. </OLButton>
  173. )}
  174. </div>
  175. <ul
  176. {...getMenuProps()}
  177. className={classnames('dropdown-menu', 'select-dropdown-menu', {
  178. show: shouldShowDropdown,
  179. })}
  180. >
  181. {hasGroupedItems ? (
  182. <>
  183. {inputItems.map((item, index) => {
  184. const previousItem = inputItems[index - 1]
  185. const hasGroupHeader =
  186. item.group &&
  187. (!previousItem || previousItem.group !== item.group)
  188. return (
  189. <Fragment key={`${item.value}${index}`}>
  190. {hasGroupHeader && index > 0 && (
  191. <li role="separator" className="dropdown-divider" />
  192. )}
  193. {hasGroupHeader && (
  194. <li>
  195. <DropdownHeader as="span">{item.group}</DropdownHeader>
  196. </li>
  197. )}
  198. <li
  199. {...getItemProps({
  200. item: {
  201. type: 'item',
  202. value: item.value,
  203. label: item.label,
  204. },
  205. index,
  206. })}
  207. >
  208. <DropdownItem
  209. as="span"
  210. role={undefined}
  211. className={classnames({
  212. 'dropdown-item-highlighted': highlightedIndex === index,
  213. })}
  214. >
  215. {item.label}
  216. </DropdownItem>
  217. </li>
  218. </Fragment>
  219. )
  220. })}
  221. {showCreateOption && (
  222. <>
  223. <li role="separator" className="dropdown-divider" />
  224. <li
  225. {...getItemProps({
  226. item: {
  227. type: 'create',
  228. inputValue: internalInputValue,
  229. },
  230. index: displayItems.length - 1,
  231. })}
  232. >
  233. <DropdownItem
  234. as="span"
  235. role={undefined}
  236. className={classnames({
  237. 'dropdown-item-highlighted':
  238. highlightedIndex === displayItems.length - 1,
  239. })}
  240. >
  241. <span className="text-muted">{createOptionPrefix} </span>
  242. <strong>'{internalInputValue}'</strong>
  243. </DropdownItem>
  244. </li>
  245. </>
  246. )}
  247. </>
  248. ) : (
  249. displayItems.map((item, index) => {
  250. const isCreateOption = item.type === 'create'
  251. const displayValue = isCreateOption ? item.inputValue : item.label
  252. return (
  253. <li
  254. key={
  255. item.type === 'create'
  256. ? `create-${item.inputValue}-${index}`
  257. : `${item.value}${index}`
  258. }
  259. {...getItemProps({
  260. item,
  261. index,
  262. })}
  263. >
  264. <DropdownItem
  265. as="span"
  266. role={undefined}
  267. className={classnames({
  268. 'dropdown-item-highlighted': highlightedIndex === index,
  269. })}
  270. >
  271. {isCreateOption ? (
  272. <>
  273. <span className="text-muted">{createOptionPrefix} </span>
  274. <strong>'{displayValue}'</strong>
  275. </>
  276. ) : (
  277. displayValue
  278. )}
  279. </DropdownItem>
  280. </li>
  281. )
  282. })
  283. )}
  284. </ul>
  285. </div>
  286. )
  287. }
  288. const OLAutocomplete = forwardRef<HTMLInputElement, OLAutocompleteProps>(
  289. (props, ref) => {
  290. return <OLAutocompleteInternal {...props} inputRef={ref} />
  291. }
  292. )
  293. OLAutocomplete.displayName = 'OLAutocomplete'
  294. export default OLAutocomplete