@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.
- package/LICENSE +21 -21
- package/README.md +70 -70
- package/dist/declarations.d.ts +6 -6
- package/package.json +52 -52
- package/src/Alert/Alert.tsx +49 -49
- package/src/AutoComplete/AutoComplete.tsx +810 -810
- package/src/Badge/Badge.tsx +53 -53
- package/src/Breadcrumb/Breadcrumb.tsx +53 -53
- package/src/Button/Button.tsx +125 -125
- package/src/Card/Card.tsx +257 -257
- package/src/Checkbox/Checkbox.tsx +59 -59
- package/src/CommandPalette/CommandPalette.tsx +185 -185
- package/src/Container/Container.tsx +31 -31
- package/src/Divider/Divider.tsx +31 -31
- package/src/Form/Form.tsx +185 -185
- package/src/Grid/Grid.tsx +66 -66
- package/src/ImageCropper/ImageCropper.tsx +911 -911
- package/src/Input/Input.tsx +74 -74
- package/src/Modal/Modal.tsx +77 -77
- package/src/Nav/Nav.tsx +708 -708
- package/src/Notification/Notification.tsx +1503 -1503
- package/src/Pagination/Pagination.tsx +76 -76
- package/src/Radio/Radio.tsx +69 -69
- package/src/RichTextArea/RichTextArea.tsx +886 -886
- package/src/Select/Select.tsx +515 -515
- package/src/Skeleton/Skeleton.tsx +70 -70
- package/src/Stack/Stack.tsx +52 -52
- package/src/Table/Table.tsx +335 -335
- package/src/Tabs/Tabs.tsx +90 -90
- package/src/Theme/ThemeProvider.tsx +253 -253
- package/src/Theme/ThemeToggle.tsx +79 -79
- package/src/Toggle/Toggle.tsx +48 -48
- package/src/Tooltip/Tooltip.tsx +38 -38
- package/src/TopNav/TopNav.tsx +1163 -1163
- package/src/TreeSelect/TreeSelect.tsx +825 -825
- package/src/css/alert.module.scss +93 -93
- package/src/css/autocomplete.module.scss +416 -416
- package/src/css/badge.module.scss +82 -82
- package/src/css/base/_color.scss +159 -159
- package/src/css/base/_theme.scss +237 -237
- package/src/css/base/_variables.scss +161 -161
- package/src/css/breadcrumb.module.scss +50 -50
- package/src/css/button.module.scss +385 -385
- package/src/css/card.module.scss +217 -217
- package/src/css/checkbox.module.scss +123 -123
- package/src/css/commandpalette.module.scss +211 -211
- package/src/css/container.module.scss +18 -18
- package/src/css/divider.module.scss +41 -41
- package/src/css/form.module.scss +245 -245
- package/src/css/imagecropper.module.scss +397 -397
- package/src/css/input.module.scss +89 -89
- package/src/css/modal.module.scss +105 -105
- package/src/css/nav.module.scss +494 -494
- package/src/css/notification.module.scss +691 -691
- package/src/css/pagination.module.scss +63 -63
- package/src/css/radio.module.scss +89 -89
- package/src/css/richtextarea.module.scss +307 -307
- package/src/css/select.module.scss +525 -525
- package/src/css/skeleton.module.scss +30 -30
- package/src/css/table.module.scss +386 -386
- package/src/css/tabs.module.scss +63 -63
- package/src/css/theme-toggle.module.scss +83 -83
- package/src/css/toggle.module.scss +54 -54
- package/src/css/tooltip.module.scss +97 -97
- package/src/css/topnav.module.scss +568 -568
- package/src/css/treeselect.module.scss +558 -558
- package/src/css/utilities/_borders.scss +111 -111
- package/src/css/utilities/_colors.scss +66 -66
- package/src/css/utilities/_effects.scss +216 -216
- package/src/css/utilities/_layout.scss +181 -181
- package/src/css/utilities/_position.scss +75 -75
- package/src/css/utilities/_sizing.scss +138 -138
- package/src/css/utilities/_spacing.scss +99 -99
- package/src/css/utilities/_typography.scss +121 -121
- package/src/css/utilities/index.scss +24 -24
- package/src/declarations.d.ts +6 -6
- 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';
|