@djangocfg/ui-tools 2.1.415 → 2.1.416

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 (110) hide show
  1. package/dist/file-icon/index.d.cts +1 -1
  2. package/dist/file-icon/index.d.ts +1 -1
  3. package/dist/slots-ClRpIzoh.d.cts +88 -0
  4. package/dist/slots-ClRpIzoh.d.ts +88 -0
  5. package/dist/tree/index.cjs +1994 -276
  6. package/dist/tree/index.cjs.map +1 -1
  7. package/dist/tree/index.d.cts +717 -72
  8. package/dist/tree/index.d.ts +717 -72
  9. package/dist/tree/index.mjs +1984 -279
  10. package/dist/tree/index.mjs.map +1 -1
  11. package/package.json +10 -6
  12. package/src/tools/chat/README.md +111 -1
  13. package/src/tools/chat/composer/Composer.tsx +138 -17
  14. package/src/tools/chat/composer/ComposerRichTextarea.tsx +25 -0
  15. package/src/tools/chat/composer/index.ts +22 -0
  16. package/src/tools/chat/composer/slash/README.md +187 -0
  17. package/src/tools/chat/composer/slash/SlashHighlightTextarea.tsx +144 -0
  18. package/src/tools/chat/composer/slash/SlashMenu.tsx +142 -0
  19. package/src/tools/chat/composer/slash/SlashToken.tsx +57 -0
  20. package/src/tools/chat/composer/slash/index.ts +44 -0
  21. package/src/tools/chat/composer/slash/labels.ts +19 -0
  22. package/src/tools/chat/composer/slash/state.ts +168 -0
  23. package/src/tools/chat/composer/slash/types.ts +64 -0
  24. package/src/tools/chat/composer/slash/useSlashCommands.ts +204 -0
  25. package/src/tools/chat/composer/types.ts +8 -0
  26. package/src/tools/chat/shell/SuggestedPrompts.tsx +194 -0
  27. package/src/tools/chat/shell/index.ts +6 -0
  28. package/src/tools/data/Listbox/lazy.tsx +1 -1
  29. package/src/tools/data/Masonry/lazy.tsx +1 -1
  30. package/src/tools/data/Timeline/lazy.tsx +1 -1
  31. package/src/tools/data/Tree/FinderTree.tsx +42 -0
  32. package/src/tools/data/Tree/README.md +337 -208
  33. package/src/tools/data/Tree/TreeDndProvider.tsx +137 -0
  34. package/src/tools/data/Tree/TreeRoot.tsx +170 -55
  35. package/src/tools/data/Tree/__tests__/dnd.test.ts +160 -0
  36. package/src/tools/data/Tree/__tests__/keyboard.test.ts +137 -0
  37. package/src/tools/data/Tree/__tests__/renameUtils.test.ts +52 -0
  38. package/src/tools/data/Tree/__tests__/selection.test.ts +227 -0
  39. package/src/tools/data/Tree/components/TreeDropIndicator.tsx +65 -0
  40. package/src/tools/data/Tree/components/TreeEmptyArea.tsx +160 -0
  41. package/src/tools/data/Tree/components/TreeRenameInput.tsx +114 -0
  42. package/src/tools/data/Tree/components/TreeRow.tsx +92 -8
  43. package/src/tools/data/Tree/components/index.ts +6 -0
  44. package/src/tools/data/Tree/context/TreeContext.tsx +204 -363
  45. package/src/tools/data/Tree/context/TreeContextValue.ts +139 -0
  46. package/src/tools/data/Tree/context/async-children/collect-ids.ts +27 -0
  47. package/src/tools/data/Tree/context/async-children/index.ts +8 -0
  48. package/src/tools/data/Tree/context/async-children/use-async-children.ts +157 -0
  49. package/src/tools/data/Tree/context/clipboard/index.ts +4 -0
  50. package/src/tools/data/Tree/context/clipboard/use-clipboard.ts +115 -0
  51. package/src/tools/data/Tree/context/dnd/index.ts +8 -0
  52. package/src/tools/data/Tree/context/dnd/use-dnd.ts +194 -0
  53. package/src/tools/data/Tree/context/expansion/index.ts +4 -0
  54. package/src/tools/data/Tree/context/expansion/use-expansion.ts +55 -0
  55. package/src/tools/data/Tree/context/hooks.ts +68 -1
  56. package/src/tools/data/Tree/context/index.ts +3 -0
  57. package/src/tools/data/Tree/context/menu/builtin-actions.ts +357 -0
  58. package/src/tools/data/Tree/context/menu/index.ts +10 -0
  59. package/src/tools/data/Tree/context/menu/use-resolved-menu.ts +127 -0
  60. package/src/tools/data/Tree/context/persist/index.ts +4 -0
  61. package/src/tools/data/Tree/context/persist/use-persist-sync.ts +74 -0
  62. package/src/tools/data/Tree/context/rename/index.ts +4 -0
  63. package/src/tools/data/Tree/context/rename/use-rename.ts +113 -0
  64. package/src/tools/data/Tree/context/selection/index.ts +4 -0
  65. package/src/tools/data/Tree/context/selection/use-selection.ts +146 -0
  66. package/src/tools/data/Tree/context/state/index.ts +6 -0
  67. package/src/tools/data/Tree/context/state/initial.ts +41 -0
  68. package/src/tools/data/Tree/context/state/reducer.ts +76 -0
  69. package/src/tools/data/Tree/context/state/types.ts +46 -0
  70. package/src/tools/data/Tree/data/clipboard.ts +33 -0
  71. package/src/tools/data/Tree/data/dnd.ts +123 -0
  72. package/src/tools/data/Tree/data/finderShortcuts.ts +67 -0
  73. package/src/tools/data/Tree/data/index.ts +19 -0
  74. package/src/tools/data/Tree/data/renameUtils.ts +51 -0
  75. package/src/tools/data/Tree/data/selection.ts +157 -0
  76. package/src/tools/data/Tree/hooks/finder-hotkeys/build-ctx.ts +48 -0
  77. package/src/tools/data/Tree/hooks/finder-hotkeys/index.ts +8 -0
  78. package/src/tools/data/Tree/hooks/finder-hotkeys/use-tree-finder-hotkeys.ts +166 -0
  79. package/src/tools/data/Tree/hooks/index.ts +23 -4
  80. package/src/tools/data/Tree/hooks/keyboard/activation.ts +27 -0
  81. package/src/tools/data/Tree/hooks/keyboard/arrow-nav.ts +26 -0
  82. package/src/tools/data/Tree/hooks/keyboard/expand-collapse.ts +54 -0
  83. package/src/tools/data/Tree/hooks/keyboard/index.ts +10 -0
  84. package/src/tools/data/Tree/hooks/keyboard/types.ts +39 -0
  85. package/src/tools/data/Tree/hooks/keyboard/use-tree-keyboard.ts +196 -0
  86. package/src/tools/data/Tree/hooks/type-ahead/index.ts +5 -0
  87. package/src/tools/data/Tree/hooks/type-ahead/match-prefix.ts +42 -0
  88. package/src/tools/data/Tree/hooks/{useTreeTypeAhead.ts → type-ahead/use-tree-type-ahead.ts} +8 -19
  89. package/src/tools/data/Tree/index.tsx +25 -2
  90. package/src/tools/data/Tree/types/activation.ts +30 -0
  91. package/src/tools/data/Tree/types/adapter.ts +70 -0
  92. package/src/tools/data/Tree/types/index.ts +27 -0
  93. package/src/tools/data/Tree/types/labels.ts +97 -0
  94. package/src/tools/data/Tree/types/loader.ts +9 -0
  95. package/src/tools/data/Tree/types/node.ts +38 -0
  96. package/src/tools/data/Tree/types/root-props.ts +142 -0
  97. package/src/tools/data/Tree/types/selection.ts +3 -0
  98. package/src/tools/data/Tree/types/slots.ts +64 -0
  99. package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +85 -0
  100. package/src/tools/forms/MarkdownEditor/index.ts +1 -0
  101. package/src/tools/forms/MarkdownEditor/lazy.tsx +6 -0
  102. package/src/tools/forms/MarkdownEditor/slash/SlashCommandNode.ts +162 -0
  103. package/src/tools/forms/MarkdownEditor/slash/index.ts +4 -0
  104. package/src/tools/forms/MarkdownEditor/slash/syncSlashNode.ts +97 -0
  105. package/src/tools/forms/MarkdownEditor/slash/types.ts +13 -0
  106. package/src/tools/forms/MarkdownEditor/styles.css +18 -0
  107. package/dist/types-j2vhn4Kv.d.cts +0 -241
  108. package/dist/types-j2vhn4Kv.d.ts +0 -241
  109. package/src/tools/data/Tree/hooks/useTreeKeyboard.ts +0 -171
  110. package/src/tools/data/Tree/types.ts +0 -217
@@ -0,0 +1,137 @@
1
+ 'use client';
2
+
3
+ import {
4
+ DndContext,
5
+ KeyboardSensor,
6
+ PointerSensor,
7
+ useSensor,
8
+ useSensors,
9
+ type DragEndEvent,
10
+ type DragMoveEvent,
11
+ type DragStartEvent,
12
+ } from '@dnd-kit/core';
13
+ import { useCallback, useRef } from 'react';
14
+
15
+ import { useTreeContext } from './context/TreeContext';
16
+ import { resolveDropZone, TREE_ROOT_DROP_ID } from './data/dnd';
17
+ import type { TreeItemId } from './types';
18
+
19
+ interface TreeDndProviderProps {
20
+ children: React.ReactNode;
21
+ }
22
+
23
+ /**
24
+ * Wrap Tree's body in a `<DndContext>` when DnD is enabled.
25
+ *
26
+ * One central drag handler decides the drop zone (`before` / `inside` /
27
+ * `after`) from the cursor position vs. the hovered row's bounding box.
28
+ * Rows themselves only register `useDraggable` + `useDroppable` — no
29
+ * per-row `onPointerMove`, so dragging across 1000 rows stays cheap.
30
+ *
31
+ * Sensors: pointer (with a small activation distance so a plain click
32
+ * doesn't initiate a drag) + keyboard (drag with Space/Enter, arrows,
33
+ * Space drops — built-in accessibility).
34
+ */
35
+ export function TreeDndProvider({ children }: TreeDndProviderProps) {
36
+ const ctx = useTreeContext();
37
+ const { dnd } = ctx;
38
+
39
+ const sensors = useSensors(
40
+ useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
41
+ useSensor(KeyboardSensor),
42
+ );
43
+
44
+ // Latest cursor position — @dnd-kit gives us the active rectangle but
45
+ // not the raw pointer Y. We grab it from pointermove ourselves and
46
+ // use it inside `onDragMove` to compute the drop zone.
47
+ const cursorYRef = useRef(0);
48
+
49
+ const handleDragStart = useCallback(
50
+ (e: DragStartEvent) => {
51
+ dnd.beginDrag(e.active.id as TreeItemId);
52
+ },
53
+ [dnd],
54
+ );
55
+
56
+ const handleDragMove = useCallback(
57
+ (e: DragMoveEvent) => {
58
+ const overId = e.over?.id;
59
+ // Root drop target — TreeEmptyArea registers under this id.
60
+ if (overId === TREE_ROOT_DROP_ID) {
61
+ const current = dnd.dropTarget;
62
+ if (current?.id !== null || current?.position !== 'inside') {
63
+ dnd.setDropTarget({ id: null, position: 'inside' });
64
+ }
65
+ return;
66
+ }
67
+ if (typeof overId !== 'string') {
68
+ if (dnd.dropTarget !== null) dnd.setDropTarget(null);
69
+ return;
70
+ }
71
+
72
+ // The dnd-kit `over.rect` is the droppable's bounding box. Combined
73
+ // with the latest cursor Y we recover the same before/inside/after
74
+ // split Finder uses.
75
+ const rect = e.over?.rect;
76
+ if (!rect) return;
77
+
78
+ // `data-folder` on the dom row tells us if `inside` is allowed.
79
+ const el = document.querySelector<HTMLElement>(
80
+ `[data-tree-row][data-id="${CSS.escape(overId)}"]`,
81
+ );
82
+ const isFolder = el?.dataset.folder === 'true';
83
+
84
+ const position = resolveDropZone({
85
+ pointerY: cursorYRef.current,
86
+ rowRect: { top: rect.top, bottom: rect.top + rect.height, height: rect.height },
87
+ isFolder,
88
+ });
89
+
90
+ const current = dnd.dropTarget;
91
+ if (current?.id !== overId || current.position !== position) {
92
+ dnd.setDropTarget({ id: overId, position });
93
+ }
94
+ },
95
+ [dnd],
96
+ );
97
+
98
+ const handleDragEnd = useCallback(
99
+ async (_e: DragEndEvent) => {
100
+ await dnd.commitDrop();
101
+ },
102
+ [dnd],
103
+ );
104
+
105
+ const handleDragCancel = useCallback(() => {
106
+ dnd.cancelDrag();
107
+ }, [dnd]);
108
+
109
+ // Track the latest cursor Y position globally while a drag is in flight.
110
+ // Used by `handleDragMove` to figure out which third of the row we're
111
+ // over. Cheap — one passive listener, no re-renders.
112
+ const handlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
113
+ cursorYRef.current = e.clientY;
114
+ }, []);
115
+
116
+ if (!dnd.active) {
117
+ return <>{children}</>;
118
+ }
119
+
120
+ return (
121
+ <DndContext
122
+ sensors={sensors}
123
+ onDragStart={handleDragStart}
124
+ onDragMove={handleDragMove}
125
+ onDragEnd={handleDragEnd}
126
+ onDragCancel={handleDragCancel}
127
+ >
128
+ <div
129
+ onPointerMove={handlePointerMove}
130
+ className="contents"
131
+ data-tree-dnd-surface=""
132
+ >
133
+ {children}
134
+ </div>
135
+ </DndContext>
136
+ );
137
+ }
@@ -11,58 +11,80 @@ import {
11
11
  ContextMenuTrigger,
12
12
  } from '@djangocfg/ui-core/components';
13
13
 
14
- import { TreeProvider, useTreeContext } from './context/TreeContext';
14
+ import { TreeContext, TreeProvider, useTreeContext } from './context/TreeContext';
15
+ import { TreeDndProvider } from './TreeDndProvider';
15
16
  import { TreeContent, treeRowDomId } from './components/TreeContent';
17
+ import { TreeEmptyArea } from './components/TreeEmptyArea';
16
18
  import { TreeSearchInput } from './components/TreeSearchInput';
17
19
  import { appearanceToStyle } from './data/appearance';
18
- import { useTreeKeyboard } from './hooks/useTreeKeyboard';
19
- import { useTreeTypeAhead } from './hooks/useTreeTypeAhead';
20
+ import {
21
+ useTreeKeyboard,
22
+ useTreeTypeAhead,
23
+ useTreeFinderHotkeys,
24
+ } from './hooks';
20
25
  import type {
21
- TreeContextMenuActionsResolver,
26
+ TreeContextMenuItem,
22
27
  TreeContextMenuSlot,
23
28
  TreeRootProps,
24
29
  TreeRowRenderProps,
25
30
  } from './types';
26
31
 
27
32
  /**
28
- * Build a `renderContextMenu` slot from a declarative `actions` resolver.
29
- * Used internally by `TreeRoot` when consumers pass `contextMenuActions`
30
- * instead of the lower-level `renderContextMenu`.
33
+ * Render an array of declarative menu items as a themed `<ContextMenu>`
34
+ * wrapped around the supplied trigger element. Pure presentational layer
35
+ * the caller resolves and merges items.
31
36
  */
32
- function actionsToRenderContextMenu<T>(
33
- resolve: TreeContextMenuActionsResolver<T>,
34
- ): TreeContextMenuSlot<T> {
35
- return (rowProps: TreeRowRenderProps<T>, trigger) => {
36
- const items = resolve(rowProps);
37
- if (!items || items.length === 0) return trigger;
38
- return (
39
- <ContextMenu>
40
- <ContextMenuTrigger asChild>{trigger}</ContextMenuTrigger>
41
- <ContextMenuContent>
42
- {items.map((item, idx) => {
43
- if (item === 'separator') {
44
- return <ContextMenuSeparator key={`sep-${idx}`} />;
45
- }
46
- const Icon = item.icon;
47
- return (
48
- <ContextMenuItem
49
- key={item.id}
50
- disabled={item.disabled}
51
- variant={item.destructive ? 'destructive' : undefined}
52
- onSelect={() => item.onSelect(rowProps)}
53
- >
54
- {Icon ? <Icon /> : null}
55
- {item.label}
56
- {item.shortcut ? (
57
- <ContextMenuShortcut>{item.shortcut}</ContextMenuShortcut>
58
- ) : null}
59
- </ContextMenuItem>
60
- );
61
- })}
62
- </ContextMenuContent>
63
- </ContextMenu>
64
- );
65
- };
37
+ function renderItemsAsContextMenu<T>(
38
+ rowProps: TreeRowRenderProps<T>,
39
+ items: TreeContextMenuItem<T>[],
40
+ trigger: React.ReactNode,
41
+ ): React.ReactNode {
42
+ return (
43
+ <ContextMenu>
44
+ <ContextMenuTrigger asChild>{trigger}</ContextMenuTrigger>
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
+ </ContextMenu>
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Drop trailing / leading / duplicate separators so a merged menu never
73
+ * shows a separator next to a section header or another separator.
74
+ */
75
+ function tidyMenuItems<T>(items: TreeContextMenuItem<T>[]): TreeContextMenuItem<T>[] {
76
+ const out: TreeContextMenuItem<T>[] = [];
77
+ for (const it of items) {
78
+ if (it === 'separator') {
79
+ if (out.length === 0) continue;
80
+ if (out[out.length - 1] === 'separator') continue;
81
+ out.push(it);
82
+ } else {
83
+ out.push(it);
84
+ }
85
+ }
86
+ while (out.length > 0 && out[out.length - 1] === 'separator') out.pop();
87
+ return out;
66
88
  }
67
89
 
68
90
  /**
@@ -89,6 +111,10 @@ function TreeRoot<T>(props: TreeRootProps<T>) {
89
111
  enableSearch = false,
90
112
  enableTypeAhead = true,
91
113
  showIndentGuides = false,
114
+ enableInlineRename = false,
115
+ enableFinderHotkeys = false,
116
+ enableDnD = false,
117
+ canDrop,
92
118
  renderRow,
93
119
  renderIcon,
94
120
  renderLabel,
@@ -98,18 +124,12 @@ function TreeRoot<T>(props: TreeRootProps<T>) {
98
124
  labels,
99
125
  persistKey,
100
126
  persistSelection = false,
127
+ adapter,
128
+ defaultMenuItems,
101
129
  className,
102
130
  style,
103
131
  } = props;
104
132
 
105
- // Short-form actions resolver is auto-converted into a renderContextMenu
106
- // slot. The lower-level slot wins if both are provided.
107
- const resolvedRenderContextMenu = useMemo<TreeContextMenuSlot<T> | undefined>(() => {
108
- if (renderContextMenu) return renderContextMenu;
109
- if (contextMenuActions) return actionsToRenderContextMenu(contextMenuActions);
110
- return undefined;
111
- }, [renderContextMenu, contextMenuActions]);
112
-
113
133
  return (
114
134
  <TreeProvider<T>
115
135
  data={data}
@@ -130,7 +150,16 @@ function TreeRoot<T>(props: TreeRootProps<T>) {
130
150
  renderIcon={renderIcon}
131
151
  renderLabel={renderLabel}
132
152
  renderActions={renderActions}
133
- renderContextMenu={resolvedRenderContextMenu}
153
+ // The provider builds the *declarative* merged resolver. Slot
154
+ // conversion happens inside <TreeRootShell /> via the inner ctx,
155
+ // so built-in actions can read live selection state.
156
+ renderContextMenu={renderContextMenu}
157
+ contextMenuActions={contextMenuActions}
158
+ adapter={adapter}
159
+ defaultMenuItems={defaultMenuItems}
160
+ enableInlineRename={enableInlineRename}
161
+ enableDnD={enableDnD}
162
+ canDrop={canDrop}
134
163
  labels={labels}
135
164
  persistKey={persistKey}
136
165
  persistSelection={persistSelection}
@@ -140,6 +169,7 @@ function TreeRoot<T>(props: TreeRootProps<T>) {
140
169
  style={style}
141
170
  enableSearch={enableSearch}
142
171
  enableTypeAhead={enableTypeAhead}
172
+ enableFinderHotkeys={enableFinderHotkeys}
143
173
  renderRow={renderRow}
144
174
  />
145
175
  </TreeProvider>
@@ -151,6 +181,7 @@ interface TreeRootShellProps<T> {
151
181
  style?: React.CSSProperties;
152
182
  enableSearch: boolean;
153
183
  enableTypeAhead: boolean;
184
+ enableFinderHotkeys: boolean;
154
185
  renderRow?: TreeRootProps<T>['renderRow'];
155
186
  }
156
187
 
@@ -159,16 +190,30 @@ function TreeRootShell<T>({
159
190
  style,
160
191
  enableSearch,
161
192
  enableTypeAhead,
193
+ enableFinderHotkeys,
162
194
  renderRow,
163
195
  }: TreeRootShellProps<T>) {
164
196
  const containerRef = useRef<HTMLDivElement>(null);
165
197
  const ctx = useTreeContext<T>();
166
198
 
167
- // Keyboard navigation (↑↓ ←→ Home/End Enter Esc) — scoped via callback ref.
199
+ // Keyboard navigation (↑↓ ←→ Home/End Enter Esc Cmd+A, Shift-extend) —
200
+ // scoped via callback ref.
201
+ const isMulti = ctx.selectionMode === 'multiple';
168
202
  const { ref: keyboardRef } = useTreeKeyboard<T>({
169
203
  rows: ctx.flatRows,
170
204
  focusedId: ctx.focused,
171
- onFocus: ctx.setFocus,
205
+ multiSelect: isMulti,
206
+ // Pause container hotkeys while inline rename is active so the
207
+ // user can type freely (TreeRenameInput stops bubbling already, but
208
+ // gating here is the cleaner second line of defence).
209
+ enabled: ctx.renamingId === null,
210
+ onFocus: (id, { extend }) => {
211
+ if (extend && isMulti) {
212
+ ctx.moveSelect(id, { extend: true });
213
+ } else {
214
+ ctx.setFocus(id);
215
+ }
216
+ },
172
217
  onSelect: ctx.select,
173
218
  onActivate: (id) => {
174
219
  // Keyboard Enter / Space is always an explicit action — pin (no preview).
@@ -178,14 +223,41 @@ function TreeRootShell<T>({
178
223
  onExpand: ctx.expand,
179
224
  onCollapse: ctx.collapse,
180
225
  onClearSelection: ctx.clearSelection,
226
+ onSelectAll: ctx.selectAll,
227
+ });
228
+
229
+ // Finder hotkeys (P4) — ⌘⌫, F2, ⌘D, ⌘N, ⌘⇧N, ⌘C/X/V. Bound only when
230
+ // `enableFinderHotkeys` is true; individual shortcuts further gated
231
+ // by adapter method availability inside the hook.
232
+ const { ref: finderHotkeysRef } = useTreeFinderHotkeys<T>({
233
+ enabled: enableFinderHotkeys,
234
+ paused: ctx.renamingId !== null,
235
+ adapter: ctx.adapter,
236
+ labels: ctx.labels,
237
+ selected: ctx.selected,
238
+ focused: ctx.focused,
239
+ getNodeById: ctx.getNodeById,
240
+ getItemName: ctx.getItemName,
241
+ startInlineRename: ctx.inlineRenameEnabled ? ctx.startRename : undefined,
242
+ clipboard: {
243
+ hasItems: !!ctx.clipboard && ctx.clipboard.ids.length > 0,
244
+ cut: ctx.cutToClipboard,
245
+ copy: ctx.copyToClipboard,
246
+ // Hotkey paste targets the currently focused row (or null = root).
247
+ paste: () => {
248
+ const target = ctx.focused ? ctx.getNodeById(ctx.focused) ?? null : null;
249
+ return ctx.pasteFromClipboard(target, 'inside');
250
+ },
251
+ },
181
252
  });
182
253
 
183
254
  const setContainerRef = useCallback(
184
255
  (instance: HTMLDivElement | null) => {
185
256
  containerRef.current = instance;
186
257
  keyboardRef(instance);
258
+ finderHotkeysRef(instance);
187
259
  },
188
- [keyboardRef],
260
+ [keyboardRef, finderHotkeysRef],
189
261
  );
190
262
 
191
263
  // Keep the focused row scrolled into view whenever focus moves (keyboard
@@ -216,7 +288,28 @@ function TreeRootShell<T>({
216
288
  enabled: enableTypeAhead,
217
289
  });
218
290
 
219
- return (
291
+ // Build the final renderContextMenu slot from the merged declarative
292
+ // resolver. `renderContextMenu` (escape-hatch slot) wins if both are
293
+ // provided. We replace `ctx.renderContextMenu` on the child context
294
+ // here so TreeRow keeps reading a single source of truth.
295
+ const finalRenderContextMenu = useMemo<TreeContextMenuSlot<T> | undefined>(() => {
296
+ if (ctx.renderContextMenu) return ctx.renderContextMenu;
297
+ const resolve = ctx.resolvedContextMenuActions;
298
+ if (!resolve) return undefined;
299
+ return (rowProps, trigger) => {
300
+ const items = resolve(rowProps);
301
+ const cleaned = items ? tidyMenuItems(items) : null;
302
+ if (!cleaned || cleaned.length === 0) return trigger;
303
+ return renderItemsAsContextMenu(rowProps, cleaned, trigger);
304
+ };
305
+ }, [ctx.renderContextMenu, ctx.resolvedContextMenuActions]);
306
+
307
+ const childCtx = useMemo(
308
+ () => ({ ...ctx, renderContextMenu: finalRenderContextMenu }),
309
+ [ctx, finalRenderContextMenu],
310
+ );
311
+
312
+ const treeBody = (
220
313
  <div
221
314
  ref={setContainerRef}
222
315
  tabIndex={0}
@@ -233,11 +326,33 @@ function TreeRootShell<T>({
233
326
  data-tree-root=""
234
327
  >
235
328
  {enableSearch ? <TreeSearchInput className="mx-2 mt-2" /> : null}
236
- <div className="min-h-0 flex-1 overflow-auto px-1">
329
+ <div className="flex min-h-0 flex-1 flex-col overflow-auto px-1">
237
330
  <TreeContent<T> role="group">{renderRow}</TreeContent>
331
+ {/* Empty-area: catches right-clicks on whitespace below the
332
+ last row + acts as the root drop target for DnD. Always
333
+ rendered — it self-disables when there's nothing to do. */}
334
+ <TreeEmptyArea />
238
335
  </div>
239
336
  </div>
240
337
  );
338
+
339
+ // If we computed a different renderContextMenu slot than the one in
340
+ // the parent provider, override it via a thin nested provider so
341
+ // <TreeRow> reads the merged slot. Otherwise skip the wrapper to keep
342
+ // the render cheap.
343
+ const body =
344
+ finalRenderContextMenu === ctx.renderContextMenu ? (
345
+ treeBody
346
+ ) : (
347
+ <TreeContext.Provider value={childCtx as never}>
348
+ {treeBody}
349
+ </TreeContext.Provider>
350
+ );
351
+
352
+ // Wrap in @dnd-kit context only when DnD is active — `TreeDndProvider`
353
+ // short-circuits to a fragment otherwise, so we don't pay the
354
+ // sensor-registration cost.
355
+ return <TreeDndProvider>{body}</TreeDndProvider>;
241
356
  }
242
357
 
243
358
  export default TreeRoot;
@@ -0,0 +1,160 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { defaultCanDrop, resolveDropZone } from '../data/dnd';
4
+ import type { TreeNode } from '../types';
5
+
6
+ const rowRect = { top: 0, bottom: 30, height: 30 };
7
+
8
+ describe('resolveDropZone (folder targets)', () => {
9
+ it('returns `before` in the top third', () => {
10
+ expect(
11
+ resolveDropZone({ pointerY: 5, rowRect, isFolder: true }),
12
+ ).toBe('before');
13
+ });
14
+
15
+ it('returns `inside` in the middle third', () => {
16
+ expect(
17
+ resolveDropZone({ pointerY: 15, rowRect, isFolder: true }),
18
+ ).toBe('inside');
19
+ });
20
+
21
+ it('returns `after` in the bottom third', () => {
22
+ expect(
23
+ resolveDropZone({ pointerY: 25, rowRect, isFolder: true }),
24
+ ).toBe('after');
25
+ });
26
+ });
27
+
28
+ describe('resolveDropZone (leaf targets)', () => {
29
+ it('splits leaves in half: before / after', () => {
30
+ expect(
31
+ resolveDropZone({ pointerY: 10, rowRect, isFolder: false }),
32
+ ).toBe('before');
33
+ expect(
34
+ resolveDropZone({ pointerY: 20, rowRect, isFolder: false }),
35
+ ).toBe('after');
36
+ });
37
+
38
+ it('never returns `inside` for a leaf', () => {
39
+ // Sweep the row — none of these should report `inside`.
40
+ for (let y = 0; y <= 30; y++) {
41
+ expect(
42
+ resolveDropZone({ pointerY: y, rowRect, isFolder: false }),
43
+ ).not.toBe('inside');
44
+ }
45
+ });
46
+ });
47
+
48
+ describe('resolveDropZone (degenerate row)', () => {
49
+ it('falls back to a sensible default when height is zero', () => {
50
+ expect(
51
+ resolveDropZone({
52
+ pointerY: 5,
53
+ rowRect: { top: 0, bottom: 0, height: 0 },
54
+ isFolder: false,
55
+ }),
56
+ ).toBe('after');
57
+ });
58
+ });
59
+
60
+ // ----------------------------------------------------------------------
61
+ // defaultCanDrop
62
+ // ----------------------------------------------------------------------
63
+
64
+ interface FsItem {
65
+ name: string;
66
+ }
67
+
68
+ const folder: TreeNode<FsItem> = {
69
+ id: 'folder',
70
+ data: { name: 'folder' },
71
+ isFolder: true,
72
+ children: [
73
+ { id: 'child', data: { name: 'child' } },
74
+ {
75
+ id: 'nested-folder',
76
+ data: { name: 'nested' },
77
+ isFolder: true,
78
+ children: [{ id: 'grandchild', data: { name: 'gc' } }],
79
+ },
80
+ ],
81
+ };
82
+
83
+ const leaf: TreeNode<FsItem> = { id: 'leaf', data: { name: 'leaf' } };
84
+
85
+ const lookup = new Map<string, TreeNode<FsItem>>();
86
+ function indexNodes(node: TreeNode<FsItem>) {
87
+ lookup.set(node.id, node);
88
+ if (Array.isArray(node.children)) node.children.forEach(indexNodes);
89
+ }
90
+ indexNodes(folder);
91
+ indexNodes(leaf);
92
+
93
+ describe('defaultCanDrop', () => {
94
+ it('allows a drop on the root (null target)', () => {
95
+ expect(
96
+ defaultCanDrop({
97
+ source: [leaf],
98
+ target: null,
99
+ position: 'inside',
100
+ getNodeById: (id) => lookup.get(id),
101
+ }),
102
+ ).toBe(true);
103
+ });
104
+
105
+ it('rejects dropping a node onto itself', () => {
106
+ expect(
107
+ defaultCanDrop({
108
+ source: [folder],
109
+ target: folder,
110
+ position: 'inside',
111
+ getNodeById: (id) => lookup.get(id),
112
+ }),
113
+ ).toBe(false);
114
+ });
115
+
116
+ it('rejects `inside` drop on a leaf', () => {
117
+ expect(
118
+ defaultCanDrop({
119
+ source: [folder],
120
+ target: leaf,
121
+ position: 'inside',
122
+ getNodeById: (id) => lookup.get(id),
123
+ }),
124
+ ).toBe(false);
125
+ });
126
+
127
+ it('allows `before`/`after` drop on a leaf', () => {
128
+ expect(
129
+ defaultCanDrop({
130
+ source: [folder],
131
+ target: leaf,
132
+ position: 'before',
133
+ getNodeById: (id) => lookup.get(id),
134
+ }),
135
+ ).toBe(true);
136
+ });
137
+
138
+ it('rejects dropping a folder into its own descendant', () => {
139
+ const nested = lookup.get('nested-folder')!;
140
+ expect(
141
+ defaultCanDrop({
142
+ source: [folder],
143
+ target: nested,
144
+ position: 'inside',
145
+ getNodeById: (id) => lookup.get(id),
146
+ }),
147
+ ).toBe(false);
148
+ });
149
+
150
+ it('rejects an empty source list', () => {
151
+ expect(
152
+ defaultCanDrop({
153
+ source: [],
154
+ target: null,
155
+ position: 'inside',
156
+ getNodeById: (id) => lookup.get(id),
157
+ }),
158
+ ).toBe(false);
159
+ });
160
+ });