@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.
Files changed (49) hide show
  1. package/README.md +4 -2
  2. package/cli/registry.json +1 -0
  3. package/dist/chunk-GRJFGIZC.mjs +417 -0
  4. package/dist/chunk-GRJFGIZC.mjs.map +1 -0
  5. package/dist/chunk-QHEHBC6M.js +421 -0
  6. package/dist/chunk-QHEHBC6M.js.map +1 -0
  7. package/dist/design-system/index.d.ts +1 -0
  8. package/dist/design-system/index.d.ts.map +1 -1
  9. package/dist/design-system/tree-view.d.ts +66 -0
  10. package/dist/design-system/tree-view.d.ts.map +1 -0
  11. package/dist/ui/tree-view/animated/animations.d.ts +6 -0
  12. package/dist/ui/tree-view/animated/animations.d.ts.map +1 -0
  13. package/dist/ui/tree-view/animated/index.d.ts +5 -0
  14. package/dist/ui/tree-view/animated/index.d.ts.map +1 -0
  15. package/dist/ui/tree-view/animated/tree-view-animated.d.ts +6 -0
  16. package/dist/ui/tree-view/animated/tree-view-animated.d.ts.map +1 -0
  17. package/dist/ui/tree-view/animated/types.d.ts +6 -0
  18. package/dist/ui/tree-view/animated/types.d.ts.map +1 -0
  19. package/dist/ui/tree-view/animated.js +53 -0
  20. package/dist/ui/tree-view/animated.js.map +1 -0
  21. package/dist/ui/tree-view/animated.mjs +50 -0
  22. package/dist/ui/tree-view/animated.mjs.map +1 -0
  23. package/dist/ui/tree-view/index.d.ts +5 -0
  24. package/dist/ui/tree-view/index.d.ts.map +1 -0
  25. package/dist/ui/tree-view/tree-view-base.d.ts +15 -0
  26. package/dist/ui/tree-view/tree-view-base.d.ts.map +1 -0
  27. package/dist/ui/tree-view/tree-view.d.ts +6 -0
  28. package/dist/ui/tree-view/tree-view.d.ts.map +1 -0
  29. package/dist/ui/tree-view/types.d.ts +61 -0
  30. package/dist/ui/tree-view/types.d.ts.map +1 -0
  31. package/dist/ui/tree-view/variants.d.ts +9 -0
  32. package/dist/ui/tree-view/variants.d.ts.map +1 -0
  33. package/dist/ui/tree-view.js +27 -0
  34. package/dist/ui/tree-view.js.map +1 -0
  35. package/dist/ui/tree-view.mjs +14 -0
  36. package/dist/ui/tree-view.mjs.map +1 -0
  37. package/package.json +1 -1
  38. package/src/design-system/index.ts +1 -0
  39. package/src/design-system/tree-view.ts +113 -0
  40. package/src/ui/tree-view/animated/animations.ts +13 -0
  41. package/src/ui/tree-view/animated/index.ts +6 -0
  42. package/src/ui/tree-view/animated/tree-view-animated.tsx +52 -0
  43. package/src/ui/tree-view/animated/types.ts +6 -0
  44. package/src/ui/tree-view/index.ts +13 -0
  45. package/src/ui/tree-view/tree-view-base.tsx +496 -0
  46. package/src/ui/tree-view/tree-view.test.tsx +136 -0
  47. package/src/ui/tree-view/tree-view.tsx +9 -0
  48. package/src/ui/tree-view/types.ts +68 -0
  49. package/src/ui/tree-view/variants.ts +32 -0
@@ -0,0 +1,6 @@
1
+ "use client";
2
+
3
+ export { TreeViewAnimated } from "./tree-view-animated";
4
+ export type { TreeViewAnimatedProps } from "./types";
5
+ export { treeViewTransitionPresets } from "./animations";
6
+ export type { TreeViewTransition } from "./animations";
@@ -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,6 @@
1
+ import type { TreeViewBaseProps } from "../types";
2
+ import type { TreeViewTransition } from "./animations";
3
+
4
+ export type TreeViewAnimatedProps = TreeViewBaseProps & {
5
+ transitionVariant?: TreeViewTransition;
6
+ };
@@ -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 };