@zentauri-ui/zentauri-components 1.7.6 → 1.7.7
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 +4 -2
- package/cli/registry.json +1 -0
- package/dist/chunk-GRJFGIZC.mjs +417 -0
- package/dist/chunk-GRJFGIZC.mjs.map +1 -0
- package/dist/chunk-QHEHBC6M.js +421 -0
- package/dist/chunk-QHEHBC6M.js.map +1 -0
- package/dist/design-system/index.d.ts +1 -0
- package/dist/design-system/index.d.ts.map +1 -1
- package/dist/design-system/tree-view.d.ts +66 -0
- package/dist/design-system/tree-view.d.ts.map +1 -0
- package/dist/ui/tree-view/animated/animations.d.ts +6 -0
- package/dist/ui/tree-view/animated/animations.d.ts.map +1 -0
- package/dist/ui/tree-view/animated/index.d.ts +5 -0
- package/dist/ui/tree-view/animated/index.d.ts.map +1 -0
- package/dist/ui/tree-view/animated/tree-view-animated.d.ts +6 -0
- package/dist/ui/tree-view/animated/tree-view-animated.d.ts.map +1 -0
- package/dist/ui/tree-view/animated/types.d.ts +6 -0
- package/dist/ui/tree-view/animated/types.d.ts.map +1 -0
- package/dist/ui/tree-view/animated.js +53 -0
- package/dist/ui/tree-view/animated.js.map +1 -0
- package/dist/ui/tree-view/animated.mjs +50 -0
- package/dist/ui/tree-view/animated.mjs.map +1 -0
- package/dist/ui/tree-view/index.d.ts +5 -0
- package/dist/ui/tree-view/index.d.ts.map +1 -0
- package/dist/ui/tree-view/tree-view-base.d.ts +15 -0
- package/dist/ui/tree-view/tree-view-base.d.ts.map +1 -0
- package/dist/ui/tree-view/tree-view.d.ts +6 -0
- package/dist/ui/tree-view/tree-view.d.ts.map +1 -0
- package/dist/ui/tree-view/types.d.ts +61 -0
- package/dist/ui/tree-view/types.d.ts.map +1 -0
- package/dist/ui/tree-view/variants.d.ts +9 -0
- package/dist/ui/tree-view/variants.d.ts.map +1 -0
- package/dist/ui/tree-view.js +27 -0
- package/dist/ui/tree-view.js.map +1 -0
- package/dist/ui/tree-view.mjs +14 -0
- package/dist/ui/tree-view.mjs.map +1 -0
- package/package.json +1 -1
- package/src/design-system/index.ts +1 -0
- package/src/design-system/tree-view.ts +113 -0
- package/src/ui/tree-view/animated/animations.ts +13 -0
- package/src/ui/tree-view/animated/index.ts +6 -0
- package/src/ui/tree-view/animated/tree-view-animated.tsx +52 -0
- package/src/ui/tree-view/animated/types.ts +6 -0
- package/src/ui/tree-view/index.ts +13 -0
- package/src/ui/tree-view/tree-view-base.tsx +496 -0
- package/src/ui/tree-view/tree-view.test.tsx +136 -0
- package/src/ui/tree-view/tree-view.tsx +9 -0
- package/src/ui/tree-view/types.ts +68 -0
- package/src/ui/tree-view/variants.ts +32 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import { AnimatePresence, motion } from "framer-motion";
|
|
5
|
+
|
|
6
|
+
import { TreeViewBase } from "../tree-view-base";
|
|
7
|
+
import type { TreeGroupProps } from "../types";
|
|
8
|
+
|
|
9
|
+
import { treeViewTransitionPresets } from "./animations";
|
|
10
|
+
import type { TreeViewAnimatedProps } from "./types";
|
|
11
|
+
import type { TreeViewTransition } from "./animations";
|
|
12
|
+
|
|
13
|
+
function createAnimatedGroup(transitionVariant: TreeViewTransition) {
|
|
14
|
+
const transition = treeViewTransitionPresets[transitionVariant];
|
|
15
|
+
const motionless = transitionVariant === "none";
|
|
16
|
+
|
|
17
|
+
function AnimatedTreeGroup({ open, children }: TreeGroupProps) {
|
|
18
|
+
return (
|
|
19
|
+
<AnimatePresence initial={false}>
|
|
20
|
+
{open ? (
|
|
21
|
+
<motion.ul
|
|
22
|
+
role="group"
|
|
23
|
+
data-slot="tree-view-group"
|
|
24
|
+
className="m-0 list-none overflow-hidden p-0"
|
|
25
|
+
initial={motionless ? false : { height: 0, opacity: 0 }}
|
|
26
|
+
animate={motionless ? undefined : { height: "auto", opacity: 1 }}
|
|
27
|
+
exit={motionless ? undefined : { height: 0, opacity: 0 }}
|
|
28
|
+
transition={transition}
|
|
29
|
+
>
|
|
30
|
+
{children}
|
|
31
|
+
</motion.ul>
|
|
32
|
+
) : null}
|
|
33
|
+
</AnimatePresence>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
AnimatedTreeGroup.displayName = "AnimatedTreeGroup";
|
|
38
|
+
return AnimatedTreeGroup;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function TreeViewAnimated({
|
|
42
|
+
transitionVariant = "default",
|
|
43
|
+
...props
|
|
44
|
+
}: TreeViewAnimatedProps) {
|
|
45
|
+
const GroupComponent = useMemo(
|
|
46
|
+
() => createAnimatedGroup(transitionVariant),
|
|
47
|
+
[transitionVariant],
|
|
48
|
+
);
|
|
49
|
+
return <TreeViewBase {...props} GroupComponent={GroupComponent} />;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
TreeViewAnimated.displayName = "TreeViewAnimated";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
export { TreeView } from "./tree-view";
|
|
4
|
+
export { TreeViewBase } from "./tree-view-base";
|
|
5
|
+
export type {
|
|
6
|
+
TreeNode,
|
|
7
|
+
TreeViewBaseProps,
|
|
8
|
+
TreeViewProps,
|
|
9
|
+
TreeViewRenderArgs,
|
|
10
|
+
TreeViewVariantProps,
|
|
11
|
+
TreeGroupProps,
|
|
12
|
+
} from "./types";
|
|
13
|
+
export { treeViewVariants, treeViewItemVariants } from "./variants";
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from "react";
|
|
11
|
+
|
|
12
|
+
import { cn } from "../../lib/utils";
|
|
13
|
+
import {
|
|
14
|
+
zuiTreeViewChevron,
|
|
15
|
+
zuiTreeViewGuide,
|
|
16
|
+
zuiTreeViewIcon,
|
|
17
|
+
} from "../../design-system/tree-view";
|
|
18
|
+
|
|
19
|
+
import type {
|
|
20
|
+
TreeGroupProps,
|
|
21
|
+
TreeNode,
|
|
22
|
+
TreeViewBaseProps,
|
|
23
|
+
TreeViewCtx,
|
|
24
|
+
} from "./types";
|
|
25
|
+
import { treeViewItemVariants, treeViewVariants } from "./variants";
|
|
26
|
+
import { FaChevronRight } from "react-icons/fa6";
|
|
27
|
+
|
|
28
|
+
const TreeViewContext = createContext<TreeViewCtx | null>(null);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Reads the shared tree-view state from context and fails early when a tree item
|
|
32
|
+
* is rendered outside the TreeView provider. The component name keeps the error
|
|
33
|
+
* message actionable for consumers composing custom tree-view pieces.
|
|
34
|
+
*/
|
|
35
|
+
function useTreeViewContext(component: string): TreeViewCtx {
|
|
36
|
+
const ctx = useContext(TreeViewContext);
|
|
37
|
+
if (!ctx) {
|
|
38
|
+
throw new Error(`${component} must be used within <TreeView>`);
|
|
39
|
+
}
|
|
40
|
+
return ctx;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type FlatNode = { node: TreeNode; level: number; parentId: string | null };
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Converts the nested tree into a flat list of the nodes that are currently
|
|
47
|
+
* visible. Keyboard navigation works against this list so ArrowUp/ArrowDown can
|
|
48
|
+
* move through the rendered rows in visual order without walking the nested
|
|
49
|
+
* structure on every key press.
|
|
50
|
+
*
|
|
51
|
+
* The same accumulator is passed through recursive calls to avoid creating and
|
|
52
|
+
* merging many intermediate arrays for large trees.
|
|
53
|
+
*/
|
|
54
|
+
function flattenVisible(
|
|
55
|
+
nodes: TreeNode[],
|
|
56
|
+
isExpanded: (id: string) => boolean,
|
|
57
|
+
level = 1,
|
|
58
|
+
parentId: string | null = null,
|
|
59
|
+
acc: FlatNode[] = [],
|
|
60
|
+
): FlatNode[] {
|
|
61
|
+
for (const node of nodes) {
|
|
62
|
+
acc.push({ node, level, parentId });
|
|
63
|
+
if (node.children?.length && isExpanded(node.id)) {
|
|
64
|
+
flattenVisible(node.children, isExpanded, level + 1, node.id, acc);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return acc;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Default non-animated group wrapper. Animated tree variants can replace this
|
|
72
|
+
* component through GroupComponent while the recursive TreeItemNode rendering
|
|
73
|
+
* remains shared.
|
|
74
|
+
*/
|
|
75
|
+
function StaticTreeGroup({ open, children }: TreeGroupProps) {
|
|
76
|
+
if (!open) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return (
|
|
80
|
+
<ul role="group" data-slot="tree-view-group" className="m-0 list-none p-0">
|
|
81
|
+
{children}
|
|
82
|
+
</ul>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function TreeItemNode({
|
|
87
|
+
node,
|
|
88
|
+
level,
|
|
89
|
+
chevronIcon,
|
|
90
|
+
}: {
|
|
91
|
+
node: TreeNode;
|
|
92
|
+
level: number;
|
|
93
|
+
chevronIcon?: React.ReactNode;
|
|
94
|
+
}) {
|
|
95
|
+
const {
|
|
96
|
+
isExpanded,
|
|
97
|
+
selectedId,
|
|
98
|
+
activeId,
|
|
99
|
+
GroupComponent,
|
|
100
|
+
registerItem,
|
|
101
|
+
appearance,
|
|
102
|
+
size,
|
|
103
|
+
toggleExpanded,
|
|
104
|
+
selectNode,
|
|
105
|
+
onItemKeyDown,
|
|
106
|
+
renderNode,
|
|
107
|
+
showGuides,
|
|
108
|
+
} = useTreeViewContext("TreeItem");
|
|
109
|
+
|
|
110
|
+
// These derived flags keep the JSX readable and mirror the ARIA state applied
|
|
111
|
+
// to each row below.
|
|
112
|
+
const hasChildren = Boolean(node.children?.length);
|
|
113
|
+
const expanded = hasChildren && isExpanded(node.id);
|
|
114
|
+
const selected = selectedId === node.id;
|
|
115
|
+
const active = activeId === node.id;
|
|
116
|
+
const disabled = Boolean(node.disabled);
|
|
117
|
+
|
|
118
|
+
// GroupComponent is intentionally resolved from context so the base tree can
|
|
119
|
+
// be reused by static and animated implementations without duplicating item
|
|
120
|
+
// rendering logic.
|
|
121
|
+
const Group = GroupComponent;
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<li role="none" data-slot="tree-view-item">
|
|
125
|
+
<div
|
|
126
|
+
role="treeitem"
|
|
127
|
+
// Register the focusable row so keyboard navigation can imperatively
|
|
128
|
+
// focus the next visible item after the active id changes.
|
|
129
|
+
ref={(el) => registerItem(node.id, el)}
|
|
130
|
+
aria-expanded={hasChildren ? expanded : undefined}
|
|
131
|
+
aria-selected={selected}
|
|
132
|
+
aria-level={level}
|
|
133
|
+
aria-disabled={disabled || undefined}
|
|
134
|
+
data-slot="tree-view-item-row"
|
|
135
|
+
data-node-id={node.id}
|
|
136
|
+
data-selected={selected}
|
|
137
|
+
data-active={active}
|
|
138
|
+
data-disabled={disabled}
|
|
139
|
+
tabIndex={active ? 0 : -1}
|
|
140
|
+
// Each level indents by a fixed step. The root still gets a small inset
|
|
141
|
+
// so chevrons and labels align with surrounding UI.
|
|
142
|
+
style={{ paddingLeft: `${(level - 1) * 1.25 + 0.5}rem` }}
|
|
143
|
+
className={treeViewItemVariants({
|
|
144
|
+
appearance: appearance,
|
|
145
|
+
size: size,
|
|
146
|
+
})}
|
|
147
|
+
onClick={() => {
|
|
148
|
+
// Disabled rows are rendered for context but should not toggle,
|
|
149
|
+
// select, or receive active focus from pointer interaction.
|
|
150
|
+
if (disabled) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Clicking a parent both toggles disclosure and selects the row,
|
|
155
|
+
// matching the same selection behavior as leaf nodes.
|
|
156
|
+
if (hasChildren) {
|
|
157
|
+
toggleExpanded(node.id);
|
|
158
|
+
}
|
|
159
|
+
selectNode(node);
|
|
160
|
+
}}
|
|
161
|
+
onKeyDown={onItemKeyDown}
|
|
162
|
+
>
|
|
163
|
+
{hasChildren ? (
|
|
164
|
+
<span
|
|
165
|
+
data-slot="tree-view-chevron"
|
|
166
|
+
data-expanded={expanded}
|
|
167
|
+
className={zuiTreeViewChevron}
|
|
168
|
+
>
|
|
169
|
+
{chevronIcon || <FaChevronRight />}
|
|
170
|
+
</span>
|
|
171
|
+
) : (
|
|
172
|
+
// Leaf nodes reserve chevron space to keep labels vertically aligned
|
|
173
|
+
// with sibling branches that do have disclosure icons.
|
|
174
|
+
<span aria-hidden className="inline-flex h-5 w-5 shrink-0" />
|
|
175
|
+
)}
|
|
176
|
+
{node.icon ? (
|
|
177
|
+
<span data-slot="tree-view-icon" className={zuiTreeViewIcon}>
|
|
178
|
+
{node.icon}
|
|
179
|
+
</span>
|
|
180
|
+
) : null}
|
|
181
|
+
<span data-slot="tree-view-label" className="truncate">
|
|
182
|
+
{renderNode
|
|
183
|
+
? renderNode({
|
|
184
|
+
node,
|
|
185
|
+
depth: level,
|
|
186
|
+
isExpanded: expanded,
|
|
187
|
+
isSelected: selected,
|
|
188
|
+
})
|
|
189
|
+
: node.label}
|
|
190
|
+
</span>
|
|
191
|
+
</div>
|
|
192
|
+
{hasChildren ? (
|
|
193
|
+
<Group open={expanded} level={level}>
|
|
194
|
+
<ol
|
|
195
|
+
className={cn(showGuides && zuiTreeViewGuide, showGuides && "ml-5")}
|
|
196
|
+
>
|
|
197
|
+
{node.children?.map((child) => (
|
|
198
|
+
// Recursion preserves each child's depth so ARIA levels,
|
|
199
|
+
// indentation, and guide spacing all stay in sync.
|
|
200
|
+
<TreeItemNode key={child.id} node={child} level={level + 1} />
|
|
201
|
+
))}
|
|
202
|
+
</ol>
|
|
203
|
+
</Group>
|
|
204
|
+
) : null}
|
|
205
|
+
</li>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function TreeViewBase({
|
|
210
|
+
data = [],
|
|
211
|
+
defaultExpanded,
|
|
212
|
+
expanded,
|
|
213
|
+
onExpandedChange,
|
|
214
|
+
defaultSelected,
|
|
215
|
+
selected,
|
|
216
|
+
onSelect,
|
|
217
|
+
renderNode,
|
|
218
|
+
showGuides = false,
|
|
219
|
+
appearance = "default",
|
|
220
|
+
size = "md",
|
|
221
|
+
className,
|
|
222
|
+
GroupComponent = StaticTreeGroup,
|
|
223
|
+
...rest
|
|
224
|
+
}: TreeViewBaseProps & {
|
|
225
|
+
GroupComponent?: TreeViewCtx["GroupComponent"];
|
|
226
|
+
}) {
|
|
227
|
+
// Mirror common React controlled/uncontrolled patterns. When a controlled prop
|
|
228
|
+
// is provided, internal state is treated as read-only fallback state and every
|
|
229
|
+
// change is reported through the matching callback.
|
|
230
|
+
const isExpandedControlled = expanded !== undefined;
|
|
231
|
+
const isSelectedControlled = selected !== undefined;
|
|
232
|
+
|
|
233
|
+
const [expandedUncontrolled, setExpandedUncontrolled] = useState<string[]>(
|
|
234
|
+
defaultExpanded ?? [],
|
|
235
|
+
);
|
|
236
|
+
const [selectedUncontrolled, setSelectedUncontrolled] = useState<
|
|
237
|
+
string | undefined
|
|
238
|
+
>(defaultSelected);
|
|
239
|
+
|
|
240
|
+
const expandedIds = isExpandedControlled
|
|
241
|
+
? (expanded ?? [])
|
|
242
|
+
: expandedUncontrolled;
|
|
243
|
+
const selectedId = isSelectedControlled ? selected : selectedUncontrolled;
|
|
244
|
+
|
|
245
|
+
// A Set gives O(1) lookup for expansion checks. This is used in render, the
|
|
246
|
+
// flattening utility, and keyboard navigation, so memoizing it prevents
|
|
247
|
+
// rebuilding the lookup unless the expanded ids actually change.
|
|
248
|
+
const expandedSet = useMemo(() => new Set(expandedIds), [expandedIds]);
|
|
249
|
+
|
|
250
|
+
const isExpanded = useCallback(
|
|
251
|
+
(id: string) => expandedSet.has(id),
|
|
252
|
+
[expandedSet],
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// Centralizes the "commit" step so controlled and uncontrolled expansion
|
|
256
|
+
// updates always notify consumers consistently.
|
|
257
|
+
const commitExpanded = useCallback(
|
|
258
|
+
(next: string[]) => {
|
|
259
|
+
if (!isExpandedControlled) {
|
|
260
|
+
setExpandedUncontrolled(next);
|
|
261
|
+
}
|
|
262
|
+
onExpandedChange?.(next);
|
|
263
|
+
},
|
|
264
|
+
[isExpandedControlled, onExpandedChange],
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const setExpanded = useCallback(
|
|
268
|
+
(id: string, open: boolean) => {
|
|
269
|
+
const has = expandedSet.has(id);
|
|
270
|
+
|
|
271
|
+
// Avoid sending duplicate updates when the requested state already
|
|
272
|
+
// matches the current expansion state.
|
|
273
|
+
if (open === has) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const next = open
|
|
278
|
+
? [...expandedIds, id]
|
|
279
|
+
: expandedIds.filter((entry) => entry !== id);
|
|
280
|
+
commitExpanded(next);
|
|
281
|
+
},
|
|
282
|
+
[commitExpanded, expandedIds, expandedSet],
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
const toggleExpanded = useCallback(
|
|
286
|
+
(id: string) => {
|
|
287
|
+
setExpanded(id, !expandedSet.has(id));
|
|
288
|
+
},
|
|
289
|
+
[expandedSet, setExpanded],
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
// Stores mounted treeitem rows by node id. The Map is held in a ref because
|
|
293
|
+
// focus targets change over time without needing to trigger React renders.
|
|
294
|
+
const itemRefs = useRef(new Map<string, HTMLDivElement>());
|
|
295
|
+
const registerItem = useCallback((id: string, el: HTMLDivElement | null) => {
|
|
296
|
+
if (el) {
|
|
297
|
+
itemRefs.current.set(id, el);
|
|
298
|
+
} else {
|
|
299
|
+
itemRefs.current.delete(id);
|
|
300
|
+
}
|
|
301
|
+
}, []);
|
|
302
|
+
|
|
303
|
+
// Only expanded branches are included. This becomes the single source of truth
|
|
304
|
+
// for roving focus and Home/End/Arrow navigation.
|
|
305
|
+
const visible = useMemo(
|
|
306
|
+
() => flattenVisible(data, isExpanded),
|
|
307
|
+
[data, isExpanded],
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
// Used as a safe fallback for tab focus when nothing has been selected or when
|
|
311
|
+
// the selected/active item is hidden by a collapsed parent.
|
|
312
|
+
const firstEnabledId = useMemo(
|
|
313
|
+
() => visible.find((entry) => !entry.node.disabled)?.node.id,
|
|
314
|
+
[visible],
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
const [activeIdState, setActiveIdState] = useState<string | undefined>(
|
|
318
|
+
undefined,
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
// A selected item can become hidden when an ancestor is collapsed. In that
|
|
322
|
+
// case it should not keep the roving tab stop; focus falls back below.
|
|
323
|
+
const isSelectedVisible =
|
|
324
|
+
selectedId !== undefined &&
|
|
325
|
+
visible.some((entry) => entry.node.id === selectedId);
|
|
326
|
+
|
|
327
|
+
// Roving tabindex chooses a single keyboard tab stop: prefer the last active
|
|
328
|
+
// visible item, then the selected visible item, then the first enabled row.
|
|
329
|
+
const activeId =
|
|
330
|
+
activeIdState && visible.some((entry) => entry.node.id === activeIdState)
|
|
331
|
+
? activeIdState
|
|
332
|
+
: isSelectedVisible
|
|
333
|
+
? selectedId
|
|
334
|
+
: firstEnabledId;
|
|
335
|
+
|
|
336
|
+
// Updates logical focus state and then focuses the mounted row when available.
|
|
337
|
+
// Optional chaining handles cases where React has not mounted the row yet.
|
|
338
|
+
const focusItem = useCallback((id: string) => {
|
|
339
|
+
setActiveIdState(id);
|
|
340
|
+
itemRefs.current.get(id)?.focus();
|
|
341
|
+
}, []);
|
|
342
|
+
|
|
343
|
+
const selectNode = useCallback(
|
|
344
|
+
(node: TreeNode) => {
|
|
345
|
+
// Selection ignores disabled nodes from both pointer and keyboard paths.
|
|
346
|
+
if (node.disabled) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
setActiveIdState(node.id);
|
|
351
|
+
if (!isSelectedControlled) {
|
|
352
|
+
setSelectedUncontrolled(node.id);
|
|
353
|
+
}
|
|
354
|
+
onSelect?.(node);
|
|
355
|
+
},
|
|
356
|
+
[isSelectedControlled, onSelect],
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
const onItemKeyDown = useCallback(
|
|
360
|
+
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
361
|
+
// Prefer the DOM row id because the event always originates from a
|
|
362
|
+
// treeitem. activeId is a fallback for unusual composed event paths.
|
|
363
|
+
const currentId = (event.currentTarget.dataset.nodeId ??
|
|
364
|
+
activeId) as string;
|
|
365
|
+
const index = visible.findIndex((entry) => entry.node.id === currentId);
|
|
366
|
+
const current = visible[index];
|
|
367
|
+
if (index === -1 || !current) {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const moveTo = (target: number) => {
|
|
372
|
+
// Clamp so repeated ArrowUp/ArrowDown at the edges keeps focus on the
|
|
373
|
+
// first or last visible item instead of producing an invalid index.
|
|
374
|
+
const clamped = Math.max(0, Math.min(visible.length - 1, target));
|
|
375
|
+
const next = visible[clamped];
|
|
376
|
+
if (next) {
|
|
377
|
+
focusItem(next.node.id);
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
switch (event.key) {
|
|
382
|
+
case "ArrowDown":
|
|
383
|
+
event.preventDefault();
|
|
384
|
+
moveTo(index + 1);
|
|
385
|
+
break;
|
|
386
|
+
case "ArrowUp":
|
|
387
|
+
event.preventDefault();
|
|
388
|
+
moveTo(index - 1);
|
|
389
|
+
break;
|
|
390
|
+
case "Home":
|
|
391
|
+
event.preventDefault();
|
|
392
|
+
moveTo(0);
|
|
393
|
+
break;
|
|
394
|
+
case "End":
|
|
395
|
+
event.preventDefault();
|
|
396
|
+
moveTo(visible.length - 1);
|
|
397
|
+
break;
|
|
398
|
+
case "ArrowRight": {
|
|
399
|
+
event.preventDefault();
|
|
400
|
+
const hasChildren = Boolean(current.node.children?.length);
|
|
401
|
+
|
|
402
|
+
// Right arrow first opens a closed branch. If it is already open,
|
|
403
|
+
// focus moves to the next visible row, which is the first child.
|
|
404
|
+
if (hasChildren && !isExpanded(current.node.id)) {
|
|
405
|
+
setExpanded(current.node.id, true);
|
|
406
|
+
} else if (hasChildren) {
|
|
407
|
+
moveTo(index + 1);
|
|
408
|
+
}
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
case "ArrowLeft": {
|
|
412
|
+
event.preventDefault();
|
|
413
|
+
const hasChildren = Boolean(current.node.children?.length);
|
|
414
|
+
|
|
415
|
+
// Left arrow closes an open branch. From a leaf or already-closed
|
|
416
|
+
// branch, it moves focus back to the parent row when there is one.
|
|
417
|
+
if (hasChildren && isExpanded(current.node.id)) {
|
|
418
|
+
setExpanded(current.node.id, false);
|
|
419
|
+
} else if (current.parentId) {
|
|
420
|
+
focusItem(current.parentId);
|
|
421
|
+
}
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
case "Enter":
|
|
425
|
+
case " ":
|
|
426
|
+
event.preventDefault();
|
|
427
|
+
// Enter and Space select the current row. Parent expansion remains on
|
|
428
|
+
// the arrow keys so keyboard users have predictable disclosure control.
|
|
429
|
+
selectNode(current.node);
|
|
430
|
+
break;
|
|
431
|
+
default:
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
[activeId, focusItem, isExpanded, selectNode, setExpanded, visible],
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
// Memoize the context payload so recursive items only re-render when the tree
|
|
439
|
+
// state or rendering options they consume actually change.
|
|
440
|
+
const ctx = useMemo<TreeViewCtx>(
|
|
441
|
+
() => ({
|
|
442
|
+
appearance: appearance ?? "default",
|
|
443
|
+
size: size ?? "md",
|
|
444
|
+
showGuides,
|
|
445
|
+
GroupComponent,
|
|
446
|
+
isExpanded,
|
|
447
|
+
toggleExpanded,
|
|
448
|
+
setExpanded,
|
|
449
|
+
selectedId,
|
|
450
|
+
activeId,
|
|
451
|
+
selectNode,
|
|
452
|
+
registerItem,
|
|
453
|
+
onItemKeyDown,
|
|
454
|
+
renderNode,
|
|
455
|
+
}),
|
|
456
|
+
[
|
|
457
|
+
activeId,
|
|
458
|
+
appearance,
|
|
459
|
+
GroupComponent,
|
|
460
|
+
isExpanded,
|
|
461
|
+
onItemKeyDown,
|
|
462
|
+
registerItem,
|
|
463
|
+
renderNode,
|
|
464
|
+
selectNode,
|
|
465
|
+
selectedId,
|
|
466
|
+
setExpanded,
|
|
467
|
+
showGuides,
|
|
468
|
+
size,
|
|
469
|
+
toggleExpanded,
|
|
470
|
+
],
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
return (
|
|
474
|
+
<TreeViewContext.Provider value={ctx}>
|
|
475
|
+
<ul
|
|
476
|
+
role="tree"
|
|
477
|
+
data-slot="tree-view"
|
|
478
|
+
aria-label={rest["aria-label"]}
|
|
479
|
+
aria-labelledby={rest["aria-labelledby"]}
|
|
480
|
+
className={cn(
|
|
481
|
+
treeViewVariants({ appearance, size }),
|
|
482
|
+
"list-none",
|
|
483
|
+
className,
|
|
484
|
+
)}
|
|
485
|
+
>
|
|
486
|
+
{data.map((node) => (
|
|
487
|
+
<TreeItemNode key={node.id} node={node} level={1} />
|
|
488
|
+
))}
|
|
489
|
+
</ul>
|
|
490
|
+
</TreeViewContext.Provider>
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
TreeViewBase.displayName = "TreeView";
|
|
495
|
+
|
|
496
|
+
export { useTreeViewContext };
|