@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,39 @@
1
+ 'use client';
2
+
3
+ import { ChevronDown, ChevronRight } from 'lucide-react';
4
+ import { cn } from '@djangocfg/ui-core/lib';
5
+
6
+ import { useTreeContext } from '../context/TreeContext';
7
+
8
+ export interface TreeChevronProps {
9
+ isExpanded: boolean;
10
+ isFolder: boolean;
11
+ className?: string;
12
+ }
13
+
14
+ export function TreeChevron({ isExpanded, isFolder, className }: TreeChevronProps) {
15
+ const { appearance } = useTreeContext();
16
+ const size = { width: 'var(--tree-icon-size)', height: 'var(--tree-icon-size)' };
17
+
18
+ if (!isFolder) {
19
+ return (
20
+ <span
21
+ aria-hidden
22
+ style={size}
23
+ className={cn('inline-block shrink-0', className)}
24
+ />
25
+ );
26
+ }
27
+ const Icon = isExpanded ? ChevronDown : ChevronRight;
28
+ return (
29
+ <Icon
30
+ aria-hidden
31
+ strokeWidth={appearance.iconStrokeWidth}
32
+ style={size}
33
+ className={cn(
34
+ 'shrink-0 text-muted-foreground/70 transition-transform',
35
+ className,
36
+ )}
37
+ />
38
+ );
39
+ }
@@ -0,0 +1,48 @@
1
+ 'use client';
2
+
3
+ import { Fragment, type ReactNode } from 'react';
4
+ import { cn } from '@djangocfg/ui-core/lib';
5
+
6
+ import { useTreeContext } from '../context/TreeContext';
7
+ import type { FlatRow, TreeRowRenderProps, TreeRowSlot } from '../types';
8
+ import { TreeRow } from './TreeRow';
9
+ import { TreeEmpty } from './TreeEmpty';
10
+
11
+ export interface TreeContentProps<T> {
12
+ /** Custom row renderer; falls back to <TreeRow />. */
13
+ children?: TreeRowSlot<T>;
14
+ className?: string;
15
+ /** Override aria-label for the container. */
16
+ ariaLabel?: string;
17
+ }
18
+
19
+ export function TreeContent<T>({ children, className, ariaLabel }: TreeContentProps<T>) {
20
+ const { flatRows, labels, selected, focused, matchingIds } = useTreeContext<T>();
21
+
22
+ if (flatRows.length === 0) {
23
+ return <TreeEmpty>{labels.empty}</TreeEmpty>;
24
+ }
25
+
26
+ return (
27
+ <div
28
+ role="tree"
29
+ aria-label={ariaLabel ?? labels.ariaLabel}
30
+ className={cn('relative flex flex-col py-1', className)}
31
+ >
32
+ {flatRows.map((row: FlatRow<T>) => {
33
+ const slot: TreeRowRenderProps<T> = {
34
+ node: row.node,
35
+ level: row.level,
36
+ isSelected: selected.has(row.node.id),
37
+ isExpanded: row.isExpanded,
38
+ isFocused: focused === row.node.id,
39
+ isFolder: row.isFolder,
40
+ isLoading: row.isLoading,
41
+ isMatchingSearch: matchingIds.has(row.node.id),
42
+ };
43
+ const node: ReactNode = children ? children(slot) : <TreeRow row={row} />;
44
+ return <Fragment key={row.node.id}>{node}</Fragment>;
45
+ })}
46
+ </div>
47
+ );
48
+ }
@@ -0,0 +1,21 @@
1
+ 'use client';
2
+
3
+ import { cn } from '@djangocfg/ui-core/lib';
4
+
5
+ export interface TreeEmptyProps {
6
+ children: React.ReactNode;
7
+ className?: string;
8
+ }
9
+
10
+ export function TreeEmpty({ children, className }: TreeEmptyProps) {
11
+ return (
12
+ <div
13
+ className={cn(
14
+ 'flex h-full min-h-32 items-center justify-center px-4 py-6 text-sm text-muted-foreground',
15
+ className,
16
+ )}
17
+ >
18
+ {children}
19
+ </div>
20
+ );
21
+ }
@@ -0,0 +1,24 @@
1
+ 'use client';
2
+
3
+ import { AlertCircle } from 'lucide-react';
4
+ import { cn } from '@djangocfg/ui-core/lib';
5
+
6
+ export interface TreeErrorProps {
7
+ children: React.ReactNode;
8
+ className?: string;
9
+ }
10
+
11
+ export function TreeError({ children, className }: TreeErrorProps) {
12
+ return (
13
+ <div
14
+ className={cn(
15
+ 'flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive',
16
+ className,
17
+ )}
18
+ role="alert"
19
+ >
20
+ <AlertCircle aria-hidden className="mt-0.5 size-4 shrink-0" />
21
+ <span>{children}</span>
22
+ </div>
23
+ );
24
+ }
@@ -0,0 +1,29 @@
1
+ 'use client';
2
+
3
+ import { File, Folder, FolderOpen } from 'lucide-react';
4
+ import { cn } from '@djangocfg/ui-core/lib';
5
+
6
+ import { useTreeContext } from '../context/TreeContext';
7
+
8
+ export interface TreeIconProps {
9
+ isFolder: boolean;
10
+ isExpanded: boolean;
11
+ className?: string;
12
+ }
13
+
14
+ export function TreeIcon({ isFolder, isExpanded, className }: TreeIconProps) {
15
+ const { appearance } = useTreeContext();
16
+ const Icon = isFolder ? (isExpanded ? FolderOpen : Folder) : File;
17
+ return (
18
+ <Icon
19
+ aria-hidden
20
+ strokeWidth={appearance.iconStrokeWidth}
21
+ style={{ width: 'var(--tree-icon-size)', height: 'var(--tree-icon-size)' }}
22
+ className={cn(
23
+ 'shrink-0',
24
+ isFolder ? 'text-foreground/70' : 'text-muted-foreground/80',
25
+ className,
26
+ )}
27
+ />
28
+ );
29
+ }
@@ -0,0 +1,33 @@
1
+ 'use client';
2
+
3
+ import { useTreeContext } from '../context/TreeContext';
4
+
5
+ export interface TreeIndentGuidesProps {
6
+ level: number;
7
+ indent: number;
8
+ }
9
+
10
+ /**
11
+ * Vertical guide lines under nested rows. Renders one absolute-positioned
12
+ * 1px column per ancestor level. Decorative — `aria-hidden` and
13
+ * pointer-events disabled. Opacity comes from the tree appearance.
14
+ */
15
+ export function TreeIndentGuides({ level, indent }: TreeIndentGuidesProps) {
16
+ const { appearance } = useTreeContext();
17
+ if (level <= 0) return null;
18
+ return (
19
+ <span
20
+ aria-hidden
21
+ className="pointer-events-none absolute inset-y-0 left-0"
22
+ style={{ width: 8 + level * indent, opacity: appearance.indentGuideOpacity }}
23
+ >
24
+ {Array.from({ length: level }).map((_, i) => (
25
+ <span
26
+ key={i}
27
+ className="absolute inset-y-0 w-px bg-border"
28
+ style={{ left: 8 + i * indent + indent / 2 - 0.5 }}
29
+ />
30
+ ))}
31
+ </span>
32
+ );
33
+ }
@@ -0,0 +1,24 @@
1
+ 'use client';
2
+
3
+ import { cn } from '@djangocfg/ui-core/lib';
4
+
5
+ export interface TreeLabelProps {
6
+ children: React.ReactNode;
7
+ isMatchingSearch?: boolean;
8
+ className?: string;
9
+ }
10
+
11
+ export function TreeLabel({ children, isMatchingSearch, className }: TreeLabelProps) {
12
+ return (
13
+ <span
14
+ style={{ fontSize: 'var(--tree-font-size)' }}
15
+ className={cn(
16
+ 'truncate leading-tight tracking-[-0.005em]',
17
+ isMatchingSearch && 'font-medium text-foreground',
18
+ className,
19
+ )}
20
+ >
21
+ {children}
22
+ </span>
23
+ );
24
+ }
@@ -0,0 +1,173 @@
1
+ 'use client';
2
+
3
+ import { Loader2 } from 'lucide-react';
4
+ import { cn } from '@djangocfg/ui-core/lib';
5
+
6
+ import { useTreeContext } from '../context/TreeContext';
7
+ import { radiusClass, rowStateClasses } from '../data/appearance';
8
+ import type { FlatRow, TreeRowRenderProps } from '../types';
9
+ import { TreeChevron } from './TreeChevron';
10
+ import { TreeIcon } from './TreeIcon';
11
+ import { TreeIndentGuides } from './TreeIndentGuides';
12
+ import { TreeLabel } from './TreeLabel';
13
+
14
+ export interface TreeRowProps<T> {
15
+ row: FlatRow<T>;
16
+ className?: string;
17
+ }
18
+
19
+ export function TreeRow<T>({ row, className }: TreeRowProps<T>) {
20
+ const ctx = useTreeContext<T>();
21
+ const {
22
+ appearance,
23
+ showIndentGuides,
24
+ selected,
25
+ focused,
26
+ matchingIds,
27
+ select,
28
+ toggle,
29
+ setFocus,
30
+ activate,
31
+ getItemName,
32
+ renderIcon,
33
+ renderLabel,
34
+ renderActions,
35
+ renderContextMenu,
36
+ } = ctx;
37
+
38
+ const { node, level, isFolder, isExpanded, isLoading } = row;
39
+ const isSelected = selected.has(node.id);
40
+ const isFocused = focused === node.id;
41
+ const isMatchingSearch = matchingIds.has(node.id);
42
+
43
+ const slot: TreeRowRenderProps<T> = {
44
+ node,
45
+ level,
46
+ isSelected,
47
+ isExpanded,
48
+ isFocused,
49
+ isFolder,
50
+ isLoading,
51
+ isMatchingSearch,
52
+ };
53
+
54
+ const handleActivate = () => {
55
+ if (node.disabled) return;
56
+ setFocus(node.id);
57
+ select(node.id);
58
+ if (isFolder) toggle(node.id);
59
+ else activate(node);
60
+ };
61
+
62
+ const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
63
+ handleActivate();
64
+ e.currentTarget.scrollIntoView?.({ block: 'nearest' });
65
+ };
66
+
67
+ const handleDoubleClick = () => {
68
+ if (node.disabled) return;
69
+ if (!isFolder) activate(node);
70
+ };
71
+
72
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
73
+ // Match native button keyboard semantics on the row itself.
74
+ if (node.disabled) return;
75
+ if (e.key === 'Enter' || e.key === ' ') {
76
+ e.preventDefault();
77
+ handleActivate();
78
+ }
79
+ };
80
+
81
+ const trigger = (
82
+ <div
83
+ role="treeitem"
84
+ aria-level={level + 1}
85
+ aria-expanded={isFolder ? isExpanded : undefined}
86
+ aria-selected={isSelected || undefined}
87
+ aria-current={isSelected ? 'true' : undefined}
88
+ aria-disabled={node.disabled || undefined}
89
+ data-tree-row=""
90
+ data-id={node.id}
91
+ data-selected={isSelected ? 'true' : undefined}
92
+ data-focused={isFocused && !isSelected ? 'true' : undefined}
93
+ data-folder={isFolder || undefined}
94
+ data-expanded={isExpanded || undefined}
95
+ tabIndex={isFocused ? 0 : -1}
96
+ style={{
97
+ paddingLeft: 6 + level * appearance.indent,
98
+ height: 'var(--tree-row-height)',
99
+ gap: 'var(--tree-gap)',
100
+ }}
101
+ onClick={handleClick}
102
+ onDoubleClick={handleDoubleClick}
103
+ onKeyDown={handleKeyDown}
104
+ onFocus={() => setFocus(node.id)}
105
+ className={cn(
106
+ 'group/row relative flex w-full select-none items-center pr-2 text-left',
107
+ 'transition-colors outline-none',
108
+ node.disabled ? 'cursor-not-allowed' : 'cursor-pointer',
109
+ radiusClass(appearance),
110
+ rowStateClasses(appearance),
111
+ 'focus-visible:ring-1 focus-visible:ring-ring/50',
112
+ isMatchingSearch && 'ring-1 ring-primary/30',
113
+ node.disabled && 'opacity-50',
114
+ className,
115
+ )}
116
+ >
117
+ {/* Active-row left indicator (VSCode style) */}
118
+ {appearance.showActiveIndicator && isSelected ? (
119
+ <span
120
+ aria-hidden
121
+ className={cn(
122
+ 'absolute left-0 top-1 bottom-1 w-0.5 rounded-r-full',
123
+ 'bg-foreground/30 group-focus-within/tree:bg-primary',
124
+ )}
125
+ />
126
+ ) : null}
127
+
128
+ {showIndentGuides && level > 0 ? (
129
+ <TreeIndentGuides level={level} indent={appearance.indent} />
130
+ ) : null}
131
+
132
+ <TreeChevron isExpanded={isExpanded} isFolder={isFolder} />
133
+
134
+ {isLoading ? (
135
+ <Loader2
136
+ aria-hidden
137
+ strokeWidth={appearance.iconStrokeWidth}
138
+ style={{ width: 'var(--tree-icon-size)', height: 'var(--tree-icon-size)' }}
139
+ className="shrink-0 animate-spin text-muted-foreground/70"
140
+ />
141
+ ) : renderIcon ? (
142
+ renderIcon(slot)
143
+ ) : (
144
+ <TreeIcon isFolder={isFolder} isExpanded={isExpanded} />
145
+ )}
146
+
147
+ <span
148
+ className="flex min-w-0 flex-1 items-center"
149
+ style={{ gap: 'var(--tree-gap)' }}
150
+ >
151
+ {renderLabel ? (
152
+ renderLabel(slot)
153
+ ) : (
154
+ <TreeLabel isMatchingSearch={isMatchingSearch}>{getItemName(node)}</TreeLabel>
155
+ )}
156
+ </span>
157
+
158
+ {renderActions ? (
159
+ <span
160
+ className="ml-auto flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover/row:opacity-100 group-focus-within/row:opacity-100"
161
+ onClick={(e) => e.stopPropagation()}
162
+ >
163
+ {renderActions(slot)}
164
+ </span>
165
+ ) : null}
166
+ </div>
167
+ );
168
+
169
+ if (renderContextMenu) {
170
+ return <>{renderContextMenu(slot, trigger)}</>;
171
+ }
172
+ return trigger;
173
+ }
@@ -0,0 +1,50 @@
1
+ 'use client';
2
+
3
+ import { Search, X } from 'lucide-react';
4
+ import { cn } from '@djangocfg/ui-core/lib';
5
+
6
+ import { useTreeContext } from '../context/TreeContext';
7
+ import { useTreeSearch } from '../context/hooks';
8
+
9
+ export interface TreeSearchInputProps {
10
+ className?: string;
11
+ showMatches?: boolean;
12
+ }
13
+
14
+ export function TreeSearchInput({ className, showMatches = true }: TreeSearchInputProps) {
15
+ const { labels } = useTreeContext();
16
+ const { query, setQuery, matchCount } = useTreeSearch();
17
+
18
+ return (
19
+ <div
20
+ className={cn(
21
+ 'flex items-center gap-2 rounded-md border border-border bg-background px-2',
22
+ className,
23
+ )}
24
+ >
25
+ <Search aria-hidden className="size-3.5 shrink-0 text-muted-foreground" />
26
+ <input
27
+ type="search"
28
+ value={query}
29
+ onChange={(e) => setQuery(e.target.value)}
30
+ placeholder={labels.searchPlaceholder}
31
+ className="h-7 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
32
+ />
33
+ {showMatches && query.trim().length > 0 ? (
34
+ <span className="shrink-0 text-xs text-muted-foreground">
35
+ {labels.searchMatches(matchCount)}
36
+ </span>
37
+ ) : null}
38
+ {query.length > 0 ? (
39
+ <button
40
+ type="button"
41
+ onClick={() => setQuery('')}
42
+ aria-label="Clear search"
43
+ className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
44
+ >
45
+ <X aria-hidden className="size-3.5" />
46
+ </button>
47
+ ) : null}
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,22 @@
1
+ 'use client';
2
+
3
+ import { cn } from '@djangocfg/ui-core/lib';
4
+
5
+ export interface TreeSkeletonProps {
6
+ rows?: number;
7
+ className?: string;
8
+ }
9
+
10
+ export function TreeSkeleton({ rows = 6, className }: TreeSkeletonProps) {
11
+ return (
12
+ <div className={cn('flex flex-col gap-1 p-2', className)} aria-hidden>
13
+ {Array.from({ length: rows }).map((_, i) => (
14
+ <div key={i} className="flex items-center gap-2" style={{ paddingLeft: (i % 3) * 16 }}>
15
+ <span className="size-4 shrink-0 animate-pulse rounded bg-muted" />
16
+ <span className="size-4 shrink-0 animate-pulse rounded bg-muted" />
17
+ <span className="h-3 flex-1 animate-pulse rounded bg-muted" />
18
+ </div>
19
+ ))}
20
+ </div>
21
+ );
22
+ }
@@ -0,0 +1,22 @@
1
+ 'use client';
2
+
3
+ export { TreeChevron } from './TreeChevron';
4
+ export type { TreeChevronProps } from './TreeChevron';
5
+ export { TreeIcon } from './TreeIcon';
6
+ export type { TreeIconProps } from './TreeIcon';
7
+ export { TreeLabel } from './TreeLabel';
8
+ export type { TreeLabelProps } from './TreeLabel';
9
+ export { TreeRow } from './TreeRow';
10
+ export type { TreeRowProps } from './TreeRow';
11
+ export { TreeContent } from './TreeContent';
12
+ export type { TreeContentProps } from './TreeContent';
13
+ export { TreeSearchInput } from './TreeSearchInput';
14
+ export type { TreeSearchInputProps } from './TreeSearchInput';
15
+ export { TreeEmpty } from './TreeEmpty';
16
+ export type { TreeEmptyProps } from './TreeEmpty';
17
+ export { TreeSkeleton } from './TreeSkeleton';
18
+ export type { TreeSkeletonProps } from './TreeSkeleton';
19
+ export { TreeError } from './TreeError';
20
+ export type { TreeErrorProps } from './TreeError';
21
+ export { TreeIndentGuides } from './TreeIndentGuides';
22
+ export type { TreeIndentGuidesProps } from './TreeIndentGuides';