@repraze/kernelkit 0.1.0-alpha.4 → 0.1.0-alpha.6
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/dist/components/alert-dialog.d.ts.map +1 -1
- package/dist/components/alert-dialog.js +4 -1
- package/dist/components/alert-dialog.js.map +1 -1
- package/dist/components/alert.js +3 -3
- package/dist/components/alert.js.map +1 -1
- package/dist/components/badge.js +1 -1
- package/dist/components/badge.js.map +1 -1
- package/dist/components/button.js +1 -1
- package/dist/components/button.js.map +1 -1
- package/dist/components/calendar.d.ts +8 -1
- package/dist/components/calendar.d.ts.map +1 -1
- package/dist/components/calendar.js +33 -11
- package/dist/components/calendar.js.map +1 -1
- package/dist/components/callout.js +2 -2
- package/dist/components/callout.js.map +1 -1
- package/dist/components/combobox.js +1 -1
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/date-picker.d.ts +11 -5
- package/dist/components/date-picker.d.ts.map +1 -1
- package/dist/components/date-picker.js +29 -7
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/description-list.d.ts +26 -0
- package/dist/components/description-list.d.ts.map +1 -0
- package/dist/components/description-list.js +59 -0
- package/dist/components/description-list.js.map +1 -0
- package/dist/components/locale.d.ts +19 -0
- package/dist/components/locale.d.ts.map +1 -0
- package/dist/components/locale.js +54 -0
- package/dist/components/locale.js.map +1 -0
- package/dist/components/pager.d.ts +1 -1
- package/dist/components/pager.d.ts.map +1 -1
- package/dist/components/pager.js +4 -4
- package/dist/components/pager.js.map +1 -1
- package/dist/components/popover.d.ts +1 -1
- package/dist/components/popover.d.ts.map +1 -1
- package/dist/components/popover.js +2 -2
- package/dist/components/popover.js.map +1 -1
- package/dist/components/progress.d.ts +19 -6
- package/dist/components/progress.d.ts.map +1 -1
- package/dist/components/progress.js +18 -3
- package/dist/components/progress.js.map +1 -1
- package/dist/components/search-trigger.d.ts +14 -4
- package/dist/components/search-trigger.d.ts.map +1 -1
- package/dist/components/search-trigger.js +25 -4
- package/dist/components/search-trigger.js.map +1 -1
- package/dist/components/shimmer.d.ts +13 -0
- package/dist/components/shimmer.d.ts.map +1 -0
- package/dist/components/shimmer.js +21 -0
- package/dist/components/shimmer.js.map +1 -0
- package/dist/components/spinner.d.ts +28 -6
- package/dist/components/spinner.d.ts.map +1 -1
- package/dist/components/spinner.js +66 -14
- package/dist/components/spinner.js.map +1 -1
- package/dist/components/stepper.d.ts +64 -0
- package/dist/components/stepper.d.ts.map +1 -0
- package/dist/components/stepper.js +167 -0
- package/dist/components/stepper.js.map +1 -0
- package/dist/components/tag-input.d.ts +47 -0
- package/dist/components/tag-input.d.ts.map +1 -0
- package/dist/components/tag-input.js +211 -0
- package/dist/components/tag-input.js.map +1 -0
- package/dist/components/time-input.d.ts +75 -0
- package/dist/components/time-input.d.ts.map +1 -0
- package/dist/components/time-input.js +341 -0
- package/dist/components/time-input.js.map +1 -0
- package/dist/components/time-select.d.ts +49 -0
- package/dist/components/time-select.d.ts.map +1 -0
- package/dist/components/time-select.js +178 -0
- package/dist/components/time-select.js.map +1 -0
- package/dist/components/timeline.d.ts +41 -0
- package/dist/components/timeline.d.ts.map +1 -0
- package/dist/components/timeline.js +83 -0
- package/dist/components/timeline.js.map +1 -0
- package/dist/components/toast.d.ts.map +1 -1
- package/dist/components/toast.js +4 -2
- package/dist/components/toast.js.map +1 -1
- package/dist/components/tree.d.ts +87 -0
- package/dist/components/tree.d.ts.map +1 -0
- package/dist/components/tree.js +516 -0
- package/dist/components/tree.js.map +1 -0
- package/dist/hooks/use-time-format.d.ts +23 -0
- package/dist/hooks/use-time-format.d.ts.map +1 -0
- package/dist/hooks/use-time-format.js +52 -0
- package/dist/hooks/use-time-format.js.map +1 -0
- package/dist/lib/color.d.ts +5 -1
- package/dist/lib/color.d.ts.map +1 -1
- package/dist/lib/color.js +14 -2
- package/dist/lib/color.js.map +1 -1
- package/dist/lib/recipes.d.ts +11 -6
- package/dist/lib/recipes.d.ts.map +1 -1
- package/dist/lib/recipes.js +13 -7
- package/dist/lib/recipes.js.map +1 -1
- package/dist/site/suite-header.d.ts +4 -1
- package/dist/site/suite-header.d.ts.map +1 -1
- package/dist/site/suite-header.js +7 -2
- package/dist/site/suite-header.js.map +1 -1
- package/package.json +1 -1
- package/src/components/alert-dialog.tsx +4 -1
- package/src/components/alert.tsx +3 -3
- package/src/components/badge.tsx +1 -1
- package/src/components/button.tsx +1 -1
- package/src/components/calendar.tsx +48 -4
- package/src/components/callout.tsx +2 -2
- package/src/components/combobox.tsx +1 -1
- package/src/components/date-picker.tsx +43 -9
- package/src/components/description-list.tsx +107 -0
- package/src/components/locale.tsx +67 -0
- package/src/components/pager.tsx +8 -8
- package/src/components/popover.tsx +3 -1
- package/src/components/progress.tsx +70 -4
- package/src/components/search-trigger.tsx +40 -7
- package/src/components/shimmer.tsx +36 -0
- package/src/components/spinner.tsx +126 -23
- package/src/components/stepper.tsx +346 -0
- package/src/components/tag-input.tsx +345 -0
- package/src/components/time-input.tsx +578 -0
- package/src/components/time-select.tsx +291 -0
- package/src/components/timeline.tsx +190 -0
- package/src/components/toast.tsx +4 -4
- package/src/components/tree.tsx +832 -0
- package/src/hooks/use-time-format.ts +72 -0
- package/src/lib/color.ts +19 -2
- package/src/lib/recipes.ts +13 -7
- package/src/site/suite-header.tsx +25 -4
- package/src/styles/kernelkit.css +56 -0
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {checkboxVariants} from "@repraze/kernelkit/components/checkbox";
|
|
4
|
+
import {Spinner} from "@repraze/kernelkit/components/spinner";
|
|
5
|
+
import {rowIndicator} from "@repraze/kernelkit/lib/recipes";
|
|
6
|
+
import {useVirtualizer} from "@tanstack/react-virtual";
|
|
7
|
+
import {cva} from "class-variance-authority";
|
|
8
|
+
import {cn} from "cn";
|
|
9
|
+
import {
|
|
10
|
+
CheckIcon,
|
|
11
|
+
ChevronRightIcon,
|
|
12
|
+
FileCodeIcon,
|
|
13
|
+
FileIcon,
|
|
14
|
+
FileImageIcon,
|
|
15
|
+
FileJsonIcon,
|
|
16
|
+
FileTextIcon,
|
|
17
|
+
FolderIcon,
|
|
18
|
+
FolderOpenIcon,
|
|
19
|
+
MinusIcon,
|
|
20
|
+
} from "lucide-react";
|
|
21
|
+
import * as React from "react";
|
|
22
|
+
|
|
23
|
+
/* ------------------------------------------------------------------------------------------------
|
|
24
|
+
* Data
|
|
25
|
+
* ----------------------------------------------------------------------------------------------*/
|
|
26
|
+
|
|
27
|
+
interface TreeNode<T = unknown> {
|
|
28
|
+
/** Unique across the whole tree. */
|
|
29
|
+
id: string;
|
|
30
|
+
label: React.ReactNode;
|
|
31
|
+
/** Text for type-ahead and the default filter when `label` is not a string. */
|
|
32
|
+
textValue?: string;
|
|
33
|
+
/** Leading icon (FileTree picks one when omitted). */
|
|
34
|
+
icon?: React.ReactNode;
|
|
35
|
+
/** Trailing slot: a badge, a status dot, a size. */
|
|
36
|
+
meta?: React.ReactNode;
|
|
37
|
+
children?: TreeNode<T>[];
|
|
38
|
+
/** A branch whose children are not loaded yet: expanding it calls `onLoadChildren`. */
|
|
39
|
+
hasChildren?: boolean;
|
|
40
|
+
disabled?: boolean;
|
|
41
|
+
data?: T;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type TreeSelectionMode = "none" | "single" | "multiple" | "checkbox";
|
|
45
|
+
type TreeCheckState = "checked" | "indeterminate" | "unchecked";
|
|
46
|
+
|
|
47
|
+
/** One visible row: the flattened tree is a plain array, so expand/collapse and virtualization are O(rows). */
|
|
48
|
+
interface TreeRow<T = unknown> {
|
|
49
|
+
node: TreeNode<T>;
|
|
50
|
+
level: number;
|
|
51
|
+
parentId: string | null;
|
|
52
|
+
posInSet: number;
|
|
53
|
+
setSize: number;
|
|
54
|
+
isBranch: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const textOf = (node: TreeNode<unknown>) =>
|
|
58
|
+
node.textValue ?? (typeof node.label === "string" || typeof node.label === "number" ? String(node.label) : "");
|
|
59
|
+
|
|
60
|
+
const isBranch = (node: TreeNode<unknown>) => !!node.children?.length || !!node.hasChildren;
|
|
61
|
+
|
|
62
|
+
/** id → node and id → parent id, built once per `nodes`. */
|
|
63
|
+
function indexTree<T>(nodes: TreeNode<T>[]) {
|
|
64
|
+
const byId = new Map<string, TreeNode<T>>();
|
|
65
|
+
const parentOf = new Map<string, string | null>();
|
|
66
|
+
const stack: [TreeNode<T>, string | null][] = nodes.map((n) => [n, null]);
|
|
67
|
+
while (stack.length) {
|
|
68
|
+
const [node, parent] = stack.pop()!;
|
|
69
|
+
byId.set(node.id, node);
|
|
70
|
+
parentOf.set(node.id, parent);
|
|
71
|
+
for (const child of node.children ?? []) {
|
|
72
|
+
stack.push([child, node.id]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return {byId, parentOf};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Ids kept by a filter: matches plus their ancestors. Branches holding a match are shown expanded. */
|
|
79
|
+
function filterTree<T>(nodes: TreeNode<T>[], match: (node: TreeNode<T>) => boolean) {
|
|
80
|
+
const keep = new Set<string>();
|
|
81
|
+
const visit = (node: TreeNode<T>): boolean => {
|
|
82
|
+
let kept = match(node);
|
|
83
|
+
for (const child of node.children ?? []) {
|
|
84
|
+
kept = visit(child) || kept;
|
|
85
|
+
}
|
|
86
|
+
if (kept) {
|
|
87
|
+
keep.add(node.id);
|
|
88
|
+
}
|
|
89
|
+
return kept;
|
|
90
|
+
};
|
|
91
|
+
nodes.forEach(visit);
|
|
92
|
+
return keep;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function flattenTree<T>(nodes: TreeNode<T>[], expanded: ReadonlySet<string>, keep: ReadonlySet<string> | null) {
|
|
96
|
+
const rows: TreeRow<T>[] = [];
|
|
97
|
+
const walk = (list: TreeNode<T>[], level: number, parentId: string | null) => {
|
|
98
|
+
const shown = keep ? list.filter((n) => keep.has(n.id)) : list;
|
|
99
|
+
shown.forEach((node, i) => {
|
|
100
|
+
const branch = isBranch(node);
|
|
101
|
+
rows.push({node, level, parentId, posInSet: i + 1, setSize: shown.length, isBranch: branch});
|
|
102
|
+
const open = keep ? branch : expanded.has(node.id);
|
|
103
|
+
if (open && node.children) {
|
|
104
|
+
walk(node.children, level + 1, node.id);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
walk(nodes, 0, null);
|
|
109
|
+
return rows;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function descendantIds<T>(node: TreeNode<T>, out: string[] = []) {
|
|
113
|
+
for (const child of node.children ?? []) {
|
|
114
|
+
if (!child.disabled) {
|
|
115
|
+
out.push(child.id);
|
|
116
|
+
descendantIds(child, out);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Checkbox mode: a branch is checked when every enabled child is, indeterminate when some are. */
|
|
123
|
+
function checkStates<T>(nodes: TreeNode<T>[], checked: ReadonlySet<string>) {
|
|
124
|
+
const states = new Map<string, TreeCheckState>();
|
|
125
|
+
const visit = (node: TreeNode<T>): TreeCheckState => {
|
|
126
|
+
const children = (node.children ?? []).filter((c) => !c.disabled);
|
|
127
|
+
if (!children.length) {
|
|
128
|
+
const state = checked.has(node.id) ? "checked" : "unchecked";
|
|
129
|
+
states.set(node.id, state);
|
|
130
|
+
node.children?.forEach(visit);
|
|
131
|
+
return state;
|
|
132
|
+
}
|
|
133
|
+
const childStates = (node.children ?? []).map((c) => [c.disabled, visit(c)] as const);
|
|
134
|
+
const enabled = childStates.filter(([disabled]) => !disabled).map(([, s]) => s);
|
|
135
|
+
const state: TreeCheckState = enabled.every((s) => s === "checked")
|
|
136
|
+
? "checked"
|
|
137
|
+
: enabled.some((s) => s !== "unchecked")
|
|
138
|
+
? "indeterminate"
|
|
139
|
+
: "unchecked";
|
|
140
|
+
states.set(node.id, state);
|
|
141
|
+
return state;
|
|
142
|
+
};
|
|
143
|
+
nodes.forEach(visit);
|
|
144
|
+
return states;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Uncontrolled-or-controlled `string[]` state, exposed as a Set. */
|
|
148
|
+
function useIdSet(value: string[] | undefined, defaultValue: string[] | undefined, onChange?: (ids: string[]) => void) {
|
|
149
|
+
const [inner, setInner] = React.useState<string[]>(defaultValue ?? []);
|
|
150
|
+
const current = value ?? inner;
|
|
151
|
+
const set = React.useMemo(() => new Set(current), [current]);
|
|
152
|
+
const controlled = value !== undefined;
|
|
153
|
+
const onChangeRef = React.useRef(onChange);
|
|
154
|
+
onChangeRef.current = onChange;
|
|
155
|
+
const update = React.useCallback(
|
|
156
|
+
(next: Set<string>) => {
|
|
157
|
+
const ids = [...next];
|
|
158
|
+
if (!controlled) {
|
|
159
|
+
setInner(ids);
|
|
160
|
+
}
|
|
161
|
+
onChangeRef.current?.(ids);
|
|
162
|
+
},
|
|
163
|
+
[controlled],
|
|
164
|
+
);
|
|
165
|
+
return [set, update] as const;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* ------------------------------------------------------------------------------------------------
|
|
169
|
+
* Styles
|
|
170
|
+
* ----------------------------------------------------------------------------------------------*/
|
|
171
|
+
|
|
172
|
+
const treeVariants = cva(
|
|
173
|
+
"group/tree relative flex w-full min-w-0 flex-col text-sm outline-none select-none data-virtual:overflow-y-auto",
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Row content. Follows the sidebar (GUIDELINES §7c): hover wash, the selected row gets the accent tint,
|
|
178
|
+
* medium weight and a primary icon, with the 2px indicator on the tree line (nested) or the row's left
|
|
179
|
+
* edge (top level). The keyboard-focused row gets an inset ring while the tree has visible focus.
|
|
180
|
+
*/
|
|
181
|
+
const treeItemVariants = cva(
|
|
182
|
+
[
|
|
183
|
+
"relative flex min-w-0 flex-1 items-center gap-1.5 rounded-md ps-1 pe-2 text-muted-foreground",
|
|
184
|
+
"transition-[color,background-color,box-shadow] duration-100 motion-reduce:transition-none",
|
|
185
|
+
"group-data-[branch]/tree-item:text-foreground group-hover/tree-item:bg-hover group-hover/tree-item:text-foreground",
|
|
186
|
+
"group-focus-visible/tree:group-data-focused/tree-item:inset-ring-2 group-focus-visible/tree:group-data-focused/tree-item:inset-ring-ring",
|
|
187
|
+
rowIndicator,
|
|
188
|
+
"group-data-[level='0']/tree-item:group-data-selected/tree-item:after:opacity-100",
|
|
189
|
+
"group-data-selected/tree-item:bg-primary-tint group-data-selected/tree-item:font-medium group-data-selected/tree-item:text-foreground group-data-selected/tree-item:[&_[data-slot=tree-item-icon]>svg:not([class*='text-'])]:text-primary-text",
|
|
190
|
+
"group-data-disabled/tree-item:opacity-50",
|
|
191
|
+
"[&_svg]:pointer-events-none [&_svg]:shrink-0",
|
|
192
|
+
],
|
|
193
|
+
{
|
|
194
|
+
variants: {
|
|
195
|
+
size: {
|
|
196
|
+
xs: "h-6 text-xs [&_[data-slot=tree-item-icon]>svg]:size-3.5",
|
|
197
|
+
sm: "h-7 text-[0.8rem] [&_[data-slot=tree-item-icon]>svg]:size-3.5",
|
|
198
|
+
default: "h-8 text-sm [&_[data-slot=tree-item-icon]>svg]:size-4",
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
defaultVariants: {size: "default"},
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const ROW_HEIGHT = {xs: 24, sm: 28, default: 32} as const;
|
|
206
|
+
|
|
207
|
+
/* ------------------------------------------------------------------------------------------------
|
|
208
|
+
* Tree
|
|
209
|
+
* ----------------------------------------------------------------------------------------------*/
|
|
210
|
+
|
|
211
|
+
interface TreeRenderState {
|
|
212
|
+
level: number;
|
|
213
|
+
expanded: boolean;
|
|
214
|
+
selected: boolean;
|
|
215
|
+
checked: TreeCheckState;
|
|
216
|
+
loading: boolean;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
interface TreeProps<T = unknown> extends Omit<React.ComponentProps<"div">, "children" | "onSelect"> {
|
|
220
|
+
nodes: TreeNode<T>[];
|
|
221
|
+
/** `none`: click only expands. `single`/`multiple` (Ctrl/⌘ toggles, Shift extends). `checkbox`: tri-state boxes. */
|
|
222
|
+
selectionMode?: TreeSelectionMode;
|
|
223
|
+
/** Selected ids (checked ids in checkbox mode, parents included when all their children are). */
|
|
224
|
+
selected?: string[];
|
|
225
|
+
defaultSelected?: string[];
|
|
226
|
+
onSelectedChange?: (ids: string[]) => void;
|
|
227
|
+
/** Expanded branch ids. */
|
|
228
|
+
expanded?: string[];
|
|
229
|
+
defaultExpanded?: string[];
|
|
230
|
+
onExpandedChange?: (ids: string[]) => void;
|
|
231
|
+
/** Enter or click on a row (open a file, navigate). */
|
|
232
|
+
onAction?: (node: TreeNode<T>) => void;
|
|
233
|
+
/** Load a lazy branch (`hasChildren` without `children`); the row shows a spinner until it resolves. */
|
|
234
|
+
onLoadChildren?: (node: TreeNode<T>) => Promise<void>;
|
|
235
|
+
/** Show only matching nodes and their ancestors (expanded). */
|
|
236
|
+
filter?: string | ((node: TreeNode<T>) => boolean);
|
|
237
|
+
/** Clicking a branch row toggles it as well as selecting it. */
|
|
238
|
+
toggleOnClick?: boolean;
|
|
239
|
+
/** Render only the rows in view. Give the tree a height (`className="h-80"`): it becomes the scroller. */
|
|
240
|
+
virtual?: boolean;
|
|
241
|
+
/** Indent guides under each branch. */
|
|
242
|
+
guides?: boolean;
|
|
243
|
+
size?: "xs" | "sm" | "default";
|
|
244
|
+
/** Custom label content; the row keeps its toggle, checkbox, icon and meta slots. */
|
|
245
|
+
renderLabel?: (node: TreeNode<T>, state: TreeRenderState) => React.ReactNode;
|
|
246
|
+
/** Icon when the node has none (FileTree uses this). */
|
|
247
|
+
getIcon?: (node: TreeNode<T>, state: TreeRenderState) => React.ReactNode;
|
|
248
|
+
/** Shown when there are no rows (nothing matches the filter). */
|
|
249
|
+
empty?: React.ReactNode;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function Tree<T = unknown>({
|
|
253
|
+
nodes,
|
|
254
|
+
selectionMode = "single",
|
|
255
|
+
selected: selectedProp,
|
|
256
|
+
defaultSelected,
|
|
257
|
+
onSelectedChange,
|
|
258
|
+
expanded: expandedProp,
|
|
259
|
+
defaultExpanded,
|
|
260
|
+
onExpandedChange,
|
|
261
|
+
onAction,
|
|
262
|
+
onLoadChildren,
|
|
263
|
+
filter,
|
|
264
|
+
toggleOnClick = true,
|
|
265
|
+
virtual = false,
|
|
266
|
+
guides = true,
|
|
267
|
+
size = "default",
|
|
268
|
+
renderLabel,
|
|
269
|
+
getIcon,
|
|
270
|
+
empty,
|
|
271
|
+
className,
|
|
272
|
+
id: idProp,
|
|
273
|
+
onKeyDown,
|
|
274
|
+
onFocus,
|
|
275
|
+
...props
|
|
276
|
+
}: TreeProps<T>) {
|
|
277
|
+
const autoId = React.useId();
|
|
278
|
+
const treeId = idProp ?? autoId;
|
|
279
|
+
const rowId = (nodeId: string) => `${treeId}-${nodeId}`;
|
|
280
|
+
|
|
281
|
+
const [expanded, setExpanded] = useIdSet(expandedProp, defaultExpanded, onExpandedChange);
|
|
282
|
+
const [selected, setSelected] = useIdSet(selectedProp, defaultSelected, onSelectedChange);
|
|
283
|
+
const [loading, setLoading] = React.useState<ReadonlySet<string>>(() => new Set());
|
|
284
|
+
|
|
285
|
+
const index = React.useMemo(() => indexTree(nodes), [nodes]);
|
|
286
|
+
const keep = React.useMemo(() => {
|
|
287
|
+
if (filter === undefined || filter === "") {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
if (typeof filter === "function") {
|
|
291
|
+
return filterTree(nodes, filter);
|
|
292
|
+
}
|
|
293
|
+
const q = filter.toLowerCase();
|
|
294
|
+
return filterTree(nodes, (n) => textOf(n).toLowerCase().includes(q));
|
|
295
|
+
}, [nodes, filter]);
|
|
296
|
+
const rows = React.useMemo(() => flattenTree(nodes, expanded, keep), [nodes, expanded, keep]);
|
|
297
|
+
const rowIndex = React.useMemo(() => new Map(rows.map((r, i) => [r.node.id, i])), [rows]);
|
|
298
|
+
const checks = React.useMemo(
|
|
299
|
+
() => (selectionMode === "checkbox" ? checkStates(nodes, selected) : null),
|
|
300
|
+
[selectionMode, nodes, selected],
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
const [focusedId, setFocusedId] = React.useState<string | null>(null);
|
|
304
|
+
// tabbing in lands on the first visible selected row (WAI-ARIA tree), else the first row; this also
|
|
305
|
+
// covers a focused row that disappeared (collapsed ancestor, filter)
|
|
306
|
+
const focused =
|
|
307
|
+
focusedId !== null && rowIndex.has(focusedId)
|
|
308
|
+
? focusedId
|
|
309
|
+
: (rows.find((r) => selected.has(r.node.id))?.node.id ?? rows[0]?.node.id ?? null);
|
|
310
|
+
const anchor = React.useRef<string | null>(null);
|
|
311
|
+
|
|
312
|
+
/* ---------- actions ---------- */
|
|
313
|
+
|
|
314
|
+
const toggleExpanded = (node: TreeNode<T>, open?: boolean) => {
|
|
315
|
+
if (!isBranch(node) || keep) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const isOpen = expanded.has(node.id);
|
|
319
|
+
const next = open ?? !isOpen;
|
|
320
|
+
if (next === isOpen) {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const ids = new Set(expanded);
|
|
324
|
+
if (next) {
|
|
325
|
+
ids.add(node.id);
|
|
326
|
+
} else {
|
|
327
|
+
ids.delete(node.id);
|
|
328
|
+
}
|
|
329
|
+
setExpanded(ids);
|
|
330
|
+
if (next && !node.children && node.hasChildren && onLoadChildren && !loading.has(node.id)) {
|
|
331
|
+
setLoading((s) => new Set(s).add(node.id));
|
|
332
|
+
onLoadChildren(node).finally(() =>
|
|
333
|
+
setLoading((s) => {
|
|
334
|
+
const n = new Set(s);
|
|
335
|
+
n.delete(node.id);
|
|
336
|
+
return n;
|
|
337
|
+
}),
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const toggleChecked = (node: TreeNode<T>) => {
|
|
343
|
+
const state = checks?.get(node.id) ?? "unchecked";
|
|
344
|
+
const ids = new Set(selected);
|
|
345
|
+
const affected = [node.id, ...descendantIds(node)];
|
|
346
|
+
for (const id of affected) {
|
|
347
|
+
if (state === "checked") {
|
|
348
|
+
ids.delete(id);
|
|
349
|
+
} else {
|
|
350
|
+
ids.add(id);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// ancestors follow their children
|
|
354
|
+
let parent = index.parentOf.get(node.id) ?? null;
|
|
355
|
+
while (parent) {
|
|
356
|
+
const p = index.byId.get(parent)!;
|
|
357
|
+
const enabled = (p.children ?? []).filter((c) => !c.disabled);
|
|
358
|
+
if (enabled.every((c) => ids.has(c.id))) {
|
|
359
|
+
ids.add(parent);
|
|
360
|
+
} else {
|
|
361
|
+
ids.delete(parent);
|
|
362
|
+
}
|
|
363
|
+
parent = index.parentOf.get(parent) ?? null;
|
|
364
|
+
}
|
|
365
|
+
setSelected(ids);
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const select = (node: TreeNode<T>, mode: "replace" | "toggle" | "range") => {
|
|
369
|
+
if (node.disabled || selectionMode === "none") {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (selectionMode === "checkbox") {
|
|
373
|
+
toggleChecked(node);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (selectionMode === "single" || mode === "replace") {
|
|
377
|
+
anchor.current = node.id;
|
|
378
|
+
setSelected(new Set([node.id]));
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (mode === "toggle") {
|
|
382
|
+
anchor.current = node.id;
|
|
383
|
+
const ids = new Set(selected);
|
|
384
|
+
if (ids.has(node.id)) {
|
|
385
|
+
ids.delete(node.id);
|
|
386
|
+
} else {
|
|
387
|
+
ids.add(node.id);
|
|
388
|
+
}
|
|
389
|
+
setSelected(ids);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const from = rowIndex.get(anchor.current ?? node.id) ?? rowIndex.get(node.id)!;
|
|
393
|
+
const to = rowIndex.get(node.id)!;
|
|
394
|
+
const [a, b] = from < to ? [from, to] : [to, from];
|
|
395
|
+
setSelected(
|
|
396
|
+
new Set(
|
|
397
|
+
rows
|
|
398
|
+
.slice(a, b + 1)
|
|
399
|
+
.filter((r) => !r.node.disabled)
|
|
400
|
+
.map((r) => r.node.id),
|
|
401
|
+
),
|
|
402
|
+
);
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
/* ---------- focus & scrolling ---------- */
|
|
406
|
+
|
|
407
|
+
const rootRef = React.useRef<HTMLDivElement | null>(null);
|
|
408
|
+
const [scrollEl, setScrollEl] = React.useState<HTMLDivElement | null>(null);
|
|
409
|
+
const virtualizer = useVirtualizer({
|
|
410
|
+
count: virtual ? rows.length : 0,
|
|
411
|
+
getScrollElement: () => scrollEl,
|
|
412
|
+
estimateSize: () => ROW_HEIGHT[size],
|
|
413
|
+
overscan: 12,
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const focusRow = (i: number) => {
|
|
417
|
+
const row = rows[Math.max(0, Math.min(rows.length - 1, i))];
|
|
418
|
+
if (!row) {
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
setFocusedId(row.node.id);
|
|
422
|
+
if (virtual) {
|
|
423
|
+
virtualizer.scrollToIndex(rowIndex.get(row.node.id)!, {align: "auto"});
|
|
424
|
+
} else {
|
|
425
|
+
document.getElementById(rowId(row.node.id))?.scrollIntoView({block: "nearest"});
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
/* ---------- keyboard (WAI-ARIA tree pattern) ---------- */
|
|
430
|
+
|
|
431
|
+
const typeahead = React.useRef({text: "", at: 0});
|
|
432
|
+
|
|
433
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
434
|
+
onKeyDown?.(e);
|
|
435
|
+
if (e.defaultPrevented || !rows.length) {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const i = focused !== null ? rowIndex.get(focused)! : 0;
|
|
439
|
+
const row = rows[i]!;
|
|
440
|
+
const node = row.node;
|
|
441
|
+
const multi = selectionMode === "multiple";
|
|
442
|
+
const handled = () => e.preventDefault();
|
|
443
|
+
|
|
444
|
+
switch (e.key) {
|
|
445
|
+
case "ArrowDown":
|
|
446
|
+
case "ArrowUp": {
|
|
447
|
+
handled();
|
|
448
|
+
const next = i + (e.key === "ArrowDown" ? 1 : -1);
|
|
449
|
+
focusRow(next);
|
|
450
|
+
if (multi && e.shiftKey && rows[next]) {
|
|
451
|
+
select(rows[next].node, "range");
|
|
452
|
+
}
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
case "ArrowRight":
|
|
456
|
+
handled();
|
|
457
|
+
if (row.isBranch && !keep && !expanded.has(node.id)) {
|
|
458
|
+
toggleExpanded(node, true);
|
|
459
|
+
} else if (row.isBranch && rows[i + 1]?.parentId === node.id) {
|
|
460
|
+
focusRow(i + 1);
|
|
461
|
+
}
|
|
462
|
+
return;
|
|
463
|
+
case "ArrowLeft":
|
|
464
|
+
handled();
|
|
465
|
+
if (row.isBranch && !keep && expanded.has(node.id)) {
|
|
466
|
+
toggleExpanded(node, false);
|
|
467
|
+
} else if (row.parentId !== null) {
|
|
468
|
+
focusRow(rowIndex.get(row.parentId)!);
|
|
469
|
+
}
|
|
470
|
+
return;
|
|
471
|
+
case "Home":
|
|
472
|
+
handled();
|
|
473
|
+
focusRow(0);
|
|
474
|
+
return;
|
|
475
|
+
case "End":
|
|
476
|
+
handled();
|
|
477
|
+
focusRow(rows.length - 1);
|
|
478
|
+
return;
|
|
479
|
+
case "Enter":
|
|
480
|
+
handled();
|
|
481
|
+
if (selectionMode !== "checkbox") {
|
|
482
|
+
select(node, "replace");
|
|
483
|
+
}
|
|
484
|
+
if (onAction) {
|
|
485
|
+
onAction(node);
|
|
486
|
+
} else {
|
|
487
|
+
toggleExpanded(node);
|
|
488
|
+
}
|
|
489
|
+
return;
|
|
490
|
+
case " ":
|
|
491
|
+
handled();
|
|
492
|
+
if (selectionMode === "none") {
|
|
493
|
+
toggleExpanded(node);
|
|
494
|
+
} else {
|
|
495
|
+
select(node, multi ? (e.shiftKey ? "range" : "toggle") : "replace");
|
|
496
|
+
}
|
|
497
|
+
return;
|
|
498
|
+
case "*": {
|
|
499
|
+
handled();
|
|
500
|
+
const siblings = rows.filter((r) => r.parentId === row.parentId && r.isBranch).map((r) => r.node.id);
|
|
501
|
+
setExpanded(new Set([...expanded, ...siblings]));
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
case "a":
|
|
505
|
+
if (multi && (e.ctrlKey || e.metaKey)) {
|
|
506
|
+
handled();
|
|
507
|
+
setSelected(new Set(rows.filter((r) => !r.node.disabled).map((r) => r.node.id)));
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// type-ahead: printable characters jump to the next row whose text starts with what was typed
|
|
514
|
+
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
515
|
+
const now = Date.now();
|
|
516
|
+
const t = typeahead.current;
|
|
517
|
+
t.text = now - t.at > 600 ? e.key.toLowerCase() : t.text + e.key.toLowerCase();
|
|
518
|
+
t.at = now;
|
|
519
|
+
const start = t.text.length === 1 ? i + 1 : i;
|
|
520
|
+
for (let k = 0; k < rows.length; k++) {
|
|
521
|
+
const j = (start + k) % rows.length;
|
|
522
|
+
if (textOf(rows[j]!.node).toLowerCase().startsWith(t.text)) {
|
|
523
|
+
handled();
|
|
524
|
+
focusRow(j);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
const handleRowClick = (e: React.MouseEvent, row: TreeRow<T>) => {
|
|
532
|
+
const node = row.node;
|
|
533
|
+
setFocusedId(node.id);
|
|
534
|
+
if (selectionMode === "checkbox") {
|
|
535
|
+
if (row.isBranch && toggleOnClick) {
|
|
536
|
+
toggleExpanded(node);
|
|
537
|
+
} else {
|
|
538
|
+
toggleChecked(node);
|
|
539
|
+
}
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
select(node, e.shiftKey ? "range" : e.metaKey || e.ctrlKey ? "toggle" : "replace");
|
|
543
|
+
if (row.isBranch && toggleOnClick && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
|
|
544
|
+
toggleExpanded(node);
|
|
545
|
+
}
|
|
546
|
+
if (!row.isBranch || !toggleOnClick) {
|
|
547
|
+
onAction?.(node);
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
/* ---------- render ---------- */
|
|
552
|
+
|
|
553
|
+
// actions read the latest render's state through this ref, so memoized rows don't re-render on every change
|
|
554
|
+
const handlers = React.useRef({handleRowClick, toggleExpanded, toggleChecked});
|
|
555
|
+
handlers.current = {handleRowClick, toggleExpanded, toggleChecked};
|
|
556
|
+
const ctx = React.useMemo<RowContext<T>>(
|
|
557
|
+
() => ({
|
|
558
|
+
size,
|
|
559
|
+
guides,
|
|
560
|
+
selectionMode,
|
|
561
|
+
renderLabel,
|
|
562
|
+
getIcon,
|
|
563
|
+
onRowClick: (e, row) => handlers.current.handleRowClick(e, row),
|
|
564
|
+
onToggle: (row) => handlers.current.toggleExpanded(row.node),
|
|
565
|
+
onCheck: (row) => handlers.current.toggleChecked(row.node),
|
|
566
|
+
}),
|
|
567
|
+
[size, guides, selectionMode, renderLabel, getIcon],
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
const renderRow = (row: TreeRow<T>, style?: React.CSSProperties) => (
|
|
571
|
+
<TreeItemRow<T>
|
|
572
|
+
key={row.node.id}
|
|
573
|
+
id={rowId(row.node.id)}
|
|
574
|
+
row={row}
|
|
575
|
+
ctx={ctx}
|
|
576
|
+
style={style}
|
|
577
|
+
expanded={keep ? row.isBranch : expanded.has(row.node.id)}
|
|
578
|
+
selected={selectionMode !== "checkbox" && selected.has(row.node.id)}
|
|
579
|
+
checked={checks?.get(row.node.id) ?? "unchecked"}
|
|
580
|
+
loading={loading.has(row.node.id)}
|
|
581
|
+
focused={row.node.id === focused}
|
|
582
|
+
/>
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
return (
|
|
586
|
+
<div
|
|
587
|
+
ref={(el) => {
|
|
588
|
+
rootRef.current = el;
|
|
589
|
+
setScrollEl(el);
|
|
590
|
+
}}
|
|
591
|
+
id={treeId}
|
|
592
|
+
role="tree"
|
|
593
|
+
tabIndex={rows.length ? 0 : -1}
|
|
594
|
+
aria-multiselectable={selectionMode === "multiple" || selectionMode === "checkbox" || undefined}
|
|
595
|
+
aria-activedescendant={focused !== null ? rowId(focused) : undefined}
|
|
596
|
+
data-slot="tree"
|
|
597
|
+
data-size={size}
|
|
598
|
+
data-virtual={virtual ? "" : undefined}
|
|
599
|
+
className={cn(treeVariants(), className)}
|
|
600
|
+
onKeyDown={handleKeyDown}
|
|
601
|
+
onFocus={(e) => {
|
|
602
|
+
onFocus?.(e);
|
|
603
|
+
if (focusedId === null && focused !== null) {
|
|
604
|
+
setFocusedId(focused);
|
|
605
|
+
}
|
|
606
|
+
}}
|
|
607
|
+
{...props}
|
|
608
|
+
>
|
|
609
|
+
{!rows.length && empty}
|
|
610
|
+
{virtual ? (
|
|
611
|
+
<div
|
|
612
|
+
data-slot="tree-virtual-content"
|
|
613
|
+
role="presentation"
|
|
614
|
+
className="relative w-full shrink-0"
|
|
615
|
+
style={{height: virtualizer.getTotalSize()}}
|
|
616
|
+
>
|
|
617
|
+
{virtualizer.getVirtualItems().map((item) =>
|
|
618
|
+
renderRow(rows[item.index]!, {
|
|
619
|
+
position: "absolute",
|
|
620
|
+
top: 0,
|
|
621
|
+
left: 0,
|
|
622
|
+
width: "100%",
|
|
623
|
+
height: item.size,
|
|
624
|
+
transform: `translateY(${item.start}px)`,
|
|
625
|
+
}),
|
|
626
|
+
)}
|
|
627
|
+
</div>
|
|
628
|
+
) : (
|
|
629
|
+
rows.map((row) => renderRow(row))
|
|
630
|
+
)}
|
|
631
|
+
</div>
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/* ------------------------------------------------------------------------------------------------
|
|
636
|
+
* Row
|
|
637
|
+
* ----------------------------------------------------------------------------------------------*/
|
|
638
|
+
|
|
639
|
+
interface RowContext<T> {
|
|
640
|
+
size: "xs" | "sm" | "default";
|
|
641
|
+
guides: boolean;
|
|
642
|
+
selectionMode: TreeSelectionMode;
|
|
643
|
+
renderLabel?: (node: TreeNode<T>, state: TreeRenderState) => React.ReactNode;
|
|
644
|
+
getIcon?: (node: TreeNode<T>, state: TreeRenderState) => React.ReactNode;
|
|
645
|
+
onRowClick: (e: React.MouseEvent, row: TreeRow<T>) => void;
|
|
646
|
+
onToggle: (row: TreeRow<T>) => void;
|
|
647
|
+
onCheck: (row: TreeRow<T>) => void;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
interface TreeItemRowProps<T> {
|
|
651
|
+
id: string;
|
|
652
|
+
row: TreeRow<T>;
|
|
653
|
+
ctx: RowContext<T>;
|
|
654
|
+
style?: React.CSSProperties;
|
|
655
|
+
expanded: boolean;
|
|
656
|
+
selected: boolean;
|
|
657
|
+
checked: TreeCheckState;
|
|
658
|
+
loading: boolean;
|
|
659
|
+
focused: boolean;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function TreeItemRowImpl<T>({id, row, ctx, style, expanded, selected, checked, loading, focused}: TreeItemRowProps<T>) {
|
|
663
|
+
const {node, level, isBranch: branch} = row;
|
|
664
|
+
const state: TreeRenderState = {level, expanded, selected, checked, loading};
|
|
665
|
+
const icon = node.icon ?? ctx.getIcon?.(node, state);
|
|
666
|
+
const checkbox = ctx.selectionMode === "checkbox";
|
|
667
|
+
|
|
668
|
+
return (
|
|
669
|
+
<div
|
|
670
|
+
id={id}
|
|
671
|
+
role="treeitem"
|
|
672
|
+
aria-level={level + 1}
|
|
673
|
+
aria-posinset={row.posInSet}
|
|
674
|
+
aria-setsize={row.setSize}
|
|
675
|
+
aria-expanded={branch ? expanded : undefined}
|
|
676
|
+
aria-selected={!checkbox && ctx.selectionMode !== "none" ? selected : undefined}
|
|
677
|
+
aria-checked={checkbox ? (checked === "indeterminate" ? "mixed" : checked === "checked") : undefined}
|
|
678
|
+
aria-disabled={node.disabled || undefined}
|
|
679
|
+
aria-busy={loading || undefined}
|
|
680
|
+
data-slot="tree-item"
|
|
681
|
+
data-level={level}
|
|
682
|
+
data-branch={branch ? "" : undefined}
|
|
683
|
+
data-expanded={branch && expanded ? "" : undefined}
|
|
684
|
+
data-selected={selected ? "true" : undefined}
|
|
685
|
+
data-focused={focused ? "" : undefined}
|
|
686
|
+
data-disabled={node.disabled ? "" : undefined}
|
|
687
|
+
className="group/tree-item flex cursor-pointer items-stretch ps-1"
|
|
688
|
+
style={style}
|
|
689
|
+
onClick={(e) => ctx.onRowClick(e, row)}
|
|
690
|
+
>
|
|
691
|
+
{ctx.guides
|
|
692
|
+
? Array.from({length: level}, (_, d) => (
|
|
693
|
+
<span
|
|
694
|
+
key={d}
|
|
695
|
+
aria-hidden
|
|
696
|
+
data-slot="tree-guide"
|
|
697
|
+
className={cn(
|
|
698
|
+
// one column per ancestor; the line sits under that ancestor's chevron
|
|
699
|
+
"relative w-5 shrink-0 before:absolute before:inset-y-0 before:start-[11px] before:w-px before:bg-border",
|
|
700
|
+
// current row (§7c): the 2px accent indicator sits on its parent's line
|
|
701
|
+
d === level - 1 &&
|
|
702
|
+
"group-data-selected/tree-item:before:start-[10.5px] group-data-selected/tree-item:before:w-0.5 group-data-selected/tree-item:before:bg-primary-text",
|
|
703
|
+
)}
|
|
704
|
+
/>
|
|
705
|
+
))
|
|
706
|
+
: level > 0 && <span aria-hidden className="shrink-0" style={{width: `${level * 1.25}rem`}} />}
|
|
707
|
+
<div data-slot="tree-item-content" className={treeItemVariants({size: ctx.size})}>
|
|
708
|
+
<span
|
|
709
|
+
data-slot="tree-item-toggle"
|
|
710
|
+
aria-hidden
|
|
711
|
+
className="flex size-4 shrink-0 items-center justify-center text-muted-foreground"
|
|
712
|
+
onClick={(e) => {
|
|
713
|
+
if (branch) {
|
|
714
|
+
e.stopPropagation();
|
|
715
|
+
ctx.onToggle(row);
|
|
716
|
+
}
|
|
717
|
+
}}
|
|
718
|
+
>
|
|
719
|
+
{loading ? (
|
|
720
|
+
<Spinner size="xs" role="presentation" aria-label={undefined} />
|
|
721
|
+
) : branch ? (
|
|
722
|
+
<ChevronRightIcon className="size-3.5 transition-transform duration-100 group-data-expanded/tree-item:rotate-90 motion-reduce:transition-none rtl:-scale-x-100 rtl:group-data-expanded/tree-item:-rotate-90" />
|
|
723
|
+
) : null}
|
|
724
|
+
</span>
|
|
725
|
+
{checkbox && (
|
|
726
|
+
<span
|
|
727
|
+
aria-hidden
|
|
728
|
+
data-slot="tree-item-checkbox"
|
|
729
|
+
data-checked={checked === "checked" ? "" : undefined}
|
|
730
|
+
data-indeterminate={checked === "indeterminate" ? "" : undefined}
|
|
731
|
+
className={cn(checkboxVariants(), "after:hidden")}
|
|
732
|
+
onClick={(e) => {
|
|
733
|
+
e.stopPropagation();
|
|
734
|
+
if (!node.disabled) {
|
|
735
|
+
ctx.onCheck(row);
|
|
736
|
+
}
|
|
737
|
+
}}
|
|
738
|
+
>
|
|
739
|
+
{checked === "checked" && <CheckIcon strokeWidth={3} className="size-3.5" />}
|
|
740
|
+
{checked === "indeterminate" && <MinusIcon strokeWidth={3} className="size-3.5" />}
|
|
741
|
+
</span>
|
|
742
|
+
)}
|
|
743
|
+
{icon && (
|
|
744
|
+
<span data-slot="tree-item-icon" className="flex shrink-0 text-muted-foreground">
|
|
745
|
+
{icon}
|
|
746
|
+
</span>
|
|
747
|
+
)}
|
|
748
|
+
<span data-slot="tree-item-label" className="min-w-0 flex-1 truncate">
|
|
749
|
+
{ctx.renderLabel ? ctx.renderLabel(node, state) : node.label}
|
|
750
|
+
</span>
|
|
751
|
+
{node.meta && (
|
|
752
|
+
<span
|
|
753
|
+
data-slot="tree-item-meta"
|
|
754
|
+
className="flex shrink-0 items-center gap-1 font-alt text-xs font-normal text-muted-foreground"
|
|
755
|
+
>
|
|
756
|
+
{node.meta}
|
|
757
|
+
</span>
|
|
758
|
+
)}
|
|
759
|
+
</div>
|
|
760
|
+
</div>
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const TreeItemRow = React.memo(TreeItemRowImpl) as typeof TreeItemRowImpl;
|
|
765
|
+
|
|
766
|
+
/* ------------------------------------------------------------------------------------------------
|
|
767
|
+
* FileTree
|
|
768
|
+
* ----------------------------------------------------------------------------------------------*/
|
|
769
|
+
|
|
770
|
+
const CODE = /\.(tsx?|jsx?|mjs|cjs|css|scss|html|py|rs|go|rb|java|c|cpp|h|sh|sql|vue|svelte)$/i;
|
|
771
|
+
const IMAGE = /\.(png|jpe?g|gif|svg|webp|avif|ico)$/i;
|
|
772
|
+
const TEXT = /\.(md|mdx|txt|rst|log)$/i;
|
|
773
|
+
|
|
774
|
+
/** Folder / file icons by extension. */
|
|
775
|
+
function fileIcon(node: TreeNode<unknown>, state: TreeRenderState): React.ReactNode {
|
|
776
|
+
if (isBranch(node)) {
|
|
777
|
+
return state.expanded ? <FolderOpenIcon /> : <FolderIcon />;
|
|
778
|
+
}
|
|
779
|
+
const name = textOf(node);
|
|
780
|
+
if (/\.json$/i.test(name)) {
|
|
781
|
+
return <FileJsonIcon />;
|
|
782
|
+
}
|
|
783
|
+
if (CODE.test(name)) {
|
|
784
|
+
return <FileCodeIcon />;
|
|
785
|
+
}
|
|
786
|
+
if (IMAGE.test(name)) {
|
|
787
|
+
return <FileImageIcon />;
|
|
788
|
+
}
|
|
789
|
+
if (TEXT.test(name)) {
|
|
790
|
+
return <FileTextIcon />;
|
|
791
|
+
}
|
|
792
|
+
return <FileIcon />;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** A Tree preset for paths: folder/file icons, compact rows. */
|
|
796
|
+
function FileTree<T = unknown>({size = "sm", getIcon = fileIcon, ...props}: TreeProps<T>) {
|
|
797
|
+
return <Tree<T> data-variant="file" size={size} getIcon={getIcon} {...props} />;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** Build TreeNodes from slash-separated paths (`src/components/tree.tsx`). Folders sort first. */
|
|
801
|
+
function treeFromPaths(paths: string[]): TreeNode[] {
|
|
802
|
+
const root: TreeNode[] = [];
|
|
803
|
+
const folders = new Map<string, TreeNode>();
|
|
804
|
+
for (const path of paths) {
|
|
805
|
+
const parts = path.split("/").filter(Boolean);
|
|
806
|
+
let list = root;
|
|
807
|
+
parts.forEach((part, i) => {
|
|
808
|
+
const id = parts.slice(0, i + 1).join("/");
|
|
809
|
+
const leaf = i === parts.length - 1;
|
|
810
|
+
if (leaf) {
|
|
811
|
+
list.push({id, label: part});
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
let folder = folders.get(id);
|
|
815
|
+
if (!folder) {
|
|
816
|
+
folder = {id, label: part, children: []};
|
|
817
|
+
folders.set(id, folder);
|
|
818
|
+
list.push(folder);
|
|
819
|
+
}
|
|
820
|
+
list = folder.children!;
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
const sort = (list: TreeNode[]) => {
|
|
824
|
+
list.sort((a, b) => Number(isBranch(b)) - Number(isBranch(a)) || textOf(a).localeCompare(textOf(b)));
|
|
825
|
+
list.forEach((n) => n.children && sort(n.children));
|
|
826
|
+
};
|
|
827
|
+
sort(root);
|
|
828
|
+
return root;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
export {fileIcon, FileTree, flattenTree, Tree, treeFromPaths, treeItemVariants, treeVariants};
|
|
832
|
+
export type {TreeCheckState, TreeNode, TreeProps, TreeRenderState, TreeRow, TreeSelectionMode};
|