@justin_evo/evo-ui 1.1.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 (80) hide show
  1. package/README.md +3 -3
  2. package/dist/TopNav/TopNav.d.ts +19 -0
  3. package/dist/declarations.d.ts +6 -6
  4. package/dist/evo-ui.css +1 -1
  5. package/dist/index.cjs.js +1 -1
  6. package/dist/index.es.js +3301 -3197
  7. package/package.json +52 -52
  8. package/src/Alert/Alert.tsx +49 -49
  9. package/src/AutoComplete/AutoComplete.tsx +810 -810
  10. package/src/Badge/Badge.tsx +53 -53
  11. package/src/Breadcrumb/Breadcrumb.tsx +53 -53
  12. package/src/Button/Button.tsx +125 -125
  13. package/src/Card/Card.tsx +257 -257
  14. package/src/Checkbox/Checkbox.tsx +59 -59
  15. package/src/CommandPalette/CommandPalette.tsx +185 -185
  16. package/src/Container/Container.tsx +31 -31
  17. package/src/Divider/Divider.tsx +31 -31
  18. package/src/Form/Form.tsx +185 -185
  19. package/src/Grid/Grid.tsx +66 -66
  20. package/src/ImageCropper/ImageCropper.tsx +911 -911
  21. package/src/Input/Input.tsx +74 -74
  22. package/src/Modal/Modal.tsx +77 -77
  23. package/src/Nav/Nav.tsx +708 -708
  24. package/src/Notification/Notification.tsx +1503 -1503
  25. package/src/Pagination/Pagination.tsx +76 -76
  26. package/src/Radio/Radio.tsx +69 -69
  27. package/src/RichTextArea/RichTextArea.tsx +886 -869
  28. package/src/Select/Select.tsx +515 -515
  29. package/src/Skeleton/Skeleton.tsx +70 -70
  30. package/src/Stack/Stack.tsx +52 -52
  31. package/src/Table/Table.tsx +335 -335
  32. package/src/Tabs/Tabs.tsx +90 -90
  33. package/src/Theme/ThemeProvider.tsx +253 -253
  34. package/src/Theme/ThemeToggle.tsx +79 -79
  35. package/src/Toggle/Toggle.tsx +48 -48
  36. package/src/Tooltip/Tooltip.tsx +38 -38
  37. package/src/TopNav/TopNav.tsx +1163 -994
  38. package/src/TreeSelect/TreeSelect.tsx +825 -825
  39. package/src/css/alert.module.scss +93 -93
  40. package/src/css/autocomplete.module.scss +416 -416
  41. package/src/css/badge.module.scss +82 -82
  42. package/src/css/base/_color.scss +159 -159
  43. package/src/css/base/_theme.scss +237 -237
  44. package/src/css/base/_variables.scss +161 -161
  45. package/src/css/breadcrumb.module.scss +50 -50
  46. package/src/css/button.module.scss +385 -385
  47. package/src/css/card.module.scss +217 -217
  48. package/src/css/checkbox.module.scss +123 -120
  49. package/src/css/commandpalette.module.scss +211 -211
  50. package/src/css/container.module.scss +18 -18
  51. package/src/css/divider.module.scss +41 -41
  52. package/src/css/form.module.scss +245 -245
  53. package/src/css/imagecropper.module.scss +397 -397
  54. package/src/css/input.module.scss +89 -89
  55. package/src/css/modal.module.scss +105 -105
  56. package/src/css/nav.module.scss +494 -494
  57. package/src/css/notification.module.scss +691 -691
  58. package/src/css/pagination.module.scss +63 -63
  59. package/src/css/radio.module.scss +89 -89
  60. package/src/css/richtextarea.module.scss +307 -307
  61. package/src/css/select.module.scss +525 -525
  62. package/src/css/skeleton.module.scss +30 -30
  63. package/src/css/table.module.scss +386 -386
  64. package/src/css/tabs.module.scss +63 -63
  65. package/src/css/theme-toggle.module.scss +83 -83
  66. package/src/css/toggle.module.scss +54 -54
  67. package/src/css/tooltip.module.scss +97 -97
  68. package/src/css/topnav.module.scss +568 -396
  69. package/src/css/treeselect.module.scss +558 -558
  70. package/src/css/utilities/_borders.scss +111 -111
  71. package/src/css/utilities/_colors.scss +66 -66
  72. package/src/css/utilities/_effects.scss +216 -216
  73. package/src/css/utilities/_layout.scss +181 -181
  74. package/src/css/utilities/_position.scss +75 -75
  75. package/src/css/utilities/_sizing.scss +138 -138
  76. package/src/css/utilities/_spacing.scss +99 -99
  77. package/src/css/utilities/_typography.scss +121 -121
  78. package/src/css/utilities/index.scss +24 -24
  79. package/src/declarations.d.ts +6 -6
  80. package/src/index.ts +60 -60
@@ -1,825 +1,825 @@
1
- import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
2
- import styles from '../css/treeselect.module.scss';
3
-
4
- /* ---------------- Types ---------------- */
5
-
6
- export interface TreeNode {
7
- value: string;
8
- label: string;
9
- description?: string;
10
- icon?: React.ReactNode;
11
- disabled?: boolean;
12
- children?: TreeNode[];
13
- /** Marks a node as having children that should be loaded via `loadChildren`. */
14
- isLeaf?: boolean;
15
- }
16
-
17
- export type CheckedStrategy = 'leaf' | 'parent' | 'all';
18
-
19
- export interface EvoTreeSelectProps {
20
- data: TreeNode[];
21
-
22
- /** Controlled value. String for single-select, string[] for multi-select. */
23
- value?: string | string[];
24
- defaultValue?: string | string[];
25
- onChange?: (value: string | string[], nodes: TreeNode | TreeNode[] | null) => void;
26
-
27
- /** Show checkboxes and allow selecting multiple nodes. */
28
- multiple?: boolean;
29
- /** When multi-select, decouple parent/child checking (no cascade). */
30
- checkStrictly?: boolean;
31
- /** How values returned by onChange are filtered when cascading. */
32
- checkedStrategy?: CheckedStrategy;
33
-
34
- /** Expanded node values (controlled). */
35
- expandedKeys?: string[];
36
- defaultExpandedKeys?: string[];
37
- onExpandedChange?: (keys: string[]) => void;
38
- /** Expand every node by default on first open. */
39
- defaultExpandAll?: boolean;
40
-
41
- /** Async children loader: called the first time a node with `isLeaf === false` is expanded. */
42
- loadChildren?: (node: TreeNode) => Promise<TreeNode[]>;
43
-
44
- /** Show an in-menu search field. */
45
- searchable?: boolean;
46
- filter?: (node: TreeNode, query: string) => boolean;
47
-
48
- /** Max number of chips shown before collapsing into "+N". */
49
- maxTagCount?: number;
50
-
51
- label?: string;
52
- placeholder?: string;
53
- helperText?: string;
54
- error?: string;
55
- size?: 'sm' | 'md' | 'lg';
56
- fullWidth?: boolean;
57
- disabled?: boolean;
58
- clearable?: boolean;
59
-
60
- id?: string;
61
- name?: string;
62
- className?: string;
63
- }
64
-
65
- /* ---------------- Icons ---------------- */
66
-
67
- const ChevronDown = () => (
68
- <svg viewBox="0 0 16 16" width="14" height="14" fill="none" aria-hidden="true">
69
- <path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
70
- </svg>
71
- );
72
-
73
- const ChevronRight = () => (
74
- <svg viewBox="0 0 16 16" width="10" height="10" fill="none" aria-hidden="true">
75
- <path d="M6 4l4 4-4 4" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
76
- </svg>
77
- );
78
-
79
- const CheckIcon = () => (
80
- <svg viewBox="0 0 16 16" width="12" height="12" fill="none" aria-hidden="true">
81
- <path d="M3.5 8.5l3 3 6-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
82
- </svg>
83
- );
84
-
85
- const MinusIcon = () => (
86
- <svg viewBox="0 0 16 16" width="10" height="10" fill="none" aria-hidden="true">
87
- <path d="M4 8h8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
88
- </svg>
89
- );
90
-
91
- const ClearIcon = () => (
92
- <svg viewBox="0 0 16 16" width="12" height="12" fill="none" aria-hidden="true">
93
- <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
94
- </svg>
95
- );
96
-
97
- const SearchIcon = () => (
98
- <svg viewBox="0 0 16 16" width="14" height="14" fill="none" aria-hidden="true">
99
- <circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.5" />
100
- <path d="M10.5 10.5L13 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
101
- </svg>
102
- );
103
-
104
- /* ---------------- Helpers ---------------- */
105
-
106
- interface NodeMaps {
107
- nodeByValue: Map<string, TreeNode>;
108
- parentByValue: Map<string, string | null>;
109
- leafValues: Set<string>;
110
- }
111
-
112
- const buildMaps = (data: TreeNode[]): NodeMaps => {
113
- const nodeByValue = new Map<string, TreeNode>();
114
- const parentByValue = new Map<string, string | null>();
115
- const leafValues = new Set<string>();
116
-
117
- const walk = (nodes: TreeNode[], parent: string | null) => {
118
- for (const n of nodes) {
119
- nodeByValue.set(n.value, n);
120
- parentByValue.set(n.value, parent);
121
- if (!n.children || n.children.length === 0) {
122
- if (n.isLeaf !== false) leafValues.add(n.value);
123
- } else {
124
- walk(n.children, n.value);
125
- }
126
- }
127
- };
128
- walk(data, null);
129
- return { nodeByValue, parentByValue, leafValues };
130
- };
131
-
132
- const collectDescendantLeaves = (node: TreeNode, acc: string[] = []): string[] => {
133
- if (!node.children || node.children.length === 0) {
134
- acc.push(node.value);
135
- return acc;
136
- }
137
- for (const c of node.children) collectDescendantLeaves(c, acc);
138
- return acc;
139
- };
140
-
141
- const collectAllDescendants = (node: TreeNode, acc: string[] = []): string[] => {
142
- acc.push(node.value);
143
- if (node.children) for (const c of node.children) collectAllDescendants(c, acc);
144
- return acc;
145
- };
146
-
147
- /** Compute filter result: keep matched nodes + all ancestors. */
148
- const filterTree = (
149
- data: TreeNode[],
150
- query: string,
151
- matcher: (n: TreeNode, q: string) => boolean,
152
- ): { visible: Set<string>; matched: Set<string> } => {
153
- const visible = new Set<string>();
154
- const matched = new Set<string>();
155
-
156
- const walk = (nodes: TreeNode[], ancestors: string[]): boolean => {
157
- let anyChildMatch = false;
158
- for (const n of nodes) {
159
- const isMatch = matcher(n, query);
160
- const childMatch = n.children ? walk(n.children, [...ancestors, n.value]) : false;
161
- if (isMatch || childMatch) {
162
- visible.add(n.value);
163
- ancestors.forEach(a => visible.add(a));
164
- anyChildMatch = true;
165
- if (isMatch) matched.add(n.value);
166
- }
167
- }
168
- return anyChildMatch;
169
- };
170
- walk(data, []);
171
- return { visible, matched };
172
- };
173
-
174
- const defaultFilter = (node: TreeNode, q: string) =>
175
- node.label.toLowerCase().includes(q.toLowerCase());
176
-
177
- /** Highlight matched substring inside a label. */
178
- const renderHighlighted = (label: string, query: string) => {
179
- if (!query) return label;
180
- const idx = label.toLowerCase().indexOf(query.toLowerCase());
181
- if (idx === -1) return label;
182
- return (
183
- <>
184
- {label.slice(0, idx)}
185
- <span className={styles.match}>{label.slice(idx, idx + query.length)}</span>
186
- {label.slice(idx + query.length)}
187
- </>
188
- );
189
- };
190
-
191
- /* ---------------- Component ---------------- */
192
-
193
- export const EvoTreeSelect = ({
194
- data,
195
- value: controlledValue,
196
- defaultValue,
197
- onChange,
198
- multiple = false,
199
- checkStrictly = false,
200
- checkedStrategy = 'leaf',
201
- expandedKeys: controlledExpanded,
202
- defaultExpandedKeys,
203
- onExpandedChange,
204
- defaultExpandAll = false,
205
- loadChildren,
206
- searchable = false,
207
- filter = defaultFilter,
208
- maxTagCount = 3,
209
- label,
210
- placeholder = multiple ? 'Select items' : 'Select an item',
211
- helperText,
212
- error,
213
- size = 'md',
214
- fullWidth = false,
215
- disabled = false,
216
- clearable = false,
217
- id,
218
- name,
219
- className = '',
220
- }: EvoTreeSelectProps) => {
221
- const reactId = useId();
222
- const selectId = id ?? `evo-tree-${reactId}`;
223
- const listId = `${selectId}-tree`;
224
-
225
- /* -------- Value state -------- */
226
- const isControlled = controlledValue !== undefined;
227
- const initial: string[] = useMemo(() => {
228
- const init = isControlled ? controlledValue : defaultValue;
229
- if (init == null) return [];
230
- return Array.isArray(init) ? init : [init];
231
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
232
- const [internalValues, setInternalValues] = useState<string[]>(initial);
233
-
234
- const values: string[] = useMemo(() => {
235
- if (!isControlled) return internalValues;
236
- if (controlledValue == null) return [];
237
- return Array.isArray(controlledValue) ? controlledValue : [controlledValue];
238
- }, [isControlled, controlledValue, internalValues]);
239
-
240
- /* -------- Lazy-loaded children + dynamic data -------- */
241
- const [dynamicChildren, setDynamicChildren] = useState<Record<string, TreeNode[]>>({});
242
- const [loadingNodes, setLoadingNodes] = useState<Set<string>>(new Set());
243
-
244
- /** A view of `data` with any lazily-loaded children merged in. */
245
- const mergedData = useMemo(() => {
246
- if (Object.keys(dynamicChildren).length === 0) return data;
247
- const inject = (nodes: TreeNode[]): TreeNode[] =>
248
- nodes.map(n => {
249
- const loaded = dynamicChildren[n.value];
250
- const children = loaded ?? n.children;
251
- return children ? { ...n, children: inject(children) } : n;
252
- });
253
- return inject(data);
254
- }, [data, dynamicChildren]);
255
-
256
- const maps = useMemo(() => buildMaps(mergedData), [mergedData]);
257
-
258
- /* -------- Expanded state -------- */
259
- const initialExpanded = useMemo(() => {
260
- if (controlledExpanded) return controlledExpanded;
261
- if (defaultExpandedKeys) return defaultExpandedKeys;
262
- if (defaultExpandAll) {
263
- const all: string[] = [];
264
- const walk = (nodes: TreeNode[]) => nodes.forEach(n => {
265
- if (n.children && n.children.length) { all.push(n.value); walk(n.children); }
266
- });
267
- walk(data);
268
- return all;
269
- }
270
- return [];
271
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
272
- const [internalExpanded, setInternalExpanded] = useState<string[]>(initialExpanded);
273
- const expanded = controlledExpanded ?? internalExpanded;
274
- const expandedSet = useMemo(() => new Set(expanded), [expanded]);
275
-
276
- const setExpanded = useCallback((next: string[]) => {
277
- if (controlledExpanded === undefined) setInternalExpanded(next);
278
- onExpandedChange?.(next);
279
- }, [controlledExpanded, onExpandedChange]);
280
-
281
- /* -------- Open / focus / search -------- */
282
- const [open, setOpen] = useState(false);
283
- const [query, setQuery] = useState('');
284
- const [activeIdx, setActiveIdx] = useState(-1);
285
-
286
- const wrapperRef = useRef<HTMLDivElement>(null);
287
- const triggerRef = useRef<HTMLButtonElement>(null);
288
- const searchRef = useRef<HTMLInputElement>(null);
289
- const listRef = useRef<HTMLDivElement>(null);
290
-
291
- /* -------- Filter (search) -------- */
292
- const filterResult = useMemo(() => {
293
- if (!searchable || !query.trim()) return null;
294
- return filterTree(mergedData, query, filter);
295
- }, [mergedData, query, searchable, filter]);
296
-
297
- /* When searching, auto-expand everything visible so matches are reachable. */
298
- const effectiveExpanded = useMemo(() => {
299
- if (filterResult) return filterResult.visible;
300
- return expandedSet;
301
- }, [filterResult, expandedSet]);
302
-
303
- /* -------- Flatten for keyboard nav -------- */
304
- interface FlatRow { node: TreeNode; level: number; hasChildren: boolean }
305
- const flat: FlatRow[] = useMemo(() => {
306
- const rows: FlatRow[] = [];
307
- const walk = (nodes: TreeNode[], level: number) => {
308
- for (const n of nodes) {
309
- if (filterResult && !filterResult.visible.has(n.value)) continue;
310
- const hasChildren =
311
- (n.children && n.children.length > 0) || n.isLeaf === false;
312
- rows.push({ node: n, level, hasChildren });
313
- if (hasChildren && effectiveExpanded.has(n.value) && n.children) {
314
- walk(n.children, level + 1);
315
- }
316
- }
317
- };
318
- walk(mergedData, 0);
319
- return rows;
320
- }, [mergedData, effectiveExpanded, filterResult]);
321
-
322
- /* -------- Selection helpers (cascade tri-state) -------- */
323
- type CheckState = 'checked' | 'mixed' | 'unchecked';
324
-
325
- const valueSet = useMemo(() => new Set(values), [values]);
326
-
327
- const checkStateFor = useCallback((node: TreeNode): CheckState => {
328
- if (!multiple || checkStrictly || !node.children || node.children.length === 0) {
329
- return valueSet.has(node.value) ? 'checked' : 'unchecked';
330
- }
331
- const leaves = collectDescendantLeaves(node);
332
- if (leaves.length === 0) return valueSet.has(node.value) ? 'checked' : 'unchecked';
333
- let checked = 0;
334
- for (const l of leaves) if (valueSet.has(l)) checked++;
335
- if (checked === 0) return 'unchecked';
336
- if (checked === leaves.length) return 'checked';
337
- return 'mixed';
338
- }, [multiple, checkStrictly, valueSet]);
339
-
340
- /** Returned value, filtered to caller's strategy preference. */
341
- const projectValue = useCallback((rawLeaves: Set<string>): string[] => {
342
- if (!multiple || checkStrictly) return Array.from(rawLeaves);
343
- if (checkedStrategy === 'leaf') {
344
- return Array.from(rawLeaves).filter(v => maps.leafValues.has(v));
345
- }
346
- if (checkedStrategy === 'all') {
347
- // Add any fully-checked parents to the leaf set.
348
- const out = new Set(rawLeaves);
349
- const walk = (nodes: TreeNode[]) => {
350
- for (const n of nodes) {
351
- if (n.children && n.children.length) {
352
- walk(n.children);
353
- const leaves = collectDescendantLeaves(n);
354
- if (leaves.every(l => rawLeaves.has(l))) out.add(n.value);
355
- }
356
- }
357
- };
358
- walk(mergedData);
359
- return Array.from(out);
360
- }
361
- // 'parent': collapse fully-checked subtrees to their topmost parent
362
- const result: string[] = [];
363
- const walk = (nodes: TreeNode[]) => {
364
- for (const n of nodes) {
365
- if (n.children && n.children.length) {
366
- const leaves = collectDescendantLeaves(n);
367
- if (leaves.every(l => rawLeaves.has(l))) {
368
- result.push(n.value);
369
- } else {
370
- walk(n.children);
371
- }
372
- } else if (rawLeaves.has(n.value)) {
373
- result.push(n.value);
374
- }
375
- }
376
- };
377
- walk(mergedData);
378
- return result;
379
- }, [multiple, checkStrictly, checkedStrategy, mergedData, maps.leafValues]);
380
-
381
- const commitMulti = useCallback((nextRawLeaves: Set<string>) => {
382
- const projected = projectValue(nextRawLeaves);
383
- if (!isControlled) setInternalValues(projected);
384
- const nodes = projected.map(v => maps.nodeByValue.get(v)).filter(Boolean) as TreeNode[];
385
- onChange?.(projected, nodes);
386
- }, [projectValue, isControlled, maps.nodeByValue, onChange]);
387
-
388
- const commitSingle = useCallback((nextValue: string) => {
389
- if (!isControlled) setInternalValues(nextValue ? [nextValue] : []);
390
- onChange?.(nextValue, nextValue ? maps.nodeByValue.get(nextValue) ?? null : null);
391
- }, [isControlled, maps.nodeByValue, onChange]);
392
-
393
- /** Translate the current `values` into a "raw leaf set" for cascade math. */
394
- const rawLeafSet = useMemo(() => {
395
- if (!multiple || checkStrictly) return new Set(values);
396
- const out = new Set<string>();
397
- for (const v of values) {
398
- const node = maps.nodeByValue.get(v);
399
- if (!node) { out.add(v); continue; }
400
- if (!node.children || node.children.length === 0) {
401
- out.add(v);
402
- } else {
403
- collectDescendantLeaves(node).forEach(l => out.add(l));
404
- }
405
- }
406
- return out;
407
- }, [values, multiple, checkStrictly, maps.nodeByValue]);
408
-
409
- /* -------- Toggle handlers -------- */
410
- const toggleExpand = useCallback((node: TreeNode) => {
411
- const isOpen = expandedSet.has(node.value);
412
- if (isOpen) {
413
- setExpanded(expanded.filter(k => k !== node.value));
414
- return;
415
- }
416
- setExpanded([...expanded, node.value]);
417
- // Lazy load
418
- const hasUnloaded = loadChildren && !dynamicChildren[node.value]
419
- && (node.isLeaf === false || (!node.children && node.isLeaf !== true));
420
- if (hasUnloaded) {
421
- setLoadingNodes(s => new Set(s).add(node.value));
422
- loadChildren!(node).then(kids => {
423
- setDynamicChildren(prev => ({ ...prev, [node.value]: kids }));
424
- }).finally(() => {
425
- setLoadingNodes(s => {
426
- const ns = new Set(s); ns.delete(node.value); return ns;
427
- });
428
- });
429
- }
430
- }, [expanded, expandedSet, setExpanded, loadChildren, dynamicChildren]);
431
-
432
- const handleSelect = useCallback((node: TreeNode) => {
433
- if (node.disabled) return;
434
-
435
- if (!multiple) {
436
- commitSingle(node.value);
437
- setOpen(false);
438
- triggerRef.current?.focus();
439
- return;
440
- }
441
-
442
- // Multi-select with checkboxes
443
- if (checkStrictly) {
444
- const next = new Set(values);
445
- if (next.has(node.value)) next.delete(node.value);
446
- else next.add(node.value);
447
- const arr = Array.from(next);
448
- if (!isControlled) setInternalValues(arr);
449
- const nodes = arr.map(v => maps.nodeByValue.get(v)).filter(Boolean) as TreeNode[];
450
- onChange?.(arr, nodes);
451
- return;
452
- }
453
-
454
- // Cascade
455
- const nextRaw = new Set(rawLeafSet);
456
- const state = checkStateFor(node);
457
- const targets = node.children && node.children.length
458
- ? collectDescendantLeaves(node)
459
- : [node.value];
460
- if (state === 'checked') {
461
- targets.forEach(t => nextRaw.delete(t));
462
- } else {
463
- targets.forEach(t => nextRaw.add(t));
464
- }
465
- commitMulti(nextRaw);
466
- }, [
467
- multiple, checkStrictly, values, rawLeafSet,
468
- checkStateFor, commitMulti, commitSingle,
469
- isControlled, maps.nodeByValue, onChange,
470
- ]);
471
-
472
- /* -------- Open / close lifecycle -------- */
473
- useEffect(() => {
474
- if (!open) return;
475
- const handler = (e: MouseEvent) => {
476
- if (!wrapperRef.current?.contains(e.target as Node)) {
477
- setOpen(false);
478
- setQuery('');
479
- }
480
- };
481
- document.addEventListener('mousedown', handler);
482
- return () => document.removeEventListener('mousedown', handler);
483
- }, [open]);
484
-
485
- useEffect(() => {
486
- if (open) {
487
- // Start cursor on first visible row or first selection.
488
- const firstSelected = flat.findIndex(r => valueSet.has(r.node.value));
489
- setActiveIdx(firstSelected >= 0 ? firstSelected : flat.findIndex(r => !r.node.disabled));
490
- if (searchable) {
491
- const t = setTimeout(() => searchRef.current?.focus(), 30);
492
- return () => clearTimeout(t);
493
- }
494
- } else {
495
- setQuery('');
496
- setActiveIdx(-1);
497
- }
498
- }, [open]); // eslint-disable-line react-hooks/exhaustive-deps
499
-
500
- useEffect(() => {
501
- if (!open || activeIdx < 0) return;
502
- const el = listRef.current?.querySelector(`[data-idx="${activeIdx}"]`) as HTMLElement | null;
503
- el?.scrollIntoView({ block: 'nearest' });
504
- }, [activeIdx, open]);
505
-
506
- /* -------- Keyboard navigation -------- */
507
- const moveActive = (dir: 1 | -1) => {
508
- if (flat.length === 0) return;
509
- let next = activeIdx;
510
- for (let i = 0; i < flat.length; i++) {
511
- next = (next + dir + flat.length) % flat.length;
512
- if (!flat[next].node.disabled) { setActiveIdx(next); return; }
513
- }
514
- };
515
-
516
- const handleKeyDown = (e: React.KeyboardEvent) => {
517
- if (disabled) return;
518
-
519
- if (!open) {
520
- if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown' || e.key === 'ArrowUp') {
521
- e.preventDefault();
522
- setOpen(true);
523
- }
524
- return;
525
- }
526
-
527
- const row = flat[activeIdx];
528
-
529
- if (e.key === 'Escape') {
530
- e.preventDefault();
531
- setOpen(false);
532
- triggerRef.current?.focus();
533
- } else if (e.key === 'ArrowDown') { e.preventDefault(); moveActive(1); }
534
- else if (e.key === 'ArrowUp') { e.preventDefault(); moveActive(-1); }
535
- else if (e.key === 'ArrowRight') {
536
- if (!row) return;
537
- e.preventDefault();
538
- if (row.hasChildren && !expandedSet.has(row.node.value)) {
539
- toggleExpand(row.node);
540
- } else if (row.hasChildren) {
541
- // move to first child
542
- const nextIdx = activeIdx + 1;
543
- if (nextIdx < flat.length && flat[nextIdx].level > row.level) setActiveIdx(nextIdx);
544
- }
545
- } else if (e.key === 'ArrowLeft') {
546
- if (!row) return;
547
- e.preventDefault();
548
- if (row.hasChildren && expandedSet.has(row.node.value)) {
549
- toggleExpand(row.node);
550
- } else {
551
- // Move to parent
552
- for (let i = activeIdx - 1; i >= 0; i--) {
553
- if (flat[i].level < row.level) { setActiveIdx(i); break; }
554
- }
555
- }
556
- } else if (e.key === 'Enter') {
557
- e.preventDefault();
558
- if (row) handleSelect(row.node);
559
- } else if (e.key === ' ' && multiple) {
560
- e.preventDefault();
561
- if (row) handleSelect(row.node);
562
- } else if (e.key === 'Home') {
563
- e.preventDefault();
564
- const idx = flat.findIndex(r => !r.node.disabled);
565
- if (idx >= 0) setActiveIdx(idx);
566
- } else if (e.key === 'End') {
567
- e.preventDefault();
568
- for (let i = flat.length - 1; i >= 0; i--) {
569
- if (!flat[i].node.disabled) { setActiveIdx(i); break; }
570
- }
571
- } else if (e.key === 'Tab') {
572
- setOpen(false);
573
- }
574
- };
575
-
576
- /* -------- Clear -------- */
577
- const handleClear = (e: React.MouseEvent) => {
578
- e.stopPropagation();
579
- if (multiple) commitMulti(new Set());
580
- else commitSingle('');
581
- };
582
-
583
- /* -------- Trigger content -------- */
584
- const renderTrigger = () => {
585
- if (multiple) {
586
- const chips = values
587
- .map(v => maps.nodeByValue.get(v))
588
- .filter(Boolean) as TreeNode[];
589
-
590
- if (chips.length === 0) {
591
- return <span className={styles.triggerPlaceholder}><span className={styles.triggerText}>{placeholder}</span></span>;
592
- }
593
-
594
- const visibleChips = chips.slice(0, maxTagCount);
595
- const overflow = chips.length - visibleChips.length;
596
- return (
597
- <span className={styles.triggerValue}>
598
- {visibleChips.map(c => (
599
- <span key={c.value} className={styles.chip}>
600
- <span className={styles.chipLabel}>{c.label}</span>
601
- <span
602
- role="button"
603
- aria-label={`Remove ${c.label}`}
604
- tabIndex={-1}
605
- className={styles.chipRemove}
606
- onMouseDown={e => e.preventDefault()}
607
- onClick={e => { e.stopPropagation(); handleSelect(c); }}
608
- ><ClearIcon /></span>
609
- </span>
610
- ))}
611
- {overflow > 0 && <span className={styles.chipOverflow}>+{overflow}</span>}
612
- </span>
613
- );
614
- }
615
-
616
- const selected = values[0] ? maps.nodeByValue.get(values[0]) : undefined;
617
- return (
618
- <span className={selected ? styles.triggerValue : styles.triggerPlaceholder}>
619
- {selected?.icon && <span className={styles.rowIcon}>{selected.icon}</span>}
620
- <span className={styles.triggerText}>{selected?.label ?? placeholder}</span>
621
- </span>
622
- );
623
- };
624
-
625
- /* -------- Render a tree row -------- */
626
- const renderRow = (row: FlatRow, idx: number) => {
627
- const { node, level, hasChildren } = row;
628
- const isExpanded = expandedSet.has(node.value);
629
- const state = checkStateFor(node);
630
- const isSelected = !multiple && valueSet.has(node.value);
631
- const isActive = idx === activeIdx;
632
- const isLoading = loadingNodes.has(node.value);
633
-
634
- return (
635
- <div
636
- key={node.value}
637
- id={`${selectId}-row-${idx}`}
638
- role="treeitem"
639
- aria-level={level + 1}
640
- aria-expanded={hasChildren ? isExpanded : undefined}
641
- aria-selected={multiple ? undefined : isSelected}
642
- aria-checked={
643
- multiple
644
- ? state === 'mixed' ? 'mixed' : state === 'checked' ? 'true' : 'false'
645
- : undefined
646
- }
647
- aria-disabled={node.disabled}
648
- data-idx={idx}
649
- className={[
650
- styles.row,
651
- isActive ? styles.rowActive : '',
652
- isSelected ? styles.rowSelected : '',
653
- node.disabled ? styles.rowDisabled : '',
654
- ].filter(Boolean).join(' ')}
655
- onClick={() => handleSelect(node)}
656
- onMouseEnter={() => !node.disabled && setActiveIdx(idx)}
657
- >
658
- <span className={styles.indent} aria-hidden>
659
- {Array.from({ length: level }).map((_, i) => (
660
- <span key={i} className={styles.indentUnit} />
661
- ))}
662
- </span>
663
-
664
- {hasChildren ? (
665
- isLoading ? (
666
- <span className={styles.toggle} aria-hidden><span className={styles.spinner} /></span>
667
- ) : (
668
- <span
669
- className={[styles.toggle, isExpanded ? styles.toggleOpen : ''].filter(Boolean).join(' ')}
670
- role="button"
671
- tabIndex={-1}
672
- aria-label={isExpanded ? 'Collapse' : 'Expand'}
673
- onClick={e => { e.stopPropagation(); toggleExpand(node); }}
674
- onMouseDown={e => e.preventDefault()}
675
- >
676
- <ChevronRight />
677
- </span>
678
- )
679
- ) : (
680
- <span className={styles.togglePlaceholder} aria-hidden />
681
- )}
682
-
683
- {multiple && (
684
- <span
685
- className={[
686
- styles.checkbox,
687
- state === 'checked' ? styles.checkboxChecked : '',
688
- state === 'mixed' ? styles.checkboxMixed : '',
689
- ].filter(Boolean).join(' ')}
690
- aria-hidden
691
- >
692
- {state === 'checked' && <CheckIcon />}
693
- {state === 'mixed' && <MinusIcon />}
694
- </span>
695
- )}
696
-
697
- {node.icon && <span className={styles.rowIcon}>{node.icon}</span>}
698
-
699
- <span className={styles.rowText}>
700
- <span className={styles.rowLabel}>
701
- {query ? renderHighlighted(node.label, query) : node.label}
702
- </span>
703
- {node.description && <span className={styles.rowDesc}>{node.description}</span>}
704
- </span>
705
-
706
- {!multiple && isSelected && (
707
- <span className={styles.check} aria-hidden><CheckIcon /></span>
708
- )}
709
- </div>
710
- );
711
- };
712
-
713
- /* -------- Render -------- */
714
- const hasValue = values.length > 0;
715
- const hiddenInputValue = multiple ? values.join(',') : (values[0] ?? '');
716
-
717
- return (
718
- <div
719
- ref={wrapperRef}
720
- className={[
721
- styles.field,
722
- fullWidth ? styles.fullWidth : '',
723
- disabled ? styles.disabled : '',
724
- className,
725
- ].filter(Boolean).join(' ')}
726
- >
727
- {label && (
728
- <label htmlFor={selectId} className={styles.label}>
729
- {label}
730
- </label>
731
- )}
732
-
733
- <div className={styles.selectWrapper}>
734
- <button
735
- ref={triggerRef}
736
- id={selectId}
737
- type="button"
738
- role="combobox"
739
- aria-haspopup="tree"
740
- aria-expanded={open}
741
- aria-controls={listId}
742
- aria-invalid={!!error}
743
- disabled={disabled}
744
- className={[
745
- styles.trigger,
746
- styles[size],
747
- open ? styles.open : '',
748
- error ? styles.hasError : '',
749
- ].filter(Boolean).join(' ')}
750
- onClick={() => !disabled && setOpen(o => !o)}
751
- onKeyDown={handleKeyDown}
752
- >
753
- {renderTrigger()}
754
-
755
- <span className={styles.triggerActions}>
756
- {clearable && hasValue && !disabled && (
757
- <span
758
- role="button"
759
- tabIndex={-1}
760
- aria-label="Clear selection"
761
- className={styles.clearBtn}
762
- onClick={handleClear}
763
- onMouseDown={e => e.preventDefault()}
764
- ><ClearIcon /></span>
765
- )}
766
- <span className={[styles.chevron, open ? styles.chevronOpen : ''].filter(Boolean).join(' ')}>
767
- <ChevronDown />
768
- </span>
769
- </span>
770
- </button>
771
-
772
- {open && (
773
- <div className={styles.menu}>
774
- {searchable && (
775
- <div className={styles.searchRow}>
776
- <span className={styles.searchIconWrap}><SearchIcon /></span>
777
- <input
778
- ref={searchRef}
779
- type="text"
780
- className={styles.searchInput}
781
- placeholder="Search..."
782
- value={query}
783
- onChange={e => { setQuery(e.target.value); setActiveIdx(0); }}
784
- onKeyDown={handleKeyDown}
785
- />
786
- </div>
787
- )}
788
-
789
- <div
790
- ref={listRef}
791
- className={styles.tree}
792
- role="tree"
793
- id={listId}
794
- aria-labelledby={selectId}
795
- aria-multiselectable={multiple || undefined}
796
- aria-activedescendant={activeIdx >= 0 ? `${selectId}-row-${activeIdx}` : undefined}
797
- >
798
- {flat.length === 0 ? (
799
- <div className={styles.empty}>No results</div>
800
- ) : (
801
- flat.map((row, idx) => renderRow(row, idx))
802
- )}
803
- </div>
804
-
805
- {multiple && hasValue && (
806
- <div className={styles.menuFooter}>
807
- <span>{values.length} selected</span>
808
- <button
809
- type="button"
810
- className={styles.footerBtn}
811
- onClick={() => commitMulti(new Set())}
812
- >Clear all</button>
813
- </div>
814
- )}
815
- </div>
816
- )}
817
-
818
- {name && <input type="hidden" name={name} value={hiddenInputValue} />}
819
- </div>
820
-
821
- {error && <p className={styles.errorText}>{error}</p>}
822
- {!error && helperText && <p className={styles.helperText}>{helperText}</p>}
823
- </div>
824
- );
825
- };
1
+ import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
2
+ import styles from '../css/treeselect.module.scss';
3
+
4
+ /* ---------------- Types ---------------- */
5
+
6
+ export interface TreeNode {
7
+ value: string;
8
+ label: string;
9
+ description?: string;
10
+ icon?: React.ReactNode;
11
+ disabled?: boolean;
12
+ children?: TreeNode[];
13
+ /** Marks a node as having children that should be loaded via `loadChildren`. */
14
+ isLeaf?: boolean;
15
+ }
16
+
17
+ export type CheckedStrategy = 'leaf' | 'parent' | 'all';
18
+
19
+ export interface EvoTreeSelectProps {
20
+ data: TreeNode[];
21
+
22
+ /** Controlled value. String for single-select, string[] for multi-select. */
23
+ value?: string | string[];
24
+ defaultValue?: string | string[];
25
+ onChange?: (value: string | string[], nodes: TreeNode | TreeNode[] | null) => void;
26
+
27
+ /** Show checkboxes and allow selecting multiple nodes. */
28
+ multiple?: boolean;
29
+ /** When multi-select, decouple parent/child checking (no cascade). */
30
+ checkStrictly?: boolean;
31
+ /** How values returned by onChange are filtered when cascading. */
32
+ checkedStrategy?: CheckedStrategy;
33
+
34
+ /** Expanded node values (controlled). */
35
+ expandedKeys?: string[];
36
+ defaultExpandedKeys?: string[];
37
+ onExpandedChange?: (keys: string[]) => void;
38
+ /** Expand every node by default on first open. */
39
+ defaultExpandAll?: boolean;
40
+
41
+ /** Async children loader: called the first time a node with `isLeaf === false` is expanded. */
42
+ loadChildren?: (node: TreeNode) => Promise<TreeNode[]>;
43
+
44
+ /** Show an in-menu search field. */
45
+ searchable?: boolean;
46
+ filter?: (node: TreeNode, query: string) => boolean;
47
+
48
+ /** Max number of chips shown before collapsing into "+N". */
49
+ maxTagCount?: number;
50
+
51
+ label?: string;
52
+ placeholder?: string;
53
+ helperText?: string;
54
+ error?: string;
55
+ size?: 'sm' | 'md' | 'lg';
56
+ fullWidth?: boolean;
57
+ disabled?: boolean;
58
+ clearable?: boolean;
59
+
60
+ id?: string;
61
+ name?: string;
62
+ className?: string;
63
+ }
64
+
65
+ /* ---------------- Icons ---------------- */
66
+
67
+ const ChevronDown = () => (
68
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none" aria-hidden="true">
69
+ <path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
70
+ </svg>
71
+ );
72
+
73
+ const ChevronRight = () => (
74
+ <svg viewBox="0 0 16 16" width="10" height="10" fill="none" aria-hidden="true">
75
+ <path d="M6 4l4 4-4 4" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
76
+ </svg>
77
+ );
78
+
79
+ const CheckIcon = () => (
80
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="none" aria-hidden="true">
81
+ <path d="M3.5 8.5l3 3 6-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
82
+ </svg>
83
+ );
84
+
85
+ const MinusIcon = () => (
86
+ <svg viewBox="0 0 16 16" width="10" height="10" fill="none" aria-hidden="true">
87
+ <path d="M4 8h8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
88
+ </svg>
89
+ );
90
+
91
+ const ClearIcon = () => (
92
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="none" aria-hidden="true">
93
+ <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
94
+ </svg>
95
+ );
96
+
97
+ const SearchIcon = () => (
98
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none" aria-hidden="true">
99
+ <circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.5" />
100
+ <path d="M10.5 10.5L13 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
101
+ </svg>
102
+ );
103
+
104
+ /* ---------------- Helpers ---------------- */
105
+
106
+ interface NodeMaps {
107
+ nodeByValue: Map<string, TreeNode>;
108
+ parentByValue: Map<string, string | null>;
109
+ leafValues: Set<string>;
110
+ }
111
+
112
+ const buildMaps = (data: TreeNode[]): NodeMaps => {
113
+ const nodeByValue = new Map<string, TreeNode>();
114
+ const parentByValue = new Map<string, string | null>();
115
+ const leafValues = new Set<string>();
116
+
117
+ const walk = (nodes: TreeNode[], parent: string | null) => {
118
+ for (const n of nodes) {
119
+ nodeByValue.set(n.value, n);
120
+ parentByValue.set(n.value, parent);
121
+ if (!n.children || n.children.length === 0) {
122
+ if (n.isLeaf !== false) leafValues.add(n.value);
123
+ } else {
124
+ walk(n.children, n.value);
125
+ }
126
+ }
127
+ };
128
+ walk(data, null);
129
+ return { nodeByValue, parentByValue, leafValues };
130
+ };
131
+
132
+ const collectDescendantLeaves = (node: TreeNode, acc: string[] = []): string[] => {
133
+ if (!node.children || node.children.length === 0) {
134
+ acc.push(node.value);
135
+ return acc;
136
+ }
137
+ for (const c of node.children) collectDescendantLeaves(c, acc);
138
+ return acc;
139
+ };
140
+
141
+ const collectAllDescendants = (node: TreeNode, acc: string[] = []): string[] => {
142
+ acc.push(node.value);
143
+ if (node.children) for (const c of node.children) collectAllDescendants(c, acc);
144
+ return acc;
145
+ };
146
+
147
+ /** Compute filter result: keep matched nodes + all ancestors. */
148
+ const filterTree = (
149
+ data: TreeNode[],
150
+ query: string,
151
+ matcher: (n: TreeNode, q: string) => boolean,
152
+ ): { visible: Set<string>; matched: Set<string> } => {
153
+ const visible = new Set<string>();
154
+ const matched = new Set<string>();
155
+
156
+ const walk = (nodes: TreeNode[], ancestors: string[]): boolean => {
157
+ let anyChildMatch = false;
158
+ for (const n of nodes) {
159
+ const isMatch = matcher(n, query);
160
+ const childMatch = n.children ? walk(n.children, [...ancestors, n.value]) : false;
161
+ if (isMatch || childMatch) {
162
+ visible.add(n.value);
163
+ ancestors.forEach(a => visible.add(a));
164
+ anyChildMatch = true;
165
+ if (isMatch) matched.add(n.value);
166
+ }
167
+ }
168
+ return anyChildMatch;
169
+ };
170
+ walk(data, []);
171
+ return { visible, matched };
172
+ };
173
+
174
+ const defaultFilter = (node: TreeNode, q: string) =>
175
+ node.label.toLowerCase().includes(q.toLowerCase());
176
+
177
+ /** Highlight matched substring inside a label. */
178
+ const renderHighlighted = (label: string, query: string) => {
179
+ if (!query) return label;
180
+ const idx = label.toLowerCase().indexOf(query.toLowerCase());
181
+ if (idx === -1) return label;
182
+ return (
183
+ <>
184
+ {label.slice(0, idx)}
185
+ <span className={styles.match}>{label.slice(idx, idx + query.length)}</span>
186
+ {label.slice(idx + query.length)}
187
+ </>
188
+ );
189
+ };
190
+
191
+ /* ---------------- Component ---------------- */
192
+
193
+ export const EvoTreeSelect = ({
194
+ data,
195
+ value: controlledValue,
196
+ defaultValue,
197
+ onChange,
198
+ multiple = false,
199
+ checkStrictly = false,
200
+ checkedStrategy = 'leaf',
201
+ expandedKeys: controlledExpanded,
202
+ defaultExpandedKeys,
203
+ onExpandedChange,
204
+ defaultExpandAll = false,
205
+ loadChildren,
206
+ searchable = false,
207
+ filter = defaultFilter,
208
+ maxTagCount = 3,
209
+ label,
210
+ placeholder = multiple ? 'Select items' : 'Select an item',
211
+ helperText,
212
+ error,
213
+ size = 'md',
214
+ fullWidth = false,
215
+ disabled = false,
216
+ clearable = false,
217
+ id,
218
+ name,
219
+ className = '',
220
+ }: EvoTreeSelectProps) => {
221
+ const reactId = useId();
222
+ const selectId = id ?? `evo-tree-${reactId}`;
223
+ const listId = `${selectId}-tree`;
224
+
225
+ /* -------- Value state -------- */
226
+ const isControlled = controlledValue !== undefined;
227
+ const initial: string[] = useMemo(() => {
228
+ const init = isControlled ? controlledValue : defaultValue;
229
+ if (init == null) return [];
230
+ return Array.isArray(init) ? init : [init];
231
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
232
+ const [internalValues, setInternalValues] = useState<string[]>(initial);
233
+
234
+ const values: string[] = useMemo(() => {
235
+ if (!isControlled) return internalValues;
236
+ if (controlledValue == null) return [];
237
+ return Array.isArray(controlledValue) ? controlledValue : [controlledValue];
238
+ }, [isControlled, controlledValue, internalValues]);
239
+
240
+ /* -------- Lazy-loaded children + dynamic data -------- */
241
+ const [dynamicChildren, setDynamicChildren] = useState<Record<string, TreeNode[]>>({});
242
+ const [loadingNodes, setLoadingNodes] = useState<Set<string>>(new Set());
243
+
244
+ /** A view of `data` with any lazily-loaded children merged in. */
245
+ const mergedData = useMemo(() => {
246
+ if (Object.keys(dynamicChildren).length === 0) return data;
247
+ const inject = (nodes: TreeNode[]): TreeNode[] =>
248
+ nodes.map(n => {
249
+ const loaded = dynamicChildren[n.value];
250
+ const children = loaded ?? n.children;
251
+ return children ? { ...n, children: inject(children) } : n;
252
+ });
253
+ return inject(data);
254
+ }, [data, dynamicChildren]);
255
+
256
+ const maps = useMemo(() => buildMaps(mergedData), [mergedData]);
257
+
258
+ /* -------- Expanded state -------- */
259
+ const initialExpanded = useMemo(() => {
260
+ if (controlledExpanded) return controlledExpanded;
261
+ if (defaultExpandedKeys) return defaultExpandedKeys;
262
+ if (defaultExpandAll) {
263
+ const all: string[] = [];
264
+ const walk = (nodes: TreeNode[]) => nodes.forEach(n => {
265
+ if (n.children && n.children.length) { all.push(n.value); walk(n.children); }
266
+ });
267
+ walk(data);
268
+ return all;
269
+ }
270
+ return [];
271
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
272
+ const [internalExpanded, setInternalExpanded] = useState<string[]>(initialExpanded);
273
+ const expanded = controlledExpanded ?? internalExpanded;
274
+ const expandedSet = useMemo(() => new Set(expanded), [expanded]);
275
+
276
+ const setExpanded = useCallback((next: string[]) => {
277
+ if (controlledExpanded === undefined) setInternalExpanded(next);
278
+ onExpandedChange?.(next);
279
+ }, [controlledExpanded, onExpandedChange]);
280
+
281
+ /* -------- Open / focus / search -------- */
282
+ const [open, setOpen] = useState(false);
283
+ const [query, setQuery] = useState('');
284
+ const [activeIdx, setActiveIdx] = useState(-1);
285
+
286
+ const wrapperRef = useRef<HTMLDivElement>(null);
287
+ const triggerRef = useRef<HTMLButtonElement>(null);
288
+ const searchRef = useRef<HTMLInputElement>(null);
289
+ const listRef = useRef<HTMLDivElement>(null);
290
+
291
+ /* -------- Filter (search) -------- */
292
+ const filterResult = useMemo(() => {
293
+ if (!searchable || !query.trim()) return null;
294
+ return filterTree(mergedData, query, filter);
295
+ }, [mergedData, query, searchable, filter]);
296
+
297
+ /* When searching, auto-expand everything visible so matches are reachable. */
298
+ const effectiveExpanded = useMemo(() => {
299
+ if (filterResult) return filterResult.visible;
300
+ return expandedSet;
301
+ }, [filterResult, expandedSet]);
302
+
303
+ /* -------- Flatten for keyboard nav -------- */
304
+ interface FlatRow { node: TreeNode; level: number; hasChildren: boolean }
305
+ const flat: FlatRow[] = useMemo(() => {
306
+ const rows: FlatRow[] = [];
307
+ const walk = (nodes: TreeNode[], level: number) => {
308
+ for (const n of nodes) {
309
+ if (filterResult && !filterResult.visible.has(n.value)) continue;
310
+ const hasChildren =
311
+ (n.children && n.children.length > 0) || n.isLeaf === false;
312
+ rows.push({ node: n, level, hasChildren });
313
+ if (hasChildren && effectiveExpanded.has(n.value) && n.children) {
314
+ walk(n.children, level + 1);
315
+ }
316
+ }
317
+ };
318
+ walk(mergedData, 0);
319
+ return rows;
320
+ }, [mergedData, effectiveExpanded, filterResult]);
321
+
322
+ /* -------- Selection helpers (cascade tri-state) -------- */
323
+ type CheckState = 'checked' | 'mixed' | 'unchecked';
324
+
325
+ const valueSet = useMemo(() => new Set(values), [values]);
326
+
327
+ const checkStateFor = useCallback((node: TreeNode): CheckState => {
328
+ if (!multiple || checkStrictly || !node.children || node.children.length === 0) {
329
+ return valueSet.has(node.value) ? 'checked' : 'unchecked';
330
+ }
331
+ const leaves = collectDescendantLeaves(node);
332
+ if (leaves.length === 0) return valueSet.has(node.value) ? 'checked' : 'unchecked';
333
+ let checked = 0;
334
+ for (const l of leaves) if (valueSet.has(l)) checked++;
335
+ if (checked === 0) return 'unchecked';
336
+ if (checked === leaves.length) return 'checked';
337
+ return 'mixed';
338
+ }, [multiple, checkStrictly, valueSet]);
339
+
340
+ /** Returned value, filtered to caller's strategy preference. */
341
+ const projectValue = useCallback((rawLeaves: Set<string>): string[] => {
342
+ if (!multiple || checkStrictly) return Array.from(rawLeaves);
343
+ if (checkedStrategy === 'leaf') {
344
+ return Array.from(rawLeaves).filter(v => maps.leafValues.has(v));
345
+ }
346
+ if (checkedStrategy === 'all') {
347
+ // Add any fully-checked parents to the leaf set.
348
+ const out = new Set(rawLeaves);
349
+ const walk = (nodes: TreeNode[]) => {
350
+ for (const n of nodes) {
351
+ if (n.children && n.children.length) {
352
+ walk(n.children);
353
+ const leaves = collectDescendantLeaves(n);
354
+ if (leaves.every(l => rawLeaves.has(l))) out.add(n.value);
355
+ }
356
+ }
357
+ };
358
+ walk(mergedData);
359
+ return Array.from(out);
360
+ }
361
+ // 'parent': collapse fully-checked subtrees to their topmost parent
362
+ const result: string[] = [];
363
+ const walk = (nodes: TreeNode[]) => {
364
+ for (const n of nodes) {
365
+ if (n.children && n.children.length) {
366
+ const leaves = collectDescendantLeaves(n);
367
+ if (leaves.every(l => rawLeaves.has(l))) {
368
+ result.push(n.value);
369
+ } else {
370
+ walk(n.children);
371
+ }
372
+ } else if (rawLeaves.has(n.value)) {
373
+ result.push(n.value);
374
+ }
375
+ }
376
+ };
377
+ walk(mergedData);
378
+ return result;
379
+ }, [multiple, checkStrictly, checkedStrategy, mergedData, maps.leafValues]);
380
+
381
+ const commitMulti = useCallback((nextRawLeaves: Set<string>) => {
382
+ const projected = projectValue(nextRawLeaves);
383
+ if (!isControlled) setInternalValues(projected);
384
+ const nodes = projected.map(v => maps.nodeByValue.get(v)).filter(Boolean) as TreeNode[];
385
+ onChange?.(projected, nodes);
386
+ }, [projectValue, isControlled, maps.nodeByValue, onChange]);
387
+
388
+ const commitSingle = useCallback((nextValue: string) => {
389
+ if (!isControlled) setInternalValues(nextValue ? [nextValue] : []);
390
+ onChange?.(nextValue, nextValue ? maps.nodeByValue.get(nextValue) ?? null : null);
391
+ }, [isControlled, maps.nodeByValue, onChange]);
392
+
393
+ /** Translate the current `values` into a "raw leaf set" for cascade math. */
394
+ const rawLeafSet = useMemo(() => {
395
+ if (!multiple || checkStrictly) return new Set(values);
396
+ const out = new Set<string>();
397
+ for (const v of values) {
398
+ const node = maps.nodeByValue.get(v);
399
+ if (!node) { out.add(v); continue; }
400
+ if (!node.children || node.children.length === 0) {
401
+ out.add(v);
402
+ } else {
403
+ collectDescendantLeaves(node).forEach(l => out.add(l));
404
+ }
405
+ }
406
+ return out;
407
+ }, [values, multiple, checkStrictly, maps.nodeByValue]);
408
+
409
+ /* -------- Toggle handlers -------- */
410
+ const toggleExpand = useCallback((node: TreeNode) => {
411
+ const isOpen = expandedSet.has(node.value);
412
+ if (isOpen) {
413
+ setExpanded(expanded.filter(k => k !== node.value));
414
+ return;
415
+ }
416
+ setExpanded([...expanded, node.value]);
417
+ // Lazy load
418
+ const hasUnloaded = loadChildren && !dynamicChildren[node.value]
419
+ && (node.isLeaf === false || (!node.children && node.isLeaf !== true));
420
+ if (hasUnloaded) {
421
+ setLoadingNodes(s => new Set(s).add(node.value));
422
+ loadChildren!(node).then(kids => {
423
+ setDynamicChildren(prev => ({ ...prev, [node.value]: kids }));
424
+ }).finally(() => {
425
+ setLoadingNodes(s => {
426
+ const ns = new Set(s); ns.delete(node.value); return ns;
427
+ });
428
+ });
429
+ }
430
+ }, [expanded, expandedSet, setExpanded, loadChildren, dynamicChildren]);
431
+
432
+ const handleSelect = useCallback((node: TreeNode) => {
433
+ if (node.disabled) return;
434
+
435
+ if (!multiple) {
436
+ commitSingle(node.value);
437
+ setOpen(false);
438
+ triggerRef.current?.focus();
439
+ return;
440
+ }
441
+
442
+ // Multi-select with checkboxes
443
+ if (checkStrictly) {
444
+ const next = new Set(values);
445
+ if (next.has(node.value)) next.delete(node.value);
446
+ else next.add(node.value);
447
+ const arr = Array.from(next);
448
+ if (!isControlled) setInternalValues(arr);
449
+ const nodes = arr.map(v => maps.nodeByValue.get(v)).filter(Boolean) as TreeNode[];
450
+ onChange?.(arr, nodes);
451
+ return;
452
+ }
453
+
454
+ // Cascade
455
+ const nextRaw = new Set(rawLeafSet);
456
+ const state = checkStateFor(node);
457
+ const targets = node.children && node.children.length
458
+ ? collectDescendantLeaves(node)
459
+ : [node.value];
460
+ if (state === 'checked') {
461
+ targets.forEach(t => nextRaw.delete(t));
462
+ } else {
463
+ targets.forEach(t => nextRaw.add(t));
464
+ }
465
+ commitMulti(nextRaw);
466
+ }, [
467
+ multiple, checkStrictly, values, rawLeafSet,
468
+ checkStateFor, commitMulti, commitSingle,
469
+ isControlled, maps.nodeByValue, onChange,
470
+ ]);
471
+
472
+ /* -------- Open / close lifecycle -------- */
473
+ useEffect(() => {
474
+ if (!open) return;
475
+ const handler = (e: MouseEvent) => {
476
+ if (!wrapperRef.current?.contains(e.target as Node)) {
477
+ setOpen(false);
478
+ setQuery('');
479
+ }
480
+ };
481
+ document.addEventListener('mousedown', handler);
482
+ return () => document.removeEventListener('mousedown', handler);
483
+ }, [open]);
484
+
485
+ useEffect(() => {
486
+ if (open) {
487
+ // Start cursor on first visible row or first selection.
488
+ const firstSelected = flat.findIndex(r => valueSet.has(r.node.value));
489
+ setActiveIdx(firstSelected >= 0 ? firstSelected : flat.findIndex(r => !r.node.disabled));
490
+ if (searchable) {
491
+ const t = setTimeout(() => searchRef.current?.focus(), 30);
492
+ return () => clearTimeout(t);
493
+ }
494
+ } else {
495
+ setQuery('');
496
+ setActiveIdx(-1);
497
+ }
498
+ }, [open]); // eslint-disable-line react-hooks/exhaustive-deps
499
+
500
+ useEffect(() => {
501
+ if (!open || activeIdx < 0) return;
502
+ const el = listRef.current?.querySelector(`[data-idx="${activeIdx}"]`) as HTMLElement | null;
503
+ el?.scrollIntoView({ block: 'nearest' });
504
+ }, [activeIdx, open]);
505
+
506
+ /* -------- Keyboard navigation -------- */
507
+ const moveActive = (dir: 1 | -1) => {
508
+ if (flat.length === 0) return;
509
+ let next = activeIdx;
510
+ for (let i = 0; i < flat.length; i++) {
511
+ next = (next + dir + flat.length) % flat.length;
512
+ if (!flat[next].node.disabled) { setActiveIdx(next); return; }
513
+ }
514
+ };
515
+
516
+ const handleKeyDown = (e: React.KeyboardEvent) => {
517
+ if (disabled) return;
518
+
519
+ if (!open) {
520
+ if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown' || e.key === 'ArrowUp') {
521
+ e.preventDefault();
522
+ setOpen(true);
523
+ }
524
+ return;
525
+ }
526
+
527
+ const row = flat[activeIdx];
528
+
529
+ if (e.key === 'Escape') {
530
+ e.preventDefault();
531
+ setOpen(false);
532
+ triggerRef.current?.focus();
533
+ } else if (e.key === 'ArrowDown') { e.preventDefault(); moveActive(1); }
534
+ else if (e.key === 'ArrowUp') { e.preventDefault(); moveActive(-1); }
535
+ else if (e.key === 'ArrowRight') {
536
+ if (!row) return;
537
+ e.preventDefault();
538
+ if (row.hasChildren && !expandedSet.has(row.node.value)) {
539
+ toggleExpand(row.node);
540
+ } else if (row.hasChildren) {
541
+ // move to first child
542
+ const nextIdx = activeIdx + 1;
543
+ if (nextIdx < flat.length && flat[nextIdx].level > row.level) setActiveIdx(nextIdx);
544
+ }
545
+ } else if (e.key === 'ArrowLeft') {
546
+ if (!row) return;
547
+ e.preventDefault();
548
+ if (row.hasChildren && expandedSet.has(row.node.value)) {
549
+ toggleExpand(row.node);
550
+ } else {
551
+ // Move to parent
552
+ for (let i = activeIdx - 1; i >= 0; i--) {
553
+ if (flat[i].level < row.level) { setActiveIdx(i); break; }
554
+ }
555
+ }
556
+ } else if (e.key === 'Enter') {
557
+ e.preventDefault();
558
+ if (row) handleSelect(row.node);
559
+ } else if (e.key === ' ' && multiple) {
560
+ e.preventDefault();
561
+ if (row) handleSelect(row.node);
562
+ } else if (e.key === 'Home') {
563
+ e.preventDefault();
564
+ const idx = flat.findIndex(r => !r.node.disabled);
565
+ if (idx >= 0) setActiveIdx(idx);
566
+ } else if (e.key === 'End') {
567
+ e.preventDefault();
568
+ for (let i = flat.length - 1; i >= 0; i--) {
569
+ if (!flat[i].node.disabled) { setActiveIdx(i); break; }
570
+ }
571
+ } else if (e.key === 'Tab') {
572
+ setOpen(false);
573
+ }
574
+ };
575
+
576
+ /* -------- Clear -------- */
577
+ const handleClear = (e: React.MouseEvent) => {
578
+ e.stopPropagation();
579
+ if (multiple) commitMulti(new Set());
580
+ else commitSingle('');
581
+ };
582
+
583
+ /* -------- Trigger content -------- */
584
+ const renderTrigger = () => {
585
+ if (multiple) {
586
+ const chips = values
587
+ .map(v => maps.nodeByValue.get(v))
588
+ .filter(Boolean) as TreeNode[];
589
+
590
+ if (chips.length === 0) {
591
+ return <span className={styles.triggerPlaceholder}><span className={styles.triggerText}>{placeholder}</span></span>;
592
+ }
593
+
594
+ const visibleChips = chips.slice(0, maxTagCount);
595
+ const overflow = chips.length - visibleChips.length;
596
+ return (
597
+ <span className={styles.triggerValue}>
598
+ {visibleChips.map(c => (
599
+ <span key={c.value} className={styles.chip}>
600
+ <span className={styles.chipLabel}>{c.label}</span>
601
+ <span
602
+ role="button"
603
+ aria-label={`Remove ${c.label}`}
604
+ tabIndex={-1}
605
+ className={styles.chipRemove}
606
+ onMouseDown={e => e.preventDefault()}
607
+ onClick={e => { e.stopPropagation(); handleSelect(c); }}
608
+ ><ClearIcon /></span>
609
+ </span>
610
+ ))}
611
+ {overflow > 0 && <span className={styles.chipOverflow}>+{overflow}</span>}
612
+ </span>
613
+ );
614
+ }
615
+
616
+ const selected = values[0] ? maps.nodeByValue.get(values[0]) : undefined;
617
+ return (
618
+ <span className={selected ? styles.triggerValue : styles.triggerPlaceholder}>
619
+ {selected?.icon && <span className={styles.rowIcon}>{selected.icon}</span>}
620
+ <span className={styles.triggerText}>{selected?.label ?? placeholder}</span>
621
+ </span>
622
+ );
623
+ };
624
+
625
+ /* -------- Render a tree row -------- */
626
+ const renderRow = (row: FlatRow, idx: number) => {
627
+ const { node, level, hasChildren } = row;
628
+ const isExpanded = expandedSet.has(node.value);
629
+ const state = checkStateFor(node);
630
+ const isSelected = !multiple && valueSet.has(node.value);
631
+ const isActive = idx === activeIdx;
632
+ const isLoading = loadingNodes.has(node.value);
633
+
634
+ return (
635
+ <div
636
+ key={node.value}
637
+ id={`${selectId}-row-${idx}`}
638
+ role="treeitem"
639
+ aria-level={level + 1}
640
+ aria-expanded={hasChildren ? isExpanded : undefined}
641
+ aria-selected={multiple ? undefined : isSelected}
642
+ aria-checked={
643
+ multiple
644
+ ? state === 'mixed' ? 'mixed' : state === 'checked' ? 'true' : 'false'
645
+ : undefined
646
+ }
647
+ aria-disabled={node.disabled}
648
+ data-idx={idx}
649
+ className={[
650
+ styles.row,
651
+ isActive ? styles.rowActive : '',
652
+ isSelected ? styles.rowSelected : '',
653
+ node.disabled ? styles.rowDisabled : '',
654
+ ].filter(Boolean).join(' ')}
655
+ onClick={() => handleSelect(node)}
656
+ onMouseEnter={() => !node.disabled && setActiveIdx(idx)}
657
+ >
658
+ <span className={styles.indent} aria-hidden>
659
+ {Array.from({ length: level }).map((_, i) => (
660
+ <span key={i} className={styles.indentUnit} />
661
+ ))}
662
+ </span>
663
+
664
+ {hasChildren ? (
665
+ isLoading ? (
666
+ <span className={styles.toggle} aria-hidden><span className={styles.spinner} /></span>
667
+ ) : (
668
+ <span
669
+ className={[styles.toggle, isExpanded ? styles.toggleOpen : ''].filter(Boolean).join(' ')}
670
+ role="button"
671
+ tabIndex={-1}
672
+ aria-label={isExpanded ? 'Collapse' : 'Expand'}
673
+ onClick={e => { e.stopPropagation(); toggleExpand(node); }}
674
+ onMouseDown={e => e.preventDefault()}
675
+ >
676
+ <ChevronRight />
677
+ </span>
678
+ )
679
+ ) : (
680
+ <span className={styles.togglePlaceholder} aria-hidden />
681
+ )}
682
+
683
+ {multiple && (
684
+ <span
685
+ className={[
686
+ styles.checkbox,
687
+ state === 'checked' ? styles.checkboxChecked : '',
688
+ state === 'mixed' ? styles.checkboxMixed : '',
689
+ ].filter(Boolean).join(' ')}
690
+ aria-hidden
691
+ >
692
+ {state === 'checked' && <CheckIcon />}
693
+ {state === 'mixed' && <MinusIcon />}
694
+ </span>
695
+ )}
696
+
697
+ {node.icon && <span className={styles.rowIcon}>{node.icon}</span>}
698
+
699
+ <span className={styles.rowText}>
700
+ <span className={styles.rowLabel}>
701
+ {query ? renderHighlighted(node.label, query) : node.label}
702
+ </span>
703
+ {node.description && <span className={styles.rowDesc}>{node.description}</span>}
704
+ </span>
705
+
706
+ {!multiple && isSelected && (
707
+ <span className={styles.check} aria-hidden><CheckIcon /></span>
708
+ )}
709
+ </div>
710
+ );
711
+ };
712
+
713
+ /* -------- Render -------- */
714
+ const hasValue = values.length > 0;
715
+ const hiddenInputValue = multiple ? values.join(',') : (values[0] ?? '');
716
+
717
+ return (
718
+ <div
719
+ ref={wrapperRef}
720
+ className={[
721
+ styles.field,
722
+ fullWidth ? styles.fullWidth : '',
723
+ disabled ? styles.disabled : '',
724
+ className,
725
+ ].filter(Boolean).join(' ')}
726
+ >
727
+ {label && (
728
+ <label htmlFor={selectId} className={styles.label}>
729
+ {label}
730
+ </label>
731
+ )}
732
+
733
+ <div className={styles.selectWrapper}>
734
+ <button
735
+ ref={triggerRef}
736
+ id={selectId}
737
+ type="button"
738
+ role="combobox"
739
+ aria-haspopup="tree"
740
+ aria-expanded={open}
741
+ aria-controls={listId}
742
+ aria-invalid={!!error}
743
+ disabled={disabled}
744
+ className={[
745
+ styles.trigger,
746
+ styles[size],
747
+ open ? styles.open : '',
748
+ error ? styles.hasError : '',
749
+ ].filter(Boolean).join(' ')}
750
+ onClick={() => !disabled && setOpen(o => !o)}
751
+ onKeyDown={handleKeyDown}
752
+ >
753
+ {renderTrigger()}
754
+
755
+ <span className={styles.triggerActions}>
756
+ {clearable && hasValue && !disabled && (
757
+ <span
758
+ role="button"
759
+ tabIndex={-1}
760
+ aria-label="Clear selection"
761
+ className={styles.clearBtn}
762
+ onClick={handleClear}
763
+ onMouseDown={e => e.preventDefault()}
764
+ ><ClearIcon /></span>
765
+ )}
766
+ <span className={[styles.chevron, open ? styles.chevronOpen : ''].filter(Boolean).join(' ')}>
767
+ <ChevronDown />
768
+ </span>
769
+ </span>
770
+ </button>
771
+
772
+ {open && (
773
+ <div className={styles.menu}>
774
+ {searchable && (
775
+ <div className={styles.searchRow}>
776
+ <span className={styles.searchIconWrap}><SearchIcon /></span>
777
+ <input
778
+ ref={searchRef}
779
+ type="text"
780
+ className={styles.searchInput}
781
+ placeholder="Search..."
782
+ value={query}
783
+ onChange={e => { setQuery(e.target.value); setActiveIdx(0); }}
784
+ onKeyDown={handleKeyDown}
785
+ />
786
+ </div>
787
+ )}
788
+
789
+ <div
790
+ ref={listRef}
791
+ className={styles.tree}
792
+ role="tree"
793
+ id={listId}
794
+ aria-labelledby={selectId}
795
+ aria-multiselectable={multiple || undefined}
796
+ aria-activedescendant={activeIdx >= 0 ? `${selectId}-row-${activeIdx}` : undefined}
797
+ >
798
+ {flat.length === 0 ? (
799
+ <div className={styles.empty}>No results</div>
800
+ ) : (
801
+ flat.map((row, idx) => renderRow(row, idx))
802
+ )}
803
+ </div>
804
+
805
+ {multiple && hasValue && (
806
+ <div className={styles.menuFooter}>
807
+ <span>{values.length} selected</span>
808
+ <button
809
+ type="button"
810
+ className={styles.footerBtn}
811
+ onClick={() => commitMulti(new Set())}
812
+ >Clear all</button>
813
+ </div>
814
+ )}
815
+ </div>
816
+ )}
817
+
818
+ {name && <input type="hidden" name={name} value={hiddenInputValue} />}
819
+ </div>
820
+
821
+ {error && <p className={styles.errorText}>{error}</p>}
822
+ {!error && helperText && <p className={styles.helperText}>{helperText}</p>}
823
+ </div>
824
+ );
825
+ };