@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,177 @@
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
+ anchor: ctx.anchor,
27
+ select: ctx.select,
28
+ setSelectedIds: ctx.setSelectedIds,
29
+ clear: ctx.clearSelection,
30
+ clickSelect: ctx.clickSelect,
31
+ moveSelect: ctx.moveSelect,
32
+ selectAll: ctx.selectAll,
33
+ isSelected,
34
+ }),
35
+ [
36
+ selectedIds,
37
+ ctx.anchor,
38
+ ctx.select,
39
+ ctx.setSelectedIds,
40
+ ctx.clearSelection,
41
+ ctx.clickSelect,
42
+ ctx.moveSelect,
43
+ ctx.selectAll,
44
+ isSelected,
45
+ ],
46
+ );
47
+ }
48
+
49
+ export function useTreeExpansion<T>() {
50
+ const ctx = useTreeContext<T>();
51
+ const expandedIds = useMemo(() => [...ctx.expanded], [ctx.expanded]);
52
+ const isExpanded = useCallback(
53
+ (id: TreeItemId) => ctx.expanded.has(id),
54
+ [ctx.expanded],
55
+ );
56
+ return useMemo(
57
+ () => ({
58
+ expandedIds,
59
+ expand: ctx.expand,
60
+ collapse: ctx.collapse,
61
+ toggle: ctx.toggle,
62
+ expandAll: ctx.expandAll,
63
+ collapseAll: ctx.collapseAll,
64
+ isExpanded,
65
+ }),
66
+ [
67
+ expandedIds,
68
+ ctx.expand,
69
+ ctx.collapse,
70
+ ctx.toggle,
71
+ ctx.expandAll,
72
+ ctx.collapseAll,
73
+ isExpanded,
74
+ ],
75
+ );
76
+ }
77
+
78
+ export function useTreeFocus<T>() {
79
+ const ctx = useTreeContext<T>();
80
+ return useMemo(
81
+ () => ({ focusedId: ctx.focused, setFocus: ctx.setFocus }),
82
+ [ctx.focused, ctx.setFocus],
83
+ );
84
+ }
85
+
86
+ export function useTreeSearch<T>() {
87
+ const ctx = useTreeContext<T>();
88
+ return useMemo(
89
+ () => ({
90
+ isOpen: ctx.enableSearch,
91
+ query: ctx.query,
92
+ setQuery: ctx.setQuery,
93
+ matchingIds: ctx.matchingIds,
94
+ matchCount: ctx.matchingIds.size,
95
+ }),
96
+ [ctx.enableSearch, ctx.query, ctx.setQuery, ctx.matchingIds],
97
+ );
98
+ }
99
+
100
+ export function useTreeDnd<T>() {
101
+ const ctx = useTreeContext<T>();
102
+ return ctx.dnd;
103
+ }
104
+
105
+ export function useTreeClipboard<T>() {
106
+ const ctx = useTreeContext<T>();
107
+ const isCut = useCallback(
108
+ (id: TreeItemId) => ctx.clipboard?.kind === 'cut' && ctx.clipboard.ids.includes(id),
109
+ [ctx.clipboard],
110
+ );
111
+ return useMemo(
112
+ () => ({
113
+ clipboard: ctx.clipboard,
114
+ isCut,
115
+ cut: ctx.cutToClipboard,
116
+ copy: ctx.copyToClipboard,
117
+ paste: ctx.pasteFromClipboard,
118
+ clear: ctx.clearClipboard,
119
+ }),
120
+ [
121
+ ctx.clipboard,
122
+ isCut,
123
+ ctx.cutToClipboard,
124
+ ctx.copyToClipboard,
125
+ ctx.pasteFromClipboard,
126
+ ctx.clearClipboard,
127
+ ],
128
+ );
129
+ }
130
+
131
+ export function useTreeRename<T>() {
132
+ const ctx = useTreeContext<T>();
133
+ return useMemo(
134
+ () => ({
135
+ /** True when the host allowed inline rename AND the adapter exposes `rename`. */
136
+ enabled: ctx.inlineRenameEnabled,
137
+ /** Currently renaming id, or `null`. */
138
+ renamingId: ctx.renamingId,
139
+ startRename: ctx.startRename,
140
+ cancelRename: ctx.cancelRename,
141
+ commitRename: ctx.commitRename,
142
+ }),
143
+ [
144
+ ctx.inlineRenameEnabled,
145
+ ctx.renamingId,
146
+ ctx.startRename,
147
+ ctx.cancelRename,
148
+ ctx.commitRename,
149
+ ],
150
+ );
151
+ }
152
+
153
+ export function useTreeActions<T>() {
154
+ const ctx = useTreeContext<T>();
155
+ return useMemo(
156
+ () => ({
157
+ expand: ctx.expand,
158
+ collapse: ctx.collapse,
159
+ toggle: ctx.toggle,
160
+ expandAll: ctx.expandAll,
161
+ collapseAll: ctx.collapseAll,
162
+ refresh: ctx.refresh,
163
+ refreshAll: ctx.refreshAll,
164
+ activate: ctx.activate,
165
+ }),
166
+ [
167
+ ctx.expand,
168
+ ctx.collapse,
169
+ ctx.toggle,
170
+ ctx.expandAll,
171
+ ctx.collapseAll,
172
+ ctx.refresh,
173
+ ctx.refreshAll,
174
+ ctx.activate,
175
+ ],
176
+ );
177
+ }
@@ -0,0 +1,16 @@
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
+ useTreeRename,
13
+ useTreeClipboard,
14
+ useTreeDnd,
15
+ useTreeActions,
16
+ } from './hooks';
@@ -0,0 +1,358 @@
1
+ 'use client';
2
+
3
+ import { getDialog } from '@djangocfg/ui-core/lib/dialog-service';
4
+ import {
5
+ Copy,
6
+ CornerUpLeft,
7
+ FilePlus,
8
+ FolderPlus,
9
+ Pencil,
10
+ Scissors,
11
+ Trash2,
12
+ } from 'lucide-react';
13
+ import type { ComponentType } from 'react';
14
+
15
+ import type {
16
+ TreeAdapter,
17
+ TreeBuiltinAction,
18
+ TreeContextMenuItem,
19
+ TreeItemId,
20
+ TreeLabels,
21
+ TreeNode,
22
+ TreeRowRenderProps,
23
+ } from '../../types';
24
+
25
+ // =====================================================================
26
+ // BuiltinActionContext — everything the action handler needs from Tree.
27
+ // Kept as a plain object (not a React context) so the same code can be
28
+ // shared by hotkeys, right-click menus, and the empty-area menu.
29
+ // =====================================================================
30
+
31
+ export interface BuiltinActionContext<T> {
32
+ adapter: TreeAdapter<T>;
33
+ labels: TreeLabels;
34
+ /** Currently selected nodes (full objects, resolved from ids). */
35
+ selectedNodes: TreeNode<T>[];
36
+ /** Row the user right-clicked / triggered the action on. May be null
37
+ * for empty-area actions (paste / new file / new folder at root). */
38
+ targetNode: TreeNode<T> | null;
39
+ /** Returns the human-readable name for a node (uses `getItemName`). */
40
+ getName: (node: TreeNode<T>) => string;
41
+ /** Imperative: start inline rename on this id (no-op if disabled). */
42
+ startInlineRename?: (id: TreeItemId) => void;
43
+ /** Clipboard hooks (P5). Provided by Tree's context; pure forwarding. */
44
+ clipboard?: {
45
+ /** Current clipboard kind, if any (so we can hide "Paste" when empty). */
46
+ hasItems: boolean;
47
+ cut: (ids: TreeItemId[]) => void;
48
+ copy: (ids: TreeItemId[]) => void;
49
+ paste: () => void | Promise<void>;
50
+ };
51
+ }
52
+
53
+ interface BuiltinActionDescriptor<T> {
54
+ id: TreeBuiltinAction;
55
+ label: (ctx: BuiltinActionContext<T>) => string;
56
+ icon: ComponentType<{ className?: string }>;
57
+ destructive?: boolean;
58
+ shortcut?: string;
59
+ /** Should this row appear in the menu given the current adapter + selection? */
60
+ available: (ctx: BuiltinActionContext<T>) => boolean;
61
+ run: (ctx: BuiltinActionContext<T>) => Promise<void> | void;
62
+ }
63
+
64
+ // =====================================================================
65
+ // Dialog helpers — thin wrappers that bail out gracefully if
66
+ // <DialogProvider /> isn't mounted. Tree should still render in that
67
+ // case, just without CRUD flows.
68
+ // =====================================================================
69
+
70
+ async function confirmDelete<T>(ctx: BuiltinActionContext<T>): Promise<boolean> {
71
+ const dialog = getDialog();
72
+ if (!dialog) return false;
73
+ const { selectedNodes, labels, getName } = ctx;
74
+ return dialog.confirm({
75
+ title: labels.confirmDeleteTitle(selectedNodes.length),
76
+ message: labels.confirmDeleteMessage(selectedNodes.map(getName)),
77
+ confirmText: labels.confirmDeleteOk,
78
+ cancelText: labels.confirmDeleteCancel,
79
+ variant: 'destructive',
80
+ });
81
+ }
82
+
83
+ async function promptName<T>(
84
+ ctx: BuiltinActionContext<T>,
85
+ spec: {
86
+ title: string;
87
+ message: string;
88
+ placeholder: string;
89
+ defaultValue: string;
90
+ },
91
+ ): Promise<string | null> {
92
+ const dialog = getDialog();
93
+ if (!dialog) return null;
94
+ return dialog.prompt({
95
+ title: spec.title,
96
+ message: spec.message,
97
+ placeholder: spec.placeholder,
98
+ defaultValue: spec.defaultValue,
99
+ });
100
+ }
101
+
102
+ async function alertError<T>(
103
+ ctx: BuiltinActionContext<T>,
104
+ message: string,
105
+ ): Promise<void> {
106
+ const dialog = getDialog();
107
+ if (!dialog) return;
108
+ await dialog.alert({ message, title: ctx.labels.error });
109
+ }
110
+
111
+ /**
112
+ * Validate a name through `adapter.validateName` and a tiny built-in
113
+ * non-empty check. Returns `null` if valid, otherwise the error message
114
+ * to surface via `dialog.alert`.
115
+ */
116
+ function validateName<T>(
117
+ ctx: BuiltinActionContext<T>,
118
+ name: string,
119
+ validateCtx: { node?: TreeNode<T>; parent?: TreeNode<T> | null },
120
+ ): string | null {
121
+ if (name.trim() === '') return ctx.labels.invalidNameEmpty;
122
+ return ctx.adapter.validateName?.(name, validateCtx) ?? null;
123
+ }
124
+
125
+ // =====================================================================
126
+ // Built-in action descriptors. Order matters — it controls menu order.
127
+ // =====================================================================
128
+
129
+ const BUILTIN_ACTIONS: BuiltinActionDescriptor<unknown>[] = [
130
+ {
131
+ id: 'open',
132
+ label: (ctx) => ctx.labels.actionOpen,
133
+ icon: CornerUpLeft,
134
+ available: () => false, // wired by Tree on activate; not in menu by default
135
+ run: () => {},
136
+ },
137
+ {
138
+ id: 'rename',
139
+ label: (ctx) => ctx.labels.actionRename,
140
+ icon: Pencil,
141
+ shortcut: 'F2',
142
+ available: (ctx) =>
143
+ !!ctx.adapter.rename &&
144
+ ctx.selectedNodes.length === 1 &&
145
+ !ctx.selectedNodes[0]?.disabled,
146
+ run: async (ctx) => {
147
+ // Prefer inline rename when Tree can drive it; fall back to a prompt.
148
+ const node = ctx.selectedNodes[0];
149
+ if (!node) return;
150
+ if (ctx.startInlineRename) {
151
+ ctx.startInlineRename(node.id);
152
+ return;
153
+ }
154
+ const name = await promptName(ctx, {
155
+ title: ctx.labels.renameTitle,
156
+ message: ctx.labels.renameMessage,
157
+ placeholder: ctx.getName(node),
158
+ defaultValue: ctx.getName(node),
159
+ });
160
+ if (name === null) return;
161
+ const err = validateName(ctx, name, { node });
162
+ if (err) return alertError(ctx, err);
163
+ await ctx.adapter.rename!(node, name);
164
+ },
165
+ },
166
+ {
167
+ id: 'duplicate',
168
+ label: (ctx) => ctx.labels.actionDuplicate,
169
+ icon: Copy,
170
+ shortcut: '⌘D',
171
+ available: (ctx) => !!ctx.adapter.duplicate && ctx.selectedNodes.length > 0,
172
+ run: (ctx) => ctx.adapter.duplicate!(ctx.selectedNodes),
173
+ },
174
+ {
175
+ id: 'cut',
176
+ label: (ctx) => ctx.labels.actionCut,
177
+ icon: Scissors,
178
+ shortcut: '⌘X',
179
+ // Only meaningful when the adapter supports `move` (paste-after-cut)
180
+ // AND Tree provided a clipboard binding.
181
+ available: (ctx) =>
182
+ !!ctx.adapter.move && !!ctx.clipboard && ctx.selectedNodes.length > 0,
183
+ run: (ctx) => {
184
+ ctx.clipboard?.cut(ctx.selectedNodes.map((n) => n.id));
185
+ },
186
+ },
187
+ {
188
+ id: 'copy',
189
+ label: (ctx) => ctx.labels.actionCopy,
190
+ icon: Copy,
191
+ shortcut: '⌘C',
192
+ available: (ctx) =>
193
+ !!ctx.adapter.copy && !!ctx.clipboard && ctx.selectedNodes.length > 0,
194
+ run: (ctx) => {
195
+ ctx.clipboard?.copy(ctx.selectedNodes.map((n) => n.id));
196
+ },
197
+ },
198
+ {
199
+ id: 'paste',
200
+ label: (ctx) => ctx.labels.actionPaste,
201
+ icon: CornerUpLeft,
202
+ shortcut: '⌘V',
203
+ available: (ctx) => !!ctx.clipboard?.hasItems,
204
+ run: async (ctx) => {
205
+ await ctx.clipboard?.paste();
206
+ },
207
+ },
208
+ {
209
+ id: 'delete',
210
+ label: (ctx) => ctx.labels.actionDelete,
211
+ icon: Trash2,
212
+ shortcut: '⌘⌫',
213
+ destructive: true,
214
+ available: (ctx) => !!ctx.adapter.remove && ctx.selectedNodes.length > 0,
215
+ run: async (ctx) => {
216
+ const ok = await confirmDelete(ctx);
217
+ if (!ok) return;
218
+ await ctx.adapter.remove!(ctx.selectedNodes);
219
+ },
220
+ },
221
+ {
222
+ id: 'new-file',
223
+ label: (ctx) => ctx.labels.actionNewFile,
224
+ icon: FilePlus,
225
+ available: (ctx) => !!ctx.adapter.createFile,
226
+ run: async (ctx) => {
227
+ const parent = resolveParentForCreate(ctx);
228
+ const name = await promptName(ctx, {
229
+ title: ctx.labels.newFileTitle,
230
+ message: ctx.labels.newFileMessage,
231
+ placeholder: ctx.labels.newFilePlaceholder,
232
+ defaultValue: ctx.labels.newFileDefault,
233
+ });
234
+ if (name === null) return;
235
+ const err = validateName(ctx, name, { parent });
236
+ if (err) return alertError(ctx, err);
237
+ await ctx.adapter.createFile!(parent, name);
238
+ },
239
+ },
240
+ {
241
+ id: 'new-folder',
242
+ label: (ctx) => ctx.labels.actionNewFolder,
243
+ icon: FolderPlus,
244
+ shortcut: '⌘⇧N',
245
+ available: (ctx) => !!ctx.adapter.createFolder,
246
+ run: async (ctx) => {
247
+ const parent = resolveParentForCreate(ctx);
248
+ const name = await promptName(ctx, {
249
+ title: ctx.labels.newFolderTitle,
250
+ message: ctx.labels.newFolderMessage,
251
+ placeholder: ctx.labels.newFolderPlaceholder,
252
+ defaultValue: ctx.labels.newFolderDefault,
253
+ });
254
+ if (name === null) return;
255
+ const err = validateName(ctx, name, { parent });
256
+ if (err) return alertError(ctx, err);
257
+ await ctx.adapter.createFolder!(parent, name);
258
+ },
259
+ },
260
+ ];
261
+
262
+ /**
263
+ * Default order of items in the auto-built context menu when the
264
+ * consumer doesn't override `defaultMenuItems`. Separators are
265
+ * conventional Finder/Explorer groupings.
266
+ */
267
+ export const DEFAULT_BUILTIN_MENU_ORDER: (TreeBuiltinAction | 'separator')[] = [
268
+ 'rename',
269
+ 'duplicate',
270
+ 'separator',
271
+ 'cut',
272
+ 'copy',
273
+ 'paste',
274
+ 'separator',
275
+ 'new-file',
276
+ 'new-folder',
277
+ 'separator',
278
+ 'delete',
279
+ ];
280
+
281
+ /**
282
+ * Where should a "new file / new folder" land?
283
+ *
284
+ * - target row is a folder → create inside it
285
+ * - target row is a leaf → create as sibling (under its parent)
286
+ * - no target row → root (null)
287
+ */
288
+ function resolveParentForCreate<T>(ctx: BuiltinActionContext<T>): TreeNode<T> | null {
289
+ const { targetNode } = ctx;
290
+ if (!targetNode) return null;
291
+ // We can't know the parent without walking the tree — Tree passes the
292
+ // target's effective "container" via targetNode for folders; leaves go
293
+ // to root for now. Consumers that need sibling-creation can pass a
294
+ // custom contextMenuActions resolver.
295
+ const isFolder = Array.isArray(targetNode.children) || !!targetNode.isFolder;
296
+ return isFolder ? targetNode : null;
297
+ }
298
+
299
+ /**
300
+ * Build the list of `TreeContextMenuItem`s for the current selection /
301
+ * target row using the configured `defaultMenuItems` (or the default
302
+ * order). Returns `null` when no items are available — caller should
303
+ * suppress the menu entirely.
304
+ */
305
+ export function buildDefaultMenuItems<T>(
306
+ ctx: BuiltinActionContext<T>,
307
+ order: (TreeBuiltinAction | 'separator')[] = DEFAULT_BUILTIN_MENU_ORDER,
308
+ ): TreeContextMenuItem<T>[] | null {
309
+ const items: TreeContextMenuItem<T>[] = [];
310
+ let pendingSeparator = false;
311
+
312
+ for (const entry of order) {
313
+ if (entry === 'separator') {
314
+ pendingSeparator = items.length > 0;
315
+ continue;
316
+ }
317
+ const desc = BUILTIN_ACTIONS.find((a) => a.id === entry) as
318
+ | BuiltinActionDescriptor<T>
319
+ | undefined;
320
+ if (!desc) continue;
321
+ if (!desc.available(ctx)) continue;
322
+ if (pendingSeparator) {
323
+ items.push('separator');
324
+ pendingSeparator = false;
325
+ }
326
+ items.push({
327
+ id: desc.id,
328
+ label: desc.label(ctx),
329
+ icon: desc.icon,
330
+ shortcut: desc.shortcut,
331
+ destructive: desc.destructive,
332
+ onSelect: () => void desc.run(ctx),
333
+ });
334
+ }
335
+
336
+ return items.length > 0 ? items : null;
337
+ }
338
+
339
+ /**
340
+ * Internal: run a built-in action by id. Used by Finder hotkeys (P4).
341
+ * Returns `false` if the action isn't currently available (e.g. delete
342
+ * with no selection).
343
+ */
344
+ export async function runBuiltinAction<T>(
345
+ id: TreeBuiltinAction,
346
+ ctx: BuiltinActionContext<T>,
347
+ ): Promise<boolean> {
348
+ const desc = BUILTIN_ACTIONS.find((a) => a.id === id) as
349
+ | BuiltinActionDescriptor<T>
350
+ | undefined;
351
+ if (!desc) return false;
352
+ if (!desc.available(ctx)) return false;
353
+ await desc.run(ctx);
354
+ return true;
355
+ }
356
+
357
+ // Re-export typed bits used by callers.
358
+ export type { TreeRowRenderProps };
@@ -0,0 +1,11 @@
1
+ 'use client';
2
+
3
+ export {
4
+ buildDefaultMenuItems,
5
+ runBuiltinAction,
6
+ DEFAULT_BUILTIN_MENU_ORDER,
7
+ } from './builtin-actions';
8
+ export type { BuiltinActionContext } from './builtin-actions';
9
+ export { useResolvedMenu } from './use-resolved-menu';
10
+ export type { UseResolvedMenuOptions } from './use-resolved-menu';
11
+ export { renderItemsAsContextMenu, tidyMenuItems } from './render';
@@ -0,0 +1,104 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import {
5
+ ContextMenu,
6
+ ContextMenuContent,
7
+ ContextMenuItem,
8
+ ContextMenuSeparator,
9
+ ContextMenuShortcut,
10
+ ContextMenuTrigger,
11
+ } from '@djangocfg/ui-core/components';
12
+
13
+ import type {
14
+ TreeContextMenuItem,
15
+ TreeRowRenderProps,
16
+ } from '../../types';
17
+
18
+ interface RowContextMenuProps<T> {
19
+ rowProps: TreeRowRenderProps<T>;
20
+ items: TreeContextMenuItem<T>[];
21
+ trigger: React.ReactNode;
22
+ }
23
+
24
+ /**
25
+ * Per-row context menu.
26
+ *
27
+ * PERF: every visible row wraps its trigger in a `<ContextMenu>`, so a
28
+ * large tree mounts hundreds of these. Building the full
29
+ * `<ContextMenuContent>` item subtree for each of them on every render is
30
+ * what made right-click feel laggy (the selection change on right-click
31
+ * re-renders all rows, each rebuilding its menu body). We now render the
32
+ * content body ONLY while this row's menu is actually open — closed rows
33
+ * cost just the lightweight `ContextMenu` + `Trigger` shell.
34
+ */
35
+ function RowContextMenu<T>({
36
+ rowProps,
37
+ items,
38
+ trigger,
39
+ }: RowContextMenuProps<T>) {
40
+ const [open, setOpen] = useState(false);
41
+ return (
42
+ <ContextMenu onOpenChange={setOpen}>
43
+ <ContextMenuTrigger asChild>{trigger}</ContextMenuTrigger>
44
+ {open ? (
45
+ <ContextMenuContent>
46
+ {items.map((item, idx) => {
47
+ if (item === 'separator') {
48
+ return <ContextMenuSeparator key={`sep-${idx}`} />;
49
+ }
50
+ const Icon = item.icon;
51
+ return (
52
+ <ContextMenuItem
53
+ key={item.id}
54
+ disabled={item.disabled}
55
+ variant={item.destructive ? 'destructive' : undefined}
56
+ onSelect={() => item.onSelect(rowProps)}
57
+ >
58
+ {Icon ? <Icon /> : null}
59
+ {item.label}
60
+ {item.shortcut ? (
61
+ <ContextMenuShortcut>{item.shortcut}</ContextMenuShortcut>
62
+ ) : null}
63
+ </ContextMenuItem>
64
+ );
65
+ })}
66
+ </ContextMenuContent>
67
+ ) : null}
68
+ </ContextMenu>
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Render an array of declarative menu items as a themed `<ContextMenu>`
74
+ * wrapped around the supplied trigger element. Pure presentational layer
75
+ * — the caller resolves and merges items.
76
+ */
77
+ export function renderItemsAsContextMenu<T>(
78
+ rowProps: TreeRowRenderProps<T>,
79
+ items: TreeContextMenuItem<T>[],
80
+ trigger: React.ReactNode,
81
+ ): React.ReactNode {
82
+ return <RowContextMenu rowProps={rowProps} items={items} trigger={trigger} />;
83
+ }
84
+
85
+ /**
86
+ * Drop trailing / leading / duplicate separators so a merged menu never
87
+ * shows a separator next to a section header or another separator.
88
+ */
89
+ export function tidyMenuItems<T>(
90
+ items: TreeContextMenuItem<T>[],
91
+ ): TreeContextMenuItem<T>[] {
92
+ const out: TreeContextMenuItem<T>[] = [];
93
+ for (const it of items) {
94
+ if (it === 'separator') {
95
+ if (out.length === 0) continue;
96
+ if (out[out.length - 1] === 'separator') continue;
97
+ out.push(it);
98
+ } else {
99
+ out.push(it);
100
+ }
101
+ }
102
+ while (out.length > 0 && out[out.length - 1] === 'separator') out.pop();
103
+ return out;
104
+ }