@djangocfg/ui-tools 2.1.314 → 2.1.316

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 (56) hide show
  1. package/dist/TreeRoot-A25RIGYE.cjs +19 -0
  2. package/dist/TreeRoot-A25RIGYE.cjs.map +1 -0
  3. package/dist/TreeRoot-HBRJEHBH.mjs +4 -0
  4. package/dist/TreeRoot-HBRJEHBH.mjs.map +1 -0
  5. package/dist/chunk-4CEOJDMB.cjs +1300 -0
  6. package/dist/chunk-4CEOJDMB.cjs.map +1 -0
  7. package/dist/chunk-KR6B3LVY.mjs +59 -0
  8. package/dist/chunk-KR6B3LVY.mjs.map +1 -0
  9. package/dist/chunk-NFIMVYJU.mjs +1249 -0
  10. package/dist/chunk-NFIMVYJU.mjs.map +1 -0
  11. package/dist/chunk-YXBOAGIM.cjs +63 -0
  12. package/dist/chunk-YXBOAGIM.cjs.map +1 -0
  13. package/dist/index.cjs +151 -5
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +5 -1
  16. package/dist/index.d.ts +5 -1
  17. package/dist/index.mjs +11 -2
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/tree/index.cjs +152 -0
  20. package/dist/tree/index.cjs.map +1 -0
  21. package/dist/tree/index.d.cts +442 -0
  22. package/dist/tree/index.d.ts +442 -0
  23. package/dist/tree/index.mjs +5 -0
  24. package/dist/tree/index.mjs.map +1 -0
  25. package/package.json +11 -6
  26. package/src/index.ts +4 -0
  27. package/src/tools/Tree/README.md +220 -0
  28. package/src/tools/Tree/Tree.story.tsx +536 -0
  29. package/src/tools/Tree/TreeRoot.tsx +164 -0
  30. package/src/tools/Tree/components/TreeChevron.tsx +39 -0
  31. package/src/tools/Tree/components/TreeContent.tsx +48 -0
  32. package/src/tools/Tree/components/TreeEmpty.tsx +21 -0
  33. package/src/tools/Tree/components/TreeError.tsx +24 -0
  34. package/src/tools/Tree/components/TreeIcon.tsx +29 -0
  35. package/src/tools/Tree/components/TreeIndentGuides.tsx +33 -0
  36. package/src/tools/Tree/components/TreeLabel.tsx +24 -0
  37. package/src/tools/Tree/components/TreeRow.tsx +163 -0
  38. package/src/tools/Tree/components/TreeSearchInput.tsx +50 -0
  39. package/src/tools/Tree/components/TreeSkeleton.tsx +22 -0
  40. package/src/tools/Tree/components/index.ts +22 -0
  41. package/src/tools/Tree/context/TreeContext.tsx +538 -0
  42. package/src/tools/Tree/context/hooks.ts +110 -0
  43. package/src/tools/Tree/context/index.ts +13 -0
  44. package/src/tools/Tree/data/appearance.ts +175 -0
  45. package/src/tools/Tree/data/childCache.ts +43 -0
  46. package/src/tools/Tree/data/createDemoTree.ts +42 -0
  47. package/src/tools/Tree/data/flatten.ts +51 -0
  48. package/src/tools/Tree/data/index.ts +24 -0
  49. package/src/tools/Tree/data/persist.ts +62 -0
  50. package/src/tools/Tree/hooks/index.ts +6 -0
  51. package/src/tools/Tree/hooks/useTreeKeyboard.ts +171 -0
  52. package/src/tools/Tree/hooks/useTreeTypeAhead.ts +100 -0
  53. package/src/tools/Tree/index.tsx +99 -0
  54. package/src/tools/Tree/lazy.tsx +14 -0
  55. package/src/tools/Tree/types.ts +136 -0
  56. package/src/tools/index.ts +75 -0
@@ -0,0 +1,175 @@
1
+ 'use client';
2
+
3
+ import type { CSSProperties } from 'react';
4
+
5
+ export type TreeDensity = 'compact' | 'cozy' | 'comfortable';
6
+ export type TreeAccentIntensity = 'subtle' | 'default' | 'strong';
7
+ export type TreeRadius = 'none' | 'sm' | 'md';
8
+
9
+ /**
10
+ * Cosmetic configuration. Every field is optional; missing values fall
11
+ * back to the `cozy` preset (a comfortable VSCode-Explorer-like density).
12
+ *
13
+ * Customize the look without re-implementing slots.
14
+ */
15
+ export interface TreeAppearance {
16
+ /** Built-in size preset. Default: `'cozy'`. */
17
+ density?: TreeDensity;
18
+ /** Override row height in px (wins over density). */
19
+ rowHeight?: number;
20
+ /** Override icon + chevron size in px (wins over density). */
21
+ iconSize?: number;
22
+ /** Lucide stroke width for icon + chevron. Default: 1.5. */
23
+ iconStrokeWidth?: number;
24
+ /** Override label font size in px (wins over density). */
25
+ fontSize?: number;
26
+ /** Pixels between chevron / icon / label. Default depends on density. */
27
+ gap?: number;
28
+ /** Pixels between nesting levels. Default: 16. */
29
+ indent?: number;
30
+ /** Hover / selected highlight intensity. Default: `'default'`. */
31
+ accent?: TreeAccentIntensity;
32
+ /** Row corner radius. Default: `'sm'`. */
33
+ radius?: TreeRadius;
34
+ /** Indent-guide line opacity (0..1). Default: 0.4. */
35
+ indentGuideOpacity?: number;
36
+ /**
37
+ * Show a 2px primary-tinted bar on the left of the selected row.
38
+ * Mimics the VSCode active-tab indicator. Default: `true`.
39
+ */
40
+ showActiveIndicator?: boolean;
41
+ }
42
+
43
+ export interface ResolvedAppearance {
44
+ density: TreeDensity;
45
+ rowHeight: number;
46
+ iconSize: number;
47
+ iconStrokeWidth: number;
48
+ fontSize: number;
49
+ gap: number;
50
+ indent: number;
51
+ accent: TreeAccentIntensity;
52
+ radius: TreeRadius;
53
+ indentGuideOpacity: number;
54
+ showActiveIndicator: boolean;
55
+ }
56
+
57
+ const DENSITY_PRESETS: Record<
58
+ TreeDensity,
59
+ Pick<ResolvedAppearance, 'rowHeight' | 'iconSize' | 'fontSize' | 'gap'>
60
+ > = {
61
+ compact: { rowHeight: 24, iconSize: 14, fontSize: 13, gap: 6 },
62
+ cozy: { rowHeight: 28, iconSize: 16, fontSize: 13, gap: 8 },
63
+ comfortable: { rowHeight: 32, iconSize: 16, fontSize: 14, gap: 8 },
64
+ };
65
+
66
+ export const DEFAULT_TREE_APPEARANCE: ResolvedAppearance = {
67
+ density: 'cozy',
68
+ ...DENSITY_PRESETS.cozy,
69
+ iconStrokeWidth: 1.5,
70
+ indent: 16,
71
+ accent: 'default',
72
+ radius: 'sm',
73
+ indentGuideOpacity: 0.4,
74
+ showActiveIndicator: true,
75
+ };
76
+
77
+ /**
78
+ * Merge a partial appearance with the default + density preset.
79
+ *
80
+ * Explicit numeric overrides (e.g. `rowHeight`) win over the density preset.
81
+ */
82
+ export function resolveAppearance(
83
+ input?: TreeAppearance,
84
+ /** Outer `indent` prop (kept on TreeRoot for back-compat). */
85
+ outerIndent?: number,
86
+ ): ResolvedAppearance {
87
+ if (!input && outerIndent === undefined) return DEFAULT_TREE_APPEARANCE;
88
+
89
+ const density: TreeDensity = input?.density ?? 'cozy';
90
+ const preset = DENSITY_PRESETS[density];
91
+
92
+ return {
93
+ density,
94
+ rowHeight: input?.rowHeight ?? preset.rowHeight,
95
+ iconSize: input?.iconSize ?? preset.iconSize,
96
+ iconStrokeWidth: input?.iconStrokeWidth ?? DEFAULT_TREE_APPEARANCE.iconStrokeWidth,
97
+ fontSize: input?.fontSize ?? preset.fontSize,
98
+ gap: input?.gap ?? preset.gap,
99
+ indent: input?.indent ?? outerIndent ?? DEFAULT_TREE_APPEARANCE.indent,
100
+ accent: input?.accent ?? DEFAULT_TREE_APPEARANCE.accent,
101
+ radius: input?.radius ?? DEFAULT_TREE_APPEARANCE.radius,
102
+ indentGuideOpacity:
103
+ input?.indentGuideOpacity ?? DEFAULT_TREE_APPEARANCE.indentGuideOpacity,
104
+ showActiveIndicator:
105
+ input?.showActiveIndicator ?? DEFAULT_TREE_APPEARANCE.showActiveIndicator,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Build the `style` object that exposes the resolved appearance to any
111
+ * descendant via CSS variables. Set on `<TreeRoot>`'s outer div.
112
+ */
113
+ export function appearanceToStyle(a: ResolvedAppearance): CSSProperties {
114
+ return {
115
+ ['--tree-row-height' as string]: `${a.rowHeight}px`,
116
+ ['--tree-icon-size' as string]: `${a.iconSize}px`,
117
+ ['--tree-icon-stroke' as string]: a.iconStrokeWidth,
118
+ ['--tree-font-size' as string]: `${a.fontSize}px`,
119
+ ['--tree-gap' as string]: `${a.gap}px`,
120
+ ['--tree-indent' as string]: `${a.indent}px`,
121
+ ['--tree-guide-opacity' as string]: a.indentGuideOpacity,
122
+ };
123
+ }
124
+
125
+ const RADIUS_CLASS: Record<TreeRadius, string> = {
126
+ none: 'rounded-none',
127
+ sm: 'rounded-sm',
128
+ md: 'rounded-md',
129
+ };
130
+ export function radiusClass(a: ResolvedAppearance) {
131
+ return RADIUS_CLASS[a.radius];
132
+ }
133
+
134
+ /**
135
+ * VSCode-style row state classes.
136
+ *
137
+ * - hover: subtle neutral wash
138
+ * - focused (keyboard nav, not yet selected): slightly stronger neutral
139
+ * - selected (tree NOT focused): muted neutral block
140
+ * - selected + tree focused-within: primary-tinted block + colored text
141
+ *
142
+ * Intensity scales with `appearance.accent`.
143
+ */
144
+ const HOVER: Record<TreeAccentIntensity, string> = {
145
+ subtle: 'hover:bg-foreground/[.03]',
146
+ default: 'hover:bg-foreground/[.06]',
147
+ strong: 'hover:bg-foreground/[.09]',
148
+ };
149
+ const FOCUSED_NOT_SELECTED: Record<TreeAccentIntensity, string> = {
150
+ subtle: 'data-[focused=true]:bg-foreground/[.05]',
151
+ default: 'data-[focused=true]:bg-foreground/[.08]',
152
+ strong: 'data-[focused=true]:bg-foreground/[.12]',
153
+ };
154
+ const SELECTED_INACTIVE: Record<TreeAccentIntensity, string> = {
155
+ subtle: 'data-[selected=true]:bg-foreground/[.06]',
156
+ default: 'data-[selected=true]:bg-foreground/[.10]',
157
+ strong: 'data-[selected=true]:bg-foreground/[.14]',
158
+ };
159
+ const SELECTED_ACTIVE: Record<TreeAccentIntensity, string> = {
160
+ subtle:
161
+ 'data-[selected=true]:group-focus-within/tree:bg-primary/10 data-[selected=true]:group-focus-within/tree:text-primary',
162
+ default:
163
+ 'data-[selected=true]:group-focus-within/tree:bg-primary/15 data-[selected=true]:group-focus-within/tree:text-primary',
164
+ strong:
165
+ 'data-[selected=true]:group-focus-within/tree:bg-primary/25 data-[selected=true]:group-focus-within/tree:text-primary',
166
+ };
167
+
168
+ export function rowStateClasses(a: ResolvedAppearance) {
169
+ return [
170
+ HOVER[a.accent],
171
+ FOCUSED_NOT_SELECTED[a.accent],
172
+ SELECTED_INACTIVE[a.accent],
173
+ SELECTED_ACTIVE[a.accent],
174
+ ].join(' ');
175
+ }
@@ -0,0 +1,43 @@
1
+ 'use client';
2
+
3
+ import type { TreeItemId, TreeNode } from '../types';
4
+
5
+ export type ChildEntryStatus = 'idle' | 'loading' | 'loaded' | 'error';
6
+
7
+ export interface ChildEntry<T> {
8
+ status: ChildEntryStatus;
9
+ children: TreeNode<T>[];
10
+ error?: string;
11
+ }
12
+
13
+ export type ChildCache<T> = Map<TreeItemId, ChildEntry<T>>;
14
+
15
+ export const createChildCache = <T>(): ChildCache<T> => new Map();
16
+
17
+ export const getEntry = <T>(
18
+ cache: ChildCache<T>,
19
+ id: TreeItemId,
20
+ ): ChildEntry<T> | undefined => cache.get(id);
21
+
22
+ /**
23
+ * Resolve a node's children for the current render.
24
+ *
25
+ * - If the node carries inline `children`, those win (no async fetch).
26
+ * - Otherwise we look in the cache.
27
+ *
28
+ * Returns `null` when nothing is loaded yet (caller may show a skeleton).
29
+ */
30
+ export const resolveChildren = <T>(
31
+ cache: ChildCache<T>,
32
+ node: TreeNode<T>,
33
+ ): { children: TreeNode<T>[] | null; status: ChildEntryStatus; error?: string } => {
34
+ if (Array.isArray(node.children)) {
35
+ return { children: node.children, status: 'loaded' };
36
+ }
37
+ const entry = cache.get(node.id);
38
+ if (!entry) return { children: null, status: 'idle' };
39
+ if (entry.status === 'loaded') {
40
+ return { children: entry.children, status: 'loaded' };
41
+ }
42
+ return { children: null, status: entry.status, error: entry.error };
43
+ };
@@ -0,0 +1,42 @@
1
+ 'use client';
2
+
3
+ import type { TreeNode } from '../types';
4
+
5
+ export interface DemoNode {
6
+ name: string;
7
+ }
8
+
9
+ /**
10
+ * Build a deterministic synthetic tree for stories and tests.
11
+ *
12
+ * @example
13
+ * const data = createDemoTree({ depth: 4, breadth: 3 });
14
+ * <TreeRoot data={data} getItemName={(n) => n.data.name} />
15
+ */
16
+ export function createDemoTree({
17
+ depth = 3,
18
+ breadth = 4,
19
+ rootPrefix = 'node',
20
+ }: {
21
+ depth?: number;
22
+ breadth?: number;
23
+ rootPrefix?: string;
24
+ } = {}): TreeNode<DemoNode>[] {
25
+ const make = (id: string, name: string, level: number): TreeNode<DemoNode> => {
26
+ if (level >= depth) {
27
+ return { id, data: { name } };
28
+ }
29
+ const children: TreeNode<DemoNode>[] = [];
30
+ for (let i = 0; i < breadth; i++) {
31
+ const childId = `${id}/${i}`;
32
+ children.push(make(childId, `${name}-${i}`, level + 1));
33
+ }
34
+ return { id, data: { name }, children };
35
+ };
36
+
37
+ const roots: TreeNode<DemoNode>[] = [];
38
+ for (let i = 0; i < breadth; i++) {
39
+ roots.push(make(`${rootPrefix}-${i}`, `${rootPrefix} ${i}`, 1));
40
+ }
41
+ return roots;
42
+ }
@@ -0,0 +1,51 @@
1
+ 'use client';
2
+
3
+ import type { FlatRow, TreeItemId, TreeNode } from '../types';
4
+ import { resolveChildren, type ChildCache } from './childCache';
5
+
6
+ export interface FlattenInput<T> {
7
+ roots: TreeNode<T>[];
8
+ expandedIds: ReadonlySet<TreeItemId>;
9
+ cache: ChildCache<T>;
10
+ }
11
+
12
+ const isNodeFolder = <T>(node: TreeNode<T>): boolean => {
13
+ if (typeof node.isFolder === 'boolean') return node.isFolder;
14
+ return Array.isArray(node.children);
15
+ };
16
+
17
+ /**
18
+ * Walk the tree top-to-bottom and produce a flat list of visible rows.
19
+ *
20
+ * Visibility rule: a child row appears only when every ancestor is in
21
+ * `expandedIds`. The output is ordered exactly as it should render,
22
+ * which keeps keyboard navigation (next/prev row) trivial.
23
+ */
24
+ export function flattenTree<T>({ roots, expandedIds, cache }: FlattenInput<T>): FlatRow<T>[] {
25
+ const out: FlatRow<T>[] = [];
26
+
27
+ const walk = (nodes: TreeNode<T>[], level: number, parentId: TreeItemId | null) => {
28
+ for (const node of nodes) {
29
+ const isFolder = isNodeFolder(node);
30
+ const isExpanded = expandedIds.has(node.id);
31
+ const resolved = isFolder ? resolveChildren(cache, node) : { children: [], status: 'loaded' as const };
32
+
33
+ out.push({
34
+ node,
35
+ level,
36
+ parentId,
37
+ isFolder,
38
+ isExpanded,
39
+ isLoading: resolved.status === 'loading',
40
+ hasError: resolved.status === 'error',
41
+ });
42
+
43
+ if (isFolder && isExpanded && resolved.children) {
44
+ walk(resolved.children, level + 1, node.id);
45
+ }
46
+ }
47
+ };
48
+
49
+ walk(roots, 0, null);
50
+ return out;
51
+ }
@@ -0,0 +1,24 @@
1
+ 'use client';
2
+
3
+ export { createChildCache, getEntry, resolveChildren } from './childCache';
4
+ export type { ChildCache, ChildEntry, ChildEntryStatus } from './childCache';
5
+ export { flattenTree } from './flatten';
6
+ export type { FlattenInput } from './flatten';
7
+ export { loadTreeState, saveTreeState, clearTreeState } from './persist';
8
+ export type { PersistedTreeState } from './persist';
9
+ export { createDemoTree } from './createDemoTree';
10
+ export type { DemoNode } from './createDemoTree';
11
+ export {
12
+ DEFAULT_TREE_APPEARANCE,
13
+ resolveAppearance,
14
+ appearanceToStyle,
15
+ radiusClass,
16
+ rowStateClasses,
17
+ } from './appearance';
18
+ export type {
19
+ TreeAppearance,
20
+ ResolvedAppearance,
21
+ TreeDensity,
22
+ TreeAccentIntensity,
23
+ TreeRadius,
24
+ } from './appearance';
@@ -0,0 +1,62 @@
1
+ 'use client';
2
+
3
+ import type { TreeItemId } from '../types';
4
+
5
+ const KEY_PREFIX = '@djangocfg/tree:';
6
+ const VERSION = 1;
7
+
8
+ export interface PersistedTreeState {
9
+ expandedItems: TreeItemId[];
10
+ selectedItems: TreeItemId[];
11
+ }
12
+
13
+ interface PersistedShape extends PersistedTreeState {
14
+ version: number;
15
+ }
16
+
17
+ function safeStorage(): Storage | null {
18
+ if (typeof window === 'undefined') return null;
19
+ try {
20
+ return window.localStorage;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ export function loadTreeState(key: string): PersistedTreeState | null {
27
+ const storage = safeStorage();
28
+ if (!storage) return null;
29
+ try {
30
+ const raw = storage.getItem(KEY_PREFIX + key);
31
+ if (!raw) return null;
32
+ const parsed = JSON.parse(raw) as Partial<PersistedShape>;
33
+ if (parsed.version !== VERSION) return null;
34
+ return {
35
+ expandedItems: Array.isArray(parsed.expandedItems) ? parsed.expandedItems : [],
36
+ selectedItems: Array.isArray(parsed.selectedItems) ? parsed.selectedItems : [],
37
+ };
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ export function saveTreeState(key: string, state: PersistedTreeState): void {
44
+ const storage = safeStorage();
45
+ if (!storage) return;
46
+ try {
47
+ const payload: PersistedShape = { ...state, version: VERSION };
48
+ storage.setItem(KEY_PREFIX + key, JSON.stringify(payload));
49
+ } catch {
50
+ /* quota / serialization — best-effort persistence */
51
+ }
52
+ }
53
+
54
+ export function clearTreeState(key: string): void {
55
+ const storage = safeStorage();
56
+ if (!storage) return;
57
+ try {
58
+ storage.removeItem(KEY_PREFIX + key);
59
+ } catch {
60
+ /* ignore */
61
+ }
62
+ }
@@ -0,0 +1,6 @@
1
+ 'use client';
2
+
3
+ export { useTreeTypeAhead } from './useTreeTypeAhead';
4
+ export type { UseTreeTypeAheadOptions } from './useTreeTypeAhead';
5
+ export { useTreeKeyboard } from './useTreeKeyboard';
6
+ export type { UseTreeKeyboardOptions } from './useTreeKeyboard';
@@ -0,0 +1,171 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useRef } from 'react';
4
+ import { useHotkey } from '@djangocfg/ui-core/hooks';
5
+
6
+ import type { FlatRow, TreeItemId } from '../types';
7
+
8
+ export interface UseTreeKeyboardOptions<T> {
9
+ rows: FlatRow<T>[];
10
+ focusedId: TreeItemId | null;
11
+ enabled?: boolean;
12
+ onFocus: (id: TreeItemId) => void;
13
+ onSelect: (id: TreeItemId) => void;
14
+ onActivate: (id: TreeItemId) => void;
15
+ onExpand: (id: TreeItemId) => void;
16
+ onCollapse: (id: TreeItemId) => void;
17
+ onClearSelection: () => void;
18
+ }
19
+
20
+ export interface UseTreeKeyboardReturn {
21
+ /** Attach to the tree container. Hotkeys only fire when focus is inside. */
22
+ ref: (instance: HTMLElement | null) => void;
23
+ }
24
+
25
+ /**
26
+ * Standard tree keyboard navigation, scoped to the container ref.
27
+ *
28
+ * - ↑ / ↓ : prev / next visible row
29
+ * - Home / End : first / last visible row
30
+ * - → : expand folder; if already expanded, jump to first child
31
+ * - ← : collapse folder; if already collapsed (or leaf), jump to parent
32
+ * - Enter / Space : activate (folder => toggle, leaf => onActivate)
33
+ * - Esc : clear selection
34
+ *
35
+ * Built on `useHotkey` (react-hotkeys-hook) — focus gating is automatic; no
36
+ * manual `addEventListener` or `data-scope` juggling.
37
+ */
38
+ export function useTreeKeyboard<T>({
39
+ rows,
40
+ focusedId,
41
+ enabled = true,
42
+ onFocus,
43
+ onSelect,
44
+ onActivate,
45
+ onExpand,
46
+ onCollapse,
47
+ onClearSelection,
48
+ }: UseTreeKeyboardOptions<T>): UseTreeKeyboardReturn {
49
+ // Keep latest values in refs so the callbacks below stay stable across
50
+ // renders — react-hotkeys-hook re-binds on dep change otherwise.
51
+ const rowsRef = useRef(rows);
52
+ const focusedIdRef = useRef(focusedId);
53
+ rowsRef.current = rows;
54
+ focusedIdRef.current = focusedId;
55
+
56
+ const getCurrent = () => {
57
+ const r = rowsRef.current;
58
+ const id = focusedIdRef.current;
59
+ const idx = id ? r.findIndex((x) => x.node.id === id) : -1;
60
+ return { rows: r, idx, current: idx >= 0 ? r[idx] : null };
61
+ };
62
+
63
+ const refDown = useHotkey(
64
+ 'down',
65
+ () => {
66
+ const { rows: r, idx } = getCurrent();
67
+ if (r.length === 0) return;
68
+ const next = r[Math.min(idx + 1, r.length - 1)] ?? r[0];
69
+ onFocus(next.node.id);
70
+ },
71
+ { enabled, preventDefault: true, description: 'Next row' },
72
+ );
73
+
74
+ const refUp = useHotkey(
75
+ 'up',
76
+ () => {
77
+ const { rows: r, idx } = getCurrent();
78
+ if (r.length === 0) return;
79
+ const prev = r[Math.max(idx - 1, 0)] ?? r[0];
80
+ onFocus(prev.node.id);
81
+ },
82
+ { enabled, preventDefault: true, description: 'Previous row' },
83
+ );
84
+
85
+ const refHome = useHotkey(
86
+ 'home',
87
+ () => {
88
+ const { rows: r } = getCurrent();
89
+ if (r.length === 0) return;
90
+ onFocus(r[0].node.id);
91
+ },
92
+ { enabled, preventDefault: true, description: 'First row' },
93
+ );
94
+
95
+ const refEnd = useHotkey(
96
+ 'end',
97
+ () => {
98
+ const { rows: r } = getCurrent();
99
+ if (r.length === 0) return;
100
+ onFocus(r[r.length - 1].node.id);
101
+ },
102
+ { enabled, preventDefault: true, description: 'Last row' },
103
+ );
104
+
105
+ const refRight = useHotkey(
106
+ 'right',
107
+ () => {
108
+ const { rows: r, idx, current } = getCurrent();
109
+ if (!current) return;
110
+ if (current.isFolder && !current.isExpanded) {
111
+ onExpand(current.node.id);
112
+ } else if (current.isFolder && current.isExpanded) {
113
+ const next = r[idx + 1];
114
+ if (next) onFocus(next.node.id);
115
+ }
116
+ },
117
+ { enabled, preventDefault: true, description: 'Expand / first child' },
118
+ );
119
+
120
+ const refLeft = useHotkey(
121
+ 'left',
122
+ () => {
123
+ const { current } = getCurrent();
124
+ if (!current) return;
125
+ if (current.isFolder && current.isExpanded) {
126
+ onCollapse(current.node.id);
127
+ } else if (current.parentId) {
128
+ onFocus(current.parentId);
129
+ }
130
+ },
131
+ { enabled, preventDefault: true, description: 'Collapse / parent' },
132
+ );
133
+
134
+ const refActivate = useHotkey(
135
+ ['enter', 'space'],
136
+ () => {
137
+ const { current } = getCurrent();
138
+ if (!current) return;
139
+ onSelect(current.node.id);
140
+ if (current.isFolder) {
141
+ if (current.isExpanded) onCollapse(current.node.id);
142
+ else onExpand(current.node.id);
143
+ } else {
144
+ onActivate(current.node.id);
145
+ }
146
+ },
147
+ { enabled, preventDefault: true, description: 'Activate / toggle' },
148
+ );
149
+
150
+ const refEscape = useHotkey(
151
+ 'escape',
152
+ () => onClearSelection(),
153
+ { enabled, preventDefault: true, description: 'Clear selection' },
154
+ );
155
+
156
+ const ref = useCallback(
157
+ (instance: HTMLElement | null) => {
158
+ refDown(instance);
159
+ refUp(instance);
160
+ refHome(instance);
161
+ refEnd(instance);
162
+ refRight(instance);
163
+ refLeft(instance);
164
+ refActivate(instance);
165
+ refEscape(instance);
166
+ },
167
+ [refDown, refUp, refHome, refEnd, refRight, refLeft, refActivate, refEscape],
168
+ );
169
+
170
+ return { ref };
171
+ }
@@ -0,0 +1,100 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+
5
+ import type { FlatRow, TreeNode } from '../types';
6
+
7
+ const FLUSH_MS = 600;
8
+
9
+ export interface UseTreeTypeAheadOptions<T> {
10
+ /** Visible flat rows in render order. */
11
+ rows: FlatRow<T>[];
12
+ /** How to read the displayed name of a node (matched case-insensitively). */
13
+ getItemName: (node: TreeNode<T>) => string;
14
+ /** Element receiving keydown events. */
15
+ containerRef: React.RefObject<HTMLElement | null>;
16
+ /** Called with the matched node id so the consumer can focus / scroll. */
17
+ onMatch: (id: string) => void;
18
+ /** Disable without removing the call site. */
19
+ enabled?: boolean;
20
+ }
21
+
22
+ /**
23
+ * Type-ahead jump (Finder / VSCode style).
24
+ *
25
+ * Builds a rolling buffer of printable keys (within ~600 ms of each other)
26
+ * and notifies the consumer of the first row whose name starts with that
27
+ * prefix. Resets on Esc / Enter / navigation keys / timeout.
28
+ */
29
+ export function useTreeTypeAhead<T>({
30
+ rows,
31
+ getItemName,
32
+ containerRef,
33
+ onMatch,
34
+ enabled = true,
35
+ }: UseTreeTypeAheadOptions<T>) {
36
+ const bufferRef = useRef('');
37
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
38
+ const rowsRef = useRef(rows);
39
+ const getNameRef = useRef(getItemName);
40
+ const onMatchRef = useRef(onMatch);
41
+ rowsRef.current = rows;
42
+ getNameRef.current = getItemName;
43
+ onMatchRef.current = onMatch;
44
+
45
+ useEffect(() => {
46
+ if (!enabled) return;
47
+ const target = containerRef.current;
48
+ if (!target) return;
49
+
50
+ const reset = () => {
51
+ bufferRef.current = '';
52
+ if (timerRef.current) {
53
+ clearTimeout(timerRef.current);
54
+ timerRef.current = null;
55
+ }
56
+ };
57
+
58
+ const handler = (e: KeyboardEvent) => {
59
+ const tag = (e.target as HTMLElement | null)?.tagName;
60
+ if (tag === 'INPUT' || tag === 'TEXTAREA') return;
61
+ if ((e.target as HTMLElement | null)?.isContentEditable) return;
62
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
63
+
64
+ if (
65
+ e.key === 'Escape' ||
66
+ e.key === 'Enter' ||
67
+ e.key === 'Tab' ||
68
+ e.key.startsWith('Arrow') ||
69
+ e.key === 'Home' ||
70
+ e.key === 'End' ||
71
+ e.key === 'PageUp' ||
72
+ e.key === 'PageDown'
73
+ ) {
74
+ reset();
75
+ return;
76
+ }
77
+
78
+ if (e.key.length !== 1) return;
79
+
80
+ bufferRef.current += e.key.toLowerCase();
81
+ if (timerRef.current) clearTimeout(timerRef.current);
82
+ timerRef.current = setTimeout(reset, FLUSH_MS);
83
+
84
+ const prefix = bufferRef.current;
85
+ const hit = rowsRef.current.find((row) =>
86
+ getNameRef.current(row.node).toLowerCase().startsWith(prefix),
87
+ );
88
+ if (hit) {
89
+ e.preventDefault();
90
+ onMatchRef.current(hit.node.id);
91
+ }
92
+ };
93
+
94
+ target.addEventListener('keydown', handler);
95
+ return () => {
96
+ target.removeEventListener('keydown', handler);
97
+ reset();
98
+ };
99
+ }, [containerRef, enabled]);
100
+ }