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.
- package/CHANGELOG.md +46 -0
- package/README.md +1 -1
- package/bin/cli.ts +1 -1
- package/bin/generate-tokens.ts +13 -7
- package/components/assistant/xertica-assistant/index.ts +2 -0
- package/components/assistant/xertica-assistant/parts/AssistantCollapsedView.tsx +97 -0
- package/components/assistant/xertica-assistant/parts/AssistantConversationList.tsx +104 -0
- package/components/assistant/xertica-assistant/parts/AssistantDocumentEditor.tsx +81 -0
- package/components/assistant/xertica-assistant/parts/AssistantFeedbackDialog.tsx +86 -0
- package/components/assistant/xertica-assistant/parts/AssistantHeader.tsx +77 -0
- package/components/assistant/xertica-assistant/parts/AssistantMessageBubble.tsx +573 -0
- package/components/assistant/xertica-assistant/parts/AssistantTabBar.tsx +65 -0
- package/components/assistant/xertica-assistant/parts/AssistantTypingIndicator.tsx +41 -0
- package/components/assistant/xertica-assistant/parts/AssistantWelcomeScreen.tsx +98 -0
- package/components/assistant/xertica-assistant/parts/index.ts +16 -0
- package/components/assistant/xertica-assistant/types.ts +139 -0
- package/components/assistant/xertica-assistant/use-assistant.ts +559 -0
- package/components/assistant/xertica-assistant/xertica-assistant.stories.tsx +200 -0
- package/components/assistant/xertica-assistant/xertica-assistant.tsx +198 -1460
- package/components/brand/theme-toggle/ThemeToggle.tsx +8 -27
- package/components/hooks/index.ts +3 -0
- package/components/hooks/use-layout-shortcuts.ts +46 -0
- package/components/layout/sidebar/index.ts +2 -0
- package/components/layout/sidebar/sidebar.stories.tsx +160 -8
- package/components/layout/sidebar/sidebar.tsx +606 -497
- package/components/layout/sidebar/use-sidebar.ts +104 -0
- package/components/media/audio-player/AudioPlayer.tsx +131 -206
- package/components/media/audio-player/use-audio-player.ts +298 -0
- package/components/pages/home-page/HomePage.tsx +1 -1
- package/components/pages/template-content/TemplateContent.tsx +5 -5
- package/components/pages/template-page/TemplatePage.tsx +5 -5
- package/components/shared/CustomTooltipContent.tsx +52 -0
- package/components/shared/layout-constants.ts +1 -1
- package/components/ui/chart/chart.stories.tsx +966 -7
- package/components/ui/chart/chart.tsx +918 -45
- package/components/ui/file-upload/file-upload.stories.tsx +100 -0
- package/components/ui/file-upload/file-upload.tsx +14 -74
- package/components/ui/file-upload/index.ts +1 -0
- package/components/ui/file-upload/use-file-upload.ts +181 -0
- package/components/ui/pagination/index.ts +2 -0
- package/components/ui/pagination/pagination.stories.tsx +94 -0
- package/components/ui/pagination/use-pagination.ts +194 -0
- package/components/ui/rich-text-editor/index.ts +2 -0
- package/components/ui/rich-text-editor/rich-text-editor.stories.tsx +129 -1
- package/components/ui/rich-text-editor/rich-text-editor.tsx +86 -305
- package/components/ui/rich-text-editor/use-rich-text-editor.ts +439 -0
- package/components/ui/stepper/index.ts +3 -1
- package/components/ui/stepper/stepper.stories.tsx +116 -0
- package/components/ui/stepper/stepper.tsx +4 -4
- package/components/ui/stepper/use-stepper.ts +137 -0
- package/components/ui/tree-view/index.ts +4 -1
- package/components/ui/tree-view/tree-view.stories.tsx +110 -4
- package/components/ui/tree-view/tree-view.tsx +17 -125
- package/components/ui/tree-view/use-tree-view.ts +229 -0
- package/contexts/AssistenteContext.tsx +17 -54
- package/contexts/BrandColorsContext.tsx +6 -17
- package/contexts/LayoutContext.tsx +5 -31
- package/dist/AssistantChart-BAudAfne.cjs +3591 -0
- package/dist/AssistantChart-BP8upjMk.js +3565 -0
- package/dist/AudioPlayer-1ypwE2Wh.cjs +936 -0
- package/dist/AudioPlayer-DuKXrCfy.js +937 -0
- package/dist/CustomTooltipContent-DHjkY0ww.js +40 -0
- package/dist/CustomTooltipContent-c_K-DWRr.cjs +56 -0
- package/dist/LanguageContext-BwhwC3G2.js +657 -0
- package/dist/LanguageContext-DvUt5jBg.cjs +656 -0
- package/dist/LayoutContext-BDmcZfMH.cjs +84 -0
- package/dist/LayoutContext-dbQvdC4O.js +85 -0
- package/dist/ThemeContext-RTy1m2Uq.js +82 -0
- package/dist/ThemeContext-bSzuOit2.cjs +81 -0
- package/dist/VerifyEmailPage-C_ihbcth.js +2828 -0
- package/dist/VerifyEmailPage-Dt7zgA4w.cjs +2827 -0
- package/dist/XerticaProvider-CW9hpCdF.cjs +39 -0
- package/dist/XerticaProvider-siSt9uG2.js +40 -0
- package/dist/XerticaXLogo-D8jf0SNv.cjs +214 -0
- package/dist/XerticaXLogo-fAJMy3H4.js +215 -0
- package/dist/assistant.cjs.js +2 -1
- package/dist/assistant.es.js +3 -2
- package/dist/brand.cjs.js +2 -2
- package/dist/brand.es.js +2 -2
- package/dist/cli.js +14 -8
- package/dist/components/assistant/xertica-assistant/index.d.ts +2 -0
- package/dist/components/assistant/xertica-assistant/parts/AssistantCollapsedView.d.ts +13 -0
- package/dist/components/assistant/xertica-assistant/parts/AssistantConversationList.d.ts +16 -0
- package/dist/components/assistant/xertica-assistant/parts/AssistantDocumentEditor.d.ts +17 -0
- package/dist/components/assistant/xertica-assistant/parts/AssistantFeedbackDialog.d.ts +19 -0
- package/dist/components/assistant/xertica-assistant/parts/AssistantHeader.d.ts +11 -0
- package/dist/components/assistant/xertica-assistant/parts/AssistantMessageBubble.d.ts +29 -0
- package/dist/components/assistant/xertica-assistant/parts/AssistantTabBar.d.ts +13 -0
- package/dist/components/assistant/xertica-assistant/parts/AssistantTypingIndicator.d.ts +4 -0
- package/dist/components/assistant/xertica-assistant/parts/AssistantWelcomeScreen.d.ts +17 -0
- package/dist/components/assistant/xertica-assistant/parts/index.d.ts +16 -0
- package/dist/components/assistant/xertica-assistant/types.d.ts +106 -0
- package/dist/components/assistant/xertica-assistant/use-assistant.d.ts +125 -0
- package/dist/components/assistant/xertica-assistant/xertica-assistant.d.ts +8 -97
- package/dist/components/hooks/index.d.ts +3 -0
- package/dist/components/hooks/use-layout-shortcuts.d.ts +22 -0
- package/dist/components/layout/sidebar/index.d.ts +2 -0
- package/dist/components/layout/sidebar/sidebar.d.ts +80 -0
- package/dist/components/layout/sidebar/use-sidebar.d.ts +22 -0
- package/dist/components/media/audio-player/AudioPlayer.d.ts +4 -1
- package/dist/components/media/audio-player/use-audio-player.d.ts +72 -0
- package/dist/components/shared/CustomTooltipContent.d.ts +20 -0
- package/dist/components/shared/layout-constants.d.ts +1 -1
- package/dist/components/ui/alert/alert.d.ts +1 -1
- package/dist/components/ui/badge/badge.d.ts +1 -1
- package/dist/components/ui/button/button.d.ts +2 -2
- package/dist/components/ui/chart/chart.d.ts +162 -5
- package/dist/components/ui/file-upload/file-upload.d.ts +2 -0
- package/dist/components/ui/file-upload/index.d.ts +1 -0
- package/dist/components/ui/file-upload/use-file-upload.d.ts +49 -0
- package/dist/components/ui/pagination/index.d.ts +2 -0
- package/dist/components/ui/pagination/use-pagination.d.ts +78 -0
- package/dist/components/ui/rich-text-editor/index.d.ts +2 -0
- package/dist/components/ui/rich-text-editor/use-rich-text-editor.d.ts +107 -0
- package/dist/components/ui/stepper/index.d.ts +3 -1
- package/dist/components/ui/stepper/stepper.d.ts +2 -2
- package/dist/components/ui/stepper/use-stepper.d.ts +60 -0
- package/dist/components/ui/tree-view/index.d.ts +4 -1
- package/dist/components/ui/tree-view/tree-view.d.ts +4 -6
- package/dist/components/ui/tree-view/use-tree-view.d.ts +60 -0
- package/dist/contexts/AssistenteContext.d.ts +10 -49
- package/dist/hooks.cjs.js +30 -10
- package/dist/hooks.es.js +25 -4
- package/dist/index.cjs.js +20 -9
- package/dist/index.es.js +38 -27
- package/dist/layout.cjs.js +82 -1
- package/dist/layout.es.js +83 -2
- package/dist/media.cjs.js +1 -1
- package/dist/media.es.js +1 -1
- package/dist/pages.cjs.js +1 -1
- package/dist/pages.es.js +1 -1
- package/dist/rich-text-editor-BmsjY03B.js +2949 -0
- package/dist/rich-text-editor-GS2kpTAK.cjs +2966 -0
- package/dist/sidebar-CVUGHOS_.cjs +756 -0
- package/dist/sidebar-CmvwjnVb.js +757 -0
- package/dist/ui.cjs.js +12 -2
- package/dist/ui.es.js +24 -14
- package/dist/use-audio-player-Bkh23vQ3.js +177 -0
- package/dist/use-audio-player-Dn1NR9xN.cjs +176 -0
- package/dist/utils/color-utils.d.ts +51 -0
- package/dist/xertica-assistant-BMqdyRVi.js +2082 -0
- package/dist/xertica-assistant-Bj3vBCq_.cjs +2081 -0
- package/dist/xertica-ui.css +1 -1
- package/docs/ai-usage.md +28 -10
- package/docs/architecture-improvements.md +463 -0
- package/docs/architecture.md +77 -1
- package/docs/components/assistant-chart.md +1 -1
- package/docs/components/assistant.md +159 -0
- package/docs/components/audio-player.md +46 -0
- package/docs/components/branding.md +251 -0
- package/docs/components/chart.md +354 -39
- package/docs/components/code-block.md +108 -0
- package/docs/components/file-upload.md +119 -2
- package/docs/components/formatted-document.md +113 -0
- package/docs/components/hooks.md +430 -0
- package/docs/components/image-with-fallback.md +106 -0
- package/docs/components/map-layers.md +140 -0
- package/docs/components/modern-chat-input.md +163 -0
- package/docs/components/pages.md +351 -0
- package/docs/components/pagination.md +187 -0
- package/docs/components/rich-text-editor.md +164 -0
- package/docs/components/sidebar.md +153 -4
- package/docs/components/stepper.md +157 -12
- package/docs/components/tree-view.md +164 -6
- package/docs/doc-audit.md +223 -0
- package/docs/getting-started.md +155 -1
- package/docs/guidelines.md +14 -8
- package/docs/layout.md +2 -2
- package/docs/llms.md +29 -9
- package/docs/patterns/detail-page.md +276 -0
- package/docs/patterns/settings.md +346 -0
- package/docs/patterns/wizard.md +217 -0
- package/guidelines/Guidelines.md +5 -3
- package/llms.txt +1 -1
- package/package.json +10 -10
- package/styles/xertica/tokens.css +41 -12
- package/templates/CLAUDE.md +16 -6
- package/templates/guidelines/Guidelines.md +16 -4
- package/templates/package.json +3 -3
- package/templates/src/styles/xertica/tokens.css +39 -10
- 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,7 +1,9 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
2
|
import { TreeView } from './tree-view';
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
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}
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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={
|
|
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
|
-
|
|
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';
|