ol-autocomplete.tsx 10 KB

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