@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.
Files changed (43) hide show
  1. package/dist/TreeRoot-A3J65L6F.mjs +4 -0
  2. package/dist/{TreeRoot-A25RIGYE.cjs.map → TreeRoot-A3J65L6F.mjs.map} +1 -1
  3. package/dist/TreeRoot-DSK5JILT.cjs +19 -0
  4. package/dist/{TreeRoot-HBRJEHBH.mjs.map → TreeRoot-DSK5JILT.cjs.map} +1 -1
  5. package/dist/{chunk-4CEOJDMB.cjs → chunk-3Z3A7FHA.cjs} +36 -15
  6. package/dist/chunk-3Z3A7FHA.cjs.map +1 -0
  7. package/dist/{chunk-NFIMVYJU.mjs → chunk-MOME6KYD.mjs} +36 -15
  8. package/dist/chunk-MOME6KYD.mjs.map +1 -0
  9. package/dist/file-icon/index.cjs +173 -0
  10. package/dist/file-icon/index.cjs.map +1 -0
  11. package/dist/file-icon/index.d.cts +98 -0
  12. package/dist/file-icon/index.d.ts +98 -0
  13. package/dist/file-icon/index.mjs +167 -0
  14. package/dist/file-icon/index.mjs.map +1 -0
  15. package/dist/index.cjs +122 -122
  16. package/dist/index.d.cts +3 -2
  17. package/dist/index.d.ts +3 -2
  18. package/dist/index.mjs +3 -3
  19. package/dist/tree/index.cjs +33 -33
  20. package/dist/tree/index.d.cts +10 -172
  21. package/dist/tree/index.d.ts +10 -172
  22. package/dist/tree/index.mjs +1 -1
  23. package/dist/types-CevSbyfD.d.cts +204 -0
  24. package/dist/types-CevSbyfD.d.ts +204 -0
  25. package/package.json +13 -7
  26. package/src/tools/FileIcon/FileIcon.tsx +102 -0
  27. package/src/tools/FileIcon/index.ts +15 -0
  28. package/src/tools/FileIcon/loader.ts +47 -0
  29. package/src/tools/FileIcon/specialFolders.ts +93 -0
  30. package/src/tools/FileIcon/treeAdapter.tsx +49 -0
  31. package/src/tools/Tree/README.md +100 -0
  32. package/src/tools/Tree/Tree.story.tsx +84 -0
  33. package/src/tools/Tree/TreeRoot.tsx +6 -1
  34. package/src/tools/Tree/components/TreeContent.tsx +3 -1
  35. package/src/tools/Tree/components/TreeRow.tsx +17 -8
  36. package/src/tools/Tree/context/TreeContext.tsx +14 -3
  37. package/src/tools/Tree/data/flatten.ts +10 -1
  38. package/src/tools/Tree/index.tsx +2 -0
  39. package/src/tools/Tree/types.ts +43 -2
  40. package/dist/TreeRoot-A25RIGYE.cjs +0 -19
  41. package/dist/TreeRoot-HBRJEHBH.mjs +0 -4
  42. package/dist/chunk-4CEOJDMB.cjs.map +0 -1
  43. 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.316",
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.316",
100
- "@djangocfg/ui-core": "^2.1.316",
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.316",
158
+ "@djangocfg/i18n": "^2.1.318",
153
159
  "@djangocfg/playground": "workspace:*",
154
- "@djangocfg/typescript-config": "^2.1.316",
155
- "@djangocfg/ui-core": "^2.1.316",
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
+ }
@@ -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
- const handleActivate = () => {
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) toggle(node.id);
59
- else activate(node);
60
- };
61
-
62
- const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
63
- handleActivate();
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 (!isFolder) activate(node);
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}