@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.
Files changed (46) hide show
  1. package/dist/TreeRoot-DO33TIS5.mjs +4 -0
  2. package/dist/TreeRoot-DO33TIS5.mjs.map +1 -0
  3. package/dist/TreeRoot-NJOZ2DMV.cjs +19 -0
  4. package/dist/TreeRoot-NJOZ2DMV.cjs.map +1 -0
  5. package/dist/chunk-E5BP4IXF.mjs +1231 -0
  6. package/dist/chunk-E5BP4IXF.mjs.map +1 -0
  7. package/dist/chunk-MA552EWC.cjs +1282 -0
  8. package/dist/chunk-MA552EWC.cjs.map +1 -0
  9. package/dist/index.cjs +186 -0
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +435 -2
  12. package/dist/index.d.ts +435 -2
  13. package/dist/index.mjs +60 -2
  14. package/dist/index.mjs.map +1 -1
  15. package/package.json +11 -6
  16. package/src/index.ts +4 -0
  17. package/src/tools/Tree/README.md +220 -0
  18. package/src/tools/Tree/Tree.story.tsx +536 -0
  19. package/src/tools/Tree/TreeRoot.tsx +157 -0
  20. package/src/tools/Tree/components/TreeChevron.tsx +39 -0
  21. package/src/tools/Tree/components/TreeContent.tsx +48 -0
  22. package/src/tools/Tree/components/TreeEmpty.tsx +21 -0
  23. package/src/tools/Tree/components/TreeError.tsx +24 -0
  24. package/src/tools/Tree/components/TreeIcon.tsx +29 -0
  25. package/src/tools/Tree/components/TreeIndentGuides.tsx +33 -0
  26. package/src/tools/Tree/components/TreeLabel.tsx +24 -0
  27. package/src/tools/Tree/components/TreeRow.tsx +173 -0
  28. package/src/tools/Tree/components/TreeSearchInput.tsx +50 -0
  29. package/src/tools/Tree/components/TreeSkeleton.tsx +22 -0
  30. package/src/tools/Tree/components/index.ts +22 -0
  31. package/src/tools/Tree/context/TreeContext.tsx +538 -0
  32. package/src/tools/Tree/context/hooks.ts +110 -0
  33. package/src/tools/Tree/context/index.ts +13 -0
  34. package/src/tools/Tree/data/appearance.ts +175 -0
  35. package/src/tools/Tree/data/childCache.ts +43 -0
  36. package/src/tools/Tree/data/createDemoTree.ts +42 -0
  37. package/src/tools/Tree/data/flatten.ts +51 -0
  38. package/src/tools/Tree/data/index.ts +24 -0
  39. package/src/tools/Tree/data/persist.ts +62 -0
  40. package/src/tools/Tree/hooks/index.ts +6 -0
  41. package/src/tools/Tree/hooks/useTreeKeyboard.ts +137 -0
  42. package/src/tools/Tree/hooks/useTreeTypeAhead.ts +100 -0
  43. package/src/tools/Tree/index.tsx +99 -0
  44. package/src/tools/Tree/lazy.tsx +14 -0
  45. package/src/tools/Tree/types.ts +136 -0
  46. 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 };