xertica-ui 2.1.2 → 2.1.4

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 (181) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +1 -1
  3. package/bin/cli.ts +1 -1
  4. package/bin/generate-tokens.ts +13 -7
  5. package/components/assistant/xertica-assistant/index.ts +2 -0
  6. package/components/assistant/xertica-assistant/parts/AssistantCollapsedView.tsx +97 -0
  7. package/components/assistant/xertica-assistant/parts/AssistantConversationList.tsx +104 -0
  8. package/components/assistant/xertica-assistant/parts/AssistantDocumentEditor.tsx +81 -0
  9. package/components/assistant/xertica-assistant/parts/AssistantFeedbackDialog.tsx +86 -0
  10. package/components/assistant/xertica-assistant/parts/AssistantHeader.tsx +77 -0
  11. package/components/assistant/xertica-assistant/parts/AssistantMessageBubble.tsx +573 -0
  12. package/components/assistant/xertica-assistant/parts/AssistantTabBar.tsx +65 -0
  13. package/components/assistant/xertica-assistant/parts/AssistantTypingIndicator.tsx +41 -0
  14. package/components/assistant/xertica-assistant/parts/AssistantWelcomeScreen.tsx +98 -0
  15. package/components/assistant/xertica-assistant/parts/index.ts +16 -0
  16. package/components/assistant/xertica-assistant/types.ts +139 -0
  17. package/components/assistant/xertica-assistant/use-assistant.ts +559 -0
  18. package/components/assistant/xertica-assistant/xertica-assistant.stories.tsx +200 -0
  19. package/components/assistant/xertica-assistant/xertica-assistant.tsx +198 -1460
  20. package/components/brand/theme-toggle/ThemeToggle.tsx +8 -27
  21. package/components/hooks/index.ts +3 -0
  22. package/components/hooks/use-layout-shortcuts.ts +46 -0
  23. package/components/layout/sidebar/index.ts +2 -0
  24. package/components/layout/sidebar/sidebar.stories.tsx +160 -8
  25. package/components/layout/sidebar/sidebar.tsx +606 -497
  26. package/components/layout/sidebar/use-sidebar.ts +104 -0
  27. package/components/media/audio-player/AudioPlayer.tsx +131 -206
  28. package/components/media/audio-player/use-audio-player.ts +298 -0
  29. package/components/pages/home-page/HomePage.tsx +1 -1
  30. package/components/pages/template-content/TemplateContent.tsx +5 -5
  31. package/components/pages/template-page/TemplatePage.tsx +5 -5
  32. package/components/shared/CustomTooltipContent.tsx +52 -0
  33. package/components/shared/layout-constants.ts +1 -1
  34. package/components/ui/chart/chart.stories.tsx +966 -7
  35. package/components/ui/chart/chart.tsx +918 -45
  36. package/components/ui/file-upload/file-upload.stories.tsx +100 -0
  37. package/components/ui/file-upload/file-upload.tsx +14 -74
  38. package/components/ui/file-upload/index.ts +1 -0
  39. package/components/ui/file-upload/use-file-upload.ts +181 -0
  40. package/components/ui/pagination/index.ts +2 -0
  41. package/components/ui/pagination/pagination.stories.tsx +94 -0
  42. package/components/ui/pagination/use-pagination.ts +194 -0
  43. package/components/ui/rich-text-editor/index.ts +2 -0
  44. package/components/ui/rich-text-editor/rich-text-editor.stories.tsx +129 -1
  45. package/components/ui/rich-text-editor/rich-text-editor.tsx +86 -305
  46. package/components/ui/rich-text-editor/use-rich-text-editor.ts +439 -0
  47. package/components/ui/stepper/index.ts +3 -1
  48. package/components/ui/stepper/stepper.stories.tsx +116 -0
  49. package/components/ui/stepper/stepper.tsx +4 -4
  50. package/components/ui/stepper/use-stepper.ts +137 -0
  51. package/components/ui/tree-view/index.ts +4 -1
  52. package/components/ui/tree-view/tree-view.stories.tsx +110 -4
  53. package/components/ui/tree-view/tree-view.tsx +17 -125
  54. package/components/ui/tree-view/use-tree-view.ts +229 -0
  55. package/contexts/AssistenteContext.tsx +17 -54
  56. package/contexts/BrandColorsContext.tsx +6 -17
  57. package/contexts/LayoutContext.tsx +5 -31
  58. package/dist/AssistantChart-BAudAfne.cjs +3591 -0
  59. package/dist/AssistantChart-BP8upjMk.js +3565 -0
  60. package/dist/AudioPlayer-1ypwE2Wh.cjs +936 -0
  61. package/dist/AudioPlayer-DuKXrCfy.js +937 -0
  62. package/dist/CustomTooltipContent-DHjkY0ww.js +40 -0
  63. package/dist/CustomTooltipContent-c_K-DWRr.cjs +56 -0
  64. package/dist/LanguageContext-BwhwC3G2.js +657 -0
  65. package/dist/LanguageContext-DvUt5jBg.cjs +656 -0
  66. package/dist/LayoutContext-BDmcZfMH.cjs +84 -0
  67. package/dist/LayoutContext-dbQvdC4O.js +85 -0
  68. package/dist/ThemeContext-RTy1m2Uq.js +82 -0
  69. package/dist/ThemeContext-bSzuOit2.cjs +81 -0
  70. package/dist/VerifyEmailPage-C_ihbcth.js +2828 -0
  71. package/dist/VerifyEmailPage-Dt7zgA4w.cjs +2827 -0
  72. package/dist/XerticaProvider-CW9hpCdF.cjs +39 -0
  73. package/dist/XerticaProvider-siSt9uG2.js +40 -0
  74. package/dist/XerticaXLogo-D8jf0SNv.cjs +214 -0
  75. package/dist/XerticaXLogo-fAJMy3H4.js +215 -0
  76. package/dist/assistant.cjs.js +2 -1
  77. package/dist/assistant.es.js +3 -2
  78. package/dist/brand.cjs.js +2 -2
  79. package/dist/brand.es.js +2 -2
  80. package/dist/cli.js +14 -8
  81. package/dist/components/assistant/xertica-assistant/index.d.ts +2 -0
  82. package/dist/components/assistant/xertica-assistant/parts/AssistantCollapsedView.d.ts +13 -0
  83. package/dist/components/assistant/xertica-assistant/parts/AssistantConversationList.d.ts +16 -0
  84. package/dist/components/assistant/xertica-assistant/parts/AssistantDocumentEditor.d.ts +17 -0
  85. package/dist/components/assistant/xertica-assistant/parts/AssistantFeedbackDialog.d.ts +19 -0
  86. package/dist/components/assistant/xertica-assistant/parts/AssistantHeader.d.ts +11 -0
  87. package/dist/components/assistant/xertica-assistant/parts/AssistantMessageBubble.d.ts +29 -0
  88. package/dist/components/assistant/xertica-assistant/parts/AssistantTabBar.d.ts +13 -0
  89. package/dist/components/assistant/xertica-assistant/parts/AssistantTypingIndicator.d.ts +4 -0
  90. package/dist/components/assistant/xertica-assistant/parts/AssistantWelcomeScreen.d.ts +17 -0
  91. package/dist/components/assistant/xertica-assistant/parts/index.d.ts +16 -0
  92. package/dist/components/assistant/xertica-assistant/types.d.ts +106 -0
  93. package/dist/components/assistant/xertica-assistant/use-assistant.d.ts +125 -0
  94. package/dist/components/assistant/xertica-assistant/xertica-assistant.d.ts +8 -97
  95. package/dist/components/hooks/index.d.ts +3 -0
  96. package/dist/components/hooks/use-layout-shortcuts.d.ts +22 -0
  97. package/dist/components/layout/sidebar/index.d.ts +2 -0
  98. package/dist/components/layout/sidebar/sidebar.d.ts +80 -0
  99. package/dist/components/layout/sidebar/use-sidebar.d.ts +22 -0
  100. package/dist/components/media/audio-player/AudioPlayer.d.ts +4 -1
  101. package/dist/components/media/audio-player/use-audio-player.d.ts +72 -0
  102. package/dist/components/shared/CustomTooltipContent.d.ts +20 -0
  103. package/dist/components/shared/layout-constants.d.ts +1 -1
  104. package/dist/components/ui/alert/alert.d.ts +1 -1
  105. package/dist/components/ui/badge/badge.d.ts +1 -1
  106. package/dist/components/ui/button/button.d.ts +2 -2
  107. package/dist/components/ui/chart/chart.d.ts +162 -5
  108. package/dist/components/ui/file-upload/file-upload.d.ts +2 -0
  109. package/dist/components/ui/file-upload/index.d.ts +1 -0
  110. package/dist/components/ui/file-upload/use-file-upload.d.ts +49 -0
  111. package/dist/components/ui/pagination/index.d.ts +2 -0
  112. package/dist/components/ui/pagination/use-pagination.d.ts +78 -0
  113. package/dist/components/ui/rich-text-editor/index.d.ts +2 -0
  114. package/dist/components/ui/rich-text-editor/use-rich-text-editor.d.ts +107 -0
  115. package/dist/components/ui/stepper/index.d.ts +3 -1
  116. package/dist/components/ui/stepper/stepper.d.ts +2 -2
  117. package/dist/components/ui/stepper/use-stepper.d.ts +60 -0
  118. package/dist/components/ui/tree-view/index.d.ts +4 -1
  119. package/dist/components/ui/tree-view/tree-view.d.ts +4 -6
  120. package/dist/components/ui/tree-view/use-tree-view.d.ts +60 -0
  121. package/dist/contexts/AssistenteContext.d.ts +10 -49
  122. package/dist/hooks.cjs.js +30 -10
  123. package/dist/hooks.es.js +25 -4
  124. package/dist/index.cjs.js +20 -9
  125. package/dist/index.es.js +38 -27
  126. package/dist/layout.cjs.js +82 -1
  127. package/dist/layout.es.js +83 -2
  128. package/dist/media.cjs.js +1 -1
  129. package/dist/media.es.js +1 -1
  130. package/dist/pages.cjs.js +1 -1
  131. package/dist/pages.es.js +1 -1
  132. package/dist/rich-text-editor-BmsjY03B.js +2949 -0
  133. package/dist/rich-text-editor-GS2kpTAK.cjs +2966 -0
  134. package/dist/sidebar-CVUGHOS_.cjs +756 -0
  135. package/dist/sidebar-CmvwjnVb.js +757 -0
  136. package/dist/ui.cjs.js +12 -2
  137. package/dist/ui.es.js +24 -14
  138. package/dist/use-audio-player-Bkh23vQ3.js +177 -0
  139. package/dist/use-audio-player-Dn1NR9xN.cjs +176 -0
  140. package/dist/utils/color-utils.d.ts +51 -0
  141. package/dist/xertica-assistant-BMqdyRVi.js +2082 -0
  142. package/dist/xertica-assistant-Bj3vBCq_.cjs +2081 -0
  143. package/dist/xertica-ui.css +1 -1
  144. package/docs/ai-usage.md +28 -10
  145. package/docs/architecture-improvements.md +463 -0
  146. package/docs/architecture.md +77 -1
  147. package/docs/components/assistant-chart.md +1 -1
  148. package/docs/components/assistant.md +159 -0
  149. package/docs/components/audio-player.md +46 -0
  150. package/docs/components/branding.md +251 -0
  151. package/docs/components/chart.md +354 -39
  152. package/docs/components/code-block.md +108 -0
  153. package/docs/components/file-upload.md +119 -2
  154. package/docs/components/formatted-document.md +113 -0
  155. package/docs/components/hooks.md +430 -0
  156. package/docs/components/image-with-fallback.md +106 -0
  157. package/docs/components/map-layers.md +140 -0
  158. package/docs/components/modern-chat-input.md +163 -0
  159. package/docs/components/pages.md +351 -0
  160. package/docs/components/pagination.md +187 -0
  161. package/docs/components/rich-text-editor.md +164 -0
  162. package/docs/components/sidebar.md +153 -4
  163. package/docs/components/stepper.md +157 -12
  164. package/docs/components/tree-view.md +164 -6
  165. package/docs/doc-audit.md +223 -0
  166. package/docs/getting-started.md +155 -1
  167. package/docs/guidelines.md +14 -8
  168. package/docs/layout.md +2 -2
  169. package/docs/llms.md +29 -9
  170. package/docs/patterns/detail-page.md +276 -0
  171. package/docs/patterns/settings.md +346 -0
  172. package/docs/patterns/wizard.md +217 -0
  173. package/guidelines/Guidelines.md +5 -3
  174. package/llms.txt +1 -1
  175. package/package.json +10 -10
  176. package/styles/xertica/tokens.css +41 -12
  177. package/templates/CLAUDE.md +16 -6
  178. package/templates/guidelines/Guidelines.md +16 -4
  179. package/templates/package.json +3 -3
  180. package/templates/src/styles/xertica/tokens.css +39 -10
  181. package/utils/color-utils.ts +72 -0
@@ -0,0 +1,137 @@
1
+ import { useState, useCallback } from 'react';
2
+
3
+ // ─────────────────────────────────────────────────────────────────────────────
4
+ // Types
5
+ // ─────────────────────────────────────────────────────────────────────────────
6
+
7
+ export interface UseStepperProps {
8
+ /** Total number of steps. */
9
+ totalSteps: number;
10
+ /** Initial active step (1-indexed). @default 1 */
11
+ initialStep?: number;
12
+ /**
13
+ * Controlled current step (1-indexed).
14
+ * When provided, step state is managed externally via `onStepChange`.
15
+ */
16
+ step?: number;
17
+ /** Called whenever the active step changes. */
18
+ onStepChange?: (step: number) => void;
19
+ /**
20
+ * Optional async guard called before advancing to the next step.
21
+ * Return `true` to allow the transition, `false` to block it.
22
+ * Useful for running form validation before moving forward.
23
+ */
24
+ onBeforeNext?: (currentStep: number) => boolean | Promise<boolean>;
25
+ }
26
+
27
+ export interface UseStepperReturn {
28
+ // ── State ─────────────────────────────────────────────────────────────────
29
+ /** The currently active step (1-indexed). */
30
+ currentStep: number;
31
+ /** Total number of steps. */
32
+ totalSteps: number;
33
+
34
+ // ── Flags ─────────────────────────────────────────────────────────────────
35
+ /** Whether the current step is the first step. */
36
+ isFirstStep: boolean;
37
+ /** Whether the current step is the last step. */
38
+ isLastStep: boolean;
39
+ /** Whether navigation to the previous step is possible. */
40
+ canGoPrev: boolean;
41
+ /** Whether navigation to the next step is possible. */
42
+ canGoNext: boolean;
43
+
44
+ // ── Handlers ──────────────────────────────────────────────────────────────
45
+ /**
46
+ * Advance to the next step.
47
+ * If `onBeforeNext` is provided, it is awaited first; the step only
48
+ * advances when the guard returns `true`.
49
+ */
50
+ next: () => Promise<void>;
51
+ /** Go back to the previous step (no-op if already on step 1). */
52
+ prev: () => void;
53
+ /** Jump directly to a specific step (1-indexed, clamped to valid range). */
54
+ goTo: (step: number) => void;
55
+ /** Jump to the first step. */
56
+ reset: () => void;
57
+ }
58
+
59
+ // ─────────────────────────────────────────────────────────────────────────────
60
+ // Hook
61
+ // ─────────────────────────────────────────────────────────────────────────────
62
+
63
+ /**
64
+ * Headless hook for multi-step wizard / stepper logic.
65
+ *
66
+ * @description
67
+ * Manages the active step state, navigation guards, and derived flags for
68
+ * multi-step flows. Supports both controlled and uncontrolled modes.
69
+ * Pair with the `<Stepper>` / `<Step>` visual components or any custom UI.
70
+ *
71
+ * @example
72
+ * ```tsx
73
+ * const { currentStep, totalSteps, next, prev, isFirstStep, isLastStep } =
74
+ * useStepper({ totalSteps: 3, onBeforeNext: () => form.trigger() });
75
+ * ```
76
+ */
77
+ export function useStepper({
78
+ totalSteps,
79
+ initialStep = 1,
80
+ step: controlledStep,
81
+ onStepChange,
82
+ onBeforeNext,
83
+ }: UseStepperProps): UseStepperReturn {
84
+ const [internalStep, setInternalStep] = useState(initialStep);
85
+
86
+ const isControlled = controlledStep !== undefined;
87
+ const currentStep = isControlled ? controlledStep! : internalStep;
88
+
89
+ const setStep = useCallback(
90
+ (s: number) => {
91
+ const clamped = Math.min(Math.max(1, s), totalSteps);
92
+ if (!isControlled) setInternalStep(clamped);
93
+ onStepChange?.(clamped);
94
+ },
95
+ [isControlled, onStepChange, totalSteps]
96
+ );
97
+
98
+ // ── Derived ───────────────────────────────────────────────────────────────
99
+
100
+ const isFirstStep = currentStep === 1;
101
+ const isLastStep = currentStep === totalSteps;
102
+ const canGoPrev = currentStep > 1;
103
+ const canGoNext = currentStep < totalSteps;
104
+
105
+ // ── Handlers ──────────────────────────────────────────────────────────────
106
+
107
+ const next = useCallback(async () => {
108
+ if (!canGoNext) return;
109
+ if (onBeforeNext) {
110
+ const allowed = await onBeforeNext(currentStep);
111
+ if (!allowed) return;
112
+ }
113
+ setStep(currentStep + 1);
114
+ }, [canGoNext, currentStep, onBeforeNext, setStep]);
115
+
116
+ const prev = useCallback(() => {
117
+ if (!canGoPrev) return;
118
+ setStep(currentStep - 1);
119
+ }, [canGoPrev, currentStep, setStep]);
120
+
121
+ const goTo = useCallback((s: number) => setStep(s), [setStep]);
122
+
123
+ const reset = useCallback(() => setStep(1), [setStep]);
124
+
125
+ return {
126
+ currentStep,
127
+ totalSteps,
128
+ isFirstStep,
129
+ isLastStep,
130
+ canGoPrev,
131
+ canGoNext,
132
+ next,
133
+ prev,
134
+ goTo,
135
+ reset,
136
+ };
137
+ }
@@ -1 +1,4 @@
1
- export * from './tree-view';
1
+ export { TreeView } from './tree-view';
2
+ export type { TreeViewProps } from './tree-view';
3
+ export { useTreeView } from './use-tree-view';
4
+ export type { TreeNode, UseTreeViewProps, UseTreeViewReturn } from './use-tree-view';
@@ -1,7 +1,9 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react';
2
2
  import { TreeView } from './tree-view';
3
- import { Folder, File, Code, Database, Globe } from 'lucide-react';
4
- import React from 'react';
3
+ import { useTreeView, type TreeNode } from './use-tree-view';
4
+ import { Folder, File, Code, Database, Globe, ChevronRight, ChevronDown } from 'lucide-react';
5
+ import React, { useState } from 'react';
6
+ import { cn } from '../../shared/utils';
5
7
 
6
8
  const meta: Meta<typeof TreeView> = {
7
9
  title: 'UI/TreeView',
@@ -12,7 +14,7 @@ const meta: Meta<typeof TreeView> = {
12
14
  export default meta;
13
15
  type Story = StoryObj<typeof TreeView>;
14
16
 
15
- const fileTree = [
17
+ const fileTree: TreeNode[] = [
16
18
  {
17
19
  id: "root",
18
20
  label: "Project",
@@ -54,7 +56,111 @@ export const Default: Story = {
54
56
  },
55
57
  render: (args) => (
56
58
  <div className="w-[300px] rounded-lg border p-4">
57
- <TreeView {...args} onSelect={(node) => console.log("Selected:", node)} />
59
+ <TreeView {...args} onNodeSelect={(node) => console.log("Selected:", node)} />
58
60
  </div>
59
61
  ),
60
62
  };
63
+
64
+ /**
65
+ * ## useTreeView — Headless Hook
66
+ *
67
+ * Use `useTreeView` when you need the full tree logic (expand/collapse,
68
+ * keyboard navigation, selection) but want to render a completely custom UI.
69
+ * The example below renders a flat indented list with custom styling instead
70
+ * of the default `<TreeView>` component.
71
+ *
72
+ * ```tsx
73
+ * import { useTreeView } from '@xertica/ui/tree-view';
74
+ *
75
+ * const { expanded, effectiveSelectedId, toggleExpand, handleSelect, handleKeyDown, getNodeRef } =
76
+ * useTreeView({ data, onNodeSelect });
77
+ * ```
78
+ */
79
+ export const HeadlessHook: Story = {
80
+ name: 'useTreeView (headless)',
81
+ render: () => {
82
+ const [selectedId, setSelectedId] = useState<string | undefined>(undefined);
83
+
84
+ const {
85
+ expanded,
86
+ effectiveSelectedId,
87
+ getNodeRef,
88
+ toggleExpand,
89
+ handleSelect,
90
+ handleKeyDown,
91
+ } = useTreeView({
92
+ data: fileTree,
93
+ defaultExpanded: ['root', 'src'],
94
+ selectedNodeId: selectedId,
95
+ onNodeSelect: (node) => {
96
+ setSelectedId(node.id);
97
+ console.log('Selected:', node.label);
98
+ },
99
+ });
100
+
101
+ const renderNode = (node: TreeNode, level: number): React.ReactNode => {
102
+ const hasChildren = !!(node.children?.length);
103
+ const isExpanded = expanded.has(node.id);
104
+ const isSelected = node.id === effectiveSelectedId;
105
+
106
+ return (
107
+ <div key={node.id}>
108
+ <button
109
+ ref={getNodeRef(node.id)}
110
+ role="treeitem"
111
+ aria-expanded={hasChildren ? isExpanded : undefined}
112
+ aria-selected={isSelected}
113
+ tabIndex={isSelected ? 0 : -1}
114
+ onClick={() => {
115
+ if (hasChildren) toggleExpand(node.id);
116
+ handleSelect(node);
117
+ }}
118
+ onKeyDown={(e) => handleKeyDown(e, node)}
119
+ style={{ paddingLeft: `${level * 16 + 8}px` }}
120
+ className={cn(
121
+ 'flex w-full items-center gap-1.5 rounded py-1 pr-2 text-left text-sm transition-colors',
122
+ 'hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
123
+ isSelected
124
+ ? 'bg-primary text-primary-foreground font-semibold'
125
+ : 'text-foreground'
126
+ )}
127
+ >
128
+ {hasChildren ? (
129
+ <span className="flex-shrink-0">
130
+ {isExpanded
131
+ ? <ChevronDown className="h-3.5 w-3.5" />
132
+ : <ChevronRight className="h-3.5 w-3.5" />}
133
+ </span>
134
+ ) : (
135
+ <span className="w-3.5 flex-shrink-0" />
136
+ )}
137
+ {node.icon && <span className="flex-shrink-0 opacity-70">{node.icon}</span>}
138
+ <span>{node.label}</span>
139
+ </button>
140
+
141
+ {hasChildren && isExpanded && (
142
+ <div role="group">
143
+ {node.children!.map((child) => renderNode(child, level + 1))}
144
+ </div>
145
+ )}
146
+ </div>
147
+ );
148
+ };
149
+
150
+ return (
151
+ <div className="w-[280px] space-y-1 rounded-xl border bg-card p-3 shadow-sm">
152
+ <p className="mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
153
+ Explorer
154
+ </p>
155
+ <div role="tree">
156
+ {fileTree.map((node) => renderNode(node, 0))}
157
+ </div>
158
+ {selectedId && (
159
+ <p className="mt-3 border-t pt-2 px-2 text-xs text-muted-foreground">
160
+ Selected: <span className="font-medium text-foreground">{selectedId}</span>
161
+ </p>
162
+ )}
163
+ </div>
164
+ );
165
+ },
166
+ };
@@ -1,13 +1,7 @@
1
1
  import * as React from "react";
2
2
  import { ChevronRight, ChevronDown } from "lucide-react";
3
3
  import { cn } from "../../shared/utils";
4
-
5
- export interface TreeNode {
6
- id: string;
7
- label: string;
8
- icon?: React.ReactNode;
9
- children?: TreeNode[];
10
- }
4
+ import { useTreeView, type TreeNode } from "./use-tree-view";
11
5
 
12
6
  interface TreeViewProps extends React.HTMLAttributes<HTMLDivElement> {
13
7
  data: TreeNode[];
@@ -29,6 +23,8 @@ interface TreeViewProps extends React.HTMLAttributes<HTMLDivElement> {
29
23
  * and collapsed independently. Supports custom icons per node, controlled
30
24
  * selection, and full WAI-ARIA keyboard navigation.
31
25
  *
26
+ * Headless logic is available via `useTreeView` for custom tree UIs.
27
+ *
32
28
  * @ai-rules
33
29
  * 1. Each `TreeNode` must have a unique `id`.
34
30
  * 2. Leaf nodes (no `children`) do not render expand arrows.
@@ -38,114 +34,14 @@ interface TreeViewProps extends React.HTMLAttributes<HTMLDivElement> {
38
34
  */
39
35
  const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
40
36
  ({ className, data, onNodeClick, onNodeSelect, defaultExpanded = [], selectedNodeId, ...props }, ref) => {
41
- const [expanded, setExpanded] = React.useState<Set<string>>(new Set(defaultExpanded));
42
- const [internalSelectedId, setInternalSelectedId] = React.useState<string | undefined>(undefined);
43
- const nodeRefs = React.useRef<Map<string, HTMLButtonElement>>(new Map());
44
-
45
- const isControlled = selectedNodeId !== undefined;
46
- const effectiveSelectedId = isControlled ? selectedNodeId : internalSelectedId;
47
-
48
- const toggleExpand = (nodeId: string) => {
49
- setExpanded(prev => {
50
- const next = new Set(prev);
51
- next.has(nodeId) ? next.delete(nodeId) : next.add(nodeId);
52
- return next;
53
- });
54
- };
55
-
56
- // Build an ordered list of all currently visible nodes (respects expand state)
57
- const getVisibleNodes = React.useCallback((): TreeNode[] => {
58
- const result: TreeNode[] = [];
59
- const traverse = (nodes: TreeNode[]) => {
60
- for (const node of nodes) {
61
- result.push(node);
62
- if (node.children?.length && expanded.has(node.id)) traverse(node.children);
63
- }
64
- };
65
- traverse(data);
66
- return result;
67
- }, [data, expanded]);
68
-
69
- // Find the parent of a given node id
70
- const findParent = (nodes: TreeNode[], targetId: string): TreeNode | null => {
71
- for (const n of nodes) {
72
- if (n.children?.some(c => c.id === targetId)) return n;
73
- if (n.children) {
74
- const found = findParent(n.children, targetId);
75
- if (found) return found;
76
- }
77
- }
78
- return null;
79
- };
80
-
81
- const handleSelect = (node: TreeNode) => {
82
- if (!isControlled) setInternalSelectedId(node.id);
83
- onNodeSelect?.(node);
84
- onNodeClick?.(node);
85
- };
86
-
87
- const handleKeyDown = (e: React.KeyboardEvent, node: TreeNode) => {
88
- const visibleNodes = getVisibleNodes();
89
- const idx = visibleNodes.findIndex(n => n.id === node.id);
90
- const hasChildren = !!(node.children?.length);
91
- const isExpanded = expanded.has(node.id);
92
-
93
- const focusNode = (id: string) => {
94
- nodeRefs.current.get(id)?.focus();
95
- };
96
-
97
- switch (e.key) {
98
- case 'ArrowDown': {
99
- e.preventDefault();
100
- const next = visibleNodes[idx + 1];
101
- if (next) focusNode(next.id);
102
- break;
103
- }
104
- case 'ArrowUp': {
105
- e.preventDefault();
106
- const prev = visibleNodes[idx - 1];
107
- if (prev) focusNode(prev.id);
108
- break;
109
- }
110
- case 'ArrowRight': {
111
- e.preventDefault();
112
- if (hasChildren && !isExpanded) {
113
- toggleExpand(node.id);
114
- } else if (hasChildren && isExpanded && node.children?.length) {
115
- focusNode(node.children[0].id);
116
- }
117
- break;
118
- }
119
- case 'ArrowLeft': {
120
- e.preventDefault();
121
- if (hasChildren && isExpanded) {
122
- toggleExpand(node.id);
123
- } else {
124
- const parent = findParent(data, node.id);
125
- if (parent) focusNode(parent.id);
126
- }
127
- break;
128
- }
129
- case 'Home': {
130
- e.preventDefault();
131
- if (visibleNodes.length > 0) focusNode(visibleNodes[0].id);
132
- break;
133
- }
134
- case 'End': {
135
- e.preventDefault();
136
- const last = visibleNodes[visibleNodes.length - 1];
137
- if (last) focusNode(last.id);
138
- break;
139
- }
140
- case 'Enter':
141
- case ' ': {
142
- e.preventDefault();
143
- if (hasChildren) toggleExpand(node.id);
144
- handleSelect(node);
145
- break;
146
- }
147
- }
148
- };
37
+ const {
38
+ expanded,
39
+ effectiveSelectedId,
40
+ getNodeRef,
41
+ toggleExpand,
42
+ handleSelect,
43
+ handleKeyDown,
44
+ } = useTreeView({ data, defaultExpanded, selectedNodeId, onNodeClick, onNodeSelect });
149
45
 
150
46
  return (
151
47
  <div ref={ref} className={cn("w-full", className)} role="tree" {...props}>
@@ -159,7 +55,7 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
159
55
  onToggle={toggleExpand}
160
56
  onSelect={handleSelect}
161
57
  onKeyDown={handleKeyDown}
162
- nodeRefs={nodeRefs}
58
+ getNodeRef={getNodeRef}
163
59
  />
164
60
  ))}
165
61
  </div>
@@ -176,11 +72,11 @@ interface TreeNodeComponentProps {
176
72
  onToggle: (nodeId: string) => void;
177
73
  onSelect: (node: TreeNode) => void;
178
74
  onKeyDown: (e: React.KeyboardEvent, node: TreeNode) => void;
179
- nodeRefs: React.MutableRefObject<Map<string, HTMLButtonElement>>;
75
+ getNodeRef: (nodeId: string) => (el: HTMLButtonElement | null) => void;
180
76
  }
181
77
 
182
78
  const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
183
- node, level, expanded, selectedId, onToggle, onSelect, onKeyDown, nodeRefs,
79
+ node, level, expanded, selectedId, onToggle, onSelect, onKeyDown, getNodeRef,
184
80
  }) => {
185
81
  const hasChildren = !!(node.children?.length);
186
82
  const isExpanded = expanded.has(node.id);
@@ -191,15 +87,10 @@ const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
191
87
  onSelect(node);
192
88
  };
193
89
 
194
- const setRef = (el: HTMLButtonElement | null) => {
195
- if (el) nodeRefs.current.set(node.id, el);
196
- else nodeRefs.current.delete(node.id);
197
- };
198
-
199
90
  return (
200
91
  <div role="none">
201
92
  <button
202
- ref={setRef}
93
+ ref={getNodeRef(node.id)}
203
94
  role="treeitem"
204
95
  aria-expanded={hasChildren ? isExpanded : undefined}
205
96
  aria-selected={isSelected}
@@ -235,7 +126,7 @@ const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
235
126
  onToggle={onToggle}
236
127
  onSelect={onSelect}
237
128
  onKeyDown={onKeyDown}
238
- nodeRefs={nodeRefs}
129
+ getNodeRef={getNodeRef}
239
130
  />
240
131
  ))}
241
132
  </div>
@@ -246,3 +137,4 @@ const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
246
137
 
247
138
  export { TreeView };
248
139
  export type { TreeViewProps };
140
+ export type { TreeNode } from './use-tree-view';