@djangocfg/ui-tools 2.1.316 → 2.1.317
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-R6XVHYQK.mjs +4 -0
- package/dist/{TreeRoot-A25RIGYE.cjs.map → TreeRoot-R6XVHYQK.mjs.map} +1 -1
- package/dist/TreeRoot-RAMQSBMO.cjs +19 -0
- package/dist/{TreeRoot-HBRJEHBH.mjs.map → TreeRoot-RAMQSBMO.cjs.map} +1 -1
- package/dist/{chunk-4CEOJDMB.cjs → chunk-44ZTWYAF.cjs} +21 -11
- package/dist/chunk-44ZTWYAF.cjs.map +1 -0
- package/dist/{chunk-NFIMVYJU.mjs → chunk-NTJL2SXK.mjs} +21 -11
- package/dist/chunk-NTJL2SXK.mjs.map +1 -0
- package/dist/file-icon/index.cjs +117 -0
- package/dist/file-icon/index.cjs.map +1 -0
- package/dist/file-icon/index.d.cts +69 -0
- package/dist/file-icon/index.d.ts +69 -0
- package/dist/file-icon/index.mjs +112 -0
- package/dist/file-icon/index.mjs.map +1 -0
- package/dist/index.cjs +122 -122
- package/dist/index.d.cts +3 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.mjs +3 -3
- package/dist/tree/index.cjs +33 -33
- package/dist/tree/index.d.cts +7 -171
- package/dist/tree/index.d.ts +7 -171
- package/dist/tree/index.mjs +1 -1
- package/dist/types-Cclwv4Hl.d.cts +198 -0
- package/dist/types-Cclwv4Hl.d.ts +198 -0
- package/package.json +13 -7
- package/src/tools/FileIcon/FileIcon.tsx +91 -0
- package/src/tools/FileIcon/index.ts +9 -0
- package/src/tools/FileIcon/loader.ts +47 -0
- package/src/tools/FileIcon/treeAdapter.tsx +41 -0
- package/src/tools/Tree/README.md +56 -0
- package/src/tools/Tree/Tree.story.tsx +48 -0
- package/src/tools/Tree/TreeRoot.tsx +4 -1
- package/src/tools/Tree/components/TreeRow.tsx +17 -8
- package/src/tools/Tree/context/TreeContext.tsx +10 -2
- package/src/tools/Tree/index.tsx +2 -0
- package/src/tools/Tree/types.ts +36 -2
- package/dist/TreeRoot-A25RIGYE.cjs +0 -19
- package/dist/TreeRoot-HBRJEHBH.mjs +0 -4
- package/dist/chunk-4CEOJDMB.cjs.map +0 -1
- package/dist/chunk-NFIMVYJU.mjs.map +0 -1
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { CSSProperties, ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
type TreeDensity = 'compact' | 'cozy' | 'comfortable';
|
|
4
|
+
type TreeAccentIntensity = 'subtle' | 'default' | 'strong';
|
|
5
|
+
type TreeRadius = 'none' | 'sm' | 'md';
|
|
6
|
+
/**
|
|
7
|
+
* Cosmetic configuration. Every field is optional; missing values fall
|
|
8
|
+
* back to the `cozy` preset (a comfortable VSCode-Explorer-like density).
|
|
9
|
+
*
|
|
10
|
+
* Customize the look without re-implementing slots.
|
|
11
|
+
*/
|
|
12
|
+
interface TreeAppearance {
|
|
13
|
+
/** Built-in size preset. Default: `'cozy'`. */
|
|
14
|
+
density?: TreeDensity;
|
|
15
|
+
/** Override row height in px (wins over density). */
|
|
16
|
+
rowHeight?: number;
|
|
17
|
+
/** Override icon + chevron size in px (wins over density). */
|
|
18
|
+
iconSize?: number;
|
|
19
|
+
/** Lucide stroke width for icon + chevron. Default: 1.5. */
|
|
20
|
+
iconStrokeWidth?: number;
|
|
21
|
+
/** Override label font size in px (wins over density). */
|
|
22
|
+
fontSize?: number;
|
|
23
|
+
/** Pixels between chevron / icon / label. Default depends on density. */
|
|
24
|
+
gap?: number;
|
|
25
|
+
/** Pixels between nesting levels. Default: 16. */
|
|
26
|
+
indent?: number;
|
|
27
|
+
/** Hover / selected highlight intensity. Default: `'default'`. */
|
|
28
|
+
accent?: TreeAccentIntensity;
|
|
29
|
+
/** Row corner radius. Default: `'sm'`. */
|
|
30
|
+
radius?: TreeRadius;
|
|
31
|
+
/** Indent-guide line opacity (0..1). Default: 0.4. */
|
|
32
|
+
indentGuideOpacity?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Show a 2px primary-tinted bar on the left of the selected row.
|
|
35
|
+
* Mimics the VSCode active-tab indicator. Default: `true`.
|
|
36
|
+
*/
|
|
37
|
+
showActiveIndicator?: boolean;
|
|
38
|
+
}
|
|
39
|
+
interface ResolvedAppearance {
|
|
40
|
+
density: TreeDensity;
|
|
41
|
+
rowHeight: number;
|
|
42
|
+
iconSize: number;
|
|
43
|
+
iconStrokeWidth: number;
|
|
44
|
+
fontSize: number;
|
|
45
|
+
gap: number;
|
|
46
|
+
indent: number;
|
|
47
|
+
accent: TreeAccentIntensity;
|
|
48
|
+
radius: TreeRadius;
|
|
49
|
+
indentGuideOpacity: number;
|
|
50
|
+
showActiveIndicator: boolean;
|
|
51
|
+
}
|
|
52
|
+
declare const DEFAULT_TREE_APPEARANCE: ResolvedAppearance;
|
|
53
|
+
/**
|
|
54
|
+
* Merge a partial appearance with the default + density preset.
|
|
55
|
+
*
|
|
56
|
+
* Explicit numeric overrides (e.g. `rowHeight`) win over the density preset.
|
|
57
|
+
*/
|
|
58
|
+
declare function resolveAppearance(input?: TreeAppearance,
|
|
59
|
+
/** Outer `indent` prop (kept on TreeRoot for back-compat). */
|
|
60
|
+
outerIndent?: number): ResolvedAppearance;
|
|
61
|
+
/**
|
|
62
|
+
* Build the `style` object that exposes the resolved appearance to any
|
|
63
|
+
* descendant via CSS variables. Set on `<TreeRoot>`'s outer div.
|
|
64
|
+
*/
|
|
65
|
+
declare function appearanceToStyle(a: ResolvedAppearance): CSSProperties;
|
|
66
|
+
|
|
67
|
+
type TreeItemId = string;
|
|
68
|
+
/** A single node in the consumer's tree data. Generic over your payload. */
|
|
69
|
+
interface TreeNode<T = unknown> {
|
|
70
|
+
id: TreeItemId;
|
|
71
|
+
data: T;
|
|
72
|
+
/** Inline children. Omit (and provide a `loadChildren`) for async loading. */
|
|
73
|
+
children?: TreeNode<T>[];
|
|
74
|
+
/**
|
|
75
|
+
* Set to `true` to mark a node as a folder even when its `children` array
|
|
76
|
+
* is empty (e.g. an unloaded async folder). Default: derived from
|
|
77
|
+
* `Array.isArray(children)`.
|
|
78
|
+
*/
|
|
79
|
+
isFolder?: boolean;
|
|
80
|
+
/** Disable interaction. */
|
|
81
|
+
disabled?: boolean;
|
|
82
|
+
}
|
|
83
|
+
interface TreeLabels {
|
|
84
|
+
loading: string;
|
|
85
|
+
empty: string;
|
|
86
|
+
error: string;
|
|
87
|
+
searchPlaceholder: string;
|
|
88
|
+
searchMatches: (count: number) => string;
|
|
89
|
+
ariaLabel: string;
|
|
90
|
+
}
|
|
91
|
+
declare const DEFAULT_TREE_LABELS: TreeLabels;
|
|
92
|
+
type TreeSelectionMode = 'none' | 'single' | 'multiple';
|
|
93
|
+
/**
|
|
94
|
+
* How a node becomes "activated" (i.e. opened) on pointer interaction.
|
|
95
|
+
*
|
|
96
|
+
* - `'single-click'` (default): single click activates a leaf immediately;
|
|
97
|
+
* double-click also activates. Folders always toggle on single click.
|
|
98
|
+
* - `'double-click'`: single click only selects + focuses; double-click is
|
|
99
|
+
* required to activate. Mirrors classic file-manager behaviour.
|
|
100
|
+
* - `'single-click-preview'`: VSCode Explorer / Cursor behaviour. Single
|
|
101
|
+
* click activates with `{ preview: true }` (consumer renders a preview
|
|
102
|
+
* tab); double-click activates with `{ preview: false }` (pinned tab).
|
|
103
|
+
*
|
|
104
|
+
* Folders ignore this setting — they always toggle on single click and
|
|
105
|
+
* never call `onActivate`.
|
|
106
|
+
*/
|
|
107
|
+
type TreeActivationMode = 'single-click' | 'double-click' | 'single-click-preview';
|
|
108
|
+
interface TreeActivateOptions {
|
|
109
|
+
/**
|
|
110
|
+
* `true` when the activation came from a single click in
|
|
111
|
+
* `'single-click-preview'` mode. `false` for double-click and for
|
|
112
|
+
* non-preview modes. Consumers typically map this to a
|
|
113
|
+
* preview-tab vs pinned-tab distinction.
|
|
114
|
+
*/
|
|
115
|
+
preview: boolean;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Async loader: called the first time a folder is expanded with no inline
|
|
119
|
+
* `children`. Result is cached; concurrent expansions are de-duplicated.
|
|
120
|
+
*/
|
|
121
|
+
type TreeLoadChildren<T> = (node: TreeNode<T>) => Promise<TreeNode<T>[]>;
|
|
122
|
+
interface TreeRowRenderProps<T> {
|
|
123
|
+
node: TreeNode<T>;
|
|
124
|
+
level: number;
|
|
125
|
+
isSelected: boolean;
|
|
126
|
+
isExpanded: boolean;
|
|
127
|
+
isFocused: boolean;
|
|
128
|
+
isFolder: boolean;
|
|
129
|
+
isLoading: boolean;
|
|
130
|
+
isMatchingSearch: boolean;
|
|
131
|
+
}
|
|
132
|
+
type TreeRowSlot<T> = (props: TreeRowRenderProps<T>) => ReactNode;
|
|
133
|
+
type TreeContextMenuSlot<T> = (props: TreeRowRenderProps<T>, trigger: ReactNode) => ReactNode;
|
|
134
|
+
interface TreeRootProps<T> {
|
|
135
|
+
/** Root nodes. Top-level items are rendered directly (no synthetic root). */
|
|
136
|
+
data: TreeNode<T>[];
|
|
137
|
+
/** Returns the human-readable name for a node (used by search/type-ahead). */
|
|
138
|
+
getItemName: (node: TreeNode<T>) => string;
|
|
139
|
+
/** Async loader for folders without inline `children`. */
|
|
140
|
+
loadChildren?: TreeLoadChildren<T>;
|
|
141
|
+
/** Selection behaviour. Default: `'single'`. */
|
|
142
|
+
selectionMode?: TreeSelectionMode;
|
|
143
|
+
/** Pointer activation behaviour. Default: `'single-click'`. */
|
|
144
|
+
activationMode?: TreeActivationMode;
|
|
145
|
+
/** Initially expanded ids. */
|
|
146
|
+
initialExpandedIds?: TreeItemId[];
|
|
147
|
+
/** Initially selected ids. */
|
|
148
|
+
initialSelectedIds?: TreeItemId[];
|
|
149
|
+
/** Pixels of indent per nesting level. Default: 16. (Shortcut for `appearance.indent`.) */
|
|
150
|
+
indent?: number;
|
|
151
|
+
/** Cosmetic configuration: density, sizes, accent intensity, radius. */
|
|
152
|
+
appearance?: TreeAppearance;
|
|
153
|
+
/** Triggered when selection changes. */
|
|
154
|
+
onSelectionChange?: (selectedIds: TreeItemId[]) => void;
|
|
155
|
+
/** Triggered when expanded set changes. */
|
|
156
|
+
onExpansionChange?: (expandedIds: TreeItemId[]) => void;
|
|
157
|
+
/**
|
|
158
|
+
* Triggered when a leaf is activated (Enter / dblclick / click depending
|
|
159
|
+
* on `activationMode`). Folders never call this — they toggle instead.
|
|
160
|
+
*/
|
|
161
|
+
onActivate?: (node: TreeNode<T>, opts: TreeActivateOptions) => void;
|
|
162
|
+
/** Show built-in search input. Default: false. */
|
|
163
|
+
enableSearch?: boolean;
|
|
164
|
+
/** Type printable letters to jump to a matching name. Default: true. */
|
|
165
|
+
enableTypeAhead?: boolean;
|
|
166
|
+
/** Render vertical indent guides under expanded folders. Default: false. */
|
|
167
|
+
showIndentGuides?: boolean;
|
|
168
|
+
/** Custom row renderer. Falls back to the default <TreeRow />. */
|
|
169
|
+
renderRow?: TreeRowSlot<T>;
|
|
170
|
+
/** Replace default folder/file icon. */
|
|
171
|
+
renderIcon?: TreeRowSlot<T>;
|
|
172
|
+
/** Replace default label rendering. */
|
|
173
|
+
renderLabel?: TreeRowSlot<T>;
|
|
174
|
+
/** Right-side actions slot (per row). */
|
|
175
|
+
renderActions?: TreeRowSlot<T>;
|
|
176
|
+
/** Wrap each row in a context menu (right-click). Receives the row meta + trigger element. */
|
|
177
|
+
renderContextMenu?: TreeContextMenuSlot<T>;
|
|
178
|
+
/** Override built-in copy in your locale. */
|
|
179
|
+
labels?: Partial<TreeLabels>;
|
|
180
|
+
/** Persist expanded + (optional) selected ids in localStorage under this key. */
|
|
181
|
+
persistKey?: string;
|
|
182
|
+
/** Persist selection alongside expansion. Default: false. */
|
|
183
|
+
persistSelection?: boolean;
|
|
184
|
+
className?: string;
|
|
185
|
+
style?: React.CSSProperties;
|
|
186
|
+
}
|
|
187
|
+
/** Internal flat-row representation used by the renderer + keyboard nav. */
|
|
188
|
+
interface FlatRow<T> {
|
|
189
|
+
node: TreeNode<T>;
|
|
190
|
+
level: number;
|
|
191
|
+
parentId: TreeItemId | null;
|
|
192
|
+
isFolder: boolean;
|
|
193
|
+
isExpanded: boolean;
|
|
194
|
+
isLoading: boolean;
|
|
195
|
+
hasError: boolean;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export { DEFAULT_TREE_LABELS as D, type FlatRow as F, type ResolvedAppearance as R, type TreeNode as T, type TreeRowSlot as a, type TreeRootProps as b, DEFAULT_TREE_APPEARANCE as c, appearanceToStyle as d, type TreeContextMenuSlot as e, type TreeItemId as f, type TreeLabels as g, type TreeSelectionMode as h, type TreeRowRenderProps as i, type TreeLoadChildren as j, type TreeAppearance as k, type TreeDensity as l, type TreeAccentIntensity as m, type TreeRadius as n, type TreeActivateOptions as o, type TreeActivationMode as p, resolveAppearance as r };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-tools",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.317",
|
|
4
4
|
"description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-tools",
|
|
@@ -79,6 +79,11 @@
|
|
|
79
79
|
"import": "./dist/tree/index.mjs",
|
|
80
80
|
"require": "./dist/tree/index.cjs"
|
|
81
81
|
},
|
|
82
|
+
"./file-icon": {
|
|
83
|
+
"types": "./dist/file-icon/index.d.ts",
|
|
84
|
+
"import": "./dist/file-icon/index.mjs",
|
|
85
|
+
"require": "./dist/file-icon/index.cjs"
|
|
86
|
+
},
|
|
82
87
|
"./styles": "./src/styles/index.css",
|
|
83
88
|
"./dist.css": "./dist/index.css"
|
|
84
89
|
},
|
|
@@ -96,8 +101,8 @@
|
|
|
96
101
|
"check": "tsc --noEmit"
|
|
97
102
|
},
|
|
98
103
|
"peerDependencies": {
|
|
99
|
-
"@djangocfg/i18n": "^2.1.
|
|
100
|
-
"@djangocfg/ui-core": "^2.1.
|
|
104
|
+
"@djangocfg/i18n": "^2.1.317",
|
|
105
|
+
"@djangocfg/ui-core": "^2.1.317",
|
|
101
106
|
"consola": "^3.4.2",
|
|
102
107
|
"lodash-es": "^4.18.1",
|
|
103
108
|
"lucide-react": "^0.545.0",
|
|
@@ -146,13 +151,14 @@
|
|
|
146
151
|
},
|
|
147
152
|
"optionalDependencies": {
|
|
148
153
|
"@mapbox/mapbox-gl-draw": "^1.4.3",
|
|
149
|
-
"@maplibre/maplibre-gl-geocoder": "^1.7.0"
|
|
154
|
+
"@maplibre/maplibre-gl-geocoder": "^1.7.0",
|
|
155
|
+
"material-file-icons": "^2.4.0"
|
|
150
156
|
},
|
|
151
157
|
"devDependencies": {
|
|
152
|
-
"@djangocfg/i18n": "^2.1.
|
|
158
|
+
"@djangocfg/i18n": "^2.1.317",
|
|
153
159
|
"@djangocfg/playground": "workspace:*",
|
|
154
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
155
|
-
"@djangocfg/ui-core": "^2.1.
|
|
160
|
+
"@djangocfg/typescript-config": "^2.1.317",
|
|
161
|
+
"@djangocfg/ui-core": "^2.1.317",
|
|
156
162
|
"@types/lodash-es": "^4.17.12",
|
|
157
163
|
"@types/mapbox__mapbox-gl-draw": "^1.4.8",
|
|
158
164
|
"@types/node": "^24.7.2",
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { File as FileFallback, Folder, FolderOpen } from 'lucide-react';
|
|
5
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
6
|
+
|
|
7
|
+
import { getMaterialIconsSync, loadMaterialIcons } from './loader';
|
|
8
|
+
|
|
9
|
+
export type FileIconSize = 12 | 14 | 16 | 18 | 20 | 24 | 28 | 32 | 40 | 48 | 64;
|
|
10
|
+
|
|
11
|
+
export interface FileIconProps {
|
|
12
|
+
/** File name (with extension) or folder name. */
|
|
13
|
+
name: string;
|
|
14
|
+
/** Render a folder icon instead of a file icon. */
|
|
15
|
+
isFolder?: boolean;
|
|
16
|
+
/** Folder open / closed state. Ignored when `isFolder` is false. */
|
|
17
|
+
isExpanded?: boolean;
|
|
18
|
+
/** Pixel size of the icon. */
|
|
19
|
+
size?: FileIconSize;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* VSCode-style file icon.
|
|
25
|
+
*
|
|
26
|
+
* - Folders render Lucide `Folder` / `FolderOpen` (amber tint).
|
|
27
|
+
* - Files use `material-file-icons` if it's installed in the consumer; otherwise
|
|
28
|
+
* fall back to a neutral Lucide `File` icon.
|
|
29
|
+
*
|
|
30
|
+
* The optional dependency is loaded lazily on first render — there is zero
|
|
31
|
+
* bundle cost for consumers who never mount this component.
|
|
32
|
+
*/
|
|
33
|
+
export function FileIcon({
|
|
34
|
+
name,
|
|
35
|
+
isFolder = false,
|
|
36
|
+
isExpanded = false,
|
|
37
|
+
size = 16,
|
|
38
|
+
className,
|
|
39
|
+
}: FileIconProps) {
|
|
40
|
+
const [svg, setSvg] = useState<string | null>(() => {
|
|
41
|
+
if (isFolder) return null;
|
|
42
|
+
const fn = getMaterialIconsSync();
|
|
43
|
+
return fn ? (fn(name)?.svg ?? null) : null;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (isFolder) {
|
|
48
|
+
setSvg(null);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
let cancelled = false;
|
|
52
|
+
void loadMaterialIcons().then((fn) => {
|
|
53
|
+
if (cancelled) return;
|
|
54
|
+
setSvg(fn ? (fn(name)?.svg ?? null) : null);
|
|
55
|
+
});
|
|
56
|
+
return () => {
|
|
57
|
+
cancelled = true;
|
|
58
|
+
};
|
|
59
|
+
}, [isFolder, name]);
|
|
60
|
+
|
|
61
|
+
if (isFolder) {
|
|
62
|
+
const Icon = isExpanded ? FolderOpen : Folder;
|
|
63
|
+
return (
|
|
64
|
+
<Icon
|
|
65
|
+
className={cn('shrink-0 text-amber-500', className)}
|
|
66
|
+
style={{ width: size, height: size }}
|
|
67
|
+
/>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!svg) {
|
|
72
|
+
return (
|
|
73
|
+
<FileFallback
|
|
74
|
+
className={cn('shrink-0 text-muted-foreground/80', className)}
|
|
75
|
+
style={{ width: size, height: size }}
|
|
76
|
+
/>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<span
|
|
82
|
+
role="img"
|
|
83
|
+
aria-hidden
|
|
84
|
+
className={cn('inline-block shrink-0', className)}
|
|
85
|
+
style={{ width: size, height: size }}
|
|
86
|
+
dangerouslySetInnerHTML={{ __html: svg }}
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export default FileIcon;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
export { FileIcon } from './FileIcon';
|
|
4
|
+
export type { FileIconProps, FileIconSize } from './FileIcon';
|
|
5
|
+
|
|
6
|
+
export { createFileIconSlot } from './treeAdapter';
|
|
7
|
+
export type { CreateFileIconSlotOptions } from './treeAdapter';
|
|
8
|
+
|
|
9
|
+
export { loadMaterialIcons, getMaterialIconsSync } from './loader';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lazy loader for `material-file-icons`. The package is an optional dependency:
|
|
5
|
+
* if it's installed in the consumer's tree, we render its rich VSCode-style
|
|
6
|
+
* SVGs; if not, the loader resolves to `null` and `<FileIcon>` falls back to a
|
|
7
|
+
* Lucide icon. Either way, ui-tools never throws or warns about a missing
|
|
8
|
+
* package.
|
|
9
|
+
*
|
|
10
|
+
* Resolution is cached per-process so we don't re-import on every render.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
type GetIconFn = (name: string) => { svg: string } | undefined;
|
|
14
|
+
|
|
15
|
+
let cached: GetIconFn | null | undefined;
|
|
16
|
+
let inflight: Promise<GetIconFn | null> | null = null;
|
|
17
|
+
|
|
18
|
+
export async function loadMaterialIcons(): Promise<GetIconFn | null> {
|
|
19
|
+
if (cached !== undefined) return cached;
|
|
20
|
+
if (inflight) return inflight;
|
|
21
|
+
|
|
22
|
+
inflight = (async () => {
|
|
23
|
+
try {
|
|
24
|
+
// Computed specifier so bundlers don't try to eagerly resolve it.
|
|
25
|
+
const specifier = 'material-file-icons';
|
|
26
|
+
const mod = (await import(/* @vite-ignore */ specifier)) as {
|
|
27
|
+
getIcon?: GetIconFn;
|
|
28
|
+
default?: { getIcon?: GetIconFn };
|
|
29
|
+
};
|
|
30
|
+
const fn = mod.getIcon ?? mod.default?.getIcon ?? null;
|
|
31
|
+
cached = fn;
|
|
32
|
+
return fn;
|
|
33
|
+
} catch {
|
|
34
|
+
cached = null;
|
|
35
|
+
return null;
|
|
36
|
+
} finally {
|
|
37
|
+
inflight = null;
|
|
38
|
+
}
|
|
39
|
+
})();
|
|
40
|
+
|
|
41
|
+
return inflight;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Synchronous accessor — returns null until `loadMaterialIcons` resolves. */
|
|
45
|
+
export function getMaterialIconsSync(): GetIconFn | null {
|
|
46
|
+
return cached ?? null;
|
|
47
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { TreeNode, TreeRowSlot } from '../Tree/types';
|
|
4
|
+
import { FileIcon, type FileIconSize } from './FileIcon';
|
|
5
|
+
|
|
6
|
+
export interface CreateFileIconSlotOptions<T> {
|
|
7
|
+
/**
|
|
8
|
+
* How to read the displayed name (with extension) for a node. Use the same
|
|
9
|
+
* accessor you pass to `<TreeRoot getItemName={...} />`.
|
|
10
|
+
*/
|
|
11
|
+
getName: (node: TreeNode<T>) => string;
|
|
12
|
+
/** Pixel size for both file and folder icons. Default: 16. */
|
|
13
|
+
size?: FileIconSize;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build a `renderIcon` slot for `<TreeRoot>` that uses `<FileIcon>` for both
|
|
18
|
+
* leaves and folders.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```tsx
|
|
22
|
+
* <TreeRoot
|
|
23
|
+
* data={data}
|
|
24
|
+
* getItemName={(n) => n.data.name}
|
|
25
|
+
* renderIcon={createFileIconSlot({ getName: (n) => n.data.name })}
|
|
26
|
+
* />
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export function createFileIconSlot<T>({
|
|
30
|
+
getName,
|
|
31
|
+
size = 16,
|
|
32
|
+
}: CreateFileIconSlotOptions<T>): TreeRowSlot<T> {
|
|
33
|
+
return ({ node, isFolder, isExpanded }) => (
|
|
34
|
+
<FileIcon
|
|
35
|
+
name={getName(node)}
|
|
36
|
+
isFolder={isFolder}
|
|
37
|
+
isExpanded={isExpanded}
|
|
38
|
+
size={size}
|
|
39
|
+
/>
|
|
40
|
+
);
|
|
41
|
+
}
|
package/src/tools/Tree/README.md
CHANGED
|
@@ -182,6 +182,7 @@ Toggle the bar with `appearance.showActiveIndicator`. Intensity scales with `app
|
|
|
182
182
|
| Option | Default |
|
|
183
183
|
| --- | --- |
|
|
184
184
|
| `selectionMode` | `'single'` |
|
|
185
|
+
| `activationMode` | `'single-click'` |
|
|
185
186
|
| `enableSearch` | `false` |
|
|
186
187
|
| `enableTypeAhead` | `true` |
|
|
187
188
|
| `showIndentGuides` | `false` |
|
|
@@ -198,6 +199,7 @@ Toggle the bar with `appearance.showActiveIndicator`. Intensity scales with `app
|
|
|
198
199
|
| Story | Demonstrates |
|
|
199
200
|
| --- | --- |
|
|
200
201
|
| Default | sensible cozy defaults |
|
|
202
|
+
| WithActivationModes | single-click / double-click / preview semantics |
|
|
201
203
|
| Densities | three density presets side-by-side |
|
|
202
204
|
| WithIcons | file-type icons through `renderIcon` |
|
|
203
205
|
| WithStatus | modified / error / disabled rows through `renderLabel` |
|
|
@@ -211,6 +213,60 @@ Toggle the bar with `appearance.showActiveIndicator`. Intensity scales with `app
|
|
|
211
213
|
| LargeTree | ~500 nodes scalability check |
|
|
212
214
|
| Playground | every knob exposed as a control |
|
|
213
215
|
|
|
216
|
+
## Activation modes
|
|
217
|
+
|
|
218
|
+
How a leaf becomes "activated" (opened) on pointer interaction is controlled
|
|
219
|
+
by `activationMode`. Folders ignore this setting — they always toggle on
|
|
220
|
+
single click and never call `onActivate`.
|
|
221
|
+
|
|
222
|
+
| Mode | Single click | Double click |
|
|
223
|
+
| --- | --- | --- |
|
|
224
|
+
| `'single-click'` *(default)* | activate `{ preview: false }` | activate `{ preview: false }` |
|
|
225
|
+
| `'double-click'` | select + focus only | activate `{ preview: false }` |
|
|
226
|
+
| `'single-click-preview'` *(VSCode / Cursor)* | activate `{ preview: true }` | activate `{ preview: false }` |
|
|
227
|
+
|
|
228
|
+
Keyboard `Enter` / `Space` always activates with `{ preview: false }` —
|
|
229
|
+
keyboard input is treated as an explicit user action.
|
|
230
|
+
|
|
231
|
+
```tsx
|
|
232
|
+
<TreeRoot<FsNode>
|
|
233
|
+
data={data}
|
|
234
|
+
getItemName={(n) => n.data.name}
|
|
235
|
+
activationMode="single-click-preview"
|
|
236
|
+
onActivate={(node, { preview }) =>
|
|
237
|
+
preview ? openPreviewTab(node) : openPinnedTab(node)
|
|
238
|
+
}
|
|
239
|
+
/>
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
The active mode is also exposed on each row as
|
|
243
|
+
`data-activation-mode="<mode>"` for CSS-level targeting.
|
|
244
|
+
|
|
245
|
+
## VSCode-style file icons
|
|
246
|
+
|
|
247
|
+
Tree is generic over `T` — it has no opinion on whether nodes are files. For a
|
|
248
|
+
ready-made VSCode-style icon set, install the optional companion subpath:
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
pnpm add material-file-icons
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
```tsx
|
|
255
|
+
import { TreeRoot } from '@djangocfg/ui-tools/tree';
|
|
256
|
+
import { createFileIconSlot } from '@djangocfg/ui-tools/file-icon';
|
|
257
|
+
|
|
258
|
+
<TreeRoot
|
|
259
|
+
data={data}
|
|
260
|
+
getItemName={(n) => n.data.name}
|
|
261
|
+
renderIcon={createFileIconSlot({ getName: (n) => n.data.name })}
|
|
262
|
+
/>
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
`material-file-icons` is declared in `optionalDependencies`. If it's not
|
|
266
|
+
installed, `<FileIcon>` falls back to a Lucide `File` icon — no warnings, no
|
|
267
|
+
runtime errors. Folders always render Lucide `Folder` / `FolderOpen` (amber
|
|
268
|
+
tint), regardless of whether the optional package is present.
|
|
269
|
+
|
|
214
270
|
## Out of scope (today)
|
|
215
271
|
|
|
216
272
|
- Inline rename UX
|
|
@@ -111,6 +111,54 @@ export const Default = () => (
|
|
|
111
111
|
</div>
|
|
112
112
|
);
|
|
113
113
|
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// 1.5) WithActivationModes — VSCode-style click semantics
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
export const WithActivationModes = () => {
|
|
119
|
+
const [log, setLog] = useState<string[]>([]);
|
|
120
|
+
const append = (s: string) =>
|
|
121
|
+
setLog((prev) => [s, ...prev].slice(0, 8));
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div className="flex flex-col gap-3">
|
|
125
|
+
<div className="grid grid-cols-3 gap-3">
|
|
126
|
+
{(['single-click', 'double-click', 'single-click-preview'] as const).map(
|
|
127
|
+
(mode) => (
|
|
128
|
+
<div key={mode} className="flex flex-col gap-2">
|
|
129
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
130
|
+
{mode}
|
|
131
|
+
</span>
|
|
132
|
+
<div className="h-72 w-64 rounded-md border border-border bg-card">
|
|
133
|
+
<TreeRoot<FsNode>
|
|
134
|
+
data={fs}
|
|
135
|
+
getItemName={getName}
|
|
136
|
+
initialExpandedIds={['src']}
|
|
137
|
+
activationMode={mode}
|
|
138
|
+
onActivate={(node, { preview }) =>
|
|
139
|
+
append(
|
|
140
|
+
`[${mode}] ${node.data.name}${preview ? ' (preview)' : ''}`,
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
),
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
<div className="rounded-md border border-border bg-muted/30 p-3 font-mono text-xs">
|
|
150
|
+
{log.length === 0 ? (
|
|
151
|
+
<span className="text-muted-foreground">
|
|
152
|
+
click / double-click a file to log activations
|
|
153
|
+
</span>
|
|
154
|
+
) : (
|
|
155
|
+
log.map((line, i) => <div key={i}>{line}</div>)
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
};
|
|
161
|
+
|
|
114
162
|
// ---------------------------------------------------------------------------
|
|
115
163
|
// 2) Densities — three presets side-by-side for comparison
|
|
116
164
|
// ---------------------------------------------------------------------------
|
|
@@ -23,6 +23,7 @@ function TreeRoot<T>(props: TreeRootProps<T>) {
|
|
|
23
23
|
getItemName,
|
|
24
24
|
loadChildren,
|
|
25
25
|
selectionMode,
|
|
26
|
+
activationMode,
|
|
26
27
|
initialExpandedIds,
|
|
27
28
|
initialSelectedIds,
|
|
28
29
|
indent,
|
|
@@ -51,6 +52,7 @@ function TreeRoot<T>(props: TreeRootProps<T>) {
|
|
|
51
52
|
getItemName={getItemName}
|
|
52
53
|
loadChildren={loadChildren}
|
|
53
54
|
selectionMode={selectionMode}
|
|
55
|
+
activationMode={activationMode}
|
|
54
56
|
initialExpandedIds={initialExpandedIds}
|
|
55
57
|
initialSelectedIds={initialSelectedIds}
|
|
56
58
|
indent={indent}
|
|
@@ -104,8 +106,9 @@ function TreeRootShell<T>({
|
|
|
104
106
|
onFocus: ctx.setFocus,
|
|
105
107
|
onSelect: ctx.select,
|
|
106
108
|
onActivate: (id) => {
|
|
109
|
+
// Keyboard Enter / Space is always an explicit action — pin (no preview).
|
|
107
110
|
const row = ctx.flatRows.find((r) => r.node.id === id);
|
|
108
|
-
if (row) ctx.activate(row.node);
|
|
111
|
+
if (row) ctx.activate(row.node, { preview: false });
|
|
109
112
|
},
|
|
110
113
|
onExpand: ctx.expand,
|
|
111
114
|
onCollapse: ctx.collapse,
|
|
@@ -20,6 +20,7 @@ export function TreeRow<T>({ row, className }: TreeRowProps<T>) {
|
|
|
20
20
|
const ctx = useTreeContext<T>();
|
|
21
21
|
const {
|
|
22
22
|
appearance,
|
|
23
|
+
activationMode,
|
|
23
24
|
showIndentGuides,
|
|
24
25
|
selected,
|
|
25
26
|
focused,
|
|
@@ -51,22 +52,29 @@ export function TreeRow<T>({ row, className }: TreeRowProps<T>) {
|
|
|
51
52
|
isMatchingSearch,
|
|
52
53
|
};
|
|
53
54
|
|
|
54
|
-
|
|
55
|
+
// Folders always toggle on single click regardless of `activationMode`.
|
|
56
|
+
// Leaves dispatch by mode:
|
|
57
|
+
// single-click → click activates {preview:false}
|
|
58
|
+
// double-click → click only selects; dblclick activates {preview:false}
|
|
59
|
+
// single-click-preview → click activates {preview:true}; dblclick activates {preview:false}
|
|
60
|
+
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
55
61
|
if (node.disabled) return;
|
|
56
62
|
setFocus(node.id);
|
|
57
63
|
select(node.id);
|
|
58
|
-
if (isFolder)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
if (isFolder) {
|
|
65
|
+
toggle(node.id);
|
|
66
|
+
} else if (activationMode === 'single-click') {
|
|
67
|
+
activate(node, { preview: false });
|
|
68
|
+
} else if (activationMode === 'single-click-preview') {
|
|
69
|
+
activate(node, { preview: true });
|
|
70
|
+
}
|
|
64
71
|
e.currentTarget.scrollIntoView?.({ block: 'nearest' });
|
|
65
72
|
};
|
|
66
73
|
|
|
67
74
|
const handleDoubleClick = () => {
|
|
68
75
|
if (node.disabled) return;
|
|
69
|
-
if (
|
|
76
|
+
if (isFolder) return;
|
|
77
|
+
activate(node, { preview: false });
|
|
70
78
|
};
|
|
71
79
|
|
|
72
80
|
const trigger = (
|
|
@@ -79,6 +87,7 @@ export function TreeRow<T>({ row, className }: TreeRowProps<T>) {
|
|
|
79
87
|
aria-disabled={node.disabled || undefined}
|
|
80
88
|
data-tree-row=""
|
|
81
89
|
data-id={node.id}
|
|
90
|
+
data-activation-mode={activationMode}
|
|
82
91
|
data-selected={isSelected ? 'true' : undefined}
|
|
83
92
|
data-focused={isFocused && !isSelected ? 'true' : undefined}
|
|
84
93
|
data-folder={isFolder || undefined}
|