@djangocfg/widget-data 0.1.1

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 (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +55 -0
  3. package/package.json +122 -0
  4. package/src/DataGrid/README.md +48 -0
  5. package/src/DataGrid/context/DataGridContext.tsx +442 -0
  6. package/src/DataGrid/context/index.ts +4 -0
  7. package/src/DataGrid/index.ts +34 -0
  8. package/src/DataGrid/lazy.tsx +23 -0
  9. package/src/DataGrid/parts/DataGrid.tsx +125 -0
  10. package/src/DataGrid/parts/DataGridCell.tsx +30 -0
  11. package/src/DataGrid/parts/DataGridHeader.tsx +111 -0
  12. package/src/DataGrid/parts/DataGridPagination.tsx +105 -0
  13. package/src/DataGrid/parts/DataGridRow.tsx +84 -0
  14. package/src/DataGrid/parts/index.ts +7 -0
  15. package/src/DataGrid/types.ts +95 -0
  16. package/src/DataTable/README.md +42 -0
  17. package/src/DataTable/context/DataTableContext.tsx +434 -0
  18. package/src/DataTable/context/index.ts +4 -0
  19. package/src/DataTable/index.ts +33 -0
  20. package/src/DataTable/lazy.tsx +23 -0
  21. package/src/DataTable/parts/DataTable.tsx +142 -0
  22. package/src/DataTable/parts/DataTableHeader.tsx +103 -0
  23. package/src/DataTable/parts/DataTablePagination.tsx +115 -0
  24. package/src/DataTable/parts/DataTableRow.tsx +96 -0
  25. package/src/DataTable/parts/index.ts +6 -0
  26. package/src/DataTable/types.ts +88 -0
  27. package/src/FileIcon/FileIcon.tsx +87 -0
  28. package/src/FileIcon/get-file-icon.ts +73 -0
  29. package/src/FileIcon/icons/icon-data.ts +399 -0
  30. package/src/FileIcon/index.ts +19 -0
  31. package/src/FileIcon/loader.ts +29 -0
  32. package/src/FileIcon/specialFolders.ts +110 -0
  33. package/src/FileIcon/treeAdapter.tsx +49 -0
  34. package/src/JsonTree/JsonViewer.tsx +720 -0
  35. package/src/JsonTree/README.md +142 -0
  36. package/src/JsonTree/index.tsx +11 -0
  37. package/src/JsonTree/lazy.tsx +22 -0
  38. package/src/JsonTree/types.ts +91 -0
  39. package/src/Kanban/context/KanbanContext.tsx +165 -0
  40. package/src/Kanban/context/index.ts +4 -0
  41. package/src/Kanban/index.ts +31 -0
  42. package/src/Kanban/lazy.tsx +23 -0
  43. package/src/Kanban/parts/Kanban.tsx +77 -0
  44. package/src/Kanban/parts/KanbanCard.tsx +76 -0
  45. package/src/Kanban/parts/KanbanColumn.tsx +79 -0
  46. package/src/Kanban/parts/KanbanDragOverlay.tsx +46 -0
  47. package/src/Kanban/parts/index.ts +6 -0
  48. package/src/Kanban/types.ts +45 -0
  49. package/src/Listbox/context/ListboxProvider.tsx +616 -0
  50. package/src/Listbox/context/index.ts +8 -0
  51. package/src/Listbox/index.ts +28 -0
  52. package/src/Listbox/lazy.tsx +31 -0
  53. package/src/Listbox/parts/ListboxGroup.tsx +71 -0
  54. package/src/Listbox/parts/ListboxItem.tsx +151 -0
  55. package/src/Listbox/parts/index.ts +12 -0
  56. package/src/Listbox/types.ts +98 -0
  57. package/src/Masonry/README.md +79 -0
  58. package/src/Masonry/context/MasonryProvider.tsx +1205 -0
  59. package/src/Masonry/context/index.ts +8 -0
  60. package/src/Masonry/index.ts +18 -0
  61. package/src/Masonry/lazy.tsx +20 -0
  62. package/src/Masonry/parts/index.ts +2 -0
  63. package/src/Masonry/types.ts +44 -0
  64. package/src/Timeline/context/TimelineProvider.tsx +474 -0
  65. package/src/Timeline/context/index.ts +16 -0
  66. package/src/Timeline/index.ts +29 -0
  67. package/src/Timeline/lazy.tsx +27 -0
  68. package/src/Timeline/parts/index.ts +2 -0
  69. package/src/Timeline/types.ts +25 -0
  70. package/src/Tree/FinderTree.tsx +42 -0
  71. package/src/Tree/README.md +542 -0
  72. package/src/Tree/TreeDndProvider.tsx +137 -0
  73. package/src/Tree/TreeRoot.tsx +290 -0
  74. package/src/Tree/components/TreeChevron.tsx +48 -0
  75. package/src/Tree/components/TreeContent.tsx +70 -0
  76. package/src/Tree/components/TreeDropIndicator.tsx +65 -0
  77. package/src/Tree/components/TreeEmpty.tsx +22 -0
  78. package/src/Tree/components/TreeEmptyArea.tsx +160 -0
  79. package/src/Tree/components/TreeError.tsx +24 -0
  80. package/src/Tree/components/TreeIcon.tsx +39 -0
  81. package/src/Tree/components/TreeIndentGuides.tsx +33 -0
  82. package/src/Tree/components/TreeLabel.tsx +39 -0
  83. package/src/Tree/components/TreeRenameInput.tsx +114 -0
  84. package/src/Tree/components/TreeRow.tsx +300 -0
  85. package/src/Tree/components/TreeSearchInput.tsx +50 -0
  86. package/src/Tree/components/TreeSkeleton.tsx +22 -0
  87. package/src/Tree/components/index.ts +28 -0
  88. package/src/Tree/context/TreeContext.tsx +409 -0
  89. package/src/Tree/context/TreeContextValue.ts +139 -0
  90. package/src/Tree/context/async-children/collect-ids.ts +27 -0
  91. package/src/Tree/context/async-children/index.ts +8 -0
  92. package/src/Tree/context/async-children/use-async-children.ts +157 -0
  93. package/src/Tree/context/clipboard/index.ts +4 -0
  94. package/src/Tree/context/clipboard/use-clipboard.ts +115 -0
  95. package/src/Tree/context/dnd/index.ts +8 -0
  96. package/src/Tree/context/dnd/use-dnd.ts +194 -0
  97. package/src/Tree/context/expansion/index.ts +4 -0
  98. package/src/Tree/context/expansion/use-expansion.ts +55 -0
  99. package/src/Tree/context/hooks.ts +177 -0
  100. package/src/Tree/context/index.ts +16 -0
  101. package/src/Tree/context/menu/builtin-actions.ts +358 -0
  102. package/src/Tree/context/menu/index.ts +11 -0
  103. package/src/Tree/context/menu/render.tsx +104 -0
  104. package/src/Tree/context/menu/use-resolved-menu.ts +141 -0
  105. package/src/Tree/context/persist/index.ts +4 -0
  106. package/src/Tree/context/persist/use-persist-sync.ts +74 -0
  107. package/src/Tree/context/rename/index.ts +4 -0
  108. package/src/Tree/context/rename/use-rename.ts +113 -0
  109. package/src/Tree/context/selection/index.ts +4 -0
  110. package/src/Tree/context/selection/use-selection.ts +146 -0
  111. package/src/Tree/context/state/index.ts +6 -0
  112. package/src/Tree/context/state/initial.ts +41 -0
  113. package/src/Tree/context/state/reducer.ts +76 -0
  114. package/src/Tree/context/state/types.ts +46 -0
  115. package/src/Tree/data/appearance.ts +237 -0
  116. package/src/Tree/data/childCache.ts +43 -0
  117. package/src/Tree/data/clipboard.ts +33 -0
  118. package/src/Tree/data/createDemoTree.ts +42 -0
  119. package/src/Tree/data/dnd.ts +123 -0
  120. package/src/Tree/data/finderShortcuts.ts +67 -0
  121. package/src/Tree/data/flatten.ts +66 -0
  122. package/src/Tree/data/index.ts +45 -0
  123. package/src/Tree/data/persist.ts +62 -0
  124. package/src/Tree/data/renameUtils.ts +51 -0
  125. package/src/Tree/data/selection.ts +161 -0
  126. package/src/Tree/hooks/finder-hotkeys/build-ctx.ts +48 -0
  127. package/src/Tree/hooks/finder-hotkeys/index.ts +8 -0
  128. package/src/Tree/hooks/finder-hotkeys/use-tree-finder-hotkeys.ts +166 -0
  129. package/src/Tree/hooks/index.ts +25 -0
  130. package/src/Tree/hooks/keyboard/activation.ts +27 -0
  131. package/src/Tree/hooks/keyboard/arrow-nav.ts +26 -0
  132. package/src/Tree/hooks/keyboard/expand-collapse.ts +54 -0
  133. package/src/Tree/hooks/keyboard/index.ts +10 -0
  134. package/src/Tree/hooks/keyboard/types.ts +39 -0
  135. package/src/Tree/hooks/keyboard/use-tree-keyboard.ts +196 -0
  136. package/src/Tree/hooks/type-ahead/index.ts +5 -0
  137. package/src/Tree/hooks/type-ahead/match-prefix.ts +42 -0
  138. package/src/Tree/hooks/type-ahead/use-tree-type-ahead.ts +89 -0
  139. package/src/Tree/index.tsx +135 -0
  140. package/src/Tree/lazy.tsx +14 -0
  141. package/src/Tree/types/activation.ts +30 -0
  142. package/src/Tree/types/adapter.ts +70 -0
  143. package/src/Tree/types/index.ts +27 -0
  144. package/src/Tree/types/labels.ts +97 -0
  145. package/src/Tree/types/loader.ts +9 -0
  146. package/src/Tree/types/node.ts +38 -0
  147. package/src/Tree/types/root-props.ts +158 -0
  148. package/src/Tree/types/selection.ts +3 -0
  149. package/src/Tree/types/slots.ts +64 -0
  150. package/src/index.ts +21 -0
  151. package/src/stories/_fixtures/categories.ts +51 -0
  152. package/src/stories/_fixtures/finder-store.ts +196 -0
  153. package/src/stories/_fixtures/fs.ts +83 -0
  154. package/src/stories/_fixtures/index.ts +5 -0
  155. package/src/stories/_fixtures/org.ts +46 -0
  156. package/src/stories/_fixtures/status.ts +21 -0
  157. package/src/styles/index.css +10 -0
@@ -0,0 +1,157 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
4
+
5
+ import { createChildCache, type ChildCache } from '../../data/childCache';
6
+ import type {
7
+ TreeItemId,
8
+ TreeLoadChildren,
9
+ TreeNode,
10
+ } from '../../types';
11
+ import { collectAllFolderIds } from './collect-ids';
12
+
13
+ export interface UseAsyncChildrenOptions<T> {
14
+ data: TreeNode<T>[];
15
+ loadChildren?: TreeLoadChildren<T>;
16
+ /** Current set of expanded ids — controls which async fetches fire. */
17
+ expanded: ReadonlySet<TreeItemId>;
18
+ /** Bumps every time the cache mutates — invalidates downstream memos. */
19
+ cacheTick: number;
20
+ /** Dispatch into the reducer (used to bump `cacheTick`). */
21
+ bumpCacheTick: () => void;
22
+ }
23
+
24
+ export interface UseAsyncChildrenReturn<T> {
25
+ /** Live async-children cache (mutable ref, not React state). */
26
+ cache: ChildCache<T>;
27
+ /** Id → node lookup across the root tree + cached async children. */
28
+ nodeById: Map<TreeItemId, TreeNode<T>>;
29
+ /** Fetch a single folder's children (deduped, idempotent). */
30
+ fetchChildren: (node: TreeNode<T>) => Promise<void> | void;
31
+ /** Drop one node's cache entry and refetch. */
32
+ refresh: (id: TreeItemId) => Promise<void>;
33
+ /** Clear the entire cache and refetch every currently-expanded folder. */
34
+ refreshAll: () => Promise<void>;
35
+ /** Collect every folder id known to Tree (root + cached). For `expandAll`. */
36
+ collectFolderIds: () => TreeItemId[];
37
+ }
38
+
39
+ /**
40
+ * Manage async children: a long-lived cache (in a ref), dedup-ed
41
+ * `loadChildren` calls, and a `nodeById` map that walks both inline
42
+ * children and cached async ones. The cache lives outside reducer state
43
+ * because mutating a Map per render is cheaper than spreading it; we
44
+ * trigger downstream re-renders via the `cacheTick` counter.
45
+ */
46
+ export function useAsyncChildren<T>({
47
+ data,
48
+ loadChildren,
49
+ expanded,
50
+ cacheTick,
51
+ bumpCacheTick,
52
+ }: UseAsyncChildrenOptions<T>): UseAsyncChildrenReturn<T> {
53
+ // The cache survives provider re-renders and async work.
54
+ const cacheRef = useRef<ChildCache<T>>(createChildCache<T>());
55
+ const inflightRef = useRef<Map<TreeItemId, Promise<void>>>(new Map());
56
+
57
+ const fetchChildren = useCallback(
58
+ async (node: TreeNode<T>) => {
59
+ if (!loadChildren) return;
60
+ if (Array.isArray(node.children)) return;
61
+ const existing = cacheRef.current.get(node.id);
62
+ if (existing?.status === 'loaded' || existing?.status === 'loading') return;
63
+ const inflight = inflightRef.current.get(node.id);
64
+ if (inflight) return inflight;
65
+
66
+ cacheRef.current.set(node.id, { status: 'loading', children: [] });
67
+ bumpCacheTick();
68
+
69
+ const promise = (async () => {
70
+ try {
71
+ const children = await loadChildren(node);
72
+ cacheRef.current.set(node.id, { status: 'loaded', children });
73
+ } catch (err) {
74
+ cacheRef.current.set(node.id, {
75
+ status: 'error',
76
+ children: [],
77
+ error: err instanceof Error ? err.message : String(err),
78
+ });
79
+ } finally {
80
+ inflightRef.current.delete(node.id);
81
+ bumpCacheTick();
82
+ }
83
+ })();
84
+
85
+ inflightRef.current.set(node.id, promise);
86
+ return promise;
87
+ },
88
+ [loadChildren, bumpCacheTick],
89
+ );
90
+
91
+ // Build a quick id → node map across roots + cached async children.
92
+ // Re-runs whenever the cache mutates (cacheTick) or `data` changes.
93
+ const nodeById = useMemo(() => {
94
+ const map = new Map<TreeItemId, TreeNode<T>>();
95
+ const walk = (nodes: TreeNode<T>[]) => {
96
+ for (const n of nodes) {
97
+ map.set(n.id, n);
98
+ if (Array.isArray(n.children)) walk(n.children);
99
+ else {
100
+ const entry = cacheRef.current.get(n.id);
101
+ if (entry?.children) walk(entry.children);
102
+ }
103
+ }
104
+ };
105
+ walk(data);
106
+ return map;
107
+ }, [data, cacheTick]);
108
+
109
+ // Auto-fetch every expanded folder that isn't already in cache. Cheap
110
+ // — `fetchChildren` short-circuits when cached or already inflight.
111
+ useEffect(() => {
112
+ if (!loadChildren) return;
113
+ for (const id of expanded) {
114
+ const node = nodeById.get(id);
115
+ if (!node) continue;
116
+ void fetchChildren(node);
117
+ }
118
+ }, [loadChildren, expanded, cacheTick, nodeById, fetchChildren]);
119
+
120
+ const refresh = useCallback(
121
+ async (id: TreeItemId) => {
122
+ const node = nodeById.get(id);
123
+ if (!node || !loadChildren) return;
124
+ cacheRef.current.delete(id);
125
+ bumpCacheTick();
126
+ await fetchChildren(node);
127
+ },
128
+ [nodeById, loadChildren, fetchChildren, bumpCacheTick],
129
+ );
130
+
131
+ const refreshAll = useCallback(async () => {
132
+ cacheRef.current.clear();
133
+ bumpCacheTick();
134
+ if (!loadChildren) return;
135
+ await Promise.all(
136
+ [...expanded].map((id) => {
137
+ const node = nodeById.get(id);
138
+ return node ? fetchChildren(node) : undefined;
139
+ }),
140
+ );
141
+ }, [loadChildren, expanded, nodeById, fetchChildren, bumpCacheTick]);
142
+
143
+ const collectFolderIds = useCallback(() => {
144
+ const ids: TreeItemId[] = [];
145
+ collectAllFolderIds(data, cacheRef.current, ids);
146
+ return ids;
147
+ }, [data]);
148
+
149
+ return {
150
+ cache: cacheRef.current,
151
+ nodeById,
152
+ fetchChildren,
153
+ refresh,
154
+ refreshAll,
155
+ collectFolderIds,
156
+ };
157
+ }
@@ -0,0 +1,4 @@
1
+ 'use client';
2
+
3
+ export { useClipboard } from './use-clipboard';
4
+ export type { UseClipboardOptions, UseClipboardReturn } from './use-clipboard';
@@ -0,0 +1,115 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useRef } from 'react';
4
+
5
+ import { getDialog } from '@djangocfg/ui-core/lib/dialog-service';
6
+
7
+ import type { ClipboardState } from '../../data/clipboard';
8
+ import type {
9
+ TreeAdapter,
10
+ TreeItemId,
11
+ TreeLabels,
12
+ TreeMovePosition,
13
+ TreeNode,
14
+ } from '../../types';
15
+ import type { Action } from '../state';
16
+
17
+ export interface UseClipboardOptions<T> {
18
+ dispatch: React.Dispatch<Action>;
19
+ /** Current clipboard snapshot — latched into a ref for async paste. */
20
+ clipboard: ClipboardState;
21
+ adapter?: TreeAdapter<T>;
22
+ nodeById: Map<TreeItemId, TreeNode<T>>;
23
+ labels: TreeLabels;
24
+ }
25
+
26
+ export interface UseClipboardReturn<T> {
27
+ cutToClipboard: (ids: TreeItemId[]) => void;
28
+ copyToClipboard: (ids: TreeItemId[]) => void;
29
+ /** Apply clipboard to `target` (a row, or `null` = root). */
30
+ pasteFromClipboard: (
31
+ target: TreeNode<T> | null,
32
+ position?: TreeMovePosition,
33
+ ) => Promise<void>;
34
+ clearClipboard: () => void;
35
+ }
36
+
37
+ /**
38
+ * Tree-local clipboard. Cut + paste dispatches `adapter.move`, copy +
39
+ * paste dispatches `adapter.copy`. Errors are surfaced through
40
+ * `window.dialog.alert` so they don't get swallowed silently.
41
+ */
42
+ export function useClipboard<T>({
43
+ dispatch,
44
+ clipboard,
45
+ adapter,
46
+ nodeById,
47
+ labels,
48
+ }: UseClipboardOptions<T>): UseClipboardReturn<T> {
49
+ const clipboardRef = useRef(clipboard);
50
+ clipboardRef.current = clipboard;
51
+
52
+ const cutToClipboard = useCallback(
53
+ (ids: TreeItemId[]) => {
54
+ if (ids.length === 0) return;
55
+ dispatch({ type: 'clipboard-set', payload: { kind: 'cut', ids } });
56
+ },
57
+ [dispatch],
58
+ );
59
+
60
+ const copyToClipboard = useCallback(
61
+ (ids: TreeItemId[]) => {
62
+ if (ids.length === 0) return;
63
+ dispatch({ type: 'clipboard-set', payload: { kind: 'copy', ids } });
64
+ },
65
+ [dispatch],
66
+ );
67
+
68
+ const clearClipboard = useCallback(
69
+ () => dispatch({ type: 'clipboard-set', payload: null }),
70
+ [dispatch],
71
+ );
72
+
73
+ const pasteFromClipboard = useCallback(
74
+ async (
75
+ target: TreeNode<T> | null,
76
+ position: TreeMovePosition = 'inside',
77
+ ) => {
78
+ const cb = clipboardRef.current;
79
+ if (!cb || cb.ids.length === 0) return;
80
+ if (!adapter) return;
81
+ const nodes = cb.ids
82
+ .map((id) => nodeById.get(id))
83
+ .filter((n): n is TreeNode<T> => !!n);
84
+ if (nodes.length === 0) {
85
+ dispatch({ type: 'clipboard-set', payload: null });
86
+ return;
87
+ }
88
+ try {
89
+ if (cb.kind === 'cut') {
90
+ if (!adapter.move) return;
91
+ await adapter.move(nodes, target, position);
92
+ // Cut + paste consumes the clipboard. Copy + paste retains it.
93
+ dispatch({ type: 'clipboard-set', payload: null });
94
+ } else {
95
+ if (!adapter.copy) return;
96
+ await adapter.copy(nodes, target, position);
97
+ }
98
+ } catch (e) {
99
+ const dialog = getDialog();
100
+ await dialog?.alert({
101
+ title: labels.error,
102
+ message: e instanceof Error ? e.message : String(e),
103
+ });
104
+ }
105
+ },
106
+ [dispatch, adapter, nodeById, labels],
107
+ );
108
+
109
+ return {
110
+ cutToClipboard,
111
+ copyToClipboard,
112
+ pasteFromClipboard,
113
+ clearClipboard,
114
+ };
115
+ }
@@ -0,0 +1,8 @@
1
+ 'use client';
2
+
3
+ export { useDnd } from './use-dnd';
4
+ export type {
5
+ UseDndOptions,
6
+ UseDndReturn,
7
+ DropTargetState,
8
+ } from './use-dnd';
@@ -0,0 +1,194 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useMemo, useState } from 'react';
4
+
5
+ import { getDialog } from '@djangocfg/ui-core/lib/dialog-service';
6
+
7
+ import { defaultCanDrop } from '../../data/dnd';
8
+ import type {
9
+ TreeAdapter,
10
+ TreeItemId,
11
+ TreeLabels,
12
+ TreeMovePosition,
13
+ TreeNode,
14
+ } from '../../types';
15
+
16
+ export interface UseDndOptions<T> {
17
+ enabled: boolean;
18
+ adapter?: TreeAdapter<T>;
19
+ nodeById: Map<TreeItemId, TreeNode<T>>;
20
+ /**
21
+ * Multi-selection at the moment dragging begins. If the dragged row
22
+ * is part of the selection, we drag all selected rows; otherwise we
23
+ * drag just the row.
24
+ */
25
+ selected: ReadonlySet<TreeItemId>;
26
+ labels: TreeLabels;
27
+ /** Optional consumer-defined drop validator (layered on top of `defaultCanDrop`). */
28
+ canDrop?: (ctx: {
29
+ source: TreeNode<T>[];
30
+ target: TreeNode<T> | null;
31
+ position: TreeMovePosition;
32
+ }) => boolean;
33
+ }
34
+
35
+ export interface DropTargetState {
36
+ id: TreeItemId | null; // null = root drop zone
37
+ position: TreeMovePosition;
38
+ }
39
+
40
+ export interface UseDndReturn<T> {
41
+ /** True when the host enabled DnD AND adapter.move is defined. */
42
+ active: boolean;
43
+ /** Ids currently being dragged (empty when not dragging). */
44
+ draggingIds: ReadonlySet<TreeItemId>;
45
+ /** Live drop target (`null` when nothing under the pointer). */
46
+ dropTarget: DropTargetState | null;
47
+ /** Called by row sensors on dragstart. */
48
+ beginDrag: (rowId: TreeItemId) => void;
49
+ /**
50
+ * Called on dragover. `null` clears the indicator. Tree already
51
+ * filters self/cycle drops via `defaultCanDrop` — the row component
52
+ * decides the position from pointer geometry first.
53
+ */
54
+ setDropTarget: (target: DropTargetState | null) => void;
55
+ /** Commit drop — calls `adapter.move` and resets transient state. */
56
+ commitDrop: () => Promise<void>;
57
+ /** Cancel without committing (Esc, drop outside). */
58
+ cancelDrag: () => void;
59
+ /**
60
+ * Validate a candidate drop in real time. Combines `defaultCanDrop`
61
+ * with the consumer's `canDrop`. Used by the row component to
62
+ * suppress the indicator on invalid hovers.
63
+ */
64
+ isAllowedDrop: (target: TreeNode<T> | null, position: TreeMovePosition) => boolean;
65
+ }
66
+
67
+ export function useDnd<T>({
68
+ enabled,
69
+ adapter,
70
+ nodeById,
71
+ selected,
72
+ labels,
73
+ canDrop,
74
+ }: UseDndOptions<T>): UseDndReturn<T> {
75
+ const active = enabled && !!adapter?.move;
76
+
77
+ const [draggingIds, setDraggingIds] = useState<ReadonlySet<TreeItemId>>(
78
+ () => new Set(),
79
+ );
80
+ const [dropTarget, setDropTarget] = useState<DropTargetState | null>(null);
81
+
82
+ const beginDrag = useCallback(
83
+ (rowId: TreeItemId) => {
84
+ if (!active) return;
85
+ // If the dragged row is part of the selection, drag the whole
86
+ // selection. Otherwise it's a single-row drag (and we don't
87
+ // touch the existing selection).
88
+ const ids = selected.has(rowId)
89
+ ? new Set(selected)
90
+ : new Set<TreeItemId>([rowId]);
91
+ setDraggingIds(ids);
92
+ },
93
+ [active, selected],
94
+ );
95
+
96
+ const cancelDrag = useCallback(() => {
97
+ setDraggingIds(new Set());
98
+ setDropTarget(null);
99
+ }, []);
100
+
101
+ const resolveSourceNodes = useCallback((): TreeNode<T>[] => {
102
+ const out: TreeNode<T>[] = [];
103
+ for (const id of draggingIds) {
104
+ const node = nodeById.get(id);
105
+ if (node) out.push(node);
106
+ }
107
+ return out;
108
+ }, [draggingIds, nodeById]);
109
+
110
+ const isAllowedDrop = useCallback(
111
+ (target: TreeNode<T> | null, position: TreeMovePosition): boolean => {
112
+ if (!active) return false;
113
+ if (draggingIds.size === 0) return false;
114
+ const source = resolveSourceNodes();
115
+ if (
116
+ !defaultCanDrop<T>({
117
+ source,
118
+ target,
119
+ position,
120
+ getNodeById: (id) => nodeById.get(id),
121
+ })
122
+ ) {
123
+ return false;
124
+ }
125
+ // Layer the consumer's rule on top.
126
+ return canDrop?.({ source, target, position }) ?? true;
127
+ },
128
+ [active, draggingIds, resolveSourceNodes, nodeById, canDrop],
129
+ );
130
+
131
+ const commitDrop = useCallback(async () => {
132
+ if (!active || !adapter?.move) {
133
+ cancelDrag();
134
+ return;
135
+ }
136
+ const t = dropTarget;
137
+ if (!t) {
138
+ cancelDrag();
139
+ return;
140
+ }
141
+ const targetNode = t.id ? nodeById.get(t.id) ?? null : null;
142
+ const source = resolveSourceNodes();
143
+ if (source.length === 0) {
144
+ cancelDrag();
145
+ return;
146
+ }
147
+ if (!isAllowedDrop(targetNode, t.position)) {
148
+ cancelDrag();
149
+ return;
150
+ }
151
+ try {
152
+ await adapter.move(source, targetNode, t.position);
153
+ } catch (e) {
154
+ const dialog = getDialog();
155
+ await dialog?.alert({
156
+ title: labels.error,
157
+ message: e instanceof Error ? e.message : String(e),
158
+ });
159
+ } finally {
160
+ cancelDrag();
161
+ }
162
+ }, [
163
+ active,
164
+ adapter,
165
+ cancelDrag,
166
+ dropTarget,
167
+ isAllowedDrop,
168
+ labels,
169
+ nodeById,
170
+ resolveSourceNodes,
171
+ ]);
172
+
173
+ return useMemo(
174
+ () => ({
175
+ active,
176
+ draggingIds,
177
+ dropTarget,
178
+ beginDrag,
179
+ setDropTarget,
180
+ commitDrop,
181
+ cancelDrag,
182
+ isAllowedDrop,
183
+ }),
184
+ [
185
+ active,
186
+ draggingIds,
187
+ dropTarget,
188
+ beginDrag,
189
+ commitDrop,
190
+ cancelDrag,
191
+ isAllowedDrop,
192
+ ],
193
+ );
194
+ }
@@ -0,0 +1,4 @@
1
+ 'use client';
2
+
3
+ export { useExpansion } from './use-expansion';
4
+ export type { UseExpansionOptions, UseExpansionReturn } from './use-expansion';
@@ -0,0 +1,55 @@
1
+ 'use client';
2
+
3
+ import { useCallback } from 'react';
4
+
5
+ import type { Action } from '../state';
6
+ import type { TreeItemId } from '../../types';
7
+
8
+ export interface UseExpansionOptions {
9
+ dispatch: React.Dispatch<Action>;
10
+ /** Build the "all folder ids" list — usually wired to `useAsyncChildren`. */
11
+ collectFolderIds: () => TreeItemId[];
12
+ }
13
+
14
+ export interface UseExpansionReturn {
15
+ expand: (id: TreeItemId) => void;
16
+ collapse: (id: TreeItemId) => void;
17
+ toggle: (id: TreeItemId) => void;
18
+ expandAll: () => void;
19
+ collapseAll: () => void;
20
+ }
21
+
22
+ /**
23
+ * Thin action wrappers around the reducer's expansion actions, plus
24
+ * `expandAll` / `collapseAll`. `expandAll` walks roots and cached async
25
+ * children so partially-loaded trees expand everything they currently
26
+ * know about.
27
+ */
28
+ export function useExpansion({
29
+ dispatch,
30
+ collectFolderIds,
31
+ }: UseExpansionOptions): UseExpansionReturn {
32
+ const expand = useCallback(
33
+ (id: TreeItemId) => dispatch({ type: 'expand', id }),
34
+ [dispatch],
35
+ );
36
+ const collapse = useCallback(
37
+ (id: TreeItemId) => dispatch({ type: 'collapse', id }),
38
+ [dispatch],
39
+ );
40
+ const toggle = useCallback(
41
+ (id: TreeItemId) => dispatch({ type: 'toggle', id }),
42
+ [dispatch],
43
+ );
44
+
45
+ const expandAll = useCallback(() => {
46
+ dispatch({ type: 'set-expanded', ids: collectFolderIds() });
47
+ }, [dispatch, collectFolderIds]);
48
+
49
+ const collapseAll = useCallback(
50
+ () => dispatch({ type: 'set-expanded', ids: [] }),
51
+ [dispatch],
52
+ );
53
+
54
+ return { expand, collapse, toggle, expandAll, collapseAll };
55
+ }