ol-autocomplete.tsx 10 KB

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