@djangocfg/ui-tools 2.1.314 → 2.1.315
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/TreeRoot-DO33TIS5.mjs +4 -0
- package/dist/TreeRoot-DO33TIS5.mjs.map +1 -0
- package/dist/TreeRoot-NJOZ2DMV.cjs +19 -0
- package/dist/TreeRoot-NJOZ2DMV.cjs.map +1 -0
- package/dist/chunk-E5BP4IXF.mjs +1231 -0
- package/dist/chunk-E5BP4IXF.mjs.map +1 -0
- package/dist/chunk-MA552EWC.cjs +1282 -0
- package/dist/chunk-MA552EWC.cjs.map +1 -0
- package/dist/index.cjs +186 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +435 -2
- package/dist/index.d.ts +435 -2
- package/dist/index.mjs +60 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +11 -6
- package/src/index.ts +4 -0
- package/src/tools/Tree/README.md +220 -0
- package/src/tools/Tree/Tree.story.tsx +536 -0
- package/src/tools/Tree/TreeRoot.tsx +157 -0
- package/src/tools/Tree/components/TreeChevron.tsx +39 -0
- package/src/tools/Tree/components/TreeContent.tsx +48 -0
- package/src/tools/Tree/components/TreeEmpty.tsx +21 -0
- package/src/tools/Tree/components/TreeError.tsx +24 -0
- package/src/tools/Tree/components/TreeIcon.tsx +29 -0
- package/src/tools/Tree/components/TreeIndentGuides.tsx +33 -0
- package/src/tools/Tree/components/TreeLabel.tsx +24 -0
- package/src/tools/Tree/components/TreeRow.tsx +173 -0
- package/src/tools/Tree/components/TreeSearchInput.tsx +50 -0
- package/src/tools/Tree/components/TreeSkeleton.tsx +22 -0
- package/src/tools/Tree/components/index.ts +22 -0
- package/src/tools/Tree/context/TreeContext.tsx +538 -0
- package/src/tools/Tree/context/hooks.ts +110 -0
- package/src/tools/Tree/context/index.ts +13 -0
- package/src/tools/Tree/data/appearance.ts +175 -0
- package/src/tools/Tree/data/childCache.ts +43 -0
- package/src/tools/Tree/data/createDemoTree.ts +42 -0
- package/src/tools/Tree/data/flatten.ts +51 -0
- package/src/tools/Tree/data/index.ts +24 -0
- package/src/tools/Tree/data/persist.ts +62 -0
- package/src/tools/Tree/hooks/index.ts +6 -0
- package/src/tools/Tree/hooks/useTreeKeyboard.ts +137 -0
- package/src/tools/Tree/hooks/useTreeTypeAhead.ts +100 -0
- package/src/tools/Tree/index.tsx +99 -0
- package/src/tools/Tree/lazy.tsx +14 -0
- package/src/tools/Tree/types.ts +136 -0
- package/src/tools/index.ts +75 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
AlertCircle,
|
|
4
|
+
Braces,
|
|
5
|
+
Code2,
|
|
6
|
+
Copy,
|
|
7
|
+
Eye,
|
|
8
|
+
FileText,
|
|
9
|
+
Folder,
|
|
10
|
+
FolderOpen,
|
|
11
|
+
Image as ImageIcon,
|
|
12
|
+
Pencil,
|
|
13
|
+
RefreshCw,
|
|
14
|
+
Settings,
|
|
15
|
+
Trash,
|
|
16
|
+
} from 'lucide-react';
|
|
17
|
+
import { defineStory, useBoolean, useSelect } from '@djangocfg/playground';
|
|
18
|
+
import {
|
|
19
|
+
ContextMenu,
|
|
20
|
+
ContextMenuContent,
|
|
21
|
+
ContextMenuItem,
|
|
22
|
+
ContextMenuSeparator,
|
|
23
|
+
ContextMenuShortcut,
|
|
24
|
+
ContextMenuTrigger,
|
|
25
|
+
} from '@djangocfg/ui-core/components';
|
|
26
|
+
|
|
27
|
+
import { TreeRoot } from './TreeRoot';
|
|
28
|
+
import { TreeProvider } from './context/TreeContext';
|
|
29
|
+
import { TreeContent } from './components/TreeContent';
|
|
30
|
+
import { useTreeActions } from './context/hooks';
|
|
31
|
+
import { createDemoTree, type DemoNode } from './data/createDemoTree';
|
|
32
|
+
import type { TreeItemId, TreeNode } from './types';
|
|
33
|
+
|
|
34
|
+
export default defineStory({
|
|
35
|
+
title: 'Tools/Tree',
|
|
36
|
+
component: TreeRoot,
|
|
37
|
+
description: 'Decomposed shadcn-style tree (own engine, no external libs).',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Sample data
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
interface FsNode {
|
|
45
|
+
name: string;
|
|
46
|
+
ext?: string;
|
|
47
|
+
status?: 'modified' | 'error' | 'disabled';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const fs: TreeNode<FsNode>[] = [
|
|
51
|
+
{
|
|
52
|
+
id: 'src',
|
|
53
|
+
data: { name: 'src' },
|
|
54
|
+
children: [
|
|
55
|
+
{
|
|
56
|
+
id: 'components',
|
|
57
|
+
data: { name: 'components' },
|
|
58
|
+
children: [
|
|
59
|
+
{ id: 'Button.tsx', data: { name: 'Button.tsx', ext: 'tsx' } },
|
|
60
|
+
{
|
|
61
|
+
id: 'Card.tsx',
|
|
62
|
+
data: { name: 'Card.tsx', ext: 'tsx', status: 'modified' },
|
|
63
|
+
},
|
|
64
|
+
{ id: 'Tree.tsx', data: { name: 'Tree.tsx', ext: 'tsx' } },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: 'hooks',
|
|
69
|
+
data: { name: 'hooks' },
|
|
70
|
+
children: [
|
|
71
|
+
{ id: 'useDebounce.ts', data: { name: 'useDebounce.ts', ext: 'ts' } },
|
|
72
|
+
{
|
|
73
|
+
id: 'useTheme.ts',
|
|
74
|
+
data: { name: 'useTheme.ts', ext: 'ts', status: 'error' },
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
{ id: 'index.ts', data: { name: 'index.ts', ext: 'ts' } },
|
|
79
|
+
{
|
|
80
|
+
id: '_old.ts',
|
|
81
|
+
data: { name: '_old.ts', ext: 'ts', status: 'disabled' },
|
|
82
|
+
disabled: true,
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: 'public',
|
|
88
|
+
data: { name: 'public' },
|
|
89
|
+
children: [
|
|
90
|
+
{ id: 'favicon.ico', data: { name: 'favicon.ico', ext: 'ico' } },
|
|
91
|
+
{ id: 'logo.svg', data: { name: 'logo.svg', ext: 'svg' } },
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
{ id: 'package.json', data: { name: 'package.json', ext: 'json' } },
|
|
95
|
+
{ id: 'README.md', data: { name: 'README.md', ext: 'md' } },
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
const getName = (n: TreeNode<FsNode | DemoNode>) => n.data.name;
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// 1) Default — sensible cozy defaults
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
export const Default = () => (
|
|
105
|
+
<div className="h-96 w-80 rounded-md border border-border bg-card">
|
|
106
|
+
<TreeRoot<FsNode>
|
|
107
|
+
data={fs}
|
|
108
|
+
getItemName={getName}
|
|
109
|
+
initialExpandedIds={['src']}
|
|
110
|
+
/>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// 2) Densities — three presets side-by-side for comparison
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
export const Densities = () => (
|
|
119
|
+
<div className="grid grid-cols-3 gap-3">
|
|
120
|
+
{(['compact', 'cozy', 'comfortable'] as const).map((density) => (
|
|
121
|
+
<div key={density} className="flex flex-col gap-2">
|
|
122
|
+
<span className="text-xs font-medium text-muted-foreground capitalize">
|
|
123
|
+
{density}
|
|
124
|
+
</span>
|
|
125
|
+
<div className="h-80 w-64 rounded-md border border-border bg-card">
|
|
126
|
+
<TreeRoot<FsNode>
|
|
127
|
+
data={fs}
|
|
128
|
+
getItemName={getName}
|
|
129
|
+
initialExpandedIds={['src', 'components']}
|
|
130
|
+
appearance={{ density }}
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// 3) WithIcons — file-type icons via renderIcon slot
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
const FileIcon = ({ ext }: { ext?: string }) => {
|
|
143
|
+
const props = {
|
|
144
|
+
'aria-hidden': true,
|
|
145
|
+
style: { width: 'var(--tree-icon-size)', height: 'var(--tree-icon-size)' },
|
|
146
|
+
strokeWidth: 1.5 as const,
|
|
147
|
+
className: 'shrink-0',
|
|
148
|
+
};
|
|
149
|
+
if (ext === 'tsx' || ext === 'ts')
|
|
150
|
+
return <Code2 {...props} className={`${props.className} text-blue-400`} />;
|
|
151
|
+
if (ext === 'json')
|
|
152
|
+
return <Braces {...props} className={`${props.className} text-amber-400`} />;
|
|
153
|
+
if (ext === 'svg' || ext === 'ico')
|
|
154
|
+
return <ImageIcon {...props} className={`${props.className} text-pink-400`} />;
|
|
155
|
+
if (ext === 'md')
|
|
156
|
+
return <FileText {...props} className={`${props.className} text-sky-400`} />;
|
|
157
|
+
return <FileText {...props} className={`${props.className} text-muted-foreground/80`} />;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const FolderTinted = ({ isExpanded }: { isExpanded: boolean }) => {
|
|
161
|
+
const Icon = isExpanded ? FolderOpen : Folder;
|
|
162
|
+
return (
|
|
163
|
+
<Icon
|
|
164
|
+
aria-hidden
|
|
165
|
+
strokeWidth={1.5}
|
|
166
|
+
style={{
|
|
167
|
+
width: 'var(--tree-icon-size)',
|
|
168
|
+
height: 'var(--tree-icon-size)',
|
|
169
|
+
}}
|
|
170
|
+
className="shrink-0 text-amber-300/90"
|
|
171
|
+
/>
|
|
172
|
+
);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export const WithIcons = () => (
|
|
176
|
+
<div className="h-96 w-80 rounded-md border border-border bg-card">
|
|
177
|
+
<TreeRoot<FsNode>
|
|
178
|
+
data={fs}
|
|
179
|
+
getItemName={getName}
|
|
180
|
+
initialExpandedIds={['src', 'components', 'hooks', 'public']}
|
|
181
|
+
renderIcon={({ node, isFolder, isExpanded }) =>
|
|
182
|
+
isFolder ? (
|
|
183
|
+
<FolderTinted isExpanded={isExpanded} />
|
|
184
|
+
) : (
|
|
185
|
+
<FileIcon ext={node.data.ext} />
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
/>
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// 4) WithStatus — modified / error / disabled rows via renderLabel
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
export const WithStatus = () => (
|
|
197
|
+
<div className="h-96 w-80 rounded-md border border-border bg-card">
|
|
198
|
+
<TreeRoot<FsNode>
|
|
199
|
+
data={fs}
|
|
200
|
+
getItemName={getName}
|
|
201
|
+
initialExpandedIds={['src', 'components', 'hooks']}
|
|
202
|
+
renderLabel={({ node }) => {
|
|
203
|
+
const { name, status } = node.data;
|
|
204
|
+
if (status === 'modified')
|
|
205
|
+
return (
|
|
206
|
+
<span className="flex min-w-0 items-center gap-1.5">
|
|
207
|
+
<span className="truncate text-amber-400" style={{ fontSize: 'var(--tree-font-size)' }}>
|
|
208
|
+
{name}
|
|
209
|
+
</span>
|
|
210
|
+
<span className="text-[10px] font-medium text-amber-400/80">M</span>
|
|
211
|
+
</span>
|
|
212
|
+
);
|
|
213
|
+
if (status === 'error')
|
|
214
|
+
return (
|
|
215
|
+
<span className="flex min-w-0 items-center gap-1.5">
|
|
216
|
+
<AlertCircle aria-hidden strokeWidth={2} className="size-3 shrink-0 text-destructive" />
|
|
217
|
+
<span className="truncate text-destructive" style={{ fontSize: 'var(--tree-font-size)' }}>
|
|
218
|
+
{name}
|
|
219
|
+
</span>
|
|
220
|
+
</span>
|
|
221
|
+
);
|
|
222
|
+
return (
|
|
223
|
+
<span
|
|
224
|
+
className="truncate"
|
|
225
|
+
style={{ fontSize: 'var(--tree-font-size)' }}
|
|
226
|
+
>
|
|
227
|
+
{name}
|
|
228
|
+
</span>
|
|
229
|
+
);
|
|
230
|
+
}}
|
|
231
|
+
/>
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// 5) WithActions — rename / delete buttons appear on hover
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
export const WithActions = () => {
|
|
240
|
+
const [last, setLast] = useState<string>('');
|
|
241
|
+
return (
|
|
242
|
+
<div className="flex h-96 w-80 flex-col gap-2">
|
|
243
|
+
<div className="text-xs text-muted-foreground">Last: {last || '—'}</div>
|
|
244
|
+
<div className="flex-1 rounded-md border border-border bg-card">
|
|
245
|
+
<TreeRoot<FsNode>
|
|
246
|
+
data={fs}
|
|
247
|
+
getItemName={getName}
|
|
248
|
+
initialExpandedIds={['src']}
|
|
249
|
+
renderActions={({ node }) => (
|
|
250
|
+
<>
|
|
251
|
+
<button
|
|
252
|
+
type="button"
|
|
253
|
+
aria-label={`Rename ${node.data.name}`}
|
|
254
|
+
onClick={() => setLast(`Rename ${node.id}`)}
|
|
255
|
+
className="rounded p-0.5 hover:bg-foreground/10"
|
|
256
|
+
>
|
|
257
|
+
<Pencil aria-hidden className="size-3 text-muted-foreground" />
|
|
258
|
+
</button>
|
|
259
|
+
<button
|
|
260
|
+
type="button"
|
|
261
|
+
aria-label={`Delete ${node.data.name}`}
|
|
262
|
+
onClick={() => setLast(`Delete ${node.id}`)}
|
|
263
|
+
className="rounded p-0.5 hover:bg-destructive/15 hover:text-destructive"
|
|
264
|
+
>
|
|
265
|
+
<Trash aria-hidden className="size-3 text-muted-foreground" />
|
|
266
|
+
</button>
|
|
267
|
+
</>
|
|
268
|
+
)}
|
|
269
|
+
/>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// 6) WithSearch — built-in search bar + match highlight
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
export const WithSearch = () => (
|
|
280
|
+
<div className="h-96 w-80 rounded-md border border-border bg-card">
|
|
281
|
+
<TreeRoot<FsNode>
|
|
282
|
+
data={fs}
|
|
283
|
+
getItemName={getName}
|
|
284
|
+
initialExpandedIds={['src', 'components', 'hooks', 'public']}
|
|
285
|
+
enableSearch
|
|
286
|
+
/>
|
|
287
|
+
</div>
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// 7) WithIndentGuides
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
export const WithIndentGuides = () => (
|
|
295
|
+
<div className="h-96 w-80 rounded-md border border-border bg-card">
|
|
296
|
+
<TreeRoot<FsNode>
|
|
297
|
+
data={fs}
|
|
298
|
+
getItemName={getName}
|
|
299
|
+
initialExpandedIds={['src', 'components']}
|
|
300
|
+
showIndentGuides
|
|
301
|
+
/>
|
|
302
|
+
</div>
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
// 8) WithContextMenu — right-click via renderContextMenu slot
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
export const WithContextMenu = () => {
|
|
310
|
+
const [last, setLast] = useState<string>('');
|
|
311
|
+
return (
|
|
312
|
+
<div className="flex h-96 w-80 flex-col gap-2">
|
|
313
|
+
<div className="text-xs text-muted-foreground">Last: {last || '—'}</div>
|
|
314
|
+
<div className="flex-1 rounded-md border border-border bg-card">
|
|
315
|
+
<TreeRoot<FsNode>
|
|
316
|
+
data={fs}
|
|
317
|
+
getItemName={getName}
|
|
318
|
+
initialExpandedIds={['src']}
|
|
319
|
+
renderContextMenu={({ node }, trigger) => (
|
|
320
|
+
<ContextMenu>
|
|
321
|
+
<ContextMenuTrigger asChild>{trigger}</ContextMenuTrigger>
|
|
322
|
+
<ContextMenuContent className="w-52">
|
|
323
|
+
<ContextMenuItem onClick={() => setLast(`Reveal ${node.id}`)}>
|
|
324
|
+
<Eye className="mr-2 size-3.5" /> Reveal
|
|
325
|
+
</ContextMenuItem>
|
|
326
|
+
<ContextMenuItem onClick={() => setLast(`Copy ${node.id}`)}>
|
|
327
|
+
<Copy className="mr-2 size-3.5" /> Copy path
|
|
328
|
+
<ContextMenuShortcut>⌘C</ContextMenuShortcut>
|
|
329
|
+
</ContextMenuItem>
|
|
330
|
+
<ContextMenuSeparator />
|
|
331
|
+
<ContextMenuItem onClick={() => setLast(`Refresh ${node.id}`)}>
|
|
332
|
+
<RefreshCw className="mr-2 size-3.5" /> Refresh
|
|
333
|
+
</ContextMenuItem>
|
|
334
|
+
<ContextMenuItem
|
|
335
|
+
variant="destructive"
|
|
336
|
+
onClick={() => setLast(`Delete ${node.id}`)}
|
|
337
|
+
>
|
|
338
|
+
<Trash className="mr-2 size-3.5" /> Delete
|
|
339
|
+
</ContextMenuItem>
|
|
340
|
+
</ContextMenuContent>
|
|
341
|
+
</ContextMenu>
|
|
342
|
+
)}
|
|
343
|
+
/>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
);
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// 9) AsyncLazyChildren — load children on expand
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
interface RemoteNode {
|
|
354
|
+
name: string;
|
|
355
|
+
}
|
|
356
|
+
const remote: Record<string, { name: string; children?: string[] }> = {
|
|
357
|
+
'a-root': { name: 'remote', children: ['a/1', 'a/2', 'a/3'] },
|
|
358
|
+
'a/1': { name: 'docs', children: ['a/1/x'] },
|
|
359
|
+
'a/2': { name: 'images', children: ['a/2/x', 'a/2/y'] },
|
|
360
|
+
'a/3': { name: 'manifest.yml' },
|
|
361
|
+
'a/1/x': { name: 'intro.md' },
|
|
362
|
+
'a/2/x': { name: 'logo.png' },
|
|
363
|
+
'a/2/y': { name: 'cover.jpg' },
|
|
364
|
+
};
|
|
365
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
366
|
+
|
|
367
|
+
export const AsyncLazyChildren = () => {
|
|
368
|
+
const data = useMemo<TreeNode<RemoteNode>[]>(
|
|
369
|
+
() => [{ id: 'a-root', data: { name: 'remote' }, isFolder: true }],
|
|
370
|
+
[],
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
const loadChildren = async (node: TreeNode<RemoteNode>) => {
|
|
374
|
+
await sleep(350);
|
|
375
|
+
const meta = remote[node.id];
|
|
376
|
+
if (!meta?.children) return [];
|
|
377
|
+
return meta.children.map<TreeNode<RemoteNode>>((id) => ({
|
|
378
|
+
id,
|
|
379
|
+
data: { name: remote[id].name },
|
|
380
|
+
isFolder: !!remote[id].children,
|
|
381
|
+
}));
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
return (
|
|
385
|
+
<div className="h-96 w-80 rounded-md border border-border bg-card">
|
|
386
|
+
<TreeRoot<RemoteNode>
|
|
387
|
+
data={data}
|
|
388
|
+
getItemName={(n) => n.data.name}
|
|
389
|
+
loadChildren={loadChildren}
|
|
390
|
+
initialExpandedIds={['a-root']}
|
|
391
|
+
/>
|
|
392
|
+
</div>
|
|
393
|
+
);
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
// 10) ExpandCollapseAll — composition mode + toolbar
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
export const ExpandCollapseAll = () => {
|
|
401
|
+
const data = useMemo(() => createDemoTree({ depth: 4, breadth: 3 }), []);
|
|
402
|
+
return (
|
|
403
|
+
<div className="flex h-96 w-80 flex-col gap-2 rounded-md border border-border bg-card p-2">
|
|
404
|
+
<TreeProvider<DemoNode> data={data} getItemName={getName}>
|
|
405
|
+
<Toolbar />
|
|
406
|
+
<div className="min-h-0 flex-1 overflow-auto">
|
|
407
|
+
<TreeContent<DemoNode> />
|
|
408
|
+
</div>
|
|
409
|
+
</TreeProvider>
|
|
410
|
+
</div>
|
|
411
|
+
);
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
function Toolbar() {
|
|
415
|
+
const { expandAll, collapseAll } = useTreeActions();
|
|
416
|
+
return (
|
|
417
|
+
<div className="flex gap-2 text-xs">
|
|
418
|
+
<button
|
|
419
|
+
type="button"
|
|
420
|
+
onClick={() => expandAll()}
|
|
421
|
+
className="rounded border border-border px-2 py-1 hover:bg-accent"
|
|
422
|
+
>
|
|
423
|
+
Expand all
|
|
424
|
+
</button>
|
|
425
|
+
<button
|
|
426
|
+
type="button"
|
|
427
|
+
onClick={() => collapseAll()}
|
|
428
|
+
className="rounded border border-border px-2 py-1 hover:bg-accent"
|
|
429
|
+
>
|
|
430
|
+
Collapse all
|
|
431
|
+
</button>
|
|
432
|
+
</div>
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
// 11) Persisted — localStorage
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
export const Persisted = () => (
|
|
441
|
+
<div className="flex h-96 w-80 flex-col gap-2">
|
|
442
|
+
<p className="text-xs text-muted-foreground">
|
|
443
|
+
Toggle folders, refresh — state restores from localStorage.
|
|
444
|
+
</p>
|
|
445
|
+
<div className="flex-1 rounded-md border border-border bg-card">
|
|
446
|
+
<TreeRoot<FsNode>
|
|
447
|
+
data={fs}
|
|
448
|
+
getItemName={getName}
|
|
449
|
+
persistKey="story.tree.fs"
|
|
450
|
+
persistSelection
|
|
451
|
+
/>
|
|
452
|
+
</div>
|
|
453
|
+
</div>
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
// 12) LargeTree — scalability sanity check (~500 nodes)
|
|
458
|
+
// ---------------------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
export const LargeTree = () => {
|
|
461
|
+
const data = useMemo(() => createDemoTree({ depth: 4, breadth: 5 }), []);
|
|
462
|
+
return (
|
|
463
|
+
<div className="h-[28rem] w-96 rounded-md border border-border bg-card">
|
|
464
|
+
<TreeRoot<DemoNode>
|
|
465
|
+
data={data}
|
|
466
|
+
getItemName={getName}
|
|
467
|
+
appearance={{ density: 'compact' }}
|
|
468
|
+
showIndentGuides
|
|
469
|
+
enableSearch
|
|
470
|
+
/>
|
|
471
|
+
</div>
|
|
472
|
+
);
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
// ---------------------------------------------------------------------------
|
|
476
|
+
// 13) Playground — every knob at once
|
|
477
|
+
// ---------------------------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
export const Playground = () => {
|
|
480
|
+
const [selectionMode] = useSelect('selectionMode', {
|
|
481
|
+
options: ['none', 'single', 'multiple'] as const,
|
|
482
|
+
defaultValue: 'single',
|
|
483
|
+
label: 'Selection mode',
|
|
484
|
+
});
|
|
485
|
+
const [density] = useSelect('density', {
|
|
486
|
+
options: ['compact', 'cozy', 'comfortable'] as const,
|
|
487
|
+
defaultValue: 'cozy',
|
|
488
|
+
label: 'Density',
|
|
489
|
+
});
|
|
490
|
+
const [accent] = useSelect('accent', {
|
|
491
|
+
options: ['subtle', 'default', 'strong'] as const,
|
|
492
|
+
defaultValue: 'default',
|
|
493
|
+
label: 'Accent',
|
|
494
|
+
});
|
|
495
|
+
const [radius] = useSelect('radius', {
|
|
496
|
+
options: ['none', 'sm', 'md'] as const,
|
|
497
|
+
defaultValue: 'sm',
|
|
498
|
+
label: 'Radius',
|
|
499
|
+
});
|
|
500
|
+
const [showSearch] = useBoolean('search', { defaultValue: true, label: 'Search' });
|
|
501
|
+
const [typeAhead] = useBoolean('typeAhead', {
|
|
502
|
+
defaultValue: true,
|
|
503
|
+
label: 'Type-ahead',
|
|
504
|
+
});
|
|
505
|
+
const [indentGuides] = useBoolean('indentGuides', {
|
|
506
|
+
defaultValue: true,
|
|
507
|
+
label: 'Indent guides',
|
|
508
|
+
});
|
|
509
|
+
const [activeIndicator] = useBoolean('activeIndicator', {
|
|
510
|
+
defaultValue: true,
|
|
511
|
+
label: 'Active row indicator',
|
|
512
|
+
});
|
|
513
|
+
const [selected, setSelected] = useState<TreeItemId[]>([]);
|
|
514
|
+
|
|
515
|
+
return (
|
|
516
|
+
<div className="flex h-[28rem] w-[28rem] flex-col gap-2">
|
|
517
|
+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
518
|
+
<Settings aria-hidden className="size-3" />
|
|
519
|
+
Selected: {selected.length === 0 ? '(none)' : selected.join(', ')}
|
|
520
|
+
</div>
|
|
521
|
+
<div className="flex-1 rounded-md border border-border bg-card">
|
|
522
|
+
<TreeRoot<FsNode>
|
|
523
|
+
data={fs}
|
|
524
|
+
getItemName={getName}
|
|
525
|
+
initialExpandedIds={['src', 'components', 'hooks', 'public']}
|
|
526
|
+
selectionMode={selectionMode}
|
|
527
|
+
appearance={{ density, accent, radius, showActiveIndicator: activeIndicator }}
|
|
528
|
+
enableSearch={showSearch}
|
|
529
|
+
enableTypeAhead={typeAhead}
|
|
530
|
+
showIndentGuides={indentGuides}
|
|
531
|
+
onSelectionChange={setSelected}
|
|
532
|
+
/>
|
|
533
|
+
</div>
|
|
534
|
+
</div>
|
|
535
|
+
);
|
|
536
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef } from 'react';
|
|
4
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
5
|
+
|
|
6
|
+
import { TreeProvider, useTreeContext } from './context/TreeContext';
|
|
7
|
+
import { TreeContent } from './components/TreeContent';
|
|
8
|
+
import { TreeSearchInput } from './components/TreeSearchInput';
|
|
9
|
+
import { appearanceToStyle } from './data/appearance';
|
|
10
|
+
import { useTreeKeyboard } from './hooks/useTreeKeyboard';
|
|
11
|
+
import { useTreeTypeAhead } from './hooks/useTreeTypeAhead';
|
|
12
|
+
import type { TreeRootProps } from './types';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* High-level entry point. Wraps Provider + (optional) search bar + content.
|
|
16
|
+
*
|
|
17
|
+
* For full control, compose with <TreeProvider>, <TreeContent>,
|
|
18
|
+
* <TreeSearchInput>, <TreeRow>, etc. directly from `@djangocfg/ui-tools/tree`.
|
|
19
|
+
*/
|
|
20
|
+
function TreeRoot<T>(props: TreeRootProps<T>) {
|
|
21
|
+
const {
|
|
22
|
+
data,
|
|
23
|
+
getItemName,
|
|
24
|
+
loadChildren,
|
|
25
|
+
selectionMode,
|
|
26
|
+
initialExpandedIds,
|
|
27
|
+
initialSelectedIds,
|
|
28
|
+
indent,
|
|
29
|
+
appearance,
|
|
30
|
+
onSelectionChange,
|
|
31
|
+
onExpansionChange,
|
|
32
|
+
onActivate,
|
|
33
|
+
enableSearch = false,
|
|
34
|
+
enableTypeAhead = true,
|
|
35
|
+
showIndentGuides = false,
|
|
36
|
+
renderRow,
|
|
37
|
+
renderIcon,
|
|
38
|
+
renderLabel,
|
|
39
|
+
renderActions,
|
|
40
|
+
renderContextMenu,
|
|
41
|
+
labels,
|
|
42
|
+
persistKey,
|
|
43
|
+
persistSelection = false,
|
|
44
|
+
className,
|
|
45
|
+
style,
|
|
46
|
+
} = props;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<TreeProvider<T>
|
|
50
|
+
data={data}
|
|
51
|
+
getItemName={getItemName}
|
|
52
|
+
loadChildren={loadChildren}
|
|
53
|
+
selectionMode={selectionMode}
|
|
54
|
+
initialExpandedIds={initialExpandedIds}
|
|
55
|
+
initialSelectedIds={initialSelectedIds}
|
|
56
|
+
indent={indent}
|
|
57
|
+
appearance={appearance}
|
|
58
|
+
onSelectionChange={onSelectionChange}
|
|
59
|
+
onExpansionChange={onExpansionChange}
|
|
60
|
+
onActivate={onActivate}
|
|
61
|
+
enableSearch={enableSearch}
|
|
62
|
+
showIndentGuides={showIndentGuides}
|
|
63
|
+
renderIcon={renderIcon}
|
|
64
|
+
renderLabel={renderLabel}
|
|
65
|
+
renderActions={renderActions}
|
|
66
|
+
renderContextMenu={renderContextMenu}
|
|
67
|
+
labels={labels}
|
|
68
|
+
persistKey={persistKey}
|
|
69
|
+
persistSelection={persistSelection}
|
|
70
|
+
>
|
|
71
|
+
<TreeRootShell<T>
|
|
72
|
+
className={className}
|
|
73
|
+
style={style}
|
|
74
|
+
enableSearch={enableSearch}
|
|
75
|
+
enableTypeAhead={enableTypeAhead}
|
|
76
|
+
renderRow={renderRow}
|
|
77
|
+
/>
|
|
78
|
+
</TreeProvider>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface TreeRootShellProps<T> {
|
|
83
|
+
className?: string;
|
|
84
|
+
style?: React.CSSProperties;
|
|
85
|
+
enableSearch: boolean;
|
|
86
|
+
enableTypeAhead: boolean;
|
|
87
|
+
renderRow?: TreeRootProps<T>['renderRow'];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function TreeRootShell<T>({
|
|
91
|
+
className,
|
|
92
|
+
style,
|
|
93
|
+
enableSearch,
|
|
94
|
+
enableTypeAhead,
|
|
95
|
+
renderRow,
|
|
96
|
+
}: TreeRootShellProps<T>) {
|
|
97
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
98
|
+
const ctx = useTreeContext<T>();
|
|
99
|
+
|
|
100
|
+
// Keyboard navigation (↑↓ ←→ Home/End Enter Esc).
|
|
101
|
+
useTreeKeyboard<T>({
|
|
102
|
+
containerRef,
|
|
103
|
+
rows: ctx.flatRows,
|
|
104
|
+
focusedId: ctx.focused,
|
|
105
|
+
onFocus: ctx.setFocus,
|
|
106
|
+
onSelect: ctx.select,
|
|
107
|
+
onActivate: (id) => {
|
|
108
|
+
const row = ctx.flatRows.find((r) => r.node.id === id);
|
|
109
|
+
if (row) ctx.activate(row.node);
|
|
110
|
+
},
|
|
111
|
+
onExpand: ctx.expand,
|
|
112
|
+
onCollapse: ctx.collapse,
|
|
113
|
+
onClearSelection: ctx.clearSelection,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Type-ahead jump.
|
|
117
|
+
const onTypeAheadMatch = useCallback(
|
|
118
|
+
(id: string) => {
|
|
119
|
+
ctx.setFocus(id);
|
|
120
|
+
// Scroll the row into view if it has rendered.
|
|
121
|
+
const el = containerRef.current?.querySelector<HTMLElement>(
|
|
122
|
+
`[data-tree-row][data-id="${CSS.escape(id)}"]`,
|
|
123
|
+
);
|
|
124
|
+
el?.scrollIntoView({ block: 'nearest' });
|
|
125
|
+
},
|
|
126
|
+
[ctx],
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
useTreeTypeAhead<T>({
|
|
130
|
+
rows: ctx.flatRows,
|
|
131
|
+
getItemName: ctx.getItemName,
|
|
132
|
+
containerRef,
|
|
133
|
+
onMatch: onTypeAheadMatch,
|
|
134
|
+
enabled: enableTypeAhead,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div
|
|
139
|
+
ref={containerRef}
|
|
140
|
+
tabIndex={0}
|
|
141
|
+
className={cn(
|
|
142
|
+
'group/tree flex h-full w-full flex-col gap-2 outline-none',
|
|
143
|
+
className,
|
|
144
|
+
)}
|
|
145
|
+
style={{ ...appearanceToStyle(ctx.appearance), ...style }}
|
|
146
|
+
data-tree-root=""
|
|
147
|
+
>
|
|
148
|
+
{enableSearch ? <TreeSearchInput className="mx-2 mt-2" /> : null}
|
|
149
|
+
<div className="min-h-0 flex-1 overflow-auto px-1">
|
|
150
|
+
<TreeContent<T>>{renderRow}</TreeContent>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export default TreeRoot;
|
|
157
|
+
export { TreeRoot };
|