@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.
- package/README.md +3 -3
- package/dist/TopNav/TopNav.d.ts +19 -0
- package/dist/declarations.d.ts +6 -6
- package/dist/evo-ui.css +1 -1
- package/dist/index.cjs.js +1 -1
- package/dist/index.es.js +3301 -3197
- 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 -869
- 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 -994
- 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 -120
- 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 -396
- 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,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
|
+
};
|