ol-autocomplete.tsx 10 KB

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