@stack-spot/citric-react 0.41.2 → 0.42.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/CHANGELOG.md +13 -13
  2. package/dist/citric.css +3090 -2846
  3. package/dist/components/Accordion.d.ts +1 -1
  4. package/dist/components/Accordion.js +1 -1
  5. package/dist/components/Alert.d.ts +1 -1
  6. package/dist/components/Alert.js +1 -1
  7. package/dist/components/AsyncContent.d.ts +1 -1
  8. package/dist/components/AsyncContent.js +1 -1
  9. package/dist/components/Autocomplete/Autocomplete.d.ts +211 -0
  10. package/dist/components/Autocomplete/Autocomplete.d.ts.map +1 -0
  11. package/dist/components/Autocomplete/Autocomplete.js +409 -0
  12. package/dist/components/Autocomplete/Autocomplete.js.map +1 -0
  13. package/dist/components/Autocomplete/index.d.ts +3 -0
  14. package/dist/components/Autocomplete/index.d.ts.map +1 -0
  15. package/dist/components/Autocomplete/index.js +2 -0
  16. package/dist/components/Autocomplete/index.js.map +1 -0
  17. package/dist/components/Avatar.d.ts +1 -1
  18. package/dist/components/Avatar.js +1 -1
  19. package/dist/components/AvatarGroup.d.ts +1 -1
  20. package/dist/components/AvatarGroup.js +1 -1
  21. package/dist/components/Badge.d.ts +1 -1
  22. package/dist/components/Badge.js +1 -1
  23. package/dist/components/Blockquote.d.ts +1 -1
  24. package/dist/components/Blockquote.js +1 -1
  25. package/dist/components/Breadcrumb.d.ts +1 -1
  26. package/dist/components/Breadcrumb.js +1 -1
  27. package/dist/components/Button.d.ts +1 -1
  28. package/dist/components/Button.js +1 -1
  29. package/dist/components/ButtonLink.d.ts +1 -1
  30. package/dist/components/ButtonLink.js +1 -1
  31. package/dist/components/Card.d.ts +1 -1
  32. package/dist/components/Card.js +1 -1
  33. package/dist/components/Checkbox.d.ts +1 -1
  34. package/dist/components/Checkbox.d.ts.map +1 -1
  35. package/dist/components/Checkbox.js +2 -2
  36. package/dist/components/Checkbox.js.map +1 -1
  37. package/dist/components/CheckboxGroup.d.ts +1 -1
  38. package/dist/components/CheckboxGroup.js +1 -1
  39. package/dist/components/Circle.d.ts +1 -1
  40. package/dist/components/Circle.js +1 -1
  41. package/dist/components/CitricComponent.d.ts +1 -1
  42. package/dist/components/CitricComponent.d.ts.map +1 -1
  43. package/dist/components/Divider.d.ts +1 -1
  44. package/dist/components/Divider.js +1 -1
  45. package/dist/components/ErrorBoundary.d.ts +1 -1
  46. package/dist/components/ErrorBoundary.js +1 -1
  47. package/dist/components/ErrorMessage.d.ts +1 -1
  48. package/dist/components/ErrorMessage.js +1 -1
  49. package/dist/components/FallbackBoundary.d.ts +1 -1
  50. package/dist/components/FallbackBoundary.js +1 -1
  51. package/dist/components/Favorite.d.ts +1 -1
  52. package/dist/components/Favorite.js +1 -1
  53. package/dist/components/FieldGroup.d.ts +1 -1
  54. package/dist/components/FieldGroup.js +1 -1
  55. package/dist/components/Form.d.ts +2 -2
  56. package/dist/components/Form.js +1 -1
  57. package/dist/components/FormGroup.d.ts +1 -1
  58. package/dist/components/FormGroup.js +1 -1
  59. package/dist/components/Icon.d.ts +1 -1
  60. package/dist/components/Icon.js +1 -1
  61. package/dist/components/IconBox.d.ts +3 -3
  62. package/dist/components/IconBox.js +1 -1
  63. package/dist/components/ImageBox.d.ts +3 -3
  64. package/dist/components/ImageBox.js +1 -1
  65. package/dist/components/ImageWithFallback.d.ts +1 -1
  66. package/dist/components/ImageWithFallback.js +1 -1
  67. package/dist/components/Input.d.ts +1 -1
  68. package/dist/components/Input.js +1 -1
  69. package/dist/components/Link.d.ts +1 -1
  70. package/dist/components/Link.js +1 -1
  71. package/dist/components/LoadingPanel.d.ts +1 -1
  72. package/dist/components/LoadingPanel.js +1 -1
  73. package/dist/components/MenuOverlay/Menu.d.ts +1 -1
  74. package/dist/components/MenuOverlay/Menu.js +1 -1
  75. package/dist/components/MenuOverlay/index.d.ts +1 -1
  76. package/dist/components/MenuOverlay/index.js +1 -1
  77. package/dist/components/Overlay/index.d.ts +1 -1
  78. package/dist/components/Overlay/index.js +1 -1
  79. package/dist/components/Pagination.d.ts +1 -1
  80. package/dist/components/Pagination.js +1 -1
  81. package/dist/components/ProgressBar.d.ts +1 -1
  82. package/dist/components/ProgressBar.js +1 -1
  83. package/dist/components/ProgressCircular.d.ts +1 -1
  84. package/dist/components/ProgressCircular.js +1 -1
  85. package/dist/components/RadioGroup.d.ts +1 -1
  86. package/dist/components/RadioGroup.js +1 -1
  87. package/dist/components/Rating.d.ts +1 -1
  88. package/dist/components/Rating.js +1 -1
  89. package/dist/components/Select/MultiSelect.d.ts +1 -1
  90. package/dist/components/Select/MultiSelect.js +1 -1
  91. package/dist/components/Select/RichSelect.d.ts +1 -1
  92. package/dist/components/Select/RichSelect.js +1 -1
  93. package/dist/components/Select/SimpleSelect.d.ts +1 -1
  94. package/dist/components/Select/SimpleSelect.js +1 -1
  95. package/dist/components/Select/index.d.ts +1 -1
  96. package/dist/components/Select/index.js +1 -1
  97. package/dist/components/SelectBox.d.ts +1 -1
  98. package/dist/components/SelectBox.js +1 -1
  99. package/dist/components/Skeleton.d.ts +1 -1
  100. package/dist/components/Skeleton.js +1 -1
  101. package/dist/components/Slider.d.ts +1 -1
  102. package/dist/components/Slider.js +1 -1
  103. package/dist/components/SmartTable.d.ts +1 -1
  104. package/dist/components/SmartTable.js +1 -1
  105. package/dist/components/Stepper.d.ts +1 -1
  106. package/dist/components/Stepper.js +1 -1
  107. package/dist/components/Table.d.ts +3 -3
  108. package/dist/components/Table.js +1 -1
  109. package/dist/components/Tabs/index.d.ts +1 -1
  110. package/dist/components/Tabs/index.js +1 -1
  111. package/dist/components/Textarea.d.ts +1 -1
  112. package/dist/components/Textarea.js +1 -1
  113. package/dist/components/Tooltip.d.ts +1 -1
  114. package/dist/components/Tooltip.js +1 -1
  115. package/dist/context/CitricProvider.d.ts +1 -1
  116. package/dist/context/CitricProvider.js +1 -1
  117. package/dist/index.d.ts +1 -0
  118. package/dist/index.d.ts.map +1 -1
  119. package/dist/index.js +1 -0
  120. package/dist/index.js.map +1 -1
  121. package/dist/overlay.js +1 -1
  122. package/dist/theme.css +415 -415
  123. package/package.json +1 -1
  124. package/scripts/build-css.ts +49 -49
  125. package/src/components/Accordion.tsx +130 -130
  126. package/src/components/Alert.tsx +24 -24
  127. package/src/components/AsyncContent.tsx +75 -75
  128. package/src/components/Autocomplete/Autocomplete.tsx +794 -0
  129. package/src/components/Autocomplete/index.ts +3 -0
  130. package/src/components/Avatar.tsx +45 -45
  131. package/src/components/AvatarGroup.tsx +49 -49
  132. package/src/components/Badge.tsx +47 -47
  133. package/src/components/Blockquote.tsx +18 -18
  134. package/src/components/Breadcrumb.tsx +33 -33
  135. package/src/components/Button.tsx +105 -105
  136. package/src/components/ButtonLink.tsx +45 -45
  137. package/src/components/Card.tsx +68 -68
  138. package/src/components/Checkbox.tsx +52 -51
  139. package/src/components/CheckboxGroup.tsx +153 -153
  140. package/src/components/Circle.tsx +43 -43
  141. package/src/components/CitricComponent.ts +47 -47
  142. package/src/components/Divider.tsx +24 -24
  143. package/src/components/ErrorBoundary.tsx +75 -75
  144. package/src/components/ErrorMessage.tsx +11 -11
  145. package/src/components/FallbackBoundary.tsx +40 -40
  146. package/src/components/Favorite.tsx +57 -57
  147. package/src/components/FieldGroup.tsx +46 -46
  148. package/src/components/Form.tsx +36 -36
  149. package/src/components/FormGroup.tsx +57 -57
  150. package/src/components/Icon.tsx +35 -35
  151. package/src/components/IconBox.tsx +134 -134
  152. package/src/components/ImageBox.tsx +125 -125
  153. package/src/components/ImageWithFallback.tsx +65 -65
  154. package/src/components/Input.tsx +49 -49
  155. package/src/components/Link.tsx +55 -55
  156. package/src/components/LoadingPanel.tsx +12 -12
  157. package/src/components/MenuOverlay/Menu.tsx +158 -158
  158. package/src/components/MenuOverlay/context.ts +20 -20
  159. package/src/components/MenuOverlay/index.tsx +55 -55
  160. package/src/components/MenuOverlay/keyboard.ts +60 -60
  161. package/src/components/MenuOverlay/types.ts +171 -171
  162. package/src/components/Overlay/context.ts +10 -10
  163. package/src/components/Overlay/index.tsx +182 -182
  164. package/src/components/Overlay/types.ts +75 -75
  165. package/src/components/Pagination.tsx +133 -133
  166. package/src/components/ProgressBar.tsx +45 -45
  167. package/src/components/ProgressCircular.tsx +45 -45
  168. package/src/components/RadioGroup.tsx +147 -147
  169. package/src/components/Rating.tsx +98 -98
  170. package/src/components/Select/MultiSelect.tsx +217 -217
  171. package/src/components/Select/RichSelect.tsx +128 -128
  172. package/src/components/Select/SimpleSelect.tsx +73 -73
  173. package/src/components/Select/hooks.ts +133 -133
  174. package/src/components/Select/index.tsx +35 -35
  175. package/src/components/Select/types.ts +134 -134
  176. package/src/components/SelectBox.tsx +167 -167
  177. package/src/components/Skeleton.tsx +53 -53
  178. package/src/components/Slider.tsx +89 -89
  179. package/src/components/SmartTable.tsx +227 -227
  180. package/src/components/Stepper.tsx +163 -163
  181. package/src/components/Table.tsx +234 -234
  182. package/src/components/Tabs/TabController.ts +54 -54
  183. package/src/components/Tabs/index.tsx +106 -106
  184. package/src/components/Tabs/types.ts +67 -67
  185. package/src/components/Tabs/utils.ts +6 -6
  186. package/src/components/Text.ts +111 -111
  187. package/src/components/Textarea.tsx +27 -27
  188. package/src/components/Tooltip.tsx +83 -83
  189. package/src/components/layout.tsx +101 -101
  190. package/src/context/CitricContext.tsx +4 -4
  191. package/src/context/CitricProvider.tsx +14 -14
  192. package/src/context/hooks.ts +6 -6
  193. package/src/index.ts +59 -58
  194. package/src/overlay.ts +348 -348
  195. package/src/types.ts +235 -235
  196. package/src/utils/ValueController.ts +28 -28
  197. package/src/utils/acessibility.ts +92 -92
  198. package/src/utils/checkbox.ts +121 -121
  199. package/src/utils/css.ts +119 -119
  200. package/src/utils/options.ts +9 -9
  201. package/src/utils/radio.ts +93 -93
  202. package/src/utils/react.ts +6 -6
  203. package/src/utils/time.ts +5 -5
  204. package/tsconfig.json +10 -10
@@ -0,0 +1,794 @@
1
+ import { ColorPaletteName, ColorSchemeName, listToClass } from '@stack-spot/portal-theme'
2
+ import { useTranslate } from '@stack-spot/portal-translate'
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
4
+ import { applyCSSVariable } from '../../utils/css'
5
+ import { defaultRenderKey, defaultRenderLabel } from '../../utils/options'
6
+ import { withRef } from '../../utils/react'
7
+ import { Badge } from '../Badge'
8
+ import { Checkbox } from '../Checkbox'
9
+ import { CitricComponent } from '../CitricComponent'
10
+ import { IconButton } from '../IconBox'
11
+ import { ProgressCircular } from '../ProgressCircular'
12
+ import { useDisabledEffect, useFocusEffect } from '../Select/hooks'
13
+ import { Row } from '../layout'
14
+
15
+ export interface BaseAutocompleteProps<T, Multiple extends boolean = false> {
16
+ /**
17
+ * The list of options available for selection.
18
+ */
19
+ options: T[],
20
+ /**
21
+ * The current value(s) selected.
22
+ * - Single selection: T | null
23
+ * - Multiple selection: T[]
24
+ */
25
+ value: Multiple extends true ? T[] : (T | null),
26
+ /**
27
+ * Callback fired when the value changes.
28
+ */
29
+ onChange: Multiple extends true ? (value: T[]) => void : (value: T | null) => void,
30
+ /**
31
+ * If true, enables multiple selection mode.
32
+ * @default false
33
+ */
34
+ multiple?: Multiple,
35
+ /**
36
+ * If true, allows the user to enter values that are not in the options list.
37
+ * @default false
38
+ */
39
+ freeSolo?: boolean,
40
+ /**
41
+ * If true, allows creating new options when no match is found.
42
+ * Shows an "Add [value]" option at the top of the list.
43
+ * @default false
44
+ */
45
+ creatable?: boolean,
46
+ /**
47
+ * Callback fired when a new option is created.
48
+ * Required when creatable is true and you want manual control.
49
+ */
50
+ onCreate?: (inputValue: string) => void,
51
+ /**
52
+ * Function to create a new option object from the input value.
53
+ * Required when creatable is true without onCreate and working with objects.
54
+ *
55
+ * @example
56
+ * ```tsx
57
+ * getOptionFromInput={(inputValue) => ({ id: Date.now(), name: inputValue })}
58
+ * ```
59
+ */
60
+ getOptionFromInput?: (inputValue: string) => T,
61
+ /**
62
+ * The input value (controlled).
63
+ */
64
+ inputValue?: string,
65
+ /**
66
+ * Callback fired when the input value changes.
67
+ */
68
+ onInputChange?: (value: string) => void,
69
+ /**
70
+ * A function to render the item label.
71
+ * @default "the item's toString() result."
72
+ */
73
+ renderLabel?: (option: T) => string,
74
+ /**
75
+ * A function to generate a unique key for each option.
76
+ */
77
+ renderKey?: (option: T) => string | number,
78
+ /**
79
+ * A function to render an option in the dropdown.
80
+ */
81
+ renderOption?: (option: T) => React.ReactNode,
82
+ /**
83
+ * A function to render a tag in multiple mode.
84
+ */
85
+ renderTag?: (option: T, onRemove: () => void) => React.ReactNode,
86
+ /**
87
+ * Custom filter function for options.
88
+ * @default filters by label includes input (case insensitive)
89
+ */
90
+ filterOptions?: (options: T[], inputValue: string) => T[],
91
+ /**
92
+ * If true, shows a loading indicator.
93
+ * @default false
94
+ */
95
+ loading?: boolean,
96
+ /**
97
+ * If true, the component is disabled.
98
+ * @default false
99
+ */
100
+ disabled?: boolean,
101
+ /**
102
+ * Placeholder text for the input.
103
+ */
104
+ placeholder?: string,
105
+ /**
106
+ * Maximum height for the dropdown panel in pixels.
107
+ */
108
+ maxHeight?: number,
109
+ /**
110
+ * Maximum number of tags to show before truncating.
111
+ * Only applies when multiple is true.
112
+ */
113
+ maxTagsToShow?: number,
114
+ /**
115
+ * If true, automatically highlights the first option.
116
+ * @default false
117
+ */
118
+ autoHighlight?: boolean,
119
+ /**
120
+ * If true, clears the input value when an option is selected.
121
+ * Only applies when multiple is true.
122
+ * @default true (for multiple)
123
+ */
124
+ clearOnSelect?: boolean,
125
+ /**
126
+ * If true, the popup will open on input focus.
127
+ * @default true
128
+ */
129
+ openOnFocus?: boolean,
130
+ /**
131
+ * Callback fired when an option is selected (before onChange).
132
+ */
133
+ onSelect?: (option: T | null) => void,
134
+ /**
135
+ * Text to display when no options are available.
136
+ */
137
+ noOptionsText?: string,
138
+ /**
139
+ * Text to display when loading.
140
+ */
141
+ loadingText?: string,
142
+ /**
143
+ * Callback fired when the user scrolls to the end of the options list.
144
+ * Useful for implementing infinite scroll/pagination.
145
+ *
146
+ * @example
147
+ * ```tsx
148
+ * <Autocomplete
149
+ * options={options}
150
+ * onScrollEnd={() => fetchMoreOptions()}
151
+ * loading={isFetchingMore}
152
+ * />
153
+ * ```
154
+ */
155
+ onScrollEnd?: () => void,
156
+ /**
157
+ * Margin in pixels before the end of the list to trigger onScrollEnd.
158
+ * @default 200
159
+ */
160
+ scrollEndMargin?: number,
161
+ /**
162
+ * Color scheme for the tags (badges) in multiple mode.
163
+ * @example 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info'
164
+ */
165
+ tagColorScheme?: ColorSchemeName,
166
+ /**
167
+ * Color palette for the tags (badges) in multiple mode.
168
+ * @example 'blue' | 'green' | 'red' | 'yellow' | 'purple'
169
+ */
170
+ tagColorPalette?: ColorPaletteName,
171
+ /**
172
+ * Appearance of the tags (badges) in multiple mode.
173
+ * @default 'circle'
174
+ */
175
+ tagAppearance?: 'square' | 'circle',
176
+ }
177
+
178
+ export type AutocompleteProps<T, Multiple extends boolean = false> =
179
+ Omit<React.JSX.IntrinsicElements['div'], 'ref' | 'onChange' | 'onSelect'> &
180
+ BaseAutocompleteProps<T, Multiple>
181
+
182
+ /**
183
+ * A combination of a text input and a dropdown that suggests options as the user types.
184
+ * Supports both single and multiple selection modes, similar to Material-UI Autocomplete.
185
+ *
186
+ * @example
187
+ * Basic usage (single selection):
188
+ * ```tsx
189
+ * const [value, setValue] = useState<Option | null>(null)
190
+ *
191
+ * <Autocomplete
192
+ * options={options}
193
+ * value={value}
194
+ * onChange={setValue}
195
+ * renderLabel={o => o.name}
196
+ * renderKey={o => o.id}
197
+ * />
198
+ * ```
199
+ *
200
+ * @example
201
+ * Multiple selection with tags:
202
+ * ```tsx
203
+ * const [value, setValue] = useState<Option[]>([])
204
+ *
205
+ * <Autocomplete
206
+ * multiple
207
+ * options={options}
208
+ * value={value}
209
+ * onChange={setValue}
210
+ * renderLabel={o => o.name}
211
+ * renderKey={o => o.id}
212
+ * />
213
+ * ```
214
+ *
215
+ * @example
216
+ * Free solo (allow custom values):
217
+ * ```tsx
218
+ * <Autocomplete
219
+ * freeSolo
220
+ * options={options}
221
+ * value={value}
222
+ * onChange={setValue}
223
+ * renderLabel={o => o.name}
224
+ * />
225
+ * ```
226
+ */
227
+ export const Autocomplete = withRef(
228
+ function Autocomplete<T, Multiple extends boolean = false>({
229
+ options,
230
+ value,
231
+ onChange,
232
+ multiple = false as Multiple,
233
+ freeSolo = false,
234
+ creatable = false,
235
+ onCreate,
236
+ getOptionFromInput,
237
+ inputValue: controlledInputValue,
238
+ onInputChange,
239
+ renderLabel = defaultRenderLabel,
240
+ renderKey = defaultRenderKey as (option: T) => string | number,
241
+ renderOption,
242
+ renderTag,
243
+ filterOptions,
244
+ loading = false,
245
+ disabled = false,
246
+ placeholder,
247
+ maxHeight,
248
+ maxTagsToShow,
249
+ autoHighlight = false,
250
+ clearOnSelect = multiple,
251
+ openOnFocus = true,
252
+ onSelect,
253
+ noOptionsText,
254
+ loadingText,
255
+ onScrollEnd,
256
+ scrollEndMargin = 200,
257
+ tagColorScheme,
258
+ tagColorPalette,
259
+ tagAppearance = 'circle',
260
+ style,
261
+ className,
262
+ ...props
263
+ }: AutocompleteProps<T, Multiple>, ref: React.Ref<HTMLDivElement>) {
264
+ const t = useTranslate(dictionary)
265
+ const _element = useRef<HTMLDivElement | null>(null)
266
+ const inputRef = useRef<HTMLInputElement | null>(null)
267
+ const dropdownRef = useRef<HTMLDivElement | null>(null)
268
+ const element = (ref as React.RefObject<HTMLDivElement>) ?? _element
269
+
270
+ const [open, setOpen] = useState(false)
271
+ const [focused, setFocused] = useState(false)
272
+ const [internalInputValue, setInternalInputValue] = useState('')
273
+ const [highlightedIndex, setHighlightedIndex] = useState<number>(-1)
274
+
275
+ useFocusEffect({ element, focused, setFocused, setOpen })
276
+ useDisabledEffect({ disabled, setOpen, setFocused })
277
+
278
+ useEffect(() => {
279
+ if (!open) return
280
+
281
+ const handleClickOutside = (event: MouseEvent) => {
282
+ if (element.current && !element.current.contains(event.target as Node)) {
283
+ setOpen(false)
284
+ setFocused(false)
285
+ }
286
+ }
287
+
288
+ setTimeout(() => {
289
+ document.addEventListener('click', handleClickOutside)
290
+ }, 10)
291
+
292
+ return () => {
293
+ document.removeEventListener('click', handleClickOutside)
294
+ }
295
+ }, [open, element])
296
+
297
+ const inputValue = controlledInputValue ?? internalInputValue
298
+ const setInputValue = useCallback((newValue: string) => {
299
+ if (onInputChange) {
300
+ onInputChange(newValue)
301
+ } else {
302
+ setInternalInputValue(newValue)
303
+ }
304
+ }, [onInputChange])
305
+
306
+ const defaultFilter = useCallback((opts: T[], input: string) => {
307
+ if (!input) return opts
308
+ return opts.filter(option =>
309
+ renderLabel(option).toLowerCase().includes(input.toLowerCase()),
310
+ )
311
+ }, [renderLabel])
312
+
313
+ const filter = filterOptions ?? defaultFilter
314
+
315
+ const filteredOptions = useMemo(() => filter(options, inputValue), [options, inputValue, filter])
316
+
317
+ const showCreateOption = useMemo(() => {
318
+ if (!creatable || !onCreate || !inputValue.trim()) return false
319
+
320
+ const hasExactMatch = filteredOptions.some(option =>
321
+ renderLabel(option).toLowerCase() === inputValue.toLowerCase(),
322
+ )
323
+
324
+ return !hasExactMatch
325
+ }, [creatable, onCreate, inputValue, filteredOptions, renderLabel])
326
+
327
+ const handleCreate = useCallback(() => {
328
+ if (!onCreate || !inputValue.trim()) return
329
+
330
+ onCreate(inputValue.trim())
331
+ setInputValue('')
332
+
333
+ if (inputRef.current) {
334
+ inputRef.current.focus()
335
+ }
336
+ }, [onCreate, inputValue, setInputValue])
337
+
338
+ const isSelected = useCallback((option: T) => {
339
+ if (multiple) {
340
+ return (value as T[]).some(v => renderKey(v) === renderKey(option))
341
+ }
342
+ return value !== null && renderKey(value as T) === renderKey(option)
343
+ }, [value, multiple, renderKey])
344
+
345
+ const handleSelect = useCallback((option: T) => {
346
+ if (onSelect) onSelect(option)
347
+
348
+ if (multiple) {
349
+ const currentValue = value as T[]
350
+ const isAlreadySelected = currentValue.some(v => renderKey(v) === renderKey(option))
351
+
352
+ if (isAlreadySelected) {
353
+ const newValue = currentValue.filter(v => renderKey(v) !== renderKey(option));
354
+ (onChange as (value: T[]) => void)(newValue)
355
+ } else {
356
+ (onChange as (value: T[]) => void)([...currentValue, option])
357
+ }
358
+
359
+ if (clearOnSelect) {
360
+ setInputValue('')
361
+ }
362
+ } else {
363
+ (onChange as (value: T | null) => void)(option)
364
+ setInputValue(renderLabel(option))
365
+ setOpen(false)
366
+ }
367
+ }, [multiple, value, onChange, renderKey, renderLabel, clearOnSelect, setInputValue, onSelect])
368
+
369
+ const handleRemoveTag = useCallback((optionToRemove: T) => {
370
+ if (!multiple) return
371
+ const newValue = (value as T[]).filter(v => renderKey(v) !== renderKey(optionToRemove));
372
+ (onChange as (value: T[]) => void)(newValue)
373
+ }, [multiple, value, onChange, renderKey])
374
+
375
+ const handleInputChange = (newValue: string) => {
376
+ setInputValue(newValue)
377
+ if (!open && newValue) {
378
+ setOpen(true)
379
+ }
380
+ setHighlightedIndex(autoHighlight ? 0 : -1)
381
+ }
382
+
383
+ const handleFocus = () => {
384
+ setFocused(true)
385
+ if (openOnFocus) {
386
+ setOpen(true)
387
+ }
388
+ }
389
+
390
+ const handleBlur = (e: React.FocusEvent) => {
391
+ if (element.current?.contains(e.relatedTarget as Node)) {
392
+ return
393
+ }
394
+
395
+ setFocused(false)
396
+ setOpen(false)
397
+
398
+ if (freeSolo && inputValue && !multiple) {
399
+ if (creatable && !onCreate) {
400
+ if (getOptionFromInput) {
401
+ const newOption = getOptionFromInput(inputValue.trim());
402
+ (onChange as (value: T | null) => void)(newOption)
403
+ } else {
404
+ (onChange as (value: T | null) => void)(inputValue as unknown as T)
405
+ }
406
+ } else {
407
+ const exactMatch = options.find(o =>
408
+ renderLabel(o).toLowerCase() === inputValue.toLowerCase(),
409
+ )
410
+ if (exactMatch) {
411
+ handleSelect(exactMatch)
412
+ }
413
+ }
414
+ }
415
+ }
416
+
417
+ const handleKeyDown = (e: React.KeyboardEvent) => {
418
+ if (disabled) return
419
+
420
+ switch (e.key) {
421
+ case 'ArrowDown':
422
+ e.preventDefault()
423
+ if (!open) {
424
+ setOpen(true)
425
+ } else {
426
+ setHighlightedIndex(prev =>
427
+ prev < filteredOptions.length - 1 ? prev + 1 : prev,
428
+ )
429
+ }
430
+ break
431
+
432
+ case 'ArrowUp':
433
+ e.preventDefault()
434
+ if (open) {
435
+ setHighlightedIndex(prev => prev > 0 ? prev - 1 : 0)
436
+ }
437
+ break
438
+
439
+ case 'Enter':
440
+ e.preventDefault()
441
+ if (open && highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {
442
+ handleSelect(filteredOptions[highlightedIndex])
443
+ } else if (creatable && inputValue.trim()) {
444
+ if (onCreate) {
445
+ handleCreate()
446
+ } else if (freeSolo && getOptionFromInput) {
447
+ const newOption = getOptionFromInput(inputValue.trim())
448
+ if (multiple) {
449
+ const currentValue = value as T[]
450
+ const isDuplicate = currentValue.some(v => renderKey(v) === renderKey(newOption))
451
+ if (!isDuplicate) {
452
+ (onChange as (value: T[]) => void)([...currentValue, newOption])
453
+ }
454
+ setInputValue('')
455
+ } else {
456
+ (onChange as (value: T | null) => void)(newOption)
457
+ setInputValue(renderLabel(newOption))
458
+ setOpen(false)
459
+ }
460
+ } else if (freeSolo) {
461
+ if (multiple) {
462
+ const currentValue = value as T[]
463
+ const inputAsOption = inputValue as unknown as T
464
+ const isDuplicate = currentValue.some(v => renderLabel(v).toLowerCase() === inputValue.toLowerCase())
465
+ if (!isDuplicate) {
466
+ (onChange as (value: T[]) => void)([...currentValue, inputAsOption])
467
+ }
468
+ setInputValue('')
469
+ } else {
470
+ (onChange as (value: T | null) => void)(inputValue as unknown as T)
471
+ setOpen(false)
472
+ }
473
+ }
474
+ } else if (freeSolo && inputValue && !multiple) {
475
+ const exactMatch = options.find(o =>
476
+ renderLabel(o).toLowerCase() === inputValue.toLowerCase(),
477
+ )
478
+ if (exactMatch) {
479
+ handleSelect(exactMatch)
480
+ }
481
+ }
482
+ break
483
+
484
+ case 'Escape':
485
+ e.preventDefault()
486
+ setOpen(false)
487
+ if (inputRef.current) {
488
+ inputRef.current.blur()
489
+ }
490
+ break
491
+
492
+ case 'Backspace':
493
+ if (multiple && !inputValue && (value as T[]).length > 0) {
494
+ const lastTag = (value as T[])[(value as T[]).length - 1]
495
+ handleRemoveTag(lastTag)
496
+ }
497
+ break
498
+
499
+ default:
500
+ break
501
+ }
502
+ }
503
+
504
+ const handleClear = () => {
505
+ if (multiple) {
506
+ (onChange as (value: T[]) => void)([])
507
+ } else {
508
+ (onChange as (value: T | null) => void)(null)
509
+ }
510
+ setInputValue('')
511
+ if (inputRef.current) {
512
+ inputRef.current.focus()
513
+ }
514
+ }
515
+
516
+ useEffect(() => {
517
+ if (!multiple && value && !focused) {
518
+ setInternalInputValue(renderLabel(value as T))
519
+ }
520
+ }, [value, multiple, renderLabel, focused])
521
+
522
+ useEffect(() => {
523
+ if (autoHighlight && open && filteredOptions.length > 0) {
524
+ setHighlightedIndex(0)
525
+ }
526
+ }, [autoHighlight, open, filteredOptions.length])
527
+
528
+ useEffect(() => {
529
+ if (highlightedIndex < 0 || !open) return
530
+
531
+ const optionsContainer = dropdownRef.current?.querySelector('.options') as HTMLElement
532
+ if (!optionsContainer) return
533
+
534
+ const highlightedOption = optionsContainer.children[highlightedIndex] as HTMLElement
535
+ if (!highlightedOption) return
536
+
537
+ const containerRect = optionsContainer.getBoundingClientRect()
538
+ const optionRect = highlightedOption.getBoundingClientRect()
539
+
540
+ if (optionRect.bottom > containerRect.bottom) {
541
+ highlightedOption.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
542
+ } else if (optionRect.top < containerRect.top) {
543
+ highlightedOption.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
544
+ }
545
+ }, [highlightedIndex, open])
546
+
547
+ useEffect(() => {
548
+ if (!onScrollEnd || !open) return
549
+
550
+ const optionsContainer = dropdownRef.current?.querySelector('.options') as HTMLElement
551
+ if (!optionsContainer) return
552
+
553
+ const handleScroll = () => {
554
+ if (loading) return
555
+
556
+ const { scrollTop, scrollHeight, clientHeight } = optionsContainer
557
+ const scrollBottom = scrollHeight - scrollTop - clientHeight
558
+
559
+ if (scrollBottom <= scrollEndMargin) {
560
+ onScrollEnd()
561
+ }
562
+ }
563
+
564
+ optionsContainer.addEventListener('scroll', handleScroll)
565
+
566
+ handleScroll()
567
+
568
+ return () => {
569
+ optionsContainer.removeEventListener('scroll', handleScroll)
570
+ }
571
+ }, [onScrollEnd, open, filteredOptions.length, loading, scrollEndMargin])
572
+
573
+ const renderTags = () => {
574
+ if (!multiple || (value as T[]).length === 0) return null
575
+
576
+ const tags = value as T[]
577
+ const visibleTags = maxTagsToShow && tags.length > maxTagsToShow
578
+ ? tags.slice(0, maxTagsToShow)
579
+ : tags
580
+ const remainingCount = maxTagsToShow && tags.length > maxTagsToShow
581
+ ? tags.length - maxTagsToShow
582
+ : 0
583
+
584
+ return (
585
+ <>
586
+ {visibleTags.map(tag => {
587
+ if (renderTag) {
588
+ return renderTag(tag, () => handleRemoveTag(tag))
589
+ }
590
+ return (
591
+ <Badge
592
+ key={renderKey(tag)}
593
+ colorScheme={tagColorScheme}
594
+ colorPalette={tagColorPalette}
595
+ appearance={tagAppearance}
596
+ >
597
+ {renderLabel(tag)}
598
+ {!disabled && (
599
+ <IconButton
600
+ icon="Times"
601
+ type="button"
602
+ appearance="none"
603
+ size="xs"
604
+ style={{ color: 'inherit' }}
605
+ onClick={(e) => {
606
+ e.stopPropagation()
607
+ if (!disabled) handleRemoveTag(tag)
608
+ }}
609
+ aria-label={`${t.removeTag} ${renderLabel(tag)}`}
610
+ disabled={disabled}
611
+ tabIndex={0}
612
+ />)}
613
+ </Badge>
614
+ )
615
+ })}
616
+ {remainingCount > 0 && (
617
+ <Badge
618
+ colorScheme={tagColorScheme}
619
+ colorPalette={tagColorPalette}
620
+ appearance={tagAppearance}
621
+ >
622
+ +{remainingCount}
623
+ </Badge>
624
+ )}
625
+ </>
626
+ )
627
+ }
628
+
629
+ const showClearButton = !disabled && (
630
+ (!multiple && value !== null) ||
631
+ (multiple && (value as T[]).length > 0)
632
+ )
633
+
634
+ return (
635
+ <CitricComponent
636
+ tag="div"
637
+ component="autocomplete"
638
+ data-citric="autocomplete"
639
+ style={maxHeight ? applyCSSVariable(style, 'max-height', `${maxHeight}px`) : style}
640
+ className={listToClass([
641
+ className,
642
+ open && 'open',
643
+ focused && 'focused',
644
+ disabled && 'disabled',
645
+ multiple && 'multiple',
646
+ ])}
647
+ ref={element}
648
+ aria-busy={loading}
649
+ {...props}
650
+ >
651
+ <header
652
+ onClick={() => {
653
+ if (disabled) return
654
+ setFocused(true)
655
+ setOpen(true)
656
+ if (inputRef.current) {
657
+ inputRef.current.focus()
658
+ }
659
+ }}
660
+ onFocus={() => setFocused(true)}
661
+ tabIndex={disabled ? undefined : 0}
662
+ >
663
+ <Row gap="4px" className="input-container">
664
+ {multiple && renderTags()}
665
+ <input
666
+ ref={inputRef}
667
+ type="text"
668
+ value={inputValue}
669
+ onChange={(e) => handleInputChange(e.target.value)}
670
+ onFocus={handleFocus}
671
+ onBlur={handleBlur}
672
+ onKeyDown={handleKeyDown}
673
+ disabled={disabled}
674
+ placeholder={multiple && (value as T[]).length > 0 ? '' : placeholder}
675
+ autoComplete="off"
676
+ aria-autocomplete="list"
677
+ aria-expanded={open}
678
+ aria-controls="autocomplete-listbox"
679
+ />
680
+ </Row>
681
+ <div className="end-adornment">
682
+ {loading && <ProgressCircular size="xs" className="loader" />}
683
+ {showClearButton && (
684
+ <IconButton
685
+ icon="Times"
686
+ appearance="none"
687
+ size="sm"
688
+ type="button"
689
+ onClick={(e) => {
690
+ e.stopPropagation()
691
+ handleClear()
692
+ }}
693
+ disabled={disabled}
694
+ aria-label={t.clear}
695
+ tabIndex={1}
696
+ style={{ width: '12px', height: '12px' }}
697
+ />
698
+ )}
699
+ <IconButton
700
+ icon={open ? 'ChevronUp' : 'ChevronDown'}
701
+ appearance="none"
702
+ size="md"
703
+ type="button"
704
+ onClick={(e) => {
705
+ e.stopPropagation()
706
+ setOpen((prev) => !prev)
707
+ }}
708
+ disabled={disabled}
709
+ aria-label={open ? t.collapse : t.expand}
710
+ tabIndex={1}
711
+ style={{ width: '12px', height: '12px' }}
712
+ />
713
+ </div>
714
+ </header>
715
+
716
+ <div
717
+ className="dropdown-panel"
718
+ ref={dropdownRef}
719
+ id="autocomplete-listbox"
720
+ role="listbox"
721
+ aria-hidden={!open}
722
+ {...(open ? {} : { inert: 'true' })}
723
+ >
724
+ {loading && !filteredOptions.length ? (
725
+ <div className="message">{loadingText || t.loading}</div>
726
+ ) : filteredOptions.length === 0 && !showCreateOption ? (
727
+ <div className="message">{noOptionsText || t.noOptions}</div>
728
+ ) : (
729
+ <div className="options">
730
+ {showCreateOption && (
731
+ <div
732
+ key="create-option"
733
+ role="option"
734
+ className="option create-option"
735
+ onMouseDown={(e) => {
736
+ e.preventDefault()
737
+ }}
738
+ onClick={handleCreate}
739
+ onMouseEnter={() => setHighlightedIndex(-1)}
740
+ >
741
+ <i data-citric="icon" className="citric-icon outline Plus" />
742
+ {t.addOption.replace('{value}', inputValue)}
743
+ </div>
744
+ )}
745
+ {filteredOptions.map((option, index) => (
746
+ <div
747
+ key={renderKey(option)}
748
+ role="option"
749
+ aria-selected={isSelected(option)}
750
+ className={listToClass([
751
+ 'option',
752
+ isSelected(option) && 'selected',
753
+ highlightedIndex === index && 'highlighted',
754
+ ])}
755
+ onMouseDown={(e) => {
756
+ e.preventDefault()
757
+ }}
758
+ onClick={() => handleSelect(option)}
759
+ onMouseEnter={() => setHighlightedIndex(index)}
760
+ >
761
+ {multiple && <Checkbox value={isSelected(option)} readOnly />}
762
+ {renderOption ? renderOption(option) : renderLabel(option)}
763
+ </div>
764
+ ))}
765
+ </div>
766
+ )}
767
+ </div>
768
+ </CitricComponent>
769
+ )
770
+ },
771
+ ) as <T, Multiple extends boolean = false>(
772
+ props: AutocompleteProps<T, Multiple>,
773
+ ) => React.ReactElement
774
+
775
+ const dictionary = {
776
+ en: {
777
+ removeTag: 'Remove',
778
+ clear: 'Clear',
779
+ loading: 'Loading...',
780
+ noOptions: 'No options',
781
+ collapse: 'Collapse',
782
+ expand: 'Expand',
783
+ addOption: 'Add "{value}"',
784
+ },
785
+ pt: {
786
+ removeTag: 'Remover',
787
+ clear: 'Limpar',
788
+ loading: 'Carregando...',
789
+ noOptions: 'Sem opções',
790
+ collapse: 'Recolher',
791
+ expand: 'Expandir',
792
+ addOption: 'Adicionar "{value}"',
793
+ },
794
+ }