@justin_evo/evo-ui 1.2.0 → 1.2.1

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 (77) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +70 -70
  3. package/dist/declarations.d.ts +6 -6
  4. package/package.json +52 -52
  5. package/src/Alert/Alert.tsx +49 -49
  6. package/src/AutoComplete/AutoComplete.tsx +810 -810
  7. package/src/Badge/Badge.tsx +53 -53
  8. package/src/Breadcrumb/Breadcrumb.tsx +53 -53
  9. package/src/Button/Button.tsx +125 -125
  10. package/src/Card/Card.tsx +257 -257
  11. package/src/Checkbox/Checkbox.tsx +59 -59
  12. package/src/CommandPalette/CommandPalette.tsx +185 -185
  13. package/src/Container/Container.tsx +31 -31
  14. package/src/Divider/Divider.tsx +31 -31
  15. package/src/Form/Form.tsx +185 -185
  16. package/src/Grid/Grid.tsx +66 -66
  17. package/src/ImageCropper/ImageCropper.tsx +911 -911
  18. package/src/Input/Input.tsx +74 -74
  19. package/src/Modal/Modal.tsx +77 -77
  20. package/src/Nav/Nav.tsx +708 -708
  21. package/src/Notification/Notification.tsx +1503 -1503
  22. package/src/Pagination/Pagination.tsx +76 -76
  23. package/src/Radio/Radio.tsx +69 -69
  24. package/src/RichTextArea/RichTextArea.tsx +886 -886
  25. package/src/Select/Select.tsx +515 -515
  26. package/src/Skeleton/Skeleton.tsx +70 -70
  27. package/src/Stack/Stack.tsx +52 -52
  28. package/src/Table/Table.tsx +335 -335
  29. package/src/Tabs/Tabs.tsx +90 -90
  30. package/src/Theme/ThemeProvider.tsx +253 -253
  31. package/src/Theme/ThemeToggle.tsx +79 -79
  32. package/src/Toggle/Toggle.tsx +48 -48
  33. package/src/Tooltip/Tooltip.tsx +38 -38
  34. package/src/TopNav/TopNav.tsx +1163 -1163
  35. package/src/TreeSelect/TreeSelect.tsx +825 -825
  36. package/src/css/alert.module.scss +93 -93
  37. package/src/css/autocomplete.module.scss +416 -416
  38. package/src/css/badge.module.scss +82 -82
  39. package/src/css/base/_color.scss +159 -159
  40. package/src/css/base/_theme.scss +237 -237
  41. package/src/css/base/_variables.scss +161 -161
  42. package/src/css/breadcrumb.module.scss +50 -50
  43. package/src/css/button.module.scss +385 -385
  44. package/src/css/card.module.scss +217 -217
  45. package/src/css/checkbox.module.scss +123 -123
  46. package/src/css/commandpalette.module.scss +211 -211
  47. package/src/css/container.module.scss +18 -18
  48. package/src/css/divider.module.scss +41 -41
  49. package/src/css/form.module.scss +245 -245
  50. package/src/css/imagecropper.module.scss +397 -397
  51. package/src/css/input.module.scss +89 -89
  52. package/src/css/modal.module.scss +105 -105
  53. package/src/css/nav.module.scss +494 -494
  54. package/src/css/notification.module.scss +691 -691
  55. package/src/css/pagination.module.scss +63 -63
  56. package/src/css/radio.module.scss +89 -89
  57. package/src/css/richtextarea.module.scss +307 -307
  58. package/src/css/select.module.scss +525 -525
  59. package/src/css/skeleton.module.scss +30 -30
  60. package/src/css/table.module.scss +386 -386
  61. package/src/css/tabs.module.scss +63 -63
  62. package/src/css/theme-toggle.module.scss +83 -83
  63. package/src/css/toggle.module.scss +54 -54
  64. package/src/css/tooltip.module.scss +97 -97
  65. package/src/css/topnav.module.scss +568 -568
  66. package/src/css/treeselect.module.scss +558 -558
  67. package/src/css/utilities/_borders.scss +111 -111
  68. package/src/css/utilities/_colors.scss +66 -66
  69. package/src/css/utilities/_effects.scss +216 -216
  70. package/src/css/utilities/_layout.scss +181 -181
  71. package/src/css/utilities/_position.scss +75 -75
  72. package/src/css/utilities/_sizing.scss +138 -138
  73. package/src/css/utilities/_spacing.scss +99 -99
  74. package/src/css/utilities/_typography.scss +121 -121
  75. package/src/css/utilities/index.scss +24 -24
  76. package/src/declarations.d.ts +6 -6
  77. package/src/index.ts +60 -60
@@ -1,810 +1,810 @@
1
- import React, {
2
- forwardRef,
3
- useCallback,
4
- useEffect,
5
- useId,
6
- useMemo,
7
- useRef,
8
- useState,
9
- } from 'react';
10
- import styles from '../css/autocomplete.module.scss';
11
-
12
- /* =====================================================================
13
- * EvoAutoComplete — editable combobox with list autocomplete.
14
- *
15
- * Research / design decisions (see CLAUDE.md §2 "research"):
16
- * - MUI Autocomplete → got the controlled `value` + `inputValue` split
17
- * right; we keep it. Got bundle size wrong; we stay zero-runtime CSS.
18
- * - Downshift / Headless UI → got strict WAI-ARIA combobox right
19
- * (aria-activedescendant, focus stays on the input); we follow it.
20
- * - Mantine → CSS Modules, no runtime styling; we match.
21
- * - react-select → rich features but degrades on big lists; we cap
22
- * rendered rows with `maxResults`.
23
- * - react-autosuggest → unmaintained; avoid its tiny API surface.
24
- *
25
- * Two things NONE of the 10 surveyed libraries ship — and every team
26
- * re-builds by hand — so we make them first-class props:
27
- * 1. `smartRecovery` — when a query returns zero results, compute the
28
- * nearest option by edit distance and offer a one-click correction
29
- * ("Did you mean…?") instead of a dead-end empty state.
30
- * 2. `recents` — built-in recent-selection memory with a pluggable
31
- * storage adapter (in-memory by default, opt into localStorage via
32
- * `evoLocalRecents`). Surfaces a "Recent" group on empty focus.
33
- * ===================================================================== */
34
-
35
- export interface AutoCompleteOption {
36
- /** Stable, unique identifier returned by `onChange`. */
37
- value: string;
38
- /** Human-readable text shown in the list and the input. */
39
- label: string;
40
- /** Optional secondary line under the label. */
41
- description?: string;
42
- /** Optional leading icon. */
43
- icon?: React.ReactNode;
44
- /** When true, the option is not selectable or keyboard-reachable. */
45
- disabled?: boolean;
46
- }
47
-
48
- /** Pluggable persistence layer for the `recents` feature. */
49
- export interface RecentsStorage {
50
- /** Return the stored option values, most-recent first. */
51
- get: () => string[];
52
- /** Persist the option values, most-recent first. */
53
- set: (values: string[]) => void;
54
- }
55
-
56
- export interface RecentsConfig {
57
- /** Max remembered selections. Default 5. */
58
- max?: number;
59
- /** Where recents live. Default: in-memory (per `id`/`name`). */
60
- storage?: RecentsStorage;
61
- /** Section heading shown above recents. Default "Recent". */
62
- label?: string;
63
- }
64
-
65
- export interface EvoAutoCompleteProps
66
- extends Omit<
67
- React.InputHTMLAttributes<HTMLInputElement>,
68
- 'size' | 'onChange' | 'value' | 'defaultValue'
69
- > {
70
- /** The options to match against. In async mode, the already-filtered set. */
71
- options: AutoCompleteOption[];
72
-
73
- /** Selected option value (controlled). */
74
- value?: string | null;
75
- /** Selected option value (uncontrolled initial). */
76
- defaultValue?: string | null;
77
- /** Fires when a selection is made or cleared. */
78
- onChange?: (value: string | null, option: AutoCompleteOption | null) => void;
79
-
80
- /** The input's text (controlled — pair with `onInputChange`). */
81
- inputValue?: string;
82
- /** Fires when the typed text changes. Debounced by `debounce`. */
83
- onInputChange?: (query: string) => void;
84
-
85
- /** Field label. */
86
- label?: string;
87
- placeholder?: string;
88
- helperText?: string;
89
- /** Error message — also flips the field into the error state. */
90
- error?: string;
91
- size?: 'sm' | 'md' | 'lg';
92
- fullWidth?: boolean;
93
- disabled?: boolean;
94
- /** Show a clear (✕) button when there is a value. Default true. */
95
- clearable?: boolean;
96
-
97
- /** Render a spinner row instead of results (for async fetches). */
98
- loading?: boolean;
99
- /** Debounce, in ms, applied to `onInputChange`. Default 0. */
100
- debounce?: number;
101
- /** Minimum query length before results show. Default 0. */
102
- minChars?: number;
103
- /** Cap on rendered rows — protects against huge lists. Default 50. */
104
- maxResults?: number;
105
- /**
106
- * Filtering strategy:
107
- * - omitted → case-insensitive substring match on `label`.
108
- * - function → custom predicate.
109
- * - `false` → caller pre-filters (async mode); render `options` as-is.
110
- */
111
- filter?: false | ((option: AutoCompleteOption, query: string) => boolean);
112
- /** Bold the matched substring in each row. Default true. */
113
- highlightMatch?: boolean;
114
- /** Open the menu when the input receives focus. Default true. */
115
- openOnFocus?: boolean;
116
- /** Message shown when a non-empty query has no matches. */
117
- emptyMessage?: React.ReactNode;
118
-
119
- /** 🆕 Offer a nearest-match correction when a query has zero results. */
120
- smartRecovery?: boolean;
121
- /** 🆕 Remember recent selections and surface them on empty focus. */
122
- recents?: boolean | RecentsConfig;
123
-
124
- /** Custom row renderer. Overrides icon/label/description layout. */
125
- renderOption?: (
126
- option: AutoCompleteOption,
127
- state: { active: boolean; query: string },
128
- ) => React.ReactNode;
129
-
130
- id?: string;
131
- name?: string;
132
- className?: string;
133
- }
134
-
135
- /* ---------------- Icons ---------------- */
136
- const SearchIcon = () => (
137
- <svg viewBox="0 0 16 16" width="14" height="14" fill="none" aria-hidden="true">
138
- <circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.5" />
139
- <path d="M10.5 10.5L13 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
140
- </svg>
141
- );
142
- const ClearIcon = () => (
143
- <svg viewBox="0 0 16 16" width="12" height="12" fill="none" aria-hidden="true">
144
- <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
145
- </svg>
146
- );
147
- const CheckIcon = () => (
148
- <svg viewBox="0 0 16 16" width="14" height="14" fill="none" aria-hidden="true">
149
- <path d="M3.5 8.5l3 3 6-7" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
150
- </svg>
151
- );
152
- const ClockIcon = () => (
153
- <svg viewBox="0 0 16 16" width="13" height="13" fill="none" aria-hidden="true">
154
- <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.5" />
155
- <path d="M8 4.5V8l2.5 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
156
- </svg>
157
- );
158
- const SparkIcon = () => (
159
- <svg viewBox="0 0 16 16" width="13" height="13" fill="none" aria-hidden="true">
160
- <path
161
- d="M8 1.5l1.6 4.9 4.9 1.6-4.9 1.6L8 14.5l-1.6-4.9L1.5 8l4.9-1.6L8 1.5z"
162
- stroke="currentColor"
163
- strokeWidth="1.4"
164
- strokeLinejoin="round"
165
- />
166
- </svg>
167
- );
168
- const SpinnerIcon = () => (
169
- <svg viewBox="0 0 16 16" width="14" height="14" fill="none" aria-hidden="true" className={styles.spinner}>
170
- <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="2" strokeOpacity="0.25" />
171
- <path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
172
- </svg>
173
- );
174
-
175
- /* =====================================================================
176
- * Recents storage adapters
177
- * ===================================================================== */
178
-
179
- /** Process-lifetime in-memory store, keyed so each field keeps its own. */
180
- const memoryStore = new Map<string, string[]>();
181
- const memoryRecents = (key: string): RecentsStorage => ({
182
- get: () => memoryStore.get(key) ?? [],
183
- set: (values) => {
184
- memoryStore.set(key, values);
185
- },
186
- });
187
-
188
- /**
189
- * A `RecentsStorage` backed by `localStorage` — survives reloads.
190
- * SSR-safe: no-ops when `window` is unavailable.
191
- *
192
- * @example
193
- * <EvoAutoComplete recents={{ storage: evoLocalRecents('country-picker') }} />
194
- */
195
- export const evoLocalRecents = (key: string): RecentsStorage => ({
196
- get: () => {
197
- if (typeof window === 'undefined') return [];
198
- try {
199
- const raw = window.localStorage.getItem(`evo-recents:${key}`);
200
- const parsed = raw ? JSON.parse(raw) : [];
201
- return Array.isArray(parsed) ? parsed.filter((v) => typeof v === 'string') : [];
202
- } catch {
203
- return [];
204
- }
205
- },
206
- set: (values) => {
207
- if (typeof window === 'undefined') return;
208
- try {
209
- window.localStorage.setItem(`evo-recents:${key}`, JSON.stringify(values));
210
- } catch {
211
- /* quota / privacy mode — fail silently */
212
- }
213
- },
214
- });
215
-
216
- /* =====================================================================
217
- * Smart Recovery — nearest-match via Levenshtein edit distance.
218
- * Zero-dependency (CLAUDE.md §7 forbids new runtime deps).
219
- * ===================================================================== */
220
-
221
- function editDistance(a: string, b: string): number {
222
- const m = a.length;
223
- const n = b.length;
224
- if (m === 0) return n;
225
- if (n === 0) return m;
226
- let prev = Array.from({ length: n + 1 }, (_, i) => i);
227
- let curr = new Array<number>(n + 1);
228
- for (let i = 1; i <= m; i++) {
229
- curr[0] = i;
230
- for (let j = 1; j <= n; j++) {
231
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
232
- curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
233
- }
234
- [prev, curr] = [curr, prev];
235
- }
236
- return prev[n];
237
- }
238
-
239
- interface Recovery {
240
- option: AutoCompleteOption;
241
- distance: number;
242
- }
243
-
244
- /** Find the closest option to `query`, or null if nothing is close enough. */
245
- function findNearest(options: AutoCompleteOption[], query: string): Recovery | null {
246
- const q = query.trim().toLowerCase();
247
- if (q.length < 2) return null;
248
- let best: AutoCompleteOption | null = null;
249
- let bestD = Infinity;
250
- for (const o of options) {
251
- if (o.disabled) continue;
252
- const label = o.label.toLowerCase();
253
- // Compare against the full label AND the label trimmed to the query
254
- // length — a typo in a long label shouldn't be drowned out.
255
- const d = Math.min(editDistance(q, label), editDistance(q, label.slice(0, q.length)));
256
- if (d < bestD) {
257
- bestD = d;
258
- best = o;
259
- }
260
- }
261
- if (!best) return null;
262
- // Only suggest when the fix is small: ~40% of the query length, 1–3 edits.
263
- const threshold = Math.max(1, Math.min(3, Math.round(q.length * 0.4)));
264
- return bestD <= threshold ? { option: best, distance: bestD } : null;
265
- }
266
-
267
- /* ---------------- Match highlighting ---------------- */
268
- function highlightLabel(label: string, query: string): React.ReactNode {
269
- const q = query.trim();
270
- if (!q) return label;
271
- const idx = label.toLowerCase().indexOf(q.toLowerCase());
272
- if (idx < 0) return label;
273
- return (
274
- <>
275
- {label.slice(0, idx)}
276
- <mark className={styles.mark}>{label.slice(idx, idx + q.length)}</mark>
277
- {label.slice(idx + q.length)}
278
- </>
279
- );
280
- }
281
-
282
- /* =====================================================================
283
- * Component
284
- * ===================================================================== */
285
-
286
- export const EvoAutoComplete = forwardRef<HTMLInputElement, EvoAutoCompleteProps>(
287
- (
288
- {
289
- options,
290
- value: controlledValue,
291
- defaultValue = null,
292
- onChange,
293
- inputValue: controlledInput,
294
- onInputChange,
295
- label,
296
- placeholder = 'Search…',
297
- helperText,
298
- error,
299
- size = 'md',
300
- fullWidth = false,
301
- disabled = false,
302
- clearable = true,
303
- loading = false,
304
- debounce = 0,
305
- minChars = 0,
306
- maxResults = 50,
307
- filter,
308
- highlightMatch = true,
309
- openOnFocus = true,
310
- emptyMessage = 'No results',
311
- smartRecovery = false,
312
- recents = false,
313
- renderOption,
314
- id,
315
- name,
316
- className = '',
317
- ...rest
318
- },
319
- ref,
320
- ) => {
321
- const reactId = useId();
322
- const acId = id ?? `evo-autocomplete-${reactId}`;
323
- const listId = `${acId}-listbox`;
324
-
325
- /* ---- value (selected option) ---- */
326
- const isValueControlled = controlledValue !== undefined;
327
- const [internalValue, setInternalValue] = useState<string | null>(defaultValue);
328
- const value = isValueControlled ? controlledValue! : internalValue;
329
- const selectedOption = useMemo(
330
- () => options.find((o) => o.value === value) ?? null,
331
- [options, value],
332
- );
333
-
334
- /* ---- input text ---- */
335
- const isInputControlled = controlledInput !== undefined;
336
- const [internalInput, setInternalInput] = useState(
337
- () => options.find((o) => o.value === defaultValue)?.label ?? '',
338
- );
339
- const inputText = isInputControlled ? controlledInput! : internalInput;
340
-
341
- /* ---- recents config ---- */
342
- const recentsCfg: Required<RecentsConfig> | null = useMemo(() => {
343
- if (!recents) return null;
344
- const base: Required<RecentsConfig> = {
345
- max: 5,
346
- storage: memoryRecents(name ?? acId),
347
- label: 'Recent',
348
- };
349
- return typeof recents === 'object' ? { ...base, ...recents } : base;
350
- }, [recents, name, acId]);
351
-
352
- const [recentValues, setRecentValues] = useState<string[]>(
353
- () => recentsCfg?.storage.get() ?? [],
354
- );
355
-
356
- /* ---- open / active descendant ---- */
357
- const [open, setOpen] = useState(false);
358
- const [activeIdx, setActiveIdx] = useState(0);
359
-
360
- const wrapperRef = useRef<HTMLDivElement>(null);
361
- const inputRef = useRef<HTMLInputElement>(null);
362
- const listRef = useRef<HTMLDivElement>(null);
363
- const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
364
-
365
- /* Merge the forwarded ref with our internal one. */
366
- const setInputRef = useCallback(
367
- (node: HTMLInputElement | null) => {
368
- inputRef.current = node;
369
- if (typeof ref === 'function') ref(node);
370
- else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = node;
371
- },
372
- [ref],
373
- );
374
-
375
- /* ---- derive what the menu shows ---- */
376
- const query = inputText;
377
- const trimmed = query.trim();
378
- const belowMin = trimmed.length > 0 && trimmed.length < minChars;
379
-
380
- const showRecents =
381
- !!recentsCfg && trimmed.length === 0 && recentValues.length > 0;
382
-
383
- const recentOptions = useMemo(() => {
384
- if (!showRecents) return [];
385
- return recentValues
386
- .map((v) => options.find((o) => o.value === v))
387
- .filter((o): o is AutoCompleteOption => !!o && !o.disabled)
388
- .slice(0, recentsCfg!.max);
389
- }, [showRecents, recentValues, options, recentsCfg]);
390
-
391
- const filteredOptions = useMemo(() => {
392
- if (belowMin) return [];
393
- if (filter === false || trimmed.length === 0) {
394
- return options.slice(0, maxResults);
395
- }
396
- const predicate =
397
- typeof filter === 'function'
398
- ? filter
399
- : (o: AutoCompleteOption, q: string) =>
400
- o.label.toLowerCase().includes(q.toLowerCase());
401
- return options.filter((o) => predicate(o, trimmed)).slice(0, maxResults);
402
- }, [options, filter, trimmed, belowMin, maxResults]);
403
-
404
- const results = showRecents ? recentOptions : filteredOptions;
405
-
406
- const recovery: Recovery | null = useMemo(() => {
407
- if (!smartRecovery || loading || belowMin) return null;
408
- if (showRecents || results.length > 0 || trimmed.length === 0) return null;
409
- return findNearest(options, trimmed);
410
- }, [smartRecovery, loading, belowMin, showRecents, results.length, trimmed, options]);
411
-
412
- /* The keyboard-navigable set: the recovery suggestion, else the results. */
413
- const navItems = recovery ? [recovery.option] : results;
414
-
415
- /* ---- close on outside click ---- */
416
- useEffect(() => {
417
- if (!open) return;
418
- const handler = (e: MouseEvent) => {
419
- if (!wrapperRef.current?.contains(e.target as Node)) closeMenu();
420
- };
421
- document.addEventListener('mousedown', handler);
422
- return () => document.removeEventListener('mousedown', handler);
423
- // eslint-disable-next-line react-hooks/exhaustive-deps
424
- }, [open]);
425
-
426
- /* ---- keep the active row in view ---- */
427
- useEffect(() => {
428
- if (!open) return;
429
- const el = listRef.current?.querySelector(
430
- `[data-idx="${activeIdx}"]`,
431
- ) as HTMLElement | null;
432
- el?.scrollIntoView({ block: 'nearest' });
433
- }, [activeIdx, open]);
434
-
435
- /* ---- sync input text when value changes while the menu is closed ---- */
436
- useEffect(() => {
437
- if (open || isInputControlled) return;
438
- setInternalInput(selectedOption?.label ?? '');
439
- // eslint-disable-next-line react-hooks/exhaustive-deps
440
- }, [value]);
441
-
442
- /* ---- flush any pending debounce on unmount ---- */
443
- useEffect(
444
- () => () => {
445
- if (debounceRef.current) clearTimeout(debounceRef.current);
446
- },
447
- [],
448
- );
449
-
450
- const emitInputChange = useCallback(
451
- (next: string) => {
452
- if (!onInputChange) return;
453
- if (debounce > 0) {
454
- if (debounceRef.current) clearTimeout(debounceRef.current);
455
- debounceRef.current = setTimeout(() => onInputChange(next), debounce);
456
- } else {
457
- onInputChange(next);
458
- }
459
- },
460
- [onInputChange, debounce],
461
- );
462
-
463
- const setValue = useCallback(
464
- (next: string | null, option: AutoCompleteOption | null) => {
465
- if (!isValueControlled) setInternalValue(next);
466
- onChange?.(next, option);
467
- },
468
- [isValueControlled, onChange],
469
- );
470
-
471
- const rememberRecent = useCallback(
472
- (optionValue: string) => {
473
- if (!recentsCfg) return;
474
- setRecentValues((prev) => {
475
- const next = [optionValue, ...prev.filter((v) => v !== optionValue)].slice(
476
- 0,
477
- recentsCfg.max,
478
- );
479
- recentsCfg.storage.set(next);
480
- return next;
481
- });
482
- },
483
- [recentsCfg],
484
- );
485
-
486
- const closeMenu = useCallback(() => {
487
- setOpen(false);
488
- setActiveIdx(0);
489
- // List-autocomplete-with-manual-selection (WAI-ARIA): on close, if a
490
- // value is selected, snap the text back to it; free text is kept only
491
- // when nothing is selected.
492
- if (!isInputControlled && selectedOption) {
493
- setInternalInput(selectedOption.label);
494
- }
495
- // eslint-disable-next-line react-hooks/exhaustive-deps
496
- }, [isInputControlled, selectedOption]);
497
-
498
- const commitOption = useCallback(
499
- (option: AutoCompleteOption) => {
500
- if (option.disabled) return;
501
- setValue(option.value, option);
502
- if (!isInputControlled) setInternalInput(option.label);
503
- emitInputChange(option.label);
504
- rememberRecent(option.value);
505
- setOpen(false);
506
- setActiveIdx(0);
507
- inputRef.current?.focus();
508
- },
509
- [setValue, isInputControlled, emitInputChange, rememberRecent],
510
- );
511
-
512
- const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
513
- const next = e.target.value;
514
- if (!isInputControlled) setInternalInput(next);
515
- emitInputChange(next);
516
- // Typing past a committed selection un-commits it.
517
- if (selectedOption && next !== selectedOption.label) setValue(null, null);
518
- setActiveIdx(0);
519
- if (!open) setOpen(true);
520
- };
521
-
522
- const handleClear = () => {
523
- if (!isInputControlled) setInternalInput('');
524
- emitInputChange('');
525
- setValue(null, null);
526
- setActiveIdx(0);
527
- inputRef.current?.focus();
528
- setOpen(true);
529
- };
530
-
531
- const moveActive = (dir: 1 | -1) => {
532
- if (navItems.length === 0) return;
533
- let next = activeIdx;
534
- for (let i = 0; i < navItems.length; i++) {
535
- next = (next + dir + navItems.length) % navItems.length;
536
- if (!navItems[next].disabled) {
537
- setActiveIdx(next);
538
- return;
539
- }
540
- }
541
- };
542
-
543
- const handleKeyDown = (e: React.KeyboardEvent) => {
544
- if (disabled) return;
545
- if (!open) {
546
- if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
547
- e.preventDefault();
548
- setOpen(true);
549
- }
550
- return;
551
- }
552
- switch (e.key) {
553
- case 'ArrowDown':
554
- e.preventDefault();
555
- moveActive(1);
556
- break;
557
- case 'ArrowUp':
558
- e.preventDefault();
559
- moveActive(-1);
560
- break;
561
- case 'Home':
562
- e.preventDefault();
563
- setActiveIdx(navItems.findIndex((o) => !o.disabled));
564
- break;
565
- case 'End':
566
- e.preventDefault();
567
- for (let i = navItems.length - 1; i >= 0; i--) {
568
- if (!navItems[i].disabled) {
569
- setActiveIdx(i);
570
- break;
571
- }
572
- }
573
- break;
574
- case 'Enter': {
575
- const opt = navItems[activeIdx];
576
- if (opt && !opt.disabled) {
577
- e.preventDefault();
578
- commitOption(opt);
579
- }
580
- break;
581
- }
582
- case 'Escape':
583
- e.preventDefault();
584
- closeMenu();
585
- break;
586
- case 'Tab':
587
- closeMenu();
588
- break;
589
- default:
590
- break;
591
- }
592
- };
593
-
594
- const sizeClass = styles[size];
595
- const activeId =
596
- open && navItems[activeIdx] ? `${acId}-opt-${activeIdx}` : undefined;
597
-
598
- /* ---- shared row renderer ---- */
599
- const renderRow = (
600
- option: AutoCompleteOption,
601
- idx: number,
602
- variant: 'option' | 'recent' | 'recovery',
603
- ) => {
604
- const isActive = idx === activeIdx;
605
- const isSelected = option.value === value;
606
- return (
607
- <div
608
- key={`${variant}-${option.value}`}
609
- id={`${acId}-opt-${idx}`}
610
- role="option"
611
- aria-selected={isSelected}
612
- aria-disabled={option.disabled || undefined}
613
- data-idx={idx}
614
- className={[
615
- styles.option,
616
- isActive ? styles.optionActive : '',
617
- isSelected ? styles.optionSelected : '',
618
- option.disabled ? styles.optionDisabled : '',
619
- variant === 'recovery' ? styles.optionRecovery : '',
620
- ]
621
- .filter(Boolean)
622
- .join(' ')}
623
- onClick={() => commitOption(option)}
624
- onMouseEnter={() => !option.disabled && setActiveIdx(idx)}
625
- >
626
- {renderOption ? (
627
- renderOption(option, { active: isActive, query: trimmed })
628
- ) : (
629
- <>
630
- {variant === 'recent' && (
631
- <span className={styles.optionIcon} aria-hidden="true">
632
- <ClockIcon />
633
- </span>
634
- )}
635
- {variant !== 'recent' && option.icon && (
636
- <span className={styles.optionIcon} aria-hidden="true">
637
- {option.icon}
638
- </span>
639
- )}
640
- <span className={styles.optionLabel}>
641
- <span className={styles.optionTitle}>
642
- {highlightMatch && variant === 'option'
643
- ? highlightLabel(option.label, trimmed)
644
- : option.label}
645
- </span>
646
- {option.description && (
647
- <span className={styles.optionDesc}>{option.description}</span>
648
- )}
649
- </span>
650
- {isSelected && (
651
- <span className={styles.check} aria-hidden="true">
652
- <CheckIcon />
653
- </span>
654
- )}
655
- </>
656
- )}
657
- </div>
658
- );
659
- };
660
-
661
- return (
662
- <div
663
- ref={wrapperRef}
664
- className={[
665
- styles.field,
666
- fullWidth ? styles.fullWidth : '',
667
- disabled ? styles.disabled : '',
668
- className,
669
- ]
670
- .filter(Boolean)
671
- .join(' ')}
672
- >
673
- {label && (
674
- <label htmlFor={acId} className={styles.label}>
675
- {label}
676
- </label>
677
- )}
678
-
679
- <div className={styles.acWrapper}>
680
- <div
681
- className={[
682
- styles.inputWrapper,
683
- sizeClass,
684
- open ? styles.open : '',
685
- error ? styles.hasError : '',
686
- ]
687
- .filter(Boolean)
688
- .join(' ')}
689
- >
690
- <span className={styles.searchIconWrap} aria-hidden="true">
691
- <SearchIcon />
692
- </span>
693
-
694
- <input
695
- {...rest}
696
- ref={setInputRef}
697
- id={acId}
698
- type="text"
699
- role="combobox"
700
- autoComplete="off"
701
- aria-autocomplete="list"
702
- aria-expanded={open}
703
- aria-controls={listId}
704
- aria-activedescendant={activeId}
705
- aria-invalid={!!error || undefined}
706
- aria-describedby={
707
- error ? `${acId}-error` : helperText ? `${acId}-helper` : undefined
708
- }
709
- className={styles.input}
710
- placeholder={placeholder}
711
- value={inputText}
712
- disabled={disabled}
713
- onChange={handleInput}
714
- onKeyDown={handleKeyDown}
715
- onFocus={() => openOnFocus && !disabled && setOpen(true)}
716
- />
717
-
718
- <span className={styles.actions}>
719
- {loading && (
720
- <span className={styles.statusIcon} aria-hidden="true">
721
- <SpinnerIcon />
722
- </span>
723
- )}
724
- {clearable && !disabled && (inputText.length > 0 || value) && (
725
- <button
726
- type="button"
727
- tabIndex={-1}
728
- aria-label="Clear"
729
- className={styles.clearBtn}
730
- onClick={handleClear}
731
- onMouseDown={(e) => e.preventDefault()}
732
- >
733
- <ClearIcon />
734
- </button>
735
- )}
736
- </span>
737
- </div>
738
-
739
- {open && (
740
- <div
741
- className={styles.menu}
742
- role="listbox"
743
- id={listId}
744
- aria-label={label ?? 'Suggestions'}
745
- >
746
- {loading ? (
747
- <div className={styles.statusRow}>
748
- <SpinnerIcon />
749
- <span>Loading…</span>
750
- </div>
751
- ) : belowMin ? (
752
- <div className={styles.empty}>
753
- Type {minChars - trimmed.length} more character
754
- {minChars - trimmed.length === 1 ? '' : 's'}…
755
- </div>
756
- ) : recovery ? (
757
- /* 🆕 Smart Recovery — nearest match instead of a dead end. */
758
- <div className={styles.recoveryBox}>
759
- <div className={styles.sectionHeader}>
760
- <SparkIcon />
761
- <span>
762
- No exact match — did you mean
763
- {recovery.distance === 1 ? ' this' : ''}?
764
- </span>
765
- </div>
766
- <div className={styles.list} ref={listRef} role="presentation">
767
- {renderRow(recovery.option, 0, 'recovery')}
768
- </div>
769
- </div>
770
- ) : navItems.length > 0 ? (
771
- <>
772
- {showRecents && (
773
- <div className={styles.sectionHeader}>
774
- <ClockIcon />
775
- <span>{recentsCfg!.label}</span>
776
- </div>
777
- )}
778
- <div className={styles.list} ref={listRef} role="presentation">
779
- {navItems.map((opt, idx) =>
780
- renderRow(opt, idx, showRecents ? 'recent' : 'option'),
781
- )}
782
- </div>
783
- </>
784
- ) : trimmed.length === 0 ? (
785
- <div className={styles.empty}>Type to search…</div>
786
- ) : (
787
- <div className={styles.empty}>{emptyMessage}</div>
788
- )}
789
- </div>
790
- )}
791
-
792
- {name && <input type="hidden" name={name} value={value ?? ''} />}
793
- </div>
794
-
795
- {error && (
796
- <p id={`${acId}-error`} className={styles.errorText}>
797
- {error}
798
- </p>
799
- )}
800
- {!error && helperText && (
801
- <p id={`${acId}-helper`} className={styles.helperText}>
802
- {helperText}
803
- </p>
804
- )}
805
- </div>
806
- );
807
- },
808
- );
809
-
810
- EvoAutoComplete.displayName = 'EvoAutoComplete';
1
+ import React, {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useId,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from 'react';
10
+ import styles from '../css/autocomplete.module.scss';
11
+
12
+ /* =====================================================================
13
+ * EvoAutoComplete — editable combobox with list autocomplete.
14
+ *
15
+ * Research / design decisions (see CLAUDE.md §2 "research"):
16
+ * - MUI Autocomplete → got the controlled `value` + `inputValue` split
17
+ * right; we keep it. Got bundle size wrong; we stay zero-runtime CSS.
18
+ * - Downshift / Headless UI → got strict WAI-ARIA combobox right
19
+ * (aria-activedescendant, focus stays on the input); we follow it.
20
+ * - Mantine → CSS Modules, no runtime styling; we match.
21
+ * - react-select → rich features but degrades on big lists; we cap
22
+ * rendered rows with `maxResults`.
23
+ * - react-autosuggest → unmaintained; avoid its tiny API surface.
24
+ *
25
+ * Two things NONE of the 10 surveyed libraries ship — and every team
26
+ * re-builds by hand — so we make them first-class props:
27
+ * 1. `smartRecovery` — when a query returns zero results, compute the
28
+ * nearest option by edit distance and offer a one-click correction
29
+ * ("Did you mean…?") instead of a dead-end empty state.
30
+ * 2. `recents` — built-in recent-selection memory with a pluggable
31
+ * storage adapter (in-memory by default, opt into localStorage via
32
+ * `evoLocalRecents`). Surfaces a "Recent" group on empty focus.
33
+ * ===================================================================== */
34
+
35
+ export interface AutoCompleteOption {
36
+ /** Stable, unique identifier returned by `onChange`. */
37
+ value: string;
38
+ /** Human-readable text shown in the list and the input. */
39
+ label: string;
40
+ /** Optional secondary line under the label. */
41
+ description?: string;
42
+ /** Optional leading icon. */
43
+ icon?: React.ReactNode;
44
+ /** When true, the option is not selectable or keyboard-reachable. */
45
+ disabled?: boolean;
46
+ }
47
+
48
+ /** Pluggable persistence layer for the `recents` feature. */
49
+ export interface RecentsStorage {
50
+ /** Return the stored option values, most-recent first. */
51
+ get: () => string[];
52
+ /** Persist the option values, most-recent first. */
53
+ set: (values: string[]) => void;
54
+ }
55
+
56
+ export interface RecentsConfig {
57
+ /** Max remembered selections. Default 5. */
58
+ max?: number;
59
+ /** Where recents live. Default: in-memory (per `id`/`name`). */
60
+ storage?: RecentsStorage;
61
+ /** Section heading shown above recents. Default "Recent". */
62
+ label?: string;
63
+ }
64
+
65
+ export interface EvoAutoCompleteProps
66
+ extends Omit<
67
+ React.InputHTMLAttributes<HTMLInputElement>,
68
+ 'size' | 'onChange' | 'value' | 'defaultValue'
69
+ > {
70
+ /** The options to match against. In async mode, the already-filtered set. */
71
+ options: AutoCompleteOption[];
72
+
73
+ /** Selected option value (controlled). */
74
+ value?: string | null;
75
+ /** Selected option value (uncontrolled initial). */
76
+ defaultValue?: string | null;
77
+ /** Fires when a selection is made or cleared. */
78
+ onChange?: (value: string | null, option: AutoCompleteOption | null) => void;
79
+
80
+ /** The input's text (controlled — pair with `onInputChange`). */
81
+ inputValue?: string;
82
+ /** Fires when the typed text changes. Debounced by `debounce`. */
83
+ onInputChange?: (query: string) => void;
84
+
85
+ /** Field label. */
86
+ label?: string;
87
+ placeholder?: string;
88
+ helperText?: string;
89
+ /** Error message — also flips the field into the error state. */
90
+ error?: string;
91
+ size?: 'sm' | 'md' | 'lg';
92
+ fullWidth?: boolean;
93
+ disabled?: boolean;
94
+ /** Show a clear (✕) button when there is a value. Default true. */
95
+ clearable?: boolean;
96
+
97
+ /** Render a spinner row instead of results (for async fetches). */
98
+ loading?: boolean;
99
+ /** Debounce, in ms, applied to `onInputChange`. Default 0. */
100
+ debounce?: number;
101
+ /** Minimum query length before results show. Default 0. */
102
+ minChars?: number;
103
+ /** Cap on rendered rows — protects against huge lists. Default 50. */
104
+ maxResults?: number;
105
+ /**
106
+ * Filtering strategy:
107
+ * - omitted → case-insensitive substring match on `label`.
108
+ * - function → custom predicate.
109
+ * - `false` → caller pre-filters (async mode); render `options` as-is.
110
+ */
111
+ filter?: false | ((option: AutoCompleteOption, query: string) => boolean);
112
+ /** Bold the matched substring in each row. Default true. */
113
+ highlightMatch?: boolean;
114
+ /** Open the menu when the input receives focus. Default true. */
115
+ openOnFocus?: boolean;
116
+ /** Message shown when a non-empty query has no matches. */
117
+ emptyMessage?: React.ReactNode;
118
+
119
+ /** 🆕 Offer a nearest-match correction when a query has zero results. */
120
+ smartRecovery?: boolean;
121
+ /** 🆕 Remember recent selections and surface them on empty focus. */
122
+ recents?: boolean | RecentsConfig;
123
+
124
+ /** Custom row renderer. Overrides icon/label/description layout. */
125
+ renderOption?: (
126
+ option: AutoCompleteOption,
127
+ state: { active: boolean; query: string },
128
+ ) => React.ReactNode;
129
+
130
+ id?: string;
131
+ name?: string;
132
+ className?: string;
133
+ }
134
+
135
+ /* ---------------- Icons ---------------- */
136
+ const SearchIcon = () => (
137
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none" aria-hidden="true">
138
+ <circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.5" />
139
+ <path d="M10.5 10.5L13 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
140
+ </svg>
141
+ );
142
+ const ClearIcon = () => (
143
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="none" aria-hidden="true">
144
+ <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
145
+ </svg>
146
+ );
147
+ const CheckIcon = () => (
148
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none" aria-hidden="true">
149
+ <path d="M3.5 8.5l3 3 6-7" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
150
+ </svg>
151
+ );
152
+ const ClockIcon = () => (
153
+ <svg viewBox="0 0 16 16" width="13" height="13" fill="none" aria-hidden="true">
154
+ <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.5" />
155
+ <path d="M8 4.5V8l2.5 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
156
+ </svg>
157
+ );
158
+ const SparkIcon = () => (
159
+ <svg viewBox="0 0 16 16" width="13" height="13" fill="none" aria-hidden="true">
160
+ <path
161
+ d="M8 1.5l1.6 4.9 4.9 1.6-4.9 1.6L8 14.5l-1.6-4.9L1.5 8l4.9-1.6L8 1.5z"
162
+ stroke="currentColor"
163
+ strokeWidth="1.4"
164
+ strokeLinejoin="round"
165
+ />
166
+ </svg>
167
+ );
168
+ const SpinnerIcon = () => (
169
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none" aria-hidden="true" className={styles.spinner}>
170
+ <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="2" strokeOpacity="0.25" />
171
+ <path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
172
+ </svg>
173
+ );
174
+
175
+ /* =====================================================================
176
+ * Recents storage adapters
177
+ * ===================================================================== */
178
+
179
+ /** Process-lifetime in-memory store, keyed so each field keeps its own. */
180
+ const memoryStore = new Map<string, string[]>();
181
+ const memoryRecents = (key: string): RecentsStorage => ({
182
+ get: () => memoryStore.get(key) ?? [],
183
+ set: (values) => {
184
+ memoryStore.set(key, values);
185
+ },
186
+ });
187
+
188
+ /**
189
+ * A `RecentsStorage` backed by `localStorage` — survives reloads.
190
+ * SSR-safe: no-ops when `window` is unavailable.
191
+ *
192
+ * @example
193
+ * <EvoAutoComplete recents={{ storage: evoLocalRecents('country-picker') }} />
194
+ */
195
+ export const evoLocalRecents = (key: string): RecentsStorage => ({
196
+ get: () => {
197
+ if (typeof window === 'undefined') return [];
198
+ try {
199
+ const raw = window.localStorage.getItem(`evo-recents:${key}`);
200
+ const parsed = raw ? JSON.parse(raw) : [];
201
+ return Array.isArray(parsed) ? parsed.filter((v) => typeof v === 'string') : [];
202
+ } catch {
203
+ return [];
204
+ }
205
+ },
206
+ set: (values) => {
207
+ if (typeof window === 'undefined') return;
208
+ try {
209
+ window.localStorage.setItem(`evo-recents:${key}`, JSON.stringify(values));
210
+ } catch {
211
+ /* quota / privacy mode — fail silently */
212
+ }
213
+ },
214
+ });
215
+
216
+ /* =====================================================================
217
+ * Smart Recovery — nearest-match via Levenshtein edit distance.
218
+ * Zero-dependency (CLAUDE.md §7 forbids new runtime deps).
219
+ * ===================================================================== */
220
+
221
+ function editDistance(a: string, b: string): number {
222
+ const m = a.length;
223
+ const n = b.length;
224
+ if (m === 0) return n;
225
+ if (n === 0) return m;
226
+ let prev = Array.from({ length: n + 1 }, (_, i) => i);
227
+ let curr = new Array<number>(n + 1);
228
+ for (let i = 1; i <= m; i++) {
229
+ curr[0] = i;
230
+ for (let j = 1; j <= n; j++) {
231
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
232
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
233
+ }
234
+ [prev, curr] = [curr, prev];
235
+ }
236
+ return prev[n];
237
+ }
238
+
239
+ interface Recovery {
240
+ option: AutoCompleteOption;
241
+ distance: number;
242
+ }
243
+
244
+ /** Find the closest option to `query`, or null if nothing is close enough. */
245
+ function findNearest(options: AutoCompleteOption[], query: string): Recovery | null {
246
+ const q = query.trim().toLowerCase();
247
+ if (q.length < 2) return null;
248
+ let best: AutoCompleteOption | null = null;
249
+ let bestD = Infinity;
250
+ for (const o of options) {
251
+ if (o.disabled) continue;
252
+ const label = o.label.toLowerCase();
253
+ // Compare against the full label AND the label trimmed to the query
254
+ // length — a typo in a long label shouldn't be drowned out.
255
+ const d = Math.min(editDistance(q, label), editDistance(q, label.slice(0, q.length)));
256
+ if (d < bestD) {
257
+ bestD = d;
258
+ best = o;
259
+ }
260
+ }
261
+ if (!best) return null;
262
+ // Only suggest when the fix is small: ~40% of the query length, 1–3 edits.
263
+ const threshold = Math.max(1, Math.min(3, Math.round(q.length * 0.4)));
264
+ return bestD <= threshold ? { option: best, distance: bestD } : null;
265
+ }
266
+
267
+ /* ---------------- Match highlighting ---------------- */
268
+ function highlightLabel(label: string, query: string): React.ReactNode {
269
+ const q = query.trim();
270
+ if (!q) return label;
271
+ const idx = label.toLowerCase().indexOf(q.toLowerCase());
272
+ if (idx < 0) return label;
273
+ return (
274
+ <>
275
+ {label.slice(0, idx)}
276
+ <mark className={styles.mark}>{label.slice(idx, idx + q.length)}</mark>
277
+ {label.slice(idx + q.length)}
278
+ </>
279
+ );
280
+ }
281
+
282
+ /* =====================================================================
283
+ * Component
284
+ * ===================================================================== */
285
+
286
+ export const EvoAutoComplete = forwardRef<HTMLInputElement, EvoAutoCompleteProps>(
287
+ (
288
+ {
289
+ options,
290
+ value: controlledValue,
291
+ defaultValue = null,
292
+ onChange,
293
+ inputValue: controlledInput,
294
+ onInputChange,
295
+ label,
296
+ placeholder = 'Search…',
297
+ helperText,
298
+ error,
299
+ size = 'md',
300
+ fullWidth = false,
301
+ disabled = false,
302
+ clearable = true,
303
+ loading = false,
304
+ debounce = 0,
305
+ minChars = 0,
306
+ maxResults = 50,
307
+ filter,
308
+ highlightMatch = true,
309
+ openOnFocus = true,
310
+ emptyMessage = 'No results',
311
+ smartRecovery = false,
312
+ recents = false,
313
+ renderOption,
314
+ id,
315
+ name,
316
+ className = '',
317
+ ...rest
318
+ },
319
+ ref,
320
+ ) => {
321
+ const reactId = useId();
322
+ const acId = id ?? `evo-autocomplete-${reactId}`;
323
+ const listId = `${acId}-listbox`;
324
+
325
+ /* ---- value (selected option) ---- */
326
+ const isValueControlled = controlledValue !== undefined;
327
+ const [internalValue, setInternalValue] = useState<string | null>(defaultValue);
328
+ const value = isValueControlled ? controlledValue! : internalValue;
329
+ const selectedOption = useMemo(
330
+ () => options.find((o) => o.value === value) ?? null,
331
+ [options, value],
332
+ );
333
+
334
+ /* ---- input text ---- */
335
+ const isInputControlled = controlledInput !== undefined;
336
+ const [internalInput, setInternalInput] = useState(
337
+ () => options.find((o) => o.value === defaultValue)?.label ?? '',
338
+ );
339
+ const inputText = isInputControlled ? controlledInput! : internalInput;
340
+
341
+ /* ---- recents config ---- */
342
+ const recentsCfg: Required<RecentsConfig> | null = useMemo(() => {
343
+ if (!recents) return null;
344
+ const base: Required<RecentsConfig> = {
345
+ max: 5,
346
+ storage: memoryRecents(name ?? acId),
347
+ label: 'Recent',
348
+ };
349
+ return typeof recents === 'object' ? { ...base, ...recents } : base;
350
+ }, [recents, name, acId]);
351
+
352
+ const [recentValues, setRecentValues] = useState<string[]>(
353
+ () => recentsCfg?.storage.get() ?? [],
354
+ );
355
+
356
+ /* ---- open / active descendant ---- */
357
+ const [open, setOpen] = useState(false);
358
+ const [activeIdx, setActiveIdx] = useState(0);
359
+
360
+ const wrapperRef = useRef<HTMLDivElement>(null);
361
+ const inputRef = useRef<HTMLInputElement>(null);
362
+ const listRef = useRef<HTMLDivElement>(null);
363
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
364
+
365
+ /* Merge the forwarded ref with our internal one. */
366
+ const setInputRef = useCallback(
367
+ (node: HTMLInputElement | null) => {
368
+ inputRef.current = node;
369
+ if (typeof ref === 'function') ref(node);
370
+ else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = node;
371
+ },
372
+ [ref],
373
+ );
374
+
375
+ /* ---- derive what the menu shows ---- */
376
+ const query = inputText;
377
+ const trimmed = query.trim();
378
+ const belowMin = trimmed.length > 0 && trimmed.length < minChars;
379
+
380
+ const showRecents =
381
+ !!recentsCfg && trimmed.length === 0 && recentValues.length > 0;
382
+
383
+ const recentOptions = useMemo(() => {
384
+ if (!showRecents) return [];
385
+ return recentValues
386
+ .map((v) => options.find((o) => o.value === v))
387
+ .filter((o): o is AutoCompleteOption => !!o && !o.disabled)
388
+ .slice(0, recentsCfg!.max);
389
+ }, [showRecents, recentValues, options, recentsCfg]);
390
+
391
+ const filteredOptions = useMemo(() => {
392
+ if (belowMin) return [];
393
+ if (filter === false || trimmed.length === 0) {
394
+ return options.slice(0, maxResults);
395
+ }
396
+ const predicate =
397
+ typeof filter === 'function'
398
+ ? filter
399
+ : (o: AutoCompleteOption, q: string) =>
400
+ o.label.toLowerCase().includes(q.toLowerCase());
401
+ return options.filter((o) => predicate(o, trimmed)).slice(0, maxResults);
402
+ }, [options, filter, trimmed, belowMin, maxResults]);
403
+
404
+ const results = showRecents ? recentOptions : filteredOptions;
405
+
406
+ const recovery: Recovery | null = useMemo(() => {
407
+ if (!smartRecovery || loading || belowMin) return null;
408
+ if (showRecents || results.length > 0 || trimmed.length === 0) return null;
409
+ return findNearest(options, trimmed);
410
+ }, [smartRecovery, loading, belowMin, showRecents, results.length, trimmed, options]);
411
+
412
+ /* The keyboard-navigable set: the recovery suggestion, else the results. */
413
+ const navItems = recovery ? [recovery.option] : results;
414
+
415
+ /* ---- close on outside click ---- */
416
+ useEffect(() => {
417
+ if (!open) return;
418
+ const handler = (e: MouseEvent) => {
419
+ if (!wrapperRef.current?.contains(e.target as Node)) closeMenu();
420
+ };
421
+ document.addEventListener('mousedown', handler);
422
+ return () => document.removeEventListener('mousedown', handler);
423
+ // eslint-disable-next-line react-hooks/exhaustive-deps
424
+ }, [open]);
425
+
426
+ /* ---- keep the active row in view ---- */
427
+ useEffect(() => {
428
+ if (!open) return;
429
+ const el = listRef.current?.querySelector(
430
+ `[data-idx="${activeIdx}"]`,
431
+ ) as HTMLElement | null;
432
+ el?.scrollIntoView({ block: 'nearest' });
433
+ }, [activeIdx, open]);
434
+
435
+ /* ---- sync input text when value changes while the menu is closed ---- */
436
+ useEffect(() => {
437
+ if (open || isInputControlled) return;
438
+ setInternalInput(selectedOption?.label ?? '');
439
+ // eslint-disable-next-line react-hooks/exhaustive-deps
440
+ }, [value]);
441
+
442
+ /* ---- flush any pending debounce on unmount ---- */
443
+ useEffect(
444
+ () => () => {
445
+ if (debounceRef.current) clearTimeout(debounceRef.current);
446
+ },
447
+ [],
448
+ );
449
+
450
+ const emitInputChange = useCallback(
451
+ (next: string) => {
452
+ if (!onInputChange) return;
453
+ if (debounce > 0) {
454
+ if (debounceRef.current) clearTimeout(debounceRef.current);
455
+ debounceRef.current = setTimeout(() => onInputChange(next), debounce);
456
+ } else {
457
+ onInputChange(next);
458
+ }
459
+ },
460
+ [onInputChange, debounce],
461
+ );
462
+
463
+ const setValue = useCallback(
464
+ (next: string | null, option: AutoCompleteOption | null) => {
465
+ if (!isValueControlled) setInternalValue(next);
466
+ onChange?.(next, option);
467
+ },
468
+ [isValueControlled, onChange],
469
+ );
470
+
471
+ const rememberRecent = useCallback(
472
+ (optionValue: string) => {
473
+ if (!recentsCfg) return;
474
+ setRecentValues((prev) => {
475
+ const next = [optionValue, ...prev.filter((v) => v !== optionValue)].slice(
476
+ 0,
477
+ recentsCfg.max,
478
+ );
479
+ recentsCfg.storage.set(next);
480
+ return next;
481
+ });
482
+ },
483
+ [recentsCfg],
484
+ );
485
+
486
+ const closeMenu = useCallback(() => {
487
+ setOpen(false);
488
+ setActiveIdx(0);
489
+ // List-autocomplete-with-manual-selection (WAI-ARIA): on close, if a
490
+ // value is selected, snap the text back to it; free text is kept only
491
+ // when nothing is selected.
492
+ if (!isInputControlled && selectedOption) {
493
+ setInternalInput(selectedOption.label);
494
+ }
495
+ // eslint-disable-next-line react-hooks/exhaustive-deps
496
+ }, [isInputControlled, selectedOption]);
497
+
498
+ const commitOption = useCallback(
499
+ (option: AutoCompleteOption) => {
500
+ if (option.disabled) return;
501
+ setValue(option.value, option);
502
+ if (!isInputControlled) setInternalInput(option.label);
503
+ emitInputChange(option.label);
504
+ rememberRecent(option.value);
505
+ setOpen(false);
506
+ setActiveIdx(0);
507
+ inputRef.current?.focus();
508
+ },
509
+ [setValue, isInputControlled, emitInputChange, rememberRecent],
510
+ );
511
+
512
+ const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
513
+ const next = e.target.value;
514
+ if (!isInputControlled) setInternalInput(next);
515
+ emitInputChange(next);
516
+ // Typing past a committed selection un-commits it.
517
+ if (selectedOption && next !== selectedOption.label) setValue(null, null);
518
+ setActiveIdx(0);
519
+ if (!open) setOpen(true);
520
+ };
521
+
522
+ const handleClear = () => {
523
+ if (!isInputControlled) setInternalInput('');
524
+ emitInputChange('');
525
+ setValue(null, null);
526
+ setActiveIdx(0);
527
+ inputRef.current?.focus();
528
+ setOpen(true);
529
+ };
530
+
531
+ const moveActive = (dir: 1 | -1) => {
532
+ if (navItems.length === 0) return;
533
+ let next = activeIdx;
534
+ for (let i = 0; i < navItems.length; i++) {
535
+ next = (next + dir + navItems.length) % navItems.length;
536
+ if (!navItems[next].disabled) {
537
+ setActiveIdx(next);
538
+ return;
539
+ }
540
+ }
541
+ };
542
+
543
+ const handleKeyDown = (e: React.KeyboardEvent) => {
544
+ if (disabled) return;
545
+ if (!open) {
546
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
547
+ e.preventDefault();
548
+ setOpen(true);
549
+ }
550
+ return;
551
+ }
552
+ switch (e.key) {
553
+ case 'ArrowDown':
554
+ e.preventDefault();
555
+ moveActive(1);
556
+ break;
557
+ case 'ArrowUp':
558
+ e.preventDefault();
559
+ moveActive(-1);
560
+ break;
561
+ case 'Home':
562
+ e.preventDefault();
563
+ setActiveIdx(navItems.findIndex((o) => !o.disabled));
564
+ break;
565
+ case 'End':
566
+ e.preventDefault();
567
+ for (let i = navItems.length - 1; i >= 0; i--) {
568
+ if (!navItems[i].disabled) {
569
+ setActiveIdx(i);
570
+ break;
571
+ }
572
+ }
573
+ break;
574
+ case 'Enter': {
575
+ const opt = navItems[activeIdx];
576
+ if (opt && !opt.disabled) {
577
+ e.preventDefault();
578
+ commitOption(opt);
579
+ }
580
+ break;
581
+ }
582
+ case 'Escape':
583
+ e.preventDefault();
584
+ closeMenu();
585
+ break;
586
+ case 'Tab':
587
+ closeMenu();
588
+ break;
589
+ default:
590
+ break;
591
+ }
592
+ };
593
+
594
+ const sizeClass = styles[size];
595
+ const activeId =
596
+ open && navItems[activeIdx] ? `${acId}-opt-${activeIdx}` : undefined;
597
+
598
+ /* ---- shared row renderer ---- */
599
+ const renderRow = (
600
+ option: AutoCompleteOption,
601
+ idx: number,
602
+ variant: 'option' | 'recent' | 'recovery',
603
+ ) => {
604
+ const isActive = idx === activeIdx;
605
+ const isSelected = option.value === value;
606
+ return (
607
+ <div
608
+ key={`${variant}-${option.value}`}
609
+ id={`${acId}-opt-${idx}`}
610
+ role="option"
611
+ aria-selected={isSelected}
612
+ aria-disabled={option.disabled || undefined}
613
+ data-idx={idx}
614
+ className={[
615
+ styles.option,
616
+ isActive ? styles.optionActive : '',
617
+ isSelected ? styles.optionSelected : '',
618
+ option.disabled ? styles.optionDisabled : '',
619
+ variant === 'recovery' ? styles.optionRecovery : '',
620
+ ]
621
+ .filter(Boolean)
622
+ .join(' ')}
623
+ onClick={() => commitOption(option)}
624
+ onMouseEnter={() => !option.disabled && setActiveIdx(idx)}
625
+ >
626
+ {renderOption ? (
627
+ renderOption(option, { active: isActive, query: trimmed })
628
+ ) : (
629
+ <>
630
+ {variant === 'recent' && (
631
+ <span className={styles.optionIcon} aria-hidden="true">
632
+ <ClockIcon />
633
+ </span>
634
+ )}
635
+ {variant !== 'recent' && option.icon && (
636
+ <span className={styles.optionIcon} aria-hidden="true">
637
+ {option.icon}
638
+ </span>
639
+ )}
640
+ <span className={styles.optionLabel}>
641
+ <span className={styles.optionTitle}>
642
+ {highlightMatch && variant === 'option'
643
+ ? highlightLabel(option.label, trimmed)
644
+ : option.label}
645
+ </span>
646
+ {option.description && (
647
+ <span className={styles.optionDesc}>{option.description}</span>
648
+ )}
649
+ </span>
650
+ {isSelected && (
651
+ <span className={styles.check} aria-hidden="true">
652
+ <CheckIcon />
653
+ </span>
654
+ )}
655
+ </>
656
+ )}
657
+ </div>
658
+ );
659
+ };
660
+
661
+ return (
662
+ <div
663
+ ref={wrapperRef}
664
+ className={[
665
+ styles.field,
666
+ fullWidth ? styles.fullWidth : '',
667
+ disabled ? styles.disabled : '',
668
+ className,
669
+ ]
670
+ .filter(Boolean)
671
+ .join(' ')}
672
+ >
673
+ {label && (
674
+ <label htmlFor={acId} className={styles.label}>
675
+ {label}
676
+ </label>
677
+ )}
678
+
679
+ <div className={styles.acWrapper}>
680
+ <div
681
+ className={[
682
+ styles.inputWrapper,
683
+ sizeClass,
684
+ open ? styles.open : '',
685
+ error ? styles.hasError : '',
686
+ ]
687
+ .filter(Boolean)
688
+ .join(' ')}
689
+ >
690
+ <span className={styles.searchIconWrap} aria-hidden="true">
691
+ <SearchIcon />
692
+ </span>
693
+
694
+ <input
695
+ {...rest}
696
+ ref={setInputRef}
697
+ id={acId}
698
+ type="text"
699
+ role="combobox"
700
+ autoComplete="off"
701
+ aria-autocomplete="list"
702
+ aria-expanded={open}
703
+ aria-controls={listId}
704
+ aria-activedescendant={activeId}
705
+ aria-invalid={!!error || undefined}
706
+ aria-describedby={
707
+ error ? `${acId}-error` : helperText ? `${acId}-helper` : undefined
708
+ }
709
+ className={styles.input}
710
+ placeholder={placeholder}
711
+ value={inputText}
712
+ disabled={disabled}
713
+ onChange={handleInput}
714
+ onKeyDown={handleKeyDown}
715
+ onFocus={() => openOnFocus && !disabled && setOpen(true)}
716
+ />
717
+
718
+ <span className={styles.actions}>
719
+ {loading && (
720
+ <span className={styles.statusIcon} aria-hidden="true">
721
+ <SpinnerIcon />
722
+ </span>
723
+ )}
724
+ {clearable && !disabled && (inputText.length > 0 || value) && (
725
+ <button
726
+ type="button"
727
+ tabIndex={-1}
728
+ aria-label="Clear"
729
+ className={styles.clearBtn}
730
+ onClick={handleClear}
731
+ onMouseDown={(e) => e.preventDefault()}
732
+ >
733
+ <ClearIcon />
734
+ </button>
735
+ )}
736
+ </span>
737
+ </div>
738
+
739
+ {open && (
740
+ <div
741
+ className={styles.menu}
742
+ role="listbox"
743
+ id={listId}
744
+ aria-label={label ?? 'Suggestions'}
745
+ >
746
+ {loading ? (
747
+ <div className={styles.statusRow}>
748
+ <SpinnerIcon />
749
+ <span>Loading…</span>
750
+ </div>
751
+ ) : belowMin ? (
752
+ <div className={styles.empty}>
753
+ Type {minChars - trimmed.length} more character
754
+ {minChars - trimmed.length === 1 ? '' : 's'}…
755
+ </div>
756
+ ) : recovery ? (
757
+ /* 🆕 Smart Recovery — nearest match instead of a dead end. */
758
+ <div className={styles.recoveryBox}>
759
+ <div className={styles.sectionHeader}>
760
+ <SparkIcon />
761
+ <span>
762
+ No exact match — did you mean
763
+ {recovery.distance === 1 ? ' this' : ''}?
764
+ </span>
765
+ </div>
766
+ <div className={styles.list} ref={listRef} role="presentation">
767
+ {renderRow(recovery.option, 0, 'recovery')}
768
+ </div>
769
+ </div>
770
+ ) : navItems.length > 0 ? (
771
+ <>
772
+ {showRecents && (
773
+ <div className={styles.sectionHeader}>
774
+ <ClockIcon />
775
+ <span>{recentsCfg!.label}</span>
776
+ </div>
777
+ )}
778
+ <div className={styles.list} ref={listRef} role="presentation">
779
+ {navItems.map((opt, idx) =>
780
+ renderRow(opt, idx, showRecents ? 'recent' : 'option'),
781
+ )}
782
+ </div>
783
+ </>
784
+ ) : trimmed.length === 0 ? (
785
+ <div className={styles.empty}>Type to search…</div>
786
+ ) : (
787
+ <div className={styles.empty}>{emptyMessage}</div>
788
+ )}
789
+ </div>
790
+ )}
791
+
792
+ {name && <input type="hidden" name={name} value={value ?? ''} />}
793
+ </div>
794
+
795
+ {error && (
796
+ <p id={`${acId}-error`} className={styles.errorText}>
797
+ {error}
798
+ </p>
799
+ )}
800
+ {!error && helperText && (
801
+ <p id={`${acId}-helper`} className={styles.helperText}>
802
+ {helperText}
803
+ </p>
804
+ )}
805
+ </div>
806
+ );
807
+ },
808
+ );
809
+
810
+ EvoAutoComplete.displayName = 'EvoAutoComplete';