@djangocfg/ui-tools 2.1.316 → 2.1.318
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-A3J65L6F.mjs +4 -0
- package/dist/{TreeRoot-A25RIGYE.cjs.map → TreeRoot-A3J65L6F.mjs.map} +1 -1
- package/dist/TreeRoot-DSK5JILT.cjs +19 -0
- package/dist/{TreeRoot-HBRJEHBH.mjs.map → TreeRoot-DSK5JILT.cjs.map} +1 -1
- package/dist/{chunk-4CEOJDMB.cjs → chunk-3Z3A7FHA.cjs} +36 -15
- package/dist/chunk-3Z3A7FHA.cjs.map +1 -0
- package/dist/{chunk-NFIMVYJU.mjs → chunk-MOME6KYD.mjs} +36 -15
- package/dist/chunk-MOME6KYD.mjs.map +1 -0
- package/dist/file-icon/index.cjs +173 -0
- package/dist/file-icon/index.cjs.map +1 -0
- package/dist/file-icon/index.d.cts +98 -0
- package/dist/file-icon/index.d.ts +98 -0
- package/dist/file-icon/index.mjs +167 -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 +10 -172
- package/dist/tree/index.d.ts +10 -172
- package/dist/tree/index.mjs +1 -1
- package/dist/types-CevSbyfD.d.cts +204 -0
- package/dist/types-CevSbyfD.d.ts +204 -0
- package/package.json +13 -7
- package/src/tools/FileIcon/FileIcon.tsx +102 -0
- package/src/tools/FileIcon/index.ts +15 -0
- package/src/tools/FileIcon/loader.ts +47 -0
- package/src/tools/FileIcon/specialFolders.ts +93 -0
- package/src/tools/FileIcon/treeAdapter.tsx +49 -0
- package/src/tools/Tree/README.md +100 -0
- package/src/tools/Tree/Tree.story.tsx +84 -0
- package/src/tools/Tree/TreeRoot.tsx +6 -1
- package/src/tools/Tree/components/TreeContent.tsx +3 -1
- package/src/tools/Tree/components/TreeRow.tsx +17 -8
- package/src/tools/Tree/context/TreeContext.tsx +14 -3
- package/src/tools/Tree/data/flatten.ts +10 -1
- package/src/tools/Tree/index.tsx +2 -0
- package/src/tools/Tree/types.ts +43 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-tools",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.318",
|
|
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.318",
|
|
105
|
+
"@djangocfg/ui-core": "^2.1.318",
|
|
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.318",
|
|
153
159
|
"@djangocfg/playground": "workspace:*",
|
|
154
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
155
|
-
"@djangocfg/ui-core": "^2.1.
|
|
160
|
+
"@djangocfg/typescript-config": "^2.1.318",
|
|
161
|
+
"@djangocfg/ui-core": "^2.1.318",
|
|
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,102 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { File as FileFallback } from 'lucide-react';
|
|
5
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
6
|
+
|
|
7
|
+
import { getMaterialIconsSync, loadMaterialIcons } from './loader';
|
|
8
|
+
import { resolveFolderIcon, type FolderIconOverrides } from './specialFolders';
|
|
9
|
+
|
|
10
|
+
export type FileIconSize = 12 | 14 | 16 | 18 | 20 | 24 | 28 | 32 | 40 | 48 | 64;
|
|
11
|
+
|
|
12
|
+
export interface FileIconProps {
|
|
13
|
+
/** File name (with extension) or folder name. */
|
|
14
|
+
name: string;
|
|
15
|
+
/** Render a folder icon instead of a file icon. */
|
|
16
|
+
isFolder?: boolean;
|
|
17
|
+
/** Folder open / closed state. Ignored when `isFolder` is false. */
|
|
18
|
+
isExpanded?: boolean;
|
|
19
|
+
/** Pixel size of the icon. */
|
|
20
|
+
size?: FileIconSize;
|
|
21
|
+
/**
|
|
22
|
+
* Override or extend the built-in special-folder mapping. Keys are
|
|
23
|
+
* matched case-insensitively. Only used when `isFolder` is true.
|
|
24
|
+
*/
|
|
25
|
+
folderOverrides?: FolderIconOverrides;
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* VSCode-style file icon.
|
|
31
|
+
*
|
|
32
|
+
* - Folders render Lucide `Folder` / `FolderOpen` (amber tint).
|
|
33
|
+
* - Files use `material-file-icons` if it's installed in the consumer; otherwise
|
|
34
|
+
* fall back to a neutral Lucide `File` icon.
|
|
35
|
+
*
|
|
36
|
+
* The optional dependency is loaded lazily on first render — there is zero
|
|
37
|
+
* bundle cost for consumers who never mount this component.
|
|
38
|
+
*/
|
|
39
|
+
export function FileIcon({
|
|
40
|
+
name,
|
|
41
|
+
isFolder = false,
|
|
42
|
+
isExpanded = false,
|
|
43
|
+
size = 16,
|
|
44
|
+
folderOverrides,
|
|
45
|
+
className,
|
|
46
|
+
}: FileIconProps) {
|
|
47
|
+
const [svg, setSvg] = useState<string | null>(() => {
|
|
48
|
+
if (isFolder) return null;
|
|
49
|
+
const fn = getMaterialIconsSync();
|
|
50
|
+
return fn ? (fn(name)?.svg ?? null) : null;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (isFolder) {
|
|
55
|
+
setSvg(null);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
let cancelled = false;
|
|
59
|
+
void loadMaterialIcons().then((fn) => {
|
|
60
|
+
if (cancelled) return;
|
|
61
|
+
setSvg(fn ? (fn(name)?.svg ?? null) : null);
|
|
62
|
+
});
|
|
63
|
+
return () => {
|
|
64
|
+
cancelled = true;
|
|
65
|
+
};
|
|
66
|
+
}, [isFolder, name]);
|
|
67
|
+
|
|
68
|
+
if (isFolder) {
|
|
69
|
+
const Icon = resolveFolderIcon({
|
|
70
|
+
name,
|
|
71
|
+
isExpanded,
|
|
72
|
+
overrides: folderOverrides,
|
|
73
|
+
});
|
|
74
|
+
return (
|
|
75
|
+
<Icon
|
|
76
|
+
className={cn('shrink-0 text-amber-500', className)}
|
|
77
|
+
style={{ width: size, height: size }}
|
|
78
|
+
/>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!svg) {
|
|
83
|
+
return (
|
|
84
|
+
<FileFallback
|
|
85
|
+
className={cn('shrink-0 text-muted-foreground/80', className)}
|
|
86
|
+
style={{ width: size, height: size }}
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<span
|
|
93
|
+
role="img"
|
|
94
|
+
aria-hidden
|
|
95
|
+
className={cn('inline-block shrink-0', className)}
|
|
96
|
+
style={{ width: size, height: size }}
|
|
97
|
+
dangerouslySetInnerHTML={{ __html: svg }}
|
|
98
|
+
/>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export default FileIcon;
|
|
@@ -0,0 +1,15 @@
|
|
|
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 { resolveFolderIcon } from './specialFolders';
|
|
10
|
+
export type {
|
|
11
|
+
ResolveFolderIconOptions,
|
|
12
|
+
FolderIconOverrides,
|
|
13
|
+
} from './specialFolders';
|
|
14
|
+
|
|
15
|
+
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,93 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
BookOpen,
|
|
5
|
+
FlaskConical,
|
|
6
|
+
Folder,
|
|
7
|
+
FolderCode,
|
|
8
|
+
FolderGit2,
|
|
9
|
+
FolderInput,
|
|
10
|
+
FolderOpen,
|
|
11
|
+
FolderOutput,
|
|
12
|
+
Github,
|
|
13
|
+
Image as ImageIcon,
|
|
14
|
+
Package,
|
|
15
|
+
Settings,
|
|
16
|
+
Terminal,
|
|
17
|
+
type LucideIcon,
|
|
18
|
+
} from 'lucide-react';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Conventional folder names → Lucide icon. Lookup is case-insensitive and
|
|
22
|
+
* trimmed. Anything not in this map falls back to a generic folder icon.
|
|
23
|
+
*
|
|
24
|
+
* Designed to feel "right" across the common dev project layouts (Node,
|
|
25
|
+
* Python, Go, Rust, Django, Next, Vite). Not exhaustive on purpose —
|
|
26
|
+
* keeps the bundle light and the behaviour predictable.
|
|
27
|
+
*/
|
|
28
|
+
const SPECIAL_FOLDERS: Record<string, LucideIcon> = {
|
|
29
|
+
src: FolderCode,
|
|
30
|
+
source: FolderCode,
|
|
31
|
+
lib: FolderCode,
|
|
32
|
+
app: FolderCode,
|
|
33
|
+
packages: Package,
|
|
34
|
+
node_modules: Package,
|
|
35
|
+
vendor: Package,
|
|
36
|
+
public: FolderInput,
|
|
37
|
+
static: FolderInput,
|
|
38
|
+
assets: ImageIcon,
|
|
39
|
+
images: ImageIcon,
|
|
40
|
+
media: ImageIcon,
|
|
41
|
+
dist: FolderOutput,
|
|
42
|
+
build: FolderOutput,
|
|
43
|
+
out: FolderOutput,
|
|
44
|
+
'.next': FolderOutput,
|
|
45
|
+
'.nuxt': FolderOutput,
|
|
46
|
+
'.turbo': FolderOutput,
|
|
47
|
+
docs: BookOpen,
|
|
48
|
+
documentation: BookOpen,
|
|
49
|
+
tests: FlaskConical,
|
|
50
|
+
test: FlaskConical,
|
|
51
|
+
__tests__: FlaskConical,
|
|
52
|
+
__test__: FlaskConical,
|
|
53
|
+
scripts: Terminal,
|
|
54
|
+
bin: Terminal,
|
|
55
|
+
config: Settings,
|
|
56
|
+
configs: Settings,
|
|
57
|
+
'.config': Settings,
|
|
58
|
+
'.vscode': Settings,
|
|
59
|
+
'.idea': Settings,
|
|
60
|
+
'.git': FolderGit2,
|
|
61
|
+
'.github': Github,
|
|
62
|
+
'.gitlab': FolderGit2,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export interface ResolveFolderIconOptions {
|
|
66
|
+
/** Folder display name (no path). */
|
|
67
|
+
name: string;
|
|
68
|
+
/** Open / closed state — only used for the *generic* folder fallback. */
|
|
69
|
+
isExpanded?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Optional override map. Wins over the built-in table. Keys are
|
|
72
|
+
* matched case-insensitively after `trim()`.
|
|
73
|
+
*/
|
|
74
|
+
overrides?: Record<string, LucideIcon>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Pick the right folder icon for `name`. Returns `Folder` / `FolderOpen`
|
|
79
|
+
* for unknown names so callers can render the generic open/closed pair.
|
|
80
|
+
*/
|
|
81
|
+
export function resolveFolderIcon({
|
|
82
|
+
name,
|
|
83
|
+
isExpanded = false,
|
|
84
|
+
overrides,
|
|
85
|
+
}: ResolveFolderIconOptions): LucideIcon {
|
|
86
|
+
const key = name.trim().toLowerCase();
|
|
87
|
+
if (overrides?.[key]) return overrides[key];
|
|
88
|
+
const special = SPECIAL_FOLDERS[key];
|
|
89
|
+
if (special) return special;
|
|
90
|
+
return isExpanded ? FolderOpen : Folder;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type FolderIconOverrides = Record<string, LucideIcon>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { TreeNode, TreeRowSlot } from '../Tree/types';
|
|
4
|
+
import { FileIcon, type FileIconSize } from './FileIcon';
|
|
5
|
+
import type { FolderIconOverrides } from './specialFolders';
|
|
6
|
+
|
|
7
|
+
export interface CreateFileIconSlotOptions<T> {
|
|
8
|
+
/**
|
|
9
|
+
* How to read the displayed name (with extension) for a node. Use the same
|
|
10
|
+
* accessor you pass to `<TreeRoot getItemName={...} />`.
|
|
11
|
+
*/
|
|
12
|
+
getName: (node: TreeNode<T>) => string;
|
|
13
|
+
/** Pixel size for both file and folder icons. Default: 16. */
|
|
14
|
+
size?: FileIconSize;
|
|
15
|
+
/**
|
|
16
|
+
* Override or extend the built-in special-folder mapping (e.g. give
|
|
17
|
+
* `tests` a custom icon). Keys are matched case-insensitively.
|
|
18
|
+
*/
|
|
19
|
+
folderOverrides?: FolderIconOverrides;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build a `renderIcon` slot for `<TreeRoot>` that uses `<FileIcon>` for both
|
|
24
|
+
* leaves and folders.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```tsx
|
|
28
|
+
* <TreeRoot
|
|
29
|
+
* data={data}
|
|
30
|
+
* getItemName={(n) => n.data.name}
|
|
31
|
+
* renderIcon={createFileIconSlot({ getName: (n) => n.data.name })}
|
|
32
|
+
* />
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export function createFileIconSlot<T>({
|
|
36
|
+
getName,
|
|
37
|
+
size = 16,
|
|
38
|
+
folderOverrides,
|
|
39
|
+
}: CreateFileIconSlotOptions<T>): TreeRowSlot<T> {
|
|
40
|
+
return ({ node, isFolder, isExpanded }) => (
|
|
41
|
+
<FileIcon
|
|
42
|
+
name={getName(node)}
|
|
43
|
+
isFolder={isFolder}
|
|
44
|
+
isExpanded={isExpanded}
|
|
45
|
+
size={size}
|
|
46
|
+
folderOverrides={folderOverrides}
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
}
|
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,8 @@ 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 |
|
|
203
|
+
| WithHiddenFilter | `filterNode` toggle hides dot-files |
|
|
201
204
|
| Densities | three density presets side-by-side |
|
|
202
205
|
| WithIcons | file-type icons through `renderIcon` |
|
|
203
206
|
| WithStatus | modified / error / disabled rows through `renderLabel` |
|
|
@@ -211,6 +214,103 @@ Toggle the bar with `appearance.showActiveIndicator`. Intensity scales with `app
|
|
|
211
214
|
| LargeTree | ~500 nodes scalability check |
|
|
212
215
|
| Playground | every knob exposed as a control |
|
|
213
216
|
|
|
217
|
+
## Filtering nodes
|
|
218
|
+
|
|
219
|
+
`filterNode` is a single predicate that decides which nodes appear at all.
|
|
220
|
+
Nodes returning `false` (and their descendants) are excluded from rendering,
|
|
221
|
+
keyboard navigation, and search.
|
|
222
|
+
|
|
223
|
+
```tsx
|
|
224
|
+
const [showHidden, setShowHidden] = useState(false);
|
|
225
|
+
|
|
226
|
+
<TreeRoot
|
|
227
|
+
data={data}
|
|
228
|
+
getItemName={(n) => n.data.name}
|
|
229
|
+
filterNode={(n) => showHidden || !n.data.name.startsWith('.')}
|
|
230
|
+
/>
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
This is intentionally minimal — Tree is generic over `T` and has no opinion
|
|
234
|
+
on what "hidden" means in your domain. If your backend already provides
|
|
235
|
+
flags like `entry.isHidden` / `entry.isSystem`, use them directly:
|
|
236
|
+
|
|
237
|
+
```tsx
|
|
238
|
+
filterNode={(n) => showHidden || (!n.data.isHidden && !n.data.isSystem)}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
> **Frontend note.** From the browser you cannot read OS-level hidden
|
|
242
|
+
> attributes (Windows `FILE_ATTRIBUTE_HIDDEN`, macOS `kIsInvisible`).
|
|
243
|
+
> Either filter by name (Unix dot-prefix is the de-facto convention), or
|
|
244
|
+
> let your backend determine those flags and forward them in `node.data`.
|
|
245
|
+
|
|
246
|
+
## Activation modes
|
|
247
|
+
|
|
248
|
+
How a leaf becomes "activated" (opened) on pointer interaction is controlled
|
|
249
|
+
by `activationMode`. Folders ignore this setting — they always toggle on
|
|
250
|
+
single click and never call `onActivate`.
|
|
251
|
+
|
|
252
|
+
| Mode | Single click | Double click |
|
|
253
|
+
| --- | --- | --- |
|
|
254
|
+
| `'single-click'` *(default)* | activate `{ preview: false }` | activate `{ preview: false }` |
|
|
255
|
+
| `'double-click'` | select + focus only | activate `{ preview: false }` |
|
|
256
|
+
| `'single-click-preview'` *(VSCode / Cursor)* | activate `{ preview: true }` | activate `{ preview: false }` |
|
|
257
|
+
|
|
258
|
+
Keyboard `Enter` / `Space` always activates with `{ preview: false }` —
|
|
259
|
+
keyboard input is treated as an explicit user action.
|
|
260
|
+
|
|
261
|
+
```tsx
|
|
262
|
+
<TreeRoot<FsNode>
|
|
263
|
+
data={data}
|
|
264
|
+
getItemName={(n) => n.data.name}
|
|
265
|
+
activationMode="single-click-preview"
|
|
266
|
+
onActivate={(node, { preview }) =>
|
|
267
|
+
preview ? openPreviewTab(node) : openPinnedTab(node)
|
|
268
|
+
}
|
|
269
|
+
/>
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
The active mode is also exposed on each row as
|
|
273
|
+
`data-activation-mode="<mode>"` for CSS-level targeting.
|
|
274
|
+
|
|
275
|
+
## VSCode-style file icons
|
|
276
|
+
|
|
277
|
+
Tree is generic over `T` — it has no opinion on whether nodes are files. For a
|
|
278
|
+
ready-made VSCode-style icon set, install the optional companion subpath:
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
pnpm add material-file-icons
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
```tsx
|
|
285
|
+
import { TreeRoot } from '@djangocfg/ui-tools/tree';
|
|
286
|
+
import { createFileIconSlot } from '@djangocfg/ui-tools/file-icon';
|
|
287
|
+
|
|
288
|
+
<TreeRoot
|
|
289
|
+
data={data}
|
|
290
|
+
getItemName={(n) => n.data.name}
|
|
291
|
+
renderIcon={createFileIconSlot({ getName: (n) => n.data.name })}
|
|
292
|
+
/>
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
`material-file-icons` is declared in `optionalDependencies`. If it's not
|
|
296
|
+
installed, `<FileIcon>` falls back to a Lucide `File` icon — no warnings, no
|
|
297
|
+
runtime errors.
|
|
298
|
+
|
|
299
|
+
Folders use a small built-in mapping (`src` → `FolderCode`,
|
|
300
|
+
`node_modules` → `Package`, `.git` → `FolderGit2`, `dist`/`build`/`.next`
|
|
301
|
+
→ `FolderOutput`, `tests`/`__tests__` → `FlaskConical`, …). Anything not
|
|
302
|
+
in the table renders the generic `Folder` / `FolderOpen` pair. Override
|
|
303
|
+
or extend the table per-call:
|
|
304
|
+
|
|
305
|
+
```tsx
|
|
306
|
+
import { FolderHeart } from 'lucide-react';
|
|
307
|
+
|
|
308
|
+
renderIcon={createFileIconSlot({
|
|
309
|
+
getName: (n) => n.data.name,
|
|
310
|
+
folderOverrides: { favorites: FolderHeart },
|
|
311
|
+
})}
|
|
312
|
+
```
|
|
313
|
+
|
|
214
314
|
## Out of scope (today)
|
|
215
315
|
|
|
216
316
|
- Inline rename UX
|
|
@@ -111,6 +111,90 @@ 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
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// 1.7) WithHiddenFilter — filterNode prop hides dot-prefixed entries
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
const fsWithDotfiles: TreeNode<FsNode>[] = [
|
|
167
|
+
...fs,
|
|
168
|
+
{ id: '.env', data: { name: '.env' } },
|
|
169
|
+
{ id: '.gitignore', data: { name: '.gitignore' } },
|
|
170
|
+
{
|
|
171
|
+
id: '.git',
|
|
172
|
+
data: { name: '.git' },
|
|
173
|
+
isFolder: true,
|
|
174
|
+
children: [
|
|
175
|
+
{ id: '.git/HEAD', data: { name: 'HEAD' } },
|
|
176
|
+
{ id: '.git/config', data: { name: 'config' } },
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
export const WithHiddenFilter = () => {
|
|
182
|
+
const [showHidden] = useBoolean('showHidden', {
|
|
183
|
+
defaultValue: false,
|
|
184
|
+
label: 'Show hidden',
|
|
185
|
+
});
|
|
186
|
+
return (
|
|
187
|
+
<div className="h-96 w-80 rounded-md border border-border bg-card">
|
|
188
|
+
<TreeRoot<FsNode>
|
|
189
|
+
data={fsWithDotfiles}
|
|
190
|
+
getItemName={getName}
|
|
191
|
+
initialExpandedIds={['src']}
|
|
192
|
+
filterNode={(n) => showHidden || !n.data.name.startsWith('.')}
|
|
193
|
+
/>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
};
|
|
197
|
+
|
|
114
198
|
// ---------------------------------------------------------------------------
|
|
115
199
|
// 2) Densities — three presets side-by-side for comparison
|
|
116
200
|
// ---------------------------------------------------------------------------
|
|
@@ -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,
|
|
@@ -30,6 +31,7 @@ function TreeRoot<T>(props: TreeRootProps<T>) {
|
|
|
30
31
|
onSelectionChange,
|
|
31
32
|
onExpansionChange,
|
|
32
33
|
onActivate,
|
|
34
|
+
filterNode,
|
|
33
35
|
enableSearch = false,
|
|
34
36
|
enableTypeAhead = true,
|
|
35
37
|
showIndentGuides = false,
|
|
@@ -51,6 +53,7 @@ function TreeRoot<T>(props: TreeRootProps<T>) {
|
|
|
51
53
|
getItemName={getItemName}
|
|
52
54
|
loadChildren={loadChildren}
|
|
53
55
|
selectionMode={selectionMode}
|
|
56
|
+
activationMode={activationMode}
|
|
54
57
|
initialExpandedIds={initialExpandedIds}
|
|
55
58
|
initialSelectedIds={initialSelectedIds}
|
|
56
59
|
indent={indent}
|
|
@@ -58,6 +61,7 @@ function TreeRoot<T>(props: TreeRootProps<T>) {
|
|
|
58
61
|
onSelectionChange={onSelectionChange}
|
|
59
62
|
onExpansionChange={onExpansionChange}
|
|
60
63
|
onActivate={onActivate}
|
|
64
|
+
filterNode={filterNode}
|
|
61
65
|
enableSearch={enableSearch}
|
|
62
66
|
showIndentGuides={showIndentGuides}
|
|
63
67
|
renderIcon={renderIcon}
|
|
@@ -104,8 +108,9 @@ function TreeRootShell<T>({
|
|
|
104
108
|
onFocus: ctx.setFocus,
|
|
105
109
|
onSelect: ctx.select,
|
|
106
110
|
onActivate: (id) => {
|
|
111
|
+
// Keyboard Enter / Space is always an explicit action — pin (no preview).
|
|
107
112
|
const row = ctx.flatRows.find((r) => r.node.id === id);
|
|
108
|
-
if (row) ctx.activate(row.node);
|
|
113
|
+
if (row) ctx.activate(row.node, { preview: false });
|
|
109
114
|
},
|
|
110
115
|
onExpand: ctx.expand,
|
|
111
116
|
onCollapse: ctx.collapse,
|
|
@@ -4,6 +4,7 @@ import { Fragment, type ReactNode } from 'react';
|
|
|
4
4
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
5
5
|
|
|
6
6
|
import { useTreeContext } from '../context/TreeContext';
|
|
7
|
+
import { appearanceToStyle } from '../data/appearance';
|
|
7
8
|
import type { FlatRow, TreeRowRenderProps, TreeRowSlot } from '../types';
|
|
8
9
|
import { TreeRow } from './TreeRow';
|
|
9
10
|
import { TreeEmpty } from './TreeEmpty';
|
|
@@ -17,7 +18,7 @@ export interface TreeContentProps<T> {
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export function TreeContent<T>({ children, className, ariaLabel }: TreeContentProps<T>) {
|
|
20
|
-
const { flatRows, labels, selected, focused, matchingIds } = useTreeContext<T>();
|
|
21
|
+
const { flatRows, labels, selected, focused, matchingIds, appearance } = useTreeContext<T>();
|
|
21
22
|
|
|
22
23
|
if (flatRows.length === 0) {
|
|
23
24
|
return <TreeEmpty>{labels.empty}</TreeEmpty>;
|
|
@@ -28,6 +29,7 @@ export function TreeContent<T>({ children, className, ariaLabel }: TreeContentPr
|
|
|
28
29
|
role="tree"
|
|
29
30
|
aria-label={ariaLabel ?? labels.ariaLabel}
|
|
30
31
|
className={cn('relative flex flex-col py-1', className)}
|
|
32
|
+
style={appearanceToStyle(appearance)}
|
|
31
33
|
>
|
|
32
34
|
{flatRows.map((row: FlatRow<T>) => {
|
|
33
35
|
const slot: TreeRowRenderProps<T> = {
|
|
@@ -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}
|