ol-autocomplete.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  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. expandUp?: boolean
  32. onClose?: () => void
  33. isOpen?: boolean
  34. scrollIntoView?: (
  35. node: HTMLElement | null,
  36. menuNode: HTMLElement | null
  37. ) => void
  38. }
  39. type OLAutocompleteDisplayItem =
  40. | {
  41. type: 'item'
  42. value: string
  43. label: string
  44. }
  45. | {
  46. type: 'create'
  47. inputValue: string
  48. }
  49. function OLAutocompleteInternal({
  50. items,
  51. onChange,
  52. placeholder,
  53. label,
  54. showLabel = false,
  55. allowCreate = true,
  56. disabled = false,
  57. createOptionPrefix = '+ Create',
  58. useFuzzySearch = false,
  59. inputRef,
  60. expandUp = false,
  61. onClose,
  62. isOpen: controlledIsOpen,
  63. scrollIntoView,
  64. }: OLAutocompleteProps) {
  65. const { t } = useTranslation()
  66. const searchIndex = useMemo(() => {
  67. if (!useFuzzySearch) return null
  68. const searchIndex = new MiniSearch({
  69. fields: ['label'],
  70. storeFields: ['value', 'label', 'group'],
  71. idField: 'value',
  72. })
  73. searchIndex.addAll(items)
  74. return searchIndex
  75. }, [items, useFuzzySearch])
  76. const [internalInputValue, setInternalInputValue] = useState('')
  77. const inputItems = useMemo(() => {
  78. if (!internalInputValue) {
  79. return items
  80. }
  81. if (useFuzzySearch && searchIndex) {
  82. const results = searchIndex.search(internalInputValue, {
  83. fuzzy: FUZZY_SEARCH_THRESHOLD,
  84. prefix: true,
  85. })
  86. return results.map(result => ({
  87. value: result.value,
  88. label: result.label,
  89. group: result.group,
  90. }))
  91. }
  92. return items.filter(item =>
  93. item.label.toLowerCase().includes(internalInputValue.toLowerCase())
  94. )
  95. }, [items, internalInputValue, searchIndex, useFuzzySearch])
  96. const exactMatch = inputItems.some(
  97. item => item.label.toLowerCase() === internalInputValue.toLowerCase()
  98. )
  99. const allowCreateForInput =
  100. typeof allowCreate === 'function'
  101. ? allowCreate(internalInputValue)
  102. : allowCreate
  103. const showCreateOption =
  104. allowCreateForInput && internalInputValue && !exactMatch
  105. const createDisplayItem: OLAutocompleteDisplayItem[] = showCreateOption
  106. ? [{ type: 'create' as const, inputValue: internalInputValue }]
  107. : []
  108. const displayItems: OLAutocompleteDisplayItem[] = [
  109. ...(expandUp ? [] : createDisplayItem),
  110. ...inputItems.map(item => ({
  111. type: 'item' as const,
  112. value: item.value,
  113. label: item.label,
  114. })),
  115. ...(expandUp ? createDisplayItem : []),
  116. ]
  117. const getDisplayIndex = (inputItemIndex: number) =>
  118. !expandUp && showCreateOption ? inputItemIndex + 1 : inputItemIndex
  119. const hasGroupedItems = inputItems.some(item => Boolean(item.group))
  120. const {
  121. isOpen,
  122. getLabelProps,
  123. getMenuProps,
  124. getInputProps,
  125. getItemProps,
  126. highlightedIndex,
  127. selectItem,
  128. } = useCombobox<OLAutocompleteDisplayItem>({
  129. inputValue: internalInputValue,
  130. items: displayItems,
  131. defaultHighlightedIndex: 0,
  132. ...(controlledIsOpen !== undefined && { isOpen: controlledIsOpen }),
  133. ...(scrollIntoView !== undefined && { scrollIntoView }),
  134. itemToString: item => {
  135. if (!item) return ''
  136. return item.type === 'create' ? item.inputValue : item.label
  137. },
  138. stateReducer: (_state, { type, changes }) => {
  139. if (type === useCombobox.stateChangeTypes.InputChange) {
  140. const newInputValue = changes.inputValue || ''
  141. const newAllowCreate =
  142. typeof allowCreate === 'function'
  143. ? allowCreate(newInputValue)
  144. : allowCreate
  145. const hasExactMatch = items.some(
  146. item => item.label.toLowerCase() === newInputValue.toLowerCase()
  147. )
  148. const hasMatchingItems = items.some(item =>
  149. item.label.toLowerCase().includes(newInputValue.toLowerCase())
  150. )
  151. const newShowCreate = newAllowCreate && newInputValue && !hasExactMatch
  152. return {
  153. ...changes,
  154. highlightedIndex:
  155. !expandUp && newShowCreate && hasMatchingItems ? 1 : 0,
  156. }
  157. }
  158. return changes
  159. },
  160. onSelectedItemChange: ({ selectedItem }) => {
  161. if (selectedItem) {
  162. if (selectedItem.type === 'create') {
  163. onChange(selectedItem.inputValue)
  164. setInternalInputValue(selectedItem.inputValue)
  165. } else {
  166. onChange(selectedItem.value)
  167. setInternalInputValue(selectedItem.label)
  168. }
  169. }
  170. },
  171. onInputValueChange: ({ inputValue = '' }) => {
  172. setInternalInputValue(inputValue)
  173. },
  174. onIsOpenChange: ({ isOpen }) => {
  175. if (!isOpen) {
  176. onClose?.()
  177. }
  178. },
  179. })
  180. const shouldShowDropdown = isOpen && displayItems.length > 0
  181. const renderCreateOption = (index: number) => (
  182. <>
  183. {hasGroupedItems && expandUp && (
  184. <li role="separator" className="dropdown-divider" />
  185. )}
  186. <li
  187. {...getItemProps({
  188. item: { type: 'create', inputValue: internalInputValue },
  189. index,
  190. })}
  191. >
  192. <OLButton
  193. variant="ghost"
  194. size="sm"
  195. className={classnames('w-100', 'justify-content-start', {
  196. 'dropdown-item-highlighted': highlightedIndex === index,
  197. })}
  198. >
  199. <span className="text-muted">{createOptionPrefix} </span>
  200. <strong>'{internalInputValue}'</strong>
  201. </OLButton>
  202. </li>
  203. {hasGroupedItems && !expandUp && (
  204. <li role="separator" className="dropdown-divider" />
  205. )}
  206. </>
  207. )
  208. const handleClear = () => {
  209. selectItem(null)
  210. setInternalInputValue('')
  211. onChange('')
  212. }
  213. const renderSearchBar = () => (
  214. <div className={classnames({ 'mb-3': !expandUp, 'mt-3': expandUp })}>
  215. <OLFormLabel
  216. {...getLabelProps()}
  217. className={showLabel ? '' : 'visually-hidden'}
  218. >
  219. {label}
  220. </OLFormLabel>
  221. <div className="position-relative">
  222. <OLFormControl
  223. {...getInputProps({
  224. ref: inputRef,
  225. })}
  226. placeholder={placeholder}
  227. disabled={disabled}
  228. />
  229. {internalInputValue && !disabled && (
  230. <OLButton
  231. variant="ghost"
  232. size="sm"
  233. className="position-absolute top-50 end-0 translate-middle-y me-1"
  234. onClick={handleClear}
  235. aria-label={t('delete')}
  236. >
  237. <MaterialIcon type="close" />
  238. </OLButton>
  239. )}
  240. </div>
  241. </div>
  242. )
  243. const renderResultsList = () => (
  244. <>
  245. <ul
  246. {...getMenuProps()}
  247. className={classnames('dropdown-menu', 'select-dropdown-menu', {
  248. show: shouldShowDropdown,
  249. 'select-dropdown-menu-expand-up': expandUp,
  250. })}
  251. >
  252. {hasGroupedItems ? (
  253. <>
  254. {!expandUp && showCreateOption && <>{renderCreateOption(0)}</>}
  255. {inputItems.map((item, index) => {
  256. const previousItem = inputItems[index - 1]
  257. const hasGroupHeader =
  258. item.group &&
  259. (!previousItem || previousItem.group !== item.group)
  260. const displayIndex = getDisplayIndex(index)
  261. return (
  262. <Fragment key={`${item.value}${index}`}>
  263. {hasGroupHeader && index > 0 && (
  264. <li role="separator" className="dropdown-divider" />
  265. )}
  266. {hasGroupHeader && (
  267. <li>
  268. <DropdownHeader as="span">{item.group}</DropdownHeader>
  269. </li>
  270. )}
  271. <li
  272. {...getItemProps({
  273. item: {
  274. type: 'item',
  275. value: item.value,
  276. label: item.label,
  277. },
  278. index: displayIndex,
  279. })}
  280. >
  281. <DropdownItem
  282. as="span"
  283. role={undefined}
  284. className={classnames({
  285. 'dropdown-item-highlighted':
  286. highlightedIndex === displayIndex,
  287. })}
  288. >
  289. {item.label}
  290. </DropdownItem>
  291. </li>
  292. </Fragment>
  293. )
  294. })}
  295. {expandUp && showCreateOption && (
  296. <>{renderCreateOption(displayItems.length - 1)}</>
  297. )}
  298. </>
  299. ) : (
  300. displayItems.map((item, index) => {
  301. if (item.type === 'create') {
  302. return (
  303. <Fragment key={`create-${item.inputValue}-${index}`}>
  304. {renderCreateOption(index)}
  305. </Fragment>
  306. )
  307. }
  308. return (
  309. <li
  310. key={`${item.value}${index}`}
  311. {...getItemProps({ item, index })}
  312. >
  313. <DropdownItem
  314. as="span"
  315. role={undefined}
  316. className={classnames({
  317. 'dropdown-item-highlighted': highlightedIndex === index,
  318. })}
  319. >
  320. {item.label}
  321. </DropdownItem>
  322. </li>
  323. )
  324. })
  325. )}
  326. </ul>
  327. </>
  328. )
  329. return (
  330. <div className={classnames('dropdown', 'd-block', 'ol-autocomplete')}>
  331. {expandUp ? (
  332. <>
  333. {renderResultsList()}
  334. {renderSearchBar()}
  335. </>
  336. ) : (
  337. <>
  338. {renderSearchBar()}
  339. {renderResultsList()}
  340. </>
  341. )}
  342. </div>
  343. )
  344. }
  345. const OLAutocomplete = forwardRef<HTMLInputElement, OLAutocompleteProps>(
  346. (props, ref) => {
  347. return <OLAutocompleteInternal {...props} inputRef={ref} />
  348. }
  349. )
  350. OLAutocomplete.displayName = 'OLAutocomplete'
  351. export default OLAutocomplete