@stack-spot/citric-react 0.42.0 → 0.43.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.
@@ -0,0 +1,1038 @@
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 CustomSelectedTagsConfig {
16
+ /**
17
+ * Color scheme for the tags (badges).
18
+ */
19
+ colorScheme?: ColorSchemeName,
20
+ /**
21
+ * Color palette for the tags (badges).
22
+ */
23
+ colorPalette?: ColorPaletteName,
24
+ /**
25
+ * Appearance of the tags (badges).
26
+ * @default 'circle'
27
+ */
28
+ appearance?: 'square' | 'circle',
29
+ /**
30
+ * Maximum number of tags to show before displaying "+N more".
31
+ */
32
+ maxItems?: number,
33
+ }
34
+
35
+ export interface BaseAutocompleteProps<T, Multiple extends boolean = false> {
36
+ /**
37
+ * The list of options available for selection.
38
+ */
39
+ options: T[],
40
+ /**
41
+ * The current value(s) selected.
42
+ * - Single selection: T | undefined
43
+ * - Multiple selection: T[]
44
+ */
45
+ value: Multiple extends true ? T[] : (T | undefined),
46
+ /**
47
+ * Callback fired when the value changes (user selects/removes an option).
48
+ * This is the main callback for getting the final selected value(s).
49
+ *
50
+ * @example
51
+ * ```tsx
52
+ * // Single selection
53
+ * <Autocomplete
54
+ * value={user}
55
+ * onChange={(selectedUser) => {
56
+ * console.log('Selected:', selectedUser)
57
+ * setUser(selectedUser)
58
+ * }}
59
+ * />
60
+ *
61
+ * // Multiple selection
62
+ * <Autocomplete
63
+ * multiple
64
+ * value={selectedUsers}
65
+ * onChange={(users) => {
66
+ * console.log('Selected users:', users)
67
+ * setSelectedUsers(users)
68
+ * }}
69
+ * />
70
+ * ```
71
+ */
72
+ onChange: Multiple extends true ? (value: T[]) => void : (value: T | undefined) => void,
73
+ /**
74
+ * If true, enables multiple selection mode.
75
+ * @default false
76
+ */
77
+ multiple?: Multiple,
78
+ /**
79
+ * If true, allows the user to enter values that are not in the options list.
80
+ * @default false
81
+ */
82
+ freeSolo?: boolean,
83
+ /**
84
+ * If true, allows creating new options when no match is found.
85
+ * Shows an "Add [value]" option at the top of the list.
86
+ * @default false
87
+ */
88
+ creatable?: boolean,
89
+ /**
90
+ * Callback fired when a new option is created.
91
+ * Required when creatable is true and you want manual control.
92
+ */
93
+ onCreate?: (inputValue: string) => void,
94
+ /**
95
+ * Function to create a new option object from the input value.
96
+ * Used when creatable is true and onCreate is NOT defined.
97
+ * Allows automatic option creation on Enter key.
98
+ *
99
+ * Note: This prop has no effect when onCreate is defined, as onCreate takes precedence.
100
+ *
101
+ * @param inputValue - The text typed by the user
102
+ * @returns A new option object
103
+ *
104
+ * @example
105
+ * ```tsx
106
+ * // Auto-create tags on Enter
107
+ * <Autocomplete
108
+ * multiple
109
+ * creatable
110
+ * freeSolo
111
+ * getOptionFromInput={(text) => ({
112
+ * id: Date.now(),
113
+ * name: text,
114
+ * isCustom: true
115
+ * })}
116
+ * />
117
+ * ```
118
+ */
119
+ getOptionFromInput?: (inputValue: string) => T,
120
+ /**
121
+ * The input value (controlled mode).
122
+ * Use this when you need full control over the input text.
123
+ * Usually used with onInputChange for controlled components.
124
+ *
125
+ * @example
126
+ * ```tsx
127
+ * const [inputValue, setInputValue] = useState('')
128
+ *
129
+ * <Autocomplete
130
+ * inputValue={inputValue}
131
+ * onInputChange={setInputValue}
132
+ * options={options}
133
+ * />
134
+ * ```
135
+ */
136
+ inputValue?: string,
137
+ /**
138
+ * Callback fired when the input text changes (user types).
139
+ * Use this to control the input value or perform side effects like API calls.
140
+ * Different from onChange which fires when an option is selected.
141
+ *
142
+ * @param value - The current text in the input field
143
+ *
144
+ * @example
145
+ * ```tsx
146
+ * // Debounced API search
147
+ * <Autocomplete
148
+ * onInputChange={(text) => {
149
+ * console.log('User typed:', text)
150
+ * debouncedSearch(text)
151
+ * }}
152
+ * />
153
+ *
154
+ * // Controlled input
155
+ * <Autocomplete
156
+ * inputValue={inputValue}
157
+ * onInputChange={(text) => setInputValue(text.toUpperCase())}
158
+ * />
159
+ * ```
160
+ */
161
+ onInputChange?: (value: string) => void,
162
+ /**
163
+ * A function to render the item label.
164
+ * @default "the item's toString() result."
165
+ */
166
+ renderLabel?: (option: T) => string,
167
+ /**
168
+ * A function to generate a unique key for each option.
169
+ */
170
+ renderKey?: (option: T) => string | number | undefined,
171
+ /**
172
+ * A function to render an option in the dropdown.
173
+ */
174
+ renderOption?: (option: T) => React.ReactNode,
175
+ /**
176
+ * Custom function to render the selected values display area in multiple mode.
177
+ * When defined, gives you full control over how selected values are displayed.
178
+ * The customSelectedTags prop has no effect when this is defined.
179
+ *
180
+ * @param values - Array of selected options
181
+ * @param onRemove - Function to call when user wants to remove an option
182
+ * @returns React element to display selected values
183
+ *
184
+ * @example
185
+ * ```tsx
186
+ * <Autocomplete
187
+ * multiple
188
+ * renderSelected={(values, onRemove) => (
189
+ * <div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
190
+ * {values.map(user => (
191
+ * <div key={user.id} style={{ background: '#e3f2fd', padding: '4px 8px', borderRadius: '12px' }}>
192
+ * <Avatar src={user.avatar} size="xs" />
193
+ * {user.name}
194
+ * <button onClick={() => onRemove(user)}>×</button>
195
+ * </div>
196
+ * ))}
197
+ * </div>
198
+ * )}
199
+ * />
200
+ * ```
201
+ */
202
+ renderSelected?: (values: T[], onRemove: (option: T) => void) => React.ReactElement,
203
+ /**
204
+ * Configuration for the default selected tags appearance in multiple mode.
205
+ * Has no effect when renderSelected is defined.
206
+ *
207
+ * @example
208
+ * ```tsx
209
+ * <Autocomplete
210
+ * multiple
211
+ * customSelectedTags={{
212
+ * colorScheme: 'primary',
213
+ * appearance: 'square',
214
+ * maxItems: 3
215
+ * }}
216
+ * />
217
+ * ```
218
+ */
219
+ customSelectedTags?: CustomSelectedTagsConfig,
220
+ /**
221
+ * Custom filter function for options.
222
+ * When not set, the filter will use the text returned by renderLabel (case-insensitive includes).
223
+ *
224
+ * @param options - The full list of options
225
+ * @param inputValue - The current input text
226
+ * @returns Filtered array of options
227
+ *
228
+ * @example
229
+ * ```tsx
230
+ * // Search by name OR email
231
+ * <Autocomplete
232
+ * filterOptions={(options, input) =>
233
+ * options.filter(user =>
234
+ * user.name.toLowerCase().includes(input.toLowerCase()) ||
235
+ * user.email.toLowerCase().includes(input.toLowerCase())
236
+ * )
237
+ * }
238
+ * />
239
+ * ```
240
+ */
241
+ filterOptions?: (options: T[], inputValue: string) => T[],
242
+ /**
243
+ * If true, shows a loading indicator.
244
+ * @default false
245
+ */
246
+ loading?: boolean,
247
+ /**
248
+ * If true, the component is disabled.
249
+ * @default false
250
+ */
251
+ disabled?: boolean,
252
+ /**
253
+ * Placeholder text for the input.
254
+ */
255
+ placeholder?: string,
256
+ /**
257
+ * Maximum height for the dropdown panel in pixels.
258
+ */
259
+ maxHeight?: number,
260
+ /**
261
+ * If true, automatically highlights the first option.
262
+ * @default false
263
+ */
264
+ autoHighlight?: boolean,
265
+ /**
266
+ * If true, clears the input value when an option is selected.
267
+ * Only applies when multiple is true.
268
+ * When false, the input keeps the text after selection, useful for adding multiple similar items quickly.
269
+ *
270
+ * Note: Adding multiple tags with similar prefixes when clearOnSelect={false}, you can select "React",
271
+ * then easily select "React Native"
272
+ * without retyping "React" from scratch
273
+ * @default true (for multiple mode)
274
+ *
275
+ * @example
276
+ * ```tsx
277
+ * // Clear input after each selection (default)
278
+ * <Autocomplete multiple clearOnSelect />
279
+ *
280
+ * // Keep input text after selection
281
+ * const [tags, setTags] = useState<Tag[]>([])
282
+ *
283
+ * <Autocomplete
284
+ * multiple
285
+ * clearOnSelect={false}
286
+ * value={tags}
287
+ * onChange={setTags}
288
+ * options={availableTags}
289
+ * renderLabel={tag => tag.name}
290
+ * />
291
+ * ```
292
+ */
293
+ clearOnSelect?: boolean,
294
+ /**
295
+ * If true, the popup will open on input focus.
296
+ * @default true
297
+ */
298
+ openOnFocus?: boolean,
299
+ /**
300
+ * Text to display when no options are available.
301
+ */
302
+ noOptionsText?: string,
303
+ /**
304
+ * Text to display when loading.
305
+ */
306
+ loadingText?: string,
307
+ /**
308
+ * Callback fired when the user scrolls to the end of the options list.
309
+ * Useful for implementing infinite scroll/pagination.
310
+ *
311
+ * @example
312
+ * ```tsx
313
+ * <Autocomplete
314
+ * options={options}
315
+ * onScrollEnd={() => fetchMoreOptions()}
316
+ * loading={isFetchingMore}
317
+ * />
318
+ * ```
319
+ */
320
+ onScrollEnd?: () => void,
321
+ /**
322
+ * Margin in pixels before the end of the list to trigger onScrollEnd.
323
+ * @default 200
324
+ */
325
+ scrollEndMargin?: number,
326
+ /**
327
+ * Color scheme for the autocomplete component.
328
+ * Applies the theme's color scheme to the component root.
329
+ */
330
+ colorScheme?: ColorSchemeName,
331
+ /**
332
+ * The id attribute for the input element.
333
+ * Useful for associating with a label element.
334
+ */
335
+ id?: string,
336
+ }
337
+
338
+ export type AutocompleteProps<T, Multiple extends boolean = false> =
339
+ Omit<React.JSX.IntrinsicElements['div'], 'ref' | 'onChange'> &
340
+ BaseAutocompleteProps<T, Multiple>
341
+
342
+ /**
343
+ * A combination of a text input and a dropdown that suggests options as the user types.
344
+ * Supports both single and multiple selection modes, similar to Material-UI Autocomplete.
345
+ *
346
+ * @example
347
+ * Basic usage (single selection):
348
+ * ```tsx
349
+ * const [value, setValue] = useState<Option | null>(null)
350
+ *
351
+ * <Autocomplete
352
+ * options={options}
353
+ * value={value}
354
+ * onChange={setValue}
355
+ * renderLabel={o => o.name}
356
+ * renderKey={o => o.id}
357
+ * />
358
+ * ```
359
+ *
360
+ * @example
361
+ * Multiple selection with tags:
362
+ * ```tsx
363
+ * const [value, setValue] = useState<Option[]>([])
364
+ *
365
+ * <Autocomplete
366
+ * multiple
367
+ * options={options}
368
+ * value={value}
369
+ * onChange={setValue}
370
+ * renderLabel={o => o.name}
371
+ * renderKey={o => o.id}
372
+ * />
373
+ * ```
374
+ *
375
+ * @example
376
+ * Free solo (allow custom values):
377
+ * ```tsx
378
+ * <Autocomplete
379
+ * freeSolo
380
+ * options={options}
381
+ * value={value}
382
+ * onChange={setValue}
383
+ * renderLabel={o => o.name}
384
+ * />
385
+ * ```
386
+ */
387
+ export const Autocomplete = withRef(
388
+ function Autocomplete<T, Multiple extends boolean = false>({
389
+ options,
390
+ value,
391
+ onChange,
392
+ multiple = false as Multiple,
393
+ freeSolo = false,
394
+ creatable = false,
395
+ onCreate,
396
+ getOptionFromInput,
397
+ inputValue: controlledInputValue,
398
+ onInputChange,
399
+ renderLabel = defaultRenderLabel,
400
+ renderKey = defaultRenderKey as (option: T) => string | number,
401
+ renderOption,
402
+ renderSelected,
403
+ customSelectedTags,
404
+ filterOptions,
405
+ loading = false,
406
+ disabled = false,
407
+ placeholder,
408
+ maxHeight,
409
+ autoHighlight = false,
410
+ clearOnSelect = multiple,
411
+ openOnFocus = true,
412
+ noOptionsText,
413
+ loadingText,
414
+ onScrollEnd,
415
+ scrollEndMargin = 200,
416
+ colorScheme,
417
+ id,
418
+ style,
419
+ className,
420
+ ...props
421
+ }: AutocompleteProps<T, Multiple>, ref: React.Ref<HTMLDivElement>) {
422
+ const t = useTranslate(dictionary)
423
+ const _element = useRef<HTMLDivElement | null>(null)
424
+ const inputRef = useRef<HTMLInputElement | null>(null)
425
+ const dropdownRef = useRef<HTMLDivElement | null>(null)
426
+ const isNavigatingWithKeyboard = useRef(false)
427
+ const element = (ref as React.RefObject<HTMLDivElement>) ?? _element
428
+
429
+ const [open, setOpen] = useState(false)
430
+ const [focused, setFocused] = useState(false)
431
+ const [internalInputValue, setInternalInputValue] = useState('')
432
+ const [highlightedIndex, setHighlightedIndex] = useState<number>(-1)
433
+
434
+ useFocusEffect({ element, focused, setFocused, setOpen })
435
+ useDisabledEffect({ disabled, setOpen, setFocused })
436
+
437
+ useEffect(() => {
438
+ if (!open) return
439
+
440
+ const handleClickOutside = (event: MouseEvent) => {
441
+ if (element.current && !element.current.contains(event.target as Node)) {
442
+ setOpen(false)
443
+ setFocused(false)
444
+ }
445
+ }
446
+
447
+ setTimeout(() => {
448
+ document.addEventListener('click', handleClickOutside)
449
+ }, 10)
450
+
451
+ return () => {
452
+ document.removeEventListener('click', handleClickOutside)
453
+ }
454
+ }, [open, element])
455
+
456
+ const inputValue = controlledInputValue ?? internalInputValue
457
+ const setInputValue = useCallback((newValue: string) => {
458
+ if (onInputChange) {
459
+ onInputChange(newValue)
460
+ } else {
461
+ setInternalInputValue(newValue)
462
+ }
463
+ }, [onInputChange])
464
+
465
+ const defaultFilter = useCallback((opts: T[], input: string) => {
466
+ if (!input) return opts
467
+ return opts.filter(option =>
468
+ renderLabel(option)?.toLowerCase()?.includes(input?.toLowerCase()),
469
+ )
470
+ }, [renderLabel])
471
+
472
+ const filter = filterOptions ?? defaultFilter
473
+
474
+ const filteredOptions = useMemo(() => {
475
+ if (!multiple && value && renderLabel(value as T) === inputValue) {
476
+ return options
477
+ }
478
+ return filter(options, inputValue)
479
+ }, [options, inputValue, filter, multiple, value, renderLabel])
480
+
481
+ const showCreateOption = useMemo(() => {
482
+ if (!creatable || !onCreate || !inputValue.trim()) return false
483
+
484
+ const hasExactMatch = filteredOptions.some(option =>
485
+ renderLabel(option).toLowerCase() === inputValue.toLowerCase(),
486
+ )
487
+
488
+ return !hasExactMatch
489
+ }, [creatable, onCreate, inputValue, filteredOptions, renderLabel])
490
+
491
+ const handleCreate = useCallback(() => {
492
+ if (!onCreate || !inputValue.trim()) return
493
+
494
+ onCreate(inputValue.trim())
495
+ setInputValue('')
496
+
497
+ if (inputRef.current) {
498
+ inputRef.current.focus()
499
+ }
500
+ }, [onCreate, inputValue, setInputValue])
501
+
502
+ const isSelected = useCallback((option: T) => {
503
+ if (multiple) {
504
+ return (value as T[]).some(v => renderKey(v) === renderKey(option))
505
+ }
506
+ return value !== null && renderKey(value as T) === renderKey(option)
507
+ }, [value, multiple, renderKey])
508
+
509
+ const handleSelect = useCallback((option: T) => {
510
+ if (multiple) {
511
+ const currentValue = value as T[]
512
+ const isAlreadySelected = currentValue.some(v => renderKey(v) === renderKey(option))
513
+
514
+ if (isAlreadySelected) {
515
+ const newValue = currentValue.filter(v => renderKey(v) !== renderKey(option));
516
+ (onChange as (value: T[]) => void)(newValue)
517
+ } else {
518
+ (onChange as (value: T[]) => void)([...currentValue, option])
519
+ }
520
+
521
+ if (clearOnSelect) {
522
+ setInputValue('')
523
+ }
524
+ } else {
525
+ (onChange as (value: T | null) => void)(option)
526
+ setInputValue(renderLabel(option))
527
+ setOpen(false)
528
+ }
529
+ }, [multiple, value, onChange, renderKey, clearOnSelect, setInputValue, renderLabel])
530
+
531
+ const handleRemoveTag = useCallback((optionToRemove: T) => {
532
+ if (!multiple) return
533
+ const newValue = (value as T[]).filter(v => renderKey(v) !== renderKey(optionToRemove));
534
+ (onChange as (value: T[]) => void)(newValue)
535
+ }, [multiple, value, onChange, renderKey])
536
+
537
+ const handleInputChange = (newValue: string) => {
538
+ setInputValue(newValue)
539
+ if (!open && newValue) {
540
+ setOpen(true)
541
+ }
542
+ setHighlightedIndex(autoHighlight ? 0 : -1)
543
+ }
544
+
545
+ const handleFocus = () => {
546
+ setFocused(true)
547
+ if (openOnFocus) {
548
+ setOpen(true)
549
+
550
+ if (autoHighlight && filteredOptions.length > 0) {
551
+ setHighlightedIndex(0)
552
+ }
553
+ }
554
+ }
555
+
556
+ const handleBlur = (e: React.FocusEvent) => {
557
+ if (element.current?.contains(e.relatedTarget as Node)) {
558
+ return
559
+ }
560
+
561
+ setFocused(false)
562
+ setOpen(false)
563
+
564
+ if (freeSolo && inputValue && !multiple) {
565
+ if (creatable && !onCreate) {
566
+ if (getOptionFromInput) {
567
+ const newOption = getOptionFromInput(inputValue.trim());
568
+ (onChange as (value: T | null) => void)(newOption)
569
+ } else {
570
+ (onChange as (value: T | null) => void)(inputValue as unknown as T)
571
+ }
572
+ } else {
573
+ const exactMatch = options.find(o =>
574
+ renderLabel(o).toLowerCase() === inputValue.toLowerCase(),
575
+ )
576
+ if (exactMatch) {
577
+ handleSelect(exactMatch)
578
+ }
579
+ }
580
+ } else if (!multiple && inputValue) {
581
+ const exactMatch = options.find(o =>
582
+ renderLabel(o).toLowerCase() === inputValue.toLowerCase(),
583
+ )
584
+
585
+ if (exactMatch) {
586
+ handleSelect(exactMatch)
587
+ } else {
588
+ if (value) {
589
+ setInputValue(renderLabel(value as T))
590
+ } else {
591
+ setInputValue('')
592
+ }
593
+ }
594
+ }
595
+ }
596
+
597
+ const handleCreateNewOption = useCallback(() => {
598
+ if (!inputValue.trim()) return false
599
+
600
+ if (onCreate) {
601
+ handleCreate()
602
+ return true
603
+ }
604
+
605
+ if (freeSolo && getOptionFromInput) {
606
+ const newOption = getOptionFromInput(inputValue.trim())
607
+ if (multiple) {
608
+ const currentValue = value as T[]
609
+ const isDuplicate = currentValue.some(v => renderKey(v) === renderKey(newOption))
610
+ if (!isDuplicate) {
611
+ (onChange as (value: T[]) => void)([...currentValue, newOption])
612
+ }
613
+ setInputValue('')
614
+ } else {
615
+ (onChange as (value: T | null) => void)(newOption)
616
+ setInputValue(renderLabel(newOption))
617
+ setOpen(false)
618
+ }
619
+ return true
620
+ }
621
+
622
+ if (freeSolo) {
623
+ if (multiple) {
624
+ const currentValue = value as T[]
625
+ const inputAsOption = inputValue as unknown as T
626
+ const isDuplicate = currentValue.some(v => renderLabel(v).toLowerCase() === inputValue.toLowerCase())
627
+ if (!isDuplicate) {
628
+ (onChange as (value: T[]) => void)([...currentValue, inputAsOption])
629
+ }
630
+ setInputValue('')
631
+ } else {
632
+ (onChange as (value: T | null) => void)(inputValue as unknown as T)
633
+ setOpen(false)
634
+ }
635
+ return true
636
+ }
637
+
638
+ return false
639
+ }, [onCreate, handleCreate, freeSolo, getOptionFromInput, inputValue, multiple, value, renderKey, onChange, setInputValue, renderLabel])
640
+
641
+ const handleEnterKey = useCallback(() => {
642
+ if (open && highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {
643
+ handleSelect(filteredOptions[highlightedIndex])
644
+ return
645
+ }
646
+
647
+ if (!open && filteredOptions.length === 1) {
648
+ handleSelect(filteredOptions[0])
649
+ return
650
+ }
651
+
652
+ if (creatable && handleCreateNewOption()) {
653
+ return
654
+ }
655
+
656
+ if (freeSolo && inputValue && !multiple) {
657
+ const exactMatch = options.find(o =>
658
+ renderLabel(o).toLowerCase() === inputValue.toLowerCase(),
659
+ )
660
+ if (exactMatch) {
661
+ handleSelect(exactMatch)
662
+ }
663
+ }
664
+ }, [
665
+ open,
666
+ highlightedIndex,
667
+ filteredOptions,
668
+ handleSelect,
669
+ creatable,
670
+ handleCreateNewOption,
671
+ freeSolo,
672
+ inputValue,
673
+ multiple,
674
+ options,
675
+ renderLabel,
676
+ ])
677
+
678
+ const handleKeyDown = (e: React.KeyboardEvent) => {
679
+ if (disabled) return
680
+
681
+ switch (e.key) {
682
+ case 'ArrowDown':
683
+ e.preventDefault()
684
+ isNavigatingWithKeyboard.current = true
685
+ if (!open) {
686
+ setOpen(true)
687
+ setHighlightedIndex(0)
688
+ } else {
689
+ setHighlightedIndex(prev =>
690
+ prev < filteredOptions.length - 1 ? prev + 1 : prev,
691
+ )
692
+ }
693
+ break
694
+
695
+ case 'ArrowUp':
696
+ e.preventDefault()
697
+ isNavigatingWithKeyboard.current = true
698
+ if (open) {
699
+ setHighlightedIndex(prev => prev > 0 ? prev - 1 : 0)
700
+ }
701
+ break
702
+
703
+ case 'Enter':
704
+ e.preventDefault()
705
+ handleEnterKey()
706
+ break
707
+
708
+ case 'Escape':
709
+ e.preventDefault()
710
+ setOpen(false)
711
+ if (inputRef.current) {
712
+ inputRef.current.blur()
713
+ }
714
+ break
715
+
716
+ case 'Backspace':
717
+ if (multiple && !inputValue && (value as T[]).length > 0) {
718
+ const lastTag = (value as T[])[(value as T[]).length - 1]
719
+ handleRemoveTag(lastTag)
720
+ } else if (!multiple && !inputValue && value) {
721
+ (onChange as (value: T | null) => void)(null)
722
+ }
723
+ break
724
+
725
+ default:
726
+ break
727
+ }
728
+ }
729
+
730
+ const handleClear = () => {
731
+ if (multiple) {
732
+ (onChange as (value: T[]) => void)([])
733
+ } else {
734
+ (onChange as (value: T | null) => void)(null)
735
+ }
736
+ setInputValue('')
737
+ if (inputRef.current) {
738
+ inputRef.current.focus()
739
+ }
740
+ }
741
+
742
+ const handleFocusAndOpen = () => {
743
+ if (disabled) return
744
+ setFocused(true)
745
+ inputRef.current?.focus()
746
+ if (openOnFocus) {
747
+ setOpen(true)
748
+ }
749
+ }
750
+
751
+ useEffect(() => {
752
+ if (highlightedIndex < 0 || !open) return
753
+
754
+ const optionsContainer = dropdownRef.current?.querySelector('.options') as HTMLElement
755
+ if (!optionsContainer) return
756
+
757
+ const highlightedOption = optionsContainer.children[highlightedIndex] as HTMLElement
758
+ if (!highlightedOption) return
759
+
760
+ const containerRect = optionsContainer.getBoundingClientRect()
761
+ const optionRect = highlightedOption.getBoundingClientRect()
762
+
763
+ if (optionRect.bottom > containerRect.bottom) {
764
+ highlightedOption.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
765
+ } else if (optionRect.top < containerRect.top) {
766
+ highlightedOption.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
767
+ }
768
+ }, [highlightedIndex, open])
769
+
770
+ useEffect(() => {
771
+ if (!onScrollEnd || !open) return
772
+
773
+ const optionsContainer = dropdownRef.current?.querySelector('.options') as HTMLElement
774
+ if (!optionsContainer) return
775
+
776
+ const handleScroll = () => {
777
+ if (loading) return
778
+
779
+ const { scrollTop, scrollHeight, clientHeight } = optionsContainer
780
+ const scrollBottom = scrollHeight - scrollTop - clientHeight
781
+
782
+ if (scrollBottom <= scrollEndMargin) {
783
+ onScrollEnd()
784
+ }
785
+ }
786
+
787
+ optionsContainer.addEventListener('scroll', handleScroll)
788
+
789
+ handleScroll()
790
+
791
+ return () => {
792
+ optionsContainer.removeEventListener('scroll', handleScroll)
793
+ }
794
+ }, [onScrollEnd, open, filteredOptions.length, loading, scrollEndMargin])
795
+
796
+ const renderTags = () => {
797
+ if (!multiple || (value as T[]).length === 0) return null
798
+
799
+ const tags = value as T[]
800
+
801
+ if (renderSelected) {
802
+ return renderSelected(tags, handleRemoveTag)
803
+ }
804
+
805
+ const config = customSelectedTags || {}
806
+ const maxItems = config.maxItems
807
+ const visibleTags = maxItems && tags.length > maxItems
808
+ ? tags.slice(0, maxItems)
809
+ : tags
810
+ const remainingCount = maxItems && tags.length > maxItems
811
+ ? tags.length - maxItems
812
+ : 0
813
+
814
+ return (
815
+ <>
816
+ {visibleTags.map(tag => (
817
+ <Badge
818
+ key={renderKey(tag)}
819
+ colorScheme={config.colorScheme}
820
+ colorPalette={config.colorPalette}
821
+ appearance={config.appearance || 'circle'}
822
+ >
823
+ {renderLabel(tag)}
824
+ {!disabled && (
825
+ <IconButton
826
+ icon="Times"
827
+ type="button"
828
+ appearance="none"
829
+ size="xs"
830
+ style={{ color: 'inherit' }}
831
+ onClick={(e) => {
832
+ e.stopPropagation()
833
+ if (!disabled) handleRemoveTag(tag)
834
+ }}
835
+ aria-label={`${t.removeTag} ${renderLabel(tag)}`}
836
+ disabled={disabled}
837
+ tabIndex={0}
838
+ />
839
+ )}
840
+ </Badge>
841
+ ))}
842
+ {remainingCount > 0 && (
843
+ <Badge
844
+ colorScheme={config.colorScheme}
845
+ colorPalette={config.colorPalette}
846
+ appearance={config.appearance || 'circle'}
847
+ >
848
+ +{remainingCount}
849
+ </Badge>
850
+ )}
851
+ </>
852
+ )
853
+ }
854
+
855
+ const showClearButton = !disabled && (
856
+ (!multiple && value !== null) ||
857
+ (multiple && (value as T[]).length > 0)
858
+ )
859
+
860
+ return (
861
+ <CitricComponent
862
+ tag="div"
863
+ component="autocomplete"
864
+ colorScheme={colorScheme}
865
+ style={maxHeight ? applyCSSVariable(style, 'max-height', `${maxHeight}px`) : style}
866
+ className={listToClass([
867
+ className,
868
+ open && 'open',
869
+ focused && 'focused',
870
+ disabled && 'disabled',
871
+ multiple && 'multiple',
872
+ ])}
873
+ ref={element}
874
+ aria-busy={loading}
875
+ {...props}
876
+ >
877
+ <header
878
+ tabIndex={disabled ? undefined : 0}
879
+ onClick={handleFocusAndOpen}
880
+ onFocus={handleFocusAndOpen}
881
+ onKeyDown={handleKeyDown}
882
+ >
883
+ <Row gap="4px" className="input-container">
884
+ {multiple && renderTags()}
885
+ <input
886
+ ref={inputRef}
887
+ id={id}
888
+ type="text"
889
+ value={inputValue}
890
+ onChange={(e) => handleInputChange(e.target.value)}
891
+ onFocus={handleFocus}
892
+ onBlur={handleBlur}
893
+ disabled={disabled}
894
+ placeholder={(multiple && (value as T[]).length > 0) ? '' : placeholder}
895
+ tabIndex={disabled ? undefined : 0}
896
+ autoComplete="off"
897
+ aria-autocomplete="list"
898
+ aria-expanded={open}
899
+ aria-controls="autocomplete-listbox"
900
+ />
901
+ </Row>
902
+ <div className="end-adornment">
903
+ {loading && <ProgressCircular size="xs" className="loader" />}
904
+ {showClearButton && (
905
+ <IconButton
906
+ icon="Times"
907
+ appearance="none"
908
+ size="sm"
909
+ type="button"
910
+ onClick={(e) => {
911
+ e.stopPropagation()
912
+ e.preventDefault()
913
+ handleClear()
914
+ }}
915
+ onMouseDown={(e) => {
916
+ e.stopPropagation()
917
+ e.preventDefault()
918
+ }}
919
+ onFocus={(e) => {
920
+ e.stopPropagation()
921
+ }}
922
+ disabled={disabled}
923
+ aria-label={t.clear}
924
+ tabIndex={0}
925
+ style={{ width: '12px', height: '12px' }}
926
+ />
927
+ )}
928
+ <IconButton
929
+ icon={open ? 'ChevronUp' : 'ChevronDown'}
930
+ appearance="none"
931
+ size="md"
932
+ type="button"
933
+ onClick={(e) => {
934
+ e.stopPropagation()
935
+ e.preventDefault()
936
+ setOpen((prev) => !prev)
937
+ }}
938
+ onMouseDown={(e) => {
939
+ e.stopPropagation()
940
+ e.preventDefault()
941
+ }}
942
+ onFocus={(e) => {
943
+ e.stopPropagation()
944
+ }}
945
+ disabled={disabled}
946
+ aria-label={open ? t.collapse : t.expand}
947
+ tabIndex={0}
948
+ style={{ width: '12px', height: '12px' }}
949
+ />
950
+ </div>
951
+ </header>
952
+
953
+ <div
954
+ className="dropdown-panel"
955
+ ref={dropdownRef}
956
+ id="autocomplete-listbox"
957
+ role="listbox"
958
+ aria-hidden={!open}
959
+ onMouseMove={() => {
960
+ isNavigatingWithKeyboard.current = false
961
+ }}
962
+ {...(open ? {} : { inert: 'true' })}
963
+ >
964
+ {loading && !filteredOptions.length ? (
965
+ <div className="message">{loadingText || t.loading}</div>
966
+ ) : filteredOptions.length === 0 && !showCreateOption && !freeSolo ? (
967
+ <div className="message">{noOptionsText || t.noOptions}</div>
968
+ ) : (
969
+ <div className="options">
970
+ {showCreateOption && (
971
+ <div
972
+ key="create-option"
973
+ role="option"
974
+ className="option create-option"
975
+ onMouseDown={(e) => {
976
+ e.preventDefault()
977
+ }}
978
+ onClick={handleCreate}
979
+ onMouseEnter={() => setHighlightedIndex(-1)}
980
+ >
981
+ <i data-citric="icon" className="citric-icon outline Plus" />
982
+ {t.addOption.replace('{value}', inputValue)}
983
+ </div>
984
+ )}
985
+ {filteredOptions.map((option, index) => (
986
+ <div
987
+ key={renderKey(option)}
988
+ role="option"
989
+ aria-selected={isSelected(option)}
990
+ className={listToClass([
991
+ 'option',
992
+ isSelected(option) && 'selected',
993
+ highlightedIndex === index && 'highlighted',
994
+ ])}
995
+ onMouseDown={(e) => {
996
+ e.preventDefault()
997
+ }}
998
+ onClick={() => handleSelect(option)}
999
+ onMouseEnter={() => {
1000
+ if (!isNavigatingWithKeyboard.current) {
1001
+ setHighlightedIndex(index)
1002
+ }
1003
+ }}
1004
+ >
1005
+ {multiple && <Checkbox value={isSelected(option)} readOnly />}
1006
+ {renderOption ? renderOption(option) : renderLabel(option)}
1007
+ </div>
1008
+ ))}
1009
+ </div>
1010
+ )}
1011
+ </div>
1012
+ </CitricComponent>
1013
+ )
1014
+ },
1015
+ ) as <T, Multiple extends boolean = false>(
1016
+ props: AutocompleteProps<T, Multiple>,
1017
+ ) => React.ReactElement
1018
+
1019
+ const dictionary = {
1020
+ en: {
1021
+ removeTag: 'Remove',
1022
+ clear: 'Clear',
1023
+ loading: 'Loading...',
1024
+ noOptions: 'No options',
1025
+ collapse: 'Collapse',
1026
+ expand: 'Expand',
1027
+ addOption: 'Add "{value}"',
1028
+ },
1029
+ pt: {
1030
+ removeTag: 'Remover',
1031
+ clear: 'Limpar',
1032
+ loading: 'Carregando...',
1033
+ noOptions: 'Sem opções',
1034
+ collapse: 'Recolher',
1035
+ expand: 'Expandir',
1036
+ addOption: 'Adicionar "{value}"',
1037
+ },
1038
+ }