@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.
- package/dist/TreeRoot-A25RIGYE.cjs +19 -0
- package/dist/TreeRoot-A25RIGYE.cjs.map +1 -0
- package/dist/TreeRoot-HBRJEHBH.mjs +4 -0
- package/dist/TreeRoot-HBRJEHBH.mjs.map +1 -0
- package/dist/chunk-4CEOJDMB.cjs +1300 -0
- package/dist/chunk-4CEOJDMB.cjs.map +1 -0
- package/dist/chunk-KR6B3LVY.mjs +59 -0
- package/dist/chunk-KR6B3LVY.mjs.map +1 -0
- package/dist/chunk-NFIMVYJU.mjs +1249 -0
- package/dist/chunk-NFIMVYJU.mjs.map +1 -0
- package/dist/chunk-YXBOAGIM.cjs +63 -0
- package/dist/chunk-YXBOAGIM.cjs.map +1 -0
- package/dist/index.cjs +151 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.mjs +11 -2
- package/dist/index.mjs.map +1 -1
- package/dist/tree/index.cjs +152 -0
- package/dist/tree/index.cjs.map +1 -0
- package/dist/tree/index.d.cts +442 -0
- package/dist/tree/index.d.ts +442 -0
- package/dist/tree/index.mjs +5 -0
- package/dist/tree/index.mjs.map +1 -0
- package/package.json +11 -6
- package/src/index.ts +4 -0
- package/src/tools/Tree/README.md +220 -0
- package/src/tools/Tree/Tree.story.tsx +536 -0
- package/src/tools/Tree/TreeRoot.tsx +164 -0
- package/src/tools/Tree/components/TreeChevron.tsx +39 -0
- package/src/tools/Tree/components/TreeContent.tsx +48 -0
- package/src/tools/Tree/components/TreeEmpty.tsx +21 -0
- package/src/tools/Tree/components/TreeError.tsx +24 -0
- package/src/tools/Tree/components/TreeIcon.tsx +29 -0
- package/src/tools/Tree/components/TreeIndentGuides.tsx +33 -0
- package/src/tools/Tree/components/TreeLabel.tsx +24 -0
- package/src/tools/Tree/components/TreeRow.tsx +163 -0
- package/src/tools/Tree/components/TreeSearchInput.tsx +50 -0
- package/src/tools/Tree/components/TreeSkeleton.tsx +22 -0
- package/src/tools/Tree/components/index.ts +22 -0
- package/src/tools/Tree/context/TreeContext.tsx +538 -0
- package/src/tools/Tree/context/hooks.ts +110 -0
- package/src/tools/Tree/context/index.ts +13 -0
- package/src/tools/Tree/data/appearance.ts +175 -0
- package/src/tools/Tree/data/childCache.ts +43 -0
- package/src/tools/Tree/data/createDemoTree.ts +42 -0
- package/src/tools/Tree/data/flatten.ts +51 -0
- package/src/tools/Tree/data/index.ts +24 -0
- package/src/tools/Tree/data/persist.ts +62 -0
- package/src/tools/Tree/hooks/index.ts +6 -0
- package/src/tools/Tree/hooks/useTreeKeyboard.ts +171 -0
- package/src/tools/Tree/hooks/useTreeTypeAhead.ts +100 -0
- package/src/tools/Tree/index.tsx +99 -0
- package/src/tools/Tree/lazy.tsx +14 -0
- package/src/tools/Tree/types.ts +136 -0
- package/src/tools/index.ts +75 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import {
|
|
5
|
+
createContext,
|
|
6
|
+
useCallback,
|
|
7
|
+
useEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useReducer,
|
|
10
|
+
useRef,
|
|
11
|
+
} from 'react';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_TREE_LABELS,
|
|
15
|
+
type FlatRow,
|
|
16
|
+
type TreeContextMenuSlot,
|
|
17
|
+
type TreeItemId,
|
|
18
|
+
type TreeLabels,
|
|
19
|
+
type TreeLoadChildren,
|
|
20
|
+
type TreeNode,
|
|
21
|
+
type TreeRootProps,
|
|
22
|
+
type TreeRowSlot,
|
|
23
|
+
type TreeSelectionMode,
|
|
24
|
+
} from '../types';
|
|
25
|
+
import {
|
|
26
|
+
createChildCache,
|
|
27
|
+
type ChildCache,
|
|
28
|
+
type ChildEntry,
|
|
29
|
+
} from '../data/childCache';
|
|
30
|
+
import { flattenTree } from '../data/flatten';
|
|
31
|
+
import { loadTreeState, saveTreeState } from '../data/persist';
|
|
32
|
+
import {
|
|
33
|
+
resolveAppearance,
|
|
34
|
+
type ResolvedAppearance,
|
|
35
|
+
type TreeAppearance,
|
|
36
|
+
} from '../data/appearance';
|
|
37
|
+
|
|
38
|
+
// =====================================================================
|
|
39
|
+
// Reducer
|
|
40
|
+
// =====================================================================
|
|
41
|
+
|
|
42
|
+
interface State<T> {
|
|
43
|
+
expanded: Set<TreeItemId>;
|
|
44
|
+
selected: Set<TreeItemId>;
|
|
45
|
+
focused: TreeItemId | null;
|
|
46
|
+
query: string;
|
|
47
|
+
/** Bumped on every cache mutation so memos see a fresh dep. */
|
|
48
|
+
cacheTick: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type Action<T> =
|
|
52
|
+
| { type: 'expand'; id: TreeItemId }
|
|
53
|
+
| { type: 'collapse'; id: TreeItemId }
|
|
54
|
+
| { type: 'toggle'; id: TreeItemId }
|
|
55
|
+
| { type: 'set-expanded'; ids: TreeItemId[] }
|
|
56
|
+
| { type: 'select'; id: TreeItemId; mode: TreeSelectionMode }
|
|
57
|
+
| { type: 'select-many'; ids: TreeItemId[] }
|
|
58
|
+
| { type: 'clear-selection' }
|
|
59
|
+
| { type: 'focus'; id: TreeItemId | null }
|
|
60
|
+
| { type: 'set-query'; q: string }
|
|
61
|
+
| { type: 'cache-tick' };
|
|
62
|
+
|
|
63
|
+
const reducer = <T,>(state: State<T>, action: Action<T>): State<T> => {
|
|
64
|
+
switch (action.type) {
|
|
65
|
+
case 'expand': {
|
|
66
|
+
if (state.expanded.has(action.id)) return state;
|
|
67
|
+
const next = new Set(state.expanded);
|
|
68
|
+
next.add(action.id);
|
|
69
|
+
return { ...state, expanded: next };
|
|
70
|
+
}
|
|
71
|
+
case 'collapse': {
|
|
72
|
+
if (!state.expanded.has(action.id)) return state;
|
|
73
|
+
const next = new Set(state.expanded);
|
|
74
|
+
next.delete(action.id);
|
|
75
|
+
return { ...state, expanded: next };
|
|
76
|
+
}
|
|
77
|
+
case 'toggle': {
|
|
78
|
+
const next = new Set(state.expanded);
|
|
79
|
+
if (next.has(action.id)) next.delete(action.id);
|
|
80
|
+
else next.add(action.id);
|
|
81
|
+
return { ...state, expanded: next };
|
|
82
|
+
}
|
|
83
|
+
case 'set-expanded':
|
|
84
|
+
return { ...state, expanded: new Set(action.ids) };
|
|
85
|
+
case 'select': {
|
|
86
|
+
if (action.mode === 'none') return state;
|
|
87
|
+
if (action.mode === 'single') {
|
|
88
|
+
return { ...state, selected: new Set([action.id]), focused: action.id };
|
|
89
|
+
}
|
|
90
|
+
const next = new Set(state.selected);
|
|
91
|
+
if (next.has(action.id)) next.delete(action.id);
|
|
92
|
+
else next.add(action.id);
|
|
93
|
+
return { ...state, selected: next, focused: action.id };
|
|
94
|
+
}
|
|
95
|
+
case 'select-many':
|
|
96
|
+
return { ...state, selected: new Set(action.ids) };
|
|
97
|
+
case 'clear-selection':
|
|
98
|
+
return { ...state, selected: new Set() };
|
|
99
|
+
case 'focus':
|
|
100
|
+
return { ...state, focused: action.id };
|
|
101
|
+
case 'set-query':
|
|
102
|
+
return { ...state, query: action.q };
|
|
103
|
+
case 'cache-tick':
|
|
104
|
+
return { ...state, cacheTick: state.cacheTick + 1 };
|
|
105
|
+
default:
|
|
106
|
+
return state;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// =====================================================================
|
|
111
|
+
// Context value
|
|
112
|
+
// =====================================================================
|
|
113
|
+
|
|
114
|
+
export interface TreeContextValue<T> {
|
|
115
|
+
// State
|
|
116
|
+
expanded: ReadonlySet<TreeItemId>;
|
|
117
|
+
selected: ReadonlySet<TreeItemId>;
|
|
118
|
+
focused: TreeItemId | null;
|
|
119
|
+
query: string;
|
|
120
|
+
|
|
121
|
+
// Flattened render rows (visible items only)
|
|
122
|
+
flatRows: FlatRow<T>[];
|
|
123
|
+
/** Search-matching node ids (subset of all flatRows). */
|
|
124
|
+
matchingIds: ReadonlySet<TreeItemId>;
|
|
125
|
+
|
|
126
|
+
// Imperative actions
|
|
127
|
+
expand: (id: TreeItemId) => void;
|
|
128
|
+
collapse: (id: TreeItemId) => void;
|
|
129
|
+
toggle: (id: TreeItemId) => void;
|
|
130
|
+
expandAll: () => void;
|
|
131
|
+
collapseAll: () => void;
|
|
132
|
+
select: (id: TreeItemId) => void;
|
|
133
|
+
setSelectedIds: (ids: TreeItemId[]) => void;
|
|
134
|
+
clearSelection: () => void;
|
|
135
|
+
setFocus: (id: TreeItemId | null) => void;
|
|
136
|
+
setQuery: (q: string) => void;
|
|
137
|
+
refresh: (id: TreeItemId) => Promise<void>;
|
|
138
|
+
refreshAll: () => Promise<void>;
|
|
139
|
+
activate: (node: TreeNode<T>) => void;
|
|
140
|
+
|
|
141
|
+
// Config / slots
|
|
142
|
+
labels: TreeLabels;
|
|
143
|
+
/** Resolved cosmetic config — never null. */
|
|
144
|
+
appearance: ResolvedAppearance;
|
|
145
|
+
/** Convenience alias for `appearance.indent`. */
|
|
146
|
+
indent: number;
|
|
147
|
+
selectionMode: TreeSelectionMode;
|
|
148
|
+
enableSearch: boolean;
|
|
149
|
+
showIndentGuides: boolean;
|
|
150
|
+
getItemName: (node: TreeNode<T>) => string;
|
|
151
|
+
|
|
152
|
+
renderIcon?: TreeRowSlot<T>;
|
|
153
|
+
renderLabel?: TreeRowSlot<T>;
|
|
154
|
+
renderActions?: TreeRowSlot<T>;
|
|
155
|
+
renderContextMenu?: TreeContextMenuSlot<T>;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const TreeContext = createContext<TreeContextValue<unknown> | null>(null);
|
|
159
|
+
|
|
160
|
+
export function useTreeContext<T>(): TreeContextValue<T> {
|
|
161
|
+
const ctx = React.useContext(TreeContext);
|
|
162
|
+
if (!ctx) {
|
|
163
|
+
throw new Error('useTreeContext must be used inside <TreeProvider>');
|
|
164
|
+
}
|
|
165
|
+
return ctx as TreeContextValue<T>;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// =====================================================================
|
|
169
|
+
// Provider
|
|
170
|
+
// =====================================================================
|
|
171
|
+
|
|
172
|
+
export interface TreeProviderProps<T>
|
|
173
|
+
extends Pick<
|
|
174
|
+
TreeRootProps<T>,
|
|
175
|
+
| 'data'
|
|
176
|
+
| 'getItemName'
|
|
177
|
+
| 'loadChildren'
|
|
178
|
+
| 'selectionMode'
|
|
179
|
+
| 'initialExpandedIds'
|
|
180
|
+
| 'initialSelectedIds'
|
|
181
|
+
| 'indent'
|
|
182
|
+
| 'appearance'
|
|
183
|
+
| 'onSelectionChange'
|
|
184
|
+
| 'onExpansionChange'
|
|
185
|
+
| 'onActivate'
|
|
186
|
+
| 'enableSearch'
|
|
187
|
+
| 'showIndentGuides'
|
|
188
|
+
| 'renderIcon'
|
|
189
|
+
| 'renderLabel'
|
|
190
|
+
| 'renderActions'
|
|
191
|
+
| 'renderContextMenu'
|
|
192
|
+
| 'labels'
|
|
193
|
+
| 'persistKey'
|
|
194
|
+
| 'persistSelection'
|
|
195
|
+
> {
|
|
196
|
+
children: React.ReactNode;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const setEqualsArr = (set: ReadonlySet<string>, arr: readonly string[]) => {
|
|
200
|
+
if (set.size !== arr.length) return false;
|
|
201
|
+
for (const id of arr) if (!set.has(id)) return false;
|
|
202
|
+
return true;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const collectAllIds = <T,>(
|
|
206
|
+
roots: TreeNode<T>[],
|
|
207
|
+
cache: ChildCache<T>,
|
|
208
|
+
out: TreeItemId[],
|
|
209
|
+
) => {
|
|
210
|
+
for (const node of roots) {
|
|
211
|
+
if (Array.isArray(node.children)) {
|
|
212
|
+
out.push(node.id);
|
|
213
|
+
collectAllIds(node.children, cache, out);
|
|
214
|
+
} else if (node.isFolder) {
|
|
215
|
+
out.push(node.id);
|
|
216
|
+
const entry = cache.get(node.id);
|
|
217
|
+
if (entry?.children) collectAllIds(entry.children, cache, out);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
export function TreeProvider<T>(props: TreeProviderProps<T>) {
|
|
223
|
+
const {
|
|
224
|
+
data,
|
|
225
|
+
getItemName,
|
|
226
|
+
loadChildren,
|
|
227
|
+
selectionMode = 'single',
|
|
228
|
+
initialExpandedIds,
|
|
229
|
+
initialSelectedIds,
|
|
230
|
+
indent,
|
|
231
|
+
appearance,
|
|
232
|
+
onSelectionChange,
|
|
233
|
+
onExpansionChange,
|
|
234
|
+
onActivate,
|
|
235
|
+
enableSearch = false,
|
|
236
|
+
showIndentGuides = false,
|
|
237
|
+
renderIcon,
|
|
238
|
+
renderLabel,
|
|
239
|
+
renderActions,
|
|
240
|
+
renderContextMenu,
|
|
241
|
+
labels: labelsOverride,
|
|
242
|
+
persistKey,
|
|
243
|
+
persistSelection = false,
|
|
244
|
+
children,
|
|
245
|
+
} = props;
|
|
246
|
+
|
|
247
|
+
const labels = useMemo(
|
|
248
|
+
() => ({ ...DEFAULT_TREE_LABELS, ...labelsOverride }),
|
|
249
|
+
[labelsOverride],
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const resolvedAppearance = useMemo(
|
|
253
|
+
() => resolveAppearance(appearance, indent),
|
|
254
|
+
[appearance, indent],
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
// Persisted state, loaded once.
|
|
258
|
+
const persisted = useMemo(
|
|
259
|
+
() => (persistKey ? loadTreeState(persistKey) : null),
|
|
260
|
+
[persistKey],
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const [state, dispatch] = useReducer(reducer<T>, undefined, () => ({
|
|
264
|
+
expanded: new Set(persisted?.expandedItems ?? initialExpandedIds ?? []),
|
|
265
|
+
selected: new Set(
|
|
266
|
+
(persistSelection ? persisted?.selectedItems : undefined) ??
|
|
267
|
+
initialSelectedIds ??
|
|
268
|
+
[],
|
|
269
|
+
),
|
|
270
|
+
focused: null,
|
|
271
|
+
query: '',
|
|
272
|
+
cacheTick: 0,
|
|
273
|
+
}));
|
|
274
|
+
|
|
275
|
+
// Async cache survives provider re-renders, lives in a ref.
|
|
276
|
+
const cacheRef = useRef<ChildCache<T>>(createChildCache<T>());
|
|
277
|
+
const inflightRef = useRef<Map<TreeItemId, Promise<void>>>(new Map());
|
|
278
|
+
|
|
279
|
+
// Trigger one fetch per (folder id) — concurrent expansions are deduped.
|
|
280
|
+
const fetchChildren = useCallback(
|
|
281
|
+
async (node: TreeNode<T>) => {
|
|
282
|
+
if (!loadChildren) return;
|
|
283
|
+
if (Array.isArray(node.children)) return;
|
|
284
|
+
const existing = cacheRef.current.get(node.id);
|
|
285
|
+
if (existing?.status === 'loaded' || existing?.status === 'loading') return;
|
|
286
|
+
const inflight = inflightRef.current.get(node.id);
|
|
287
|
+
if (inflight) return inflight;
|
|
288
|
+
|
|
289
|
+
cacheRef.current.set(node.id, { status: 'loading', children: [] });
|
|
290
|
+
dispatch({ type: 'cache-tick' });
|
|
291
|
+
|
|
292
|
+
const promise = (async () => {
|
|
293
|
+
try {
|
|
294
|
+
const children = await loadChildren(node);
|
|
295
|
+
cacheRef.current.set(node.id, { status: 'loaded', children });
|
|
296
|
+
} catch (err) {
|
|
297
|
+
cacheRef.current.set(node.id, {
|
|
298
|
+
status: 'error',
|
|
299
|
+
children: [],
|
|
300
|
+
error: err instanceof Error ? err.message : String(err),
|
|
301
|
+
});
|
|
302
|
+
} finally {
|
|
303
|
+
inflightRef.current.delete(node.id);
|
|
304
|
+
dispatch({ type: 'cache-tick' });
|
|
305
|
+
}
|
|
306
|
+
})();
|
|
307
|
+
|
|
308
|
+
inflightRef.current.set(node.id, promise);
|
|
309
|
+
return promise;
|
|
310
|
+
},
|
|
311
|
+
[loadChildren],
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
// Build a quick id → node map for any visible node (incl. cached children).
|
|
315
|
+
const nodeById = useMemo(() => {
|
|
316
|
+
const map = new Map<TreeItemId, TreeNode<T>>();
|
|
317
|
+
const walk = (nodes: TreeNode<T>[]) => {
|
|
318
|
+
for (const n of nodes) {
|
|
319
|
+
map.set(n.id, n);
|
|
320
|
+
if (Array.isArray(n.children)) walk(n.children);
|
|
321
|
+
else {
|
|
322
|
+
const entry = cacheRef.current.get(n.id);
|
|
323
|
+
if (entry?.children) walk(entry.children);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
walk(data);
|
|
328
|
+
return map;
|
|
329
|
+
}, [data, state.cacheTick]);
|
|
330
|
+
|
|
331
|
+
// On expand, kick async fetch (no-op if already cached or has inline children).
|
|
332
|
+
useEffect(() => {
|
|
333
|
+
if (!loadChildren) return;
|
|
334
|
+
for (const id of state.expanded) {
|
|
335
|
+
const node = nodeById.get(id);
|
|
336
|
+
if (!node) continue;
|
|
337
|
+
void fetchChildren(node);
|
|
338
|
+
}
|
|
339
|
+
}, [loadChildren, state.expanded, state.cacheTick, nodeById, fetchChildren]);
|
|
340
|
+
|
|
341
|
+
// Flatten on relevant changes.
|
|
342
|
+
const flatRows = useMemo(
|
|
343
|
+
() =>
|
|
344
|
+
flattenTree<T>({
|
|
345
|
+
roots: data,
|
|
346
|
+
expandedIds: state.expanded,
|
|
347
|
+
cache: cacheRef.current,
|
|
348
|
+
}),
|
|
349
|
+
[data, state.expanded, state.cacheTick],
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
// Search matches (case-insensitive substring on getItemName).
|
|
353
|
+
const matchingIds = useMemo(() => {
|
|
354
|
+
const set = new Set<TreeItemId>();
|
|
355
|
+
if (!enableSearch || state.query.trim() === '') return set;
|
|
356
|
+
const q = state.query.trim().toLowerCase();
|
|
357
|
+
for (const row of flatRows) {
|
|
358
|
+
if (getItemName(row.node).toLowerCase().includes(q)) {
|
|
359
|
+
set.add(row.node.id);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return set;
|
|
363
|
+
}, [enableSearch, state.query, flatRows, getItemName]);
|
|
364
|
+
|
|
365
|
+
// External callbacks via stable refs.
|
|
366
|
+
const onSelectionChangeRef = useRef(onSelectionChange);
|
|
367
|
+
const onExpansionChangeRef = useRef(onExpansionChange);
|
|
368
|
+
const onActivateRef = useRef(onActivate);
|
|
369
|
+
onSelectionChangeRef.current = onSelectionChange;
|
|
370
|
+
onExpansionChangeRef.current = onExpansionChange;
|
|
371
|
+
onActivateRef.current = onActivate;
|
|
372
|
+
|
|
373
|
+
// Notify on changes + persist.
|
|
374
|
+
const lastSelectedArrRef = useRef<TreeItemId[]>([...state.selected]);
|
|
375
|
+
const lastExpandedArrRef = useRef<TreeItemId[]>([...state.expanded]);
|
|
376
|
+
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
const arr = [...state.expanded];
|
|
379
|
+
if (!setEqualsArr(state.expanded, lastExpandedArrRef.current)) {
|
|
380
|
+
lastExpandedArrRef.current = arr;
|
|
381
|
+
onExpansionChangeRef.current?.(arr);
|
|
382
|
+
if (persistKey) {
|
|
383
|
+
saveTreeState(persistKey, {
|
|
384
|
+
expandedItems: arr,
|
|
385
|
+
selectedItems: persistSelection ? [...state.selected] : [],
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}, [state.expanded, persistKey, persistSelection, state.selected]);
|
|
390
|
+
|
|
391
|
+
useEffect(() => {
|
|
392
|
+
const arr = [...state.selected];
|
|
393
|
+
if (!setEqualsArr(state.selected, lastSelectedArrRef.current)) {
|
|
394
|
+
lastSelectedArrRef.current = arr;
|
|
395
|
+
onSelectionChangeRef.current?.(arr);
|
|
396
|
+
if (persistKey && persistSelection) {
|
|
397
|
+
saveTreeState(persistKey, {
|
|
398
|
+
expandedItems: [...state.expanded],
|
|
399
|
+
selectedItems: arr,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}, [state.selected, persistKey, persistSelection, state.expanded]);
|
|
404
|
+
|
|
405
|
+
// Imperative actions.
|
|
406
|
+
const expand = useCallback((id: TreeItemId) => dispatch({ type: 'expand', id }), []);
|
|
407
|
+
const collapse = useCallback((id: TreeItemId) => dispatch({ type: 'collapse', id }), []);
|
|
408
|
+
const toggle = useCallback((id: TreeItemId) => dispatch({ type: 'toggle', id }), []);
|
|
409
|
+
|
|
410
|
+
const expandAll = useCallback(() => {
|
|
411
|
+
const ids: TreeItemId[] = [];
|
|
412
|
+
collectAllIds(data, cacheRef.current, ids);
|
|
413
|
+
dispatch({ type: 'set-expanded', ids });
|
|
414
|
+
}, [data]);
|
|
415
|
+
|
|
416
|
+
const collapseAll = useCallback(
|
|
417
|
+
() => dispatch({ type: 'set-expanded', ids: [] }),
|
|
418
|
+
[],
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
const select = useCallback(
|
|
422
|
+
(id: TreeItemId) => dispatch({ type: 'select', id, mode: selectionMode }),
|
|
423
|
+
[selectionMode],
|
|
424
|
+
);
|
|
425
|
+
const setSelectedIds = useCallback(
|
|
426
|
+
(ids: TreeItemId[]) => dispatch({ type: 'select-many', ids }),
|
|
427
|
+
[],
|
|
428
|
+
);
|
|
429
|
+
const clearSelection = useCallback(() => dispatch({ type: 'clear-selection' }), []);
|
|
430
|
+
const setFocus = useCallback(
|
|
431
|
+
(id: TreeItemId | null) => dispatch({ type: 'focus', id }),
|
|
432
|
+
[],
|
|
433
|
+
);
|
|
434
|
+
const setQuery = useCallback((q: string) => dispatch({ type: 'set-query', q }), []);
|
|
435
|
+
|
|
436
|
+
const refresh = useCallback(
|
|
437
|
+
async (id: TreeItemId) => {
|
|
438
|
+
const node = nodeById.get(id);
|
|
439
|
+
if (!node || !loadChildren) return;
|
|
440
|
+
cacheRef.current.delete(id);
|
|
441
|
+
dispatch({ type: 'cache-tick' });
|
|
442
|
+
await fetchChildren(node);
|
|
443
|
+
},
|
|
444
|
+
[nodeById, loadChildren, fetchChildren],
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
const refreshAll = useCallback(async () => {
|
|
448
|
+
cacheRef.current.clear();
|
|
449
|
+
dispatch({ type: 'cache-tick' });
|
|
450
|
+
if (!loadChildren) return;
|
|
451
|
+
await Promise.all(
|
|
452
|
+
[...state.expanded].map((id) => {
|
|
453
|
+
const node = nodeById.get(id);
|
|
454
|
+
return node ? fetchChildren(node) : undefined;
|
|
455
|
+
}),
|
|
456
|
+
);
|
|
457
|
+
}, [loadChildren, state.expanded, nodeById, fetchChildren]);
|
|
458
|
+
|
|
459
|
+
const activate = useCallback(
|
|
460
|
+
(node: TreeNode<T>) => onActivateRef.current?.(node),
|
|
461
|
+
[],
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
const value = useMemo<TreeContextValue<T>>(
|
|
465
|
+
() => ({
|
|
466
|
+
expanded: state.expanded,
|
|
467
|
+
selected: state.selected,
|
|
468
|
+
focused: state.focused,
|
|
469
|
+
query: state.query,
|
|
470
|
+
flatRows,
|
|
471
|
+
matchingIds,
|
|
472
|
+
expand,
|
|
473
|
+
collapse,
|
|
474
|
+
toggle,
|
|
475
|
+
expandAll,
|
|
476
|
+
collapseAll,
|
|
477
|
+
select,
|
|
478
|
+
setSelectedIds,
|
|
479
|
+
clearSelection,
|
|
480
|
+
setFocus,
|
|
481
|
+
setQuery,
|
|
482
|
+
refresh,
|
|
483
|
+
refreshAll,
|
|
484
|
+
activate,
|
|
485
|
+
labels,
|
|
486
|
+
appearance: resolvedAppearance,
|
|
487
|
+
indent: resolvedAppearance.indent,
|
|
488
|
+
selectionMode,
|
|
489
|
+
enableSearch,
|
|
490
|
+
showIndentGuides,
|
|
491
|
+
getItemName,
|
|
492
|
+
renderIcon,
|
|
493
|
+
renderLabel,
|
|
494
|
+
renderActions,
|
|
495
|
+
renderContextMenu,
|
|
496
|
+
}),
|
|
497
|
+
[
|
|
498
|
+
state.expanded,
|
|
499
|
+
state.selected,
|
|
500
|
+
state.focused,
|
|
501
|
+
state.query,
|
|
502
|
+
flatRows,
|
|
503
|
+
matchingIds,
|
|
504
|
+
expand,
|
|
505
|
+
collapse,
|
|
506
|
+
toggle,
|
|
507
|
+
expandAll,
|
|
508
|
+
collapseAll,
|
|
509
|
+
select,
|
|
510
|
+
setSelectedIds,
|
|
511
|
+
clearSelection,
|
|
512
|
+
setFocus,
|
|
513
|
+
setQuery,
|
|
514
|
+
refresh,
|
|
515
|
+
refreshAll,
|
|
516
|
+
activate,
|
|
517
|
+
labels,
|
|
518
|
+
resolvedAppearance,
|
|
519
|
+
selectionMode,
|
|
520
|
+
enableSearch,
|
|
521
|
+
showIndentGuides,
|
|
522
|
+
getItemName,
|
|
523
|
+
renderIcon,
|
|
524
|
+
renderLabel,
|
|
525
|
+
renderActions,
|
|
526
|
+
renderContextMenu,
|
|
527
|
+
],
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
return (
|
|
531
|
+
<TreeContext.Provider value={value as TreeContextValue<unknown>}>
|
|
532
|
+
{children}
|
|
533
|
+
</TreeContext.Provider>
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Re-export internal types referenced by hook consumers.
|
|
538
|
+
export type { ChildCache, ChildEntry };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useMemo } from 'react';
|
|
4
|
+
|
|
5
|
+
import type { TreeItemId } from '../types';
|
|
6
|
+
import { useTreeContext } from './TreeContext';
|
|
7
|
+
|
|
8
|
+
export function useTreeLabels() {
|
|
9
|
+
return useTreeContext().labels;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function useTreeRows<T>() {
|
|
13
|
+
return useTreeContext<T>().flatRows;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useTreeSelection<T>() {
|
|
17
|
+
const ctx = useTreeContext<T>();
|
|
18
|
+
const selectedIds = useMemo(() => [...ctx.selected], [ctx.selected]);
|
|
19
|
+
const isSelected = useCallback(
|
|
20
|
+
(id: TreeItemId) => ctx.selected.has(id),
|
|
21
|
+
[ctx.selected],
|
|
22
|
+
);
|
|
23
|
+
return useMemo(
|
|
24
|
+
() => ({
|
|
25
|
+
selectedIds,
|
|
26
|
+
select: ctx.select,
|
|
27
|
+
setSelectedIds: ctx.setSelectedIds,
|
|
28
|
+
clear: ctx.clearSelection,
|
|
29
|
+
isSelected,
|
|
30
|
+
}),
|
|
31
|
+
[selectedIds, ctx.select, ctx.setSelectedIds, ctx.clearSelection, isSelected],
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function useTreeExpansion<T>() {
|
|
36
|
+
const ctx = useTreeContext<T>();
|
|
37
|
+
const expandedIds = useMemo(() => [...ctx.expanded], [ctx.expanded]);
|
|
38
|
+
const isExpanded = useCallback(
|
|
39
|
+
(id: TreeItemId) => ctx.expanded.has(id),
|
|
40
|
+
[ctx.expanded],
|
|
41
|
+
);
|
|
42
|
+
return useMemo(
|
|
43
|
+
() => ({
|
|
44
|
+
expandedIds,
|
|
45
|
+
expand: ctx.expand,
|
|
46
|
+
collapse: ctx.collapse,
|
|
47
|
+
toggle: ctx.toggle,
|
|
48
|
+
expandAll: ctx.expandAll,
|
|
49
|
+
collapseAll: ctx.collapseAll,
|
|
50
|
+
isExpanded,
|
|
51
|
+
}),
|
|
52
|
+
[
|
|
53
|
+
expandedIds,
|
|
54
|
+
ctx.expand,
|
|
55
|
+
ctx.collapse,
|
|
56
|
+
ctx.toggle,
|
|
57
|
+
ctx.expandAll,
|
|
58
|
+
ctx.collapseAll,
|
|
59
|
+
isExpanded,
|
|
60
|
+
],
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function useTreeFocus<T>() {
|
|
65
|
+
const ctx = useTreeContext<T>();
|
|
66
|
+
return useMemo(
|
|
67
|
+
() => ({ focusedId: ctx.focused, setFocus: ctx.setFocus }),
|
|
68
|
+
[ctx.focused, ctx.setFocus],
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function useTreeSearch<T>() {
|
|
73
|
+
const ctx = useTreeContext<T>();
|
|
74
|
+
return useMemo(
|
|
75
|
+
() => ({
|
|
76
|
+
isOpen: ctx.enableSearch,
|
|
77
|
+
query: ctx.query,
|
|
78
|
+
setQuery: ctx.setQuery,
|
|
79
|
+
matchingIds: ctx.matchingIds,
|
|
80
|
+
matchCount: ctx.matchingIds.size,
|
|
81
|
+
}),
|
|
82
|
+
[ctx.enableSearch, ctx.query, ctx.setQuery, ctx.matchingIds],
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function useTreeActions<T>() {
|
|
87
|
+
const ctx = useTreeContext<T>();
|
|
88
|
+
return useMemo(
|
|
89
|
+
() => ({
|
|
90
|
+
expand: ctx.expand,
|
|
91
|
+
collapse: ctx.collapse,
|
|
92
|
+
toggle: ctx.toggle,
|
|
93
|
+
expandAll: ctx.expandAll,
|
|
94
|
+
collapseAll: ctx.collapseAll,
|
|
95
|
+
refresh: ctx.refresh,
|
|
96
|
+
refreshAll: ctx.refreshAll,
|
|
97
|
+
activate: ctx.activate,
|
|
98
|
+
}),
|
|
99
|
+
[
|
|
100
|
+
ctx.expand,
|
|
101
|
+
ctx.collapse,
|
|
102
|
+
ctx.toggle,
|
|
103
|
+
ctx.expandAll,
|
|
104
|
+
ctx.collapseAll,
|
|
105
|
+
ctx.refresh,
|
|
106
|
+
ctx.refreshAll,
|
|
107
|
+
ctx.activate,
|
|
108
|
+
],
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
export { TreeProvider, useTreeContext } from './TreeContext';
|
|
4
|
+
export type { TreeProviderProps, TreeContextValue } from './TreeContext';
|
|
5
|
+
export {
|
|
6
|
+
useTreeLabels,
|
|
7
|
+
useTreeRows,
|
|
8
|
+
useTreeSelection,
|
|
9
|
+
useTreeExpansion,
|
|
10
|
+
useTreeFocus,
|
|
11
|
+
useTreeSearch,
|
|
12
|
+
useTreeActions,
|
|
13
|
+
} from './hooks';
|