@djangocfg/ui-tools 2.1.415 → 2.1.417

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 (140) hide show
  1. package/dist/audio-player/index.cjs +2099 -0
  2. package/dist/audio-player/index.cjs.map +1 -0
  3. package/dist/audio-player/index.css +65 -0
  4. package/dist/audio-player/index.css.map +1 -0
  5. package/dist/audio-player/index.d.cts +174 -0
  6. package/dist/audio-player/index.d.ts +174 -0
  7. package/dist/audio-player/index.mjs +2076 -0
  8. package/dist/audio-player/index.mjs.map +1 -0
  9. package/dist/composer-registry/index.cjs +45 -0
  10. package/dist/composer-registry/index.cjs.map +1 -0
  11. package/dist/composer-registry/index.d.cts +73 -0
  12. package/dist/composer-registry/index.d.ts +73 -0
  13. package/dist/composer-registry/index.mjs +39 -0
  14. package/dist/composer-registry/index.mjs.map +1 -0
  15. package/dist/file-icon/index.d.cts +1 -1
  16. package/dist/file-icon/index.d.ts +1 -1
  17. package/dist/slots-ClRpIzoh.d.cts +88 -0
  18. package/dist/slots-ClRpIzoh.d.ts +88 -0
  19. package/dist/tree/index.cjs +2019 -279
  20. package/dist/tree/index.cjs.map +1 -1
  21. package/dist/tree/index.d.cts +731 -72
  22. package/dist/tree/index.d.ts +731 -72
  23. package/dist/tree/index.mjs +2009 -282
  24. package/dist/tree/index.mjs.map +1 -1
  25. package/package.json +18 -9
  26. package/src/tools/chat/README.md +111 -1
  27. package/src/tools/chat/composer/Composer.tsx +146 -25
  28. package/src/tools/chat/composer/ComposerRichTextarea.tsx +25 -0
  29. package/src/tools/chat/composer/index.ts +22 -0
  30. package/src/tools/chat/composer/slash/README.md +187 -0
  31. package/src/tools/chat/composer/slash/SlashHighlightTextarea.tsx +144 -0
  32. package/src/tools/chat/composer/slash/SlashMenu.tsx +142 -0
  33. package/src/tools/chat/composer/slash/SlashToken.tsx +57 -0
  34. package/src/tools/chat/composer/slash/index.ts +44 -0
  35. package/src/tools/chat/composer/slash/labels.ts +19 -0
  36. package/src/tools/chat/composer/slash/state.ts +168 -0
  37. package/src/tools/chat/composer/slash/types.ts +64 -0
  38. package/src/tools/chat/composer/slash/useSlashCommands.ts +204 -0
  39. package/src/tools/chat/composer/types.ts +8 -0
  40. package/src/tools/chat/context/ChatProvider.tsx +13 -78
  41. package/src/tools/chat/hooks/useAutoFocusOnStreamEnd.ts +12 -15
  42. package/src/tools/chat/hooks/useFocusOnEmptyClick.ts +4 -5
  43. package/src/tools/chat/launcher/header/ChatHeader.tsx +14 -19
  44. package/src/tools/chat/launcher/header/ChatHeaderActionButton.tsx +8 -12
  45. package/src/tools/chat/shell/SuggestedPrompts.tsx +194 -0
  46. package/src/tools/chat/shell/index.ts +6 -0
  47. package/src/tools/data/Listbox/lazy.tsx +1 -1
  48. package/src/tools/data/Masonry/lazy.tsx +1 -1
  49. package/src/tools/data/Timeline/lazy.tsx +1 -1
  50. package/src/tools/data/Tree/FinderTree.tsx +42 -0
  51. package/src/tools/data/Tree/README.md +337 -208
  52. package/src/tools/data/Tree/TreeDndProvider.tsx +137 -0
  53. package/src/tools/data/Tree/TreeRoot.tsx +111 -72
  54. package/src/tools/data/Tree/__tests__/dnd.test.ts +160 -0
  55. package/src/tools/data/Tree/__tests__/keyboard.test.ts +137 -0
  56. package/src/tools/data/Tree/__tests__/renameUtils.test.ts +52 -0
  57. package/src/tools/data/Tree/__tests__/selection.test.ts +227 -0
  58. package/src/tools/data/Tree/components/TreeDropIndicator.tsx +65 -0
  59. package/src/tools/data/Tree/components/TreeEmptyArea.tsx +160 -0
  60. package/src/tools/data/Tree/components/TreeRenameInput.tsx +114 -0
  61. package/src/tools/data/Tree/components/TreeRow.tsx +103 -8
  62. package/src/tools/data/Tree/components/index.ts +6 -0
  63. package/src/tools/data/Tree/context/TreeContext.tsx +223 -363
  64. package/src/tools/data/Tree/context/TreeContextValue.ts +139 -0
  65. package/src/tools/data/Tree/context/async-children/collect-ids.ts +27 -0
  66. package/src/tools/data/Tree/context/async-children/index.ts +8 -0
  67. package/src/tools/data/Tree/context/async-children/use-async-children.ts +157 -0
  68. package/src/tools/data/Tree/context/clipboard/index.ts +4 -0
  69. package/src/tools/data/Tree/context/clipboard/use-clipboard.ts +115 -0
  70. package/src/tools/data/Tree/context/dnd/index.ts +8 -0
  71. package/src/tools/data/Tree/context/dnd/use-dnd.ts +194 -0
  72. package/src/tools/data/Tree/context/expansion/index.ts +4 -0
  73. package/src/tools/data/Tree/context/expansion/use-expansion.ts +55 -0
  74. package/src/tools/data/Tree/context/hooks.ts +68 -1
  75. package/src/tools/data/Tree/context/index.ts +3 -0
  76. package/src/tools/data/Tree/context/menu/builtin-actions.ts +357 -0
  77. package/src/tools/data/Tree/context/menu/index.ts +11 -0
  78. package/src/tools/data/Tree/context/menu/render.tsx +75 -0
  79. package/src/tools/data/Tree/context/menu/use-resolved-menu.ts +141 -0
  80. package/src/tools/data/Tree/context/persist/index.ts +4 -0
  81. package/src/tools/data/Tree/context/persist/use-persist-sync.ts +74 -0
  82. package/src/tools/data/Tree/context/rename/index.ts +4 -0
  83. package/src/tools/data/Tree/context/rename/use-rename.ts +113 -0
  84. package/src/tools/data/Tree/context/selection/index.ts +4 -0
  85. package/src/tools/data/Tree/context/selection/use-selection.ts +146 -0
  86. package/src/tools/data/Tree/context/state/index.ts +6 -0
  87. package/src/tools/data/Tree/context/state/initial.ts +41 -0
  88. package/src/tools/data/Tree/context/state/reducer.ts +76 -0
  89. package/src/tools/data/Tree/context/state/types.ts +46 -0
  90. package/src/tools/data/Tree/data/clipboard.ts +33 -0
  91. package/src/tools/data/Tree/data/dnd.ts +123 -0
  92. package/src/tools/data/Tree/data/finderShortcuts.ts +67 -0
  93. package/src/tools/data/Tree/data/index.ts +19 -0
  94. package/src/tools/data/Tree/data/renameUtils.ts +51 -0
  95. package/src/tools/data/Tree/data/selection.ts +157 -0
  96. package/src/tools/data/Tree/hooks/finder-hotkeys/build-ctx.ts +48 -0
  97. package/src/tools/data/Tree/hooks/finder-hotkeys/index.ts +8 -0
  98. package/src/tools/data/Tree/hooks/finder-hotkeys/use-tree-finder-hotkeys.ts +166 -0
  99. package/src/tools/data/Tree/hooks/index.ts +23 -4
  100. package/src/tools/data/Tree/hooks/keyboard/activation.ts +27 -0
  101. package/src/tools/data/Tree/hooks/keyboard/arrow-nav.ts +26 -0
  102. package/src/tools/data/Tree/hooks/keyboard/expand-collapse.ts +54 -0
  103. package/src/tools/data/Tree/hooks/keyboard/index.ts +10 -0
  104. package/src/tools/data/Tree/hooks/keyboard/types.ts +39 -0
  105. package/src/tools/data/Tree/hooks/keyboard/use-tree-keyboard.ts +196 -0
  106. package/src/tools/data/Tree/hooks/type-ahead/index.ts +5 -0
  107. package/src/tools/data/Tree/hooks/type-ahead/match-prefix.ts +42 -0
  108. package/src/tools/data/Tree/hooks/{useTreeTypeAhead.ts → type-ahead/use-tree-type-ahead.ts} +8 -19
  109. package/src/tools/data/Tree/index.tsx +26 -2
  110. package/src/tools/data/Tree/types/activation.ts +30 -0
  111. package/src/tools/data/Tree/types/adapter.ts +70 -0
  112. package/src/tools/data/Tree/types/index.ts +27 -0
  113. package/src/tools/data/Tree/types/labels.ts +97 -0
  114. package/src/tools/data/Tree/types/loader.ts +9 -0
  115. package/src/tools/data/Tree/types/node.ts +38 -0
  116. package/src/tools/data/Tree/types/root-props.ts +158 -0
  117. package/src/tools/data/Tree/types/selection.ts +3 -0
  118. package/src/tools/data/Tree/types/slots.ts +64 -0
  119. package/src/tools/dev/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +6 -9
  120. package/src/tools/dev/OpenapiViewer/components/DocsLayout/index.tsx +2 -4
  121. package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +85 -0
  122. package/src/tools/forms/MarkdownEditor/index.ts +1 -0
  123. package/src/tools/forms/MarkdownEditor/lazy.tsx +6 -0
  124. package/src/tools/forms/MarkdownEditor/slash/SlashCommandNode.ts +162 -0
  125. package/src/tools/forms/MarkdownEditor/slash/index.ts +4 -0
  126. package/src/tools/forms/MarkdownEditor/slash/syncSlashNode.ts +97 -0
  127. package/src/tools/forms/MarkdownEditor/slash/types.ts +13 -0
  128. package/src/tools/forms/MarkdownEditor/styles.css +18 -0
  129. package/src/tools/input/SpeechRecognition/widgets/VoiceComposerSlot.tsx +11 -12
  130. package/src/tools/integration/ComposerRegistry/index.ts +105 -0
  131. package/src/tools/media/AudioPlayer/Player.tsx +2 -0
  132. package/src/tools/media/AudioPlayer/PlayerShell.tsx +37 -22
  133. package/src/tools/media/AudioPlayer/lazy.tsx +30 -42
  134. package/src/tools/media/AudioPlayer/parts/Controls/IconButton.tsx +10 -11
  135. package/src/tools/media/AudioPlayer/parts/Controls/VolumeControl.tsx +52 -115
  136. package/src/tools/media/AudioPlayer/types.ts +15 -0
  137. package/dist/types-j2vhn4Kv.d.cts +0 -241
  138. package/dist/types-j2vhn4Kv.d.ts +0 -241
  139. package/src/tools/data/Tree/hooks/useTreeKeyboard.ts +0 -171
  140. 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
+ }
@@ -1,69 +1,20 @@
1
1
  'use client';
2
2
 
3
- import { useCallback, useEffect, useMemo, useRef } from 'react';
3
+ import { useCallback, useEffect, useRef } from 'react';
4
4
  import { cn } from '@djangocfg/ui-core/lib';
5
- import {
6
- ContextMenu,
7
- ContextMenuContent,
8
- ContextMenuItem,
9
- ContextMenuSeparator,
10
- ContextMenuShortcut,
11
- ContextMenuTrigger,
12
- } from '@djangocfg/ui-core/components';
13
5
 
14
6
  import { TreeProvider, useTreeContext } from './context/TreeContext';
7
+ import { TreeDndProvider } from './TreeDndProvider';
15
8
  import { TreeContent, treeRowDomId } from './components/TreeContent';
9
+ import { TreeEmptyArea } from './components/TreeEmptyArea';
16
10
  import { TreeSearchInput } from './components/TreeSearchInput';
17
11
  import { appearanceToStyle } from './data/appearance';
18
- import { useTreeKeyboard } from './hooks/useTreeKeyboard';
19
- import { useTreeTypeAhead } from './hooks/useTreeTypeAhead';
20
- import type {
21
- TreeContextMenuActionsResolver,
22
- TreeContextMenuSlot,
23
- TreeRootProps,
24
- TreeRowRenderProps,
25
- } from './types';
26
-
27
- /**
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`.
31
- */
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
- };
66
- }
12
+ import {
13
+ useTreeKeyboard,
14
+ useTreeTypeAhead,
15
+ useTreeFinderHotkeys,
16
+ } from './hooks';
17
+ import type { TreeActionsHandle, TreeRootProps } from './types';
67
18
 
68
19
  /**
69
20
  * High-level entry point. Wraps Provider + (optional) search bar + content.
@@ -89,6 +40,10 @@ function TreeRoot<T>(props: TreeRootProps<T>) {
89
40
  enableSearch = false,
90
41
  enableTypeAhead = true,
91
42
  showIndentGuides = false,
43
+ enableInlineRename = false,
44
+ enableFinderHotkeys = false,
45
+ enableDnD = false,
46
+ canDrop,
92
47
  renderRow,
93
48
  renderIcon,
94
49
  renderLabel,
@@ -98,18 +53,13 @@ function TreeRoot<T>(props: TreeRootProps<T>) {
98
53
  labels,
99
54
  persistKey,
100
55
  persistSelection = false,
56
+ adapter,
57
+ defaultMenuItems,
58
+ actionsRef,
101
59
  className,
102
60
  style,
103
61
  } = props;
104
62
 
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
63
  return (
114
64
  <TreeProvider<T>
115
65
  data={data}
@@ -130,7 +80,16 @@ function TreeRoot<T>(props: TreeRootProps<T>) {
130
80
  renderIcon={renderIcon}
131
81
  renderLabel={renderLabel}
132
82
  renderActions={renderActions}
133
- renderContextMenu={resolvedRenderContextMenu}
83
+ // The provider builds the *declarative* merged resolver. Slot
84
+ // conversion happens inside <TreeRootShell /> via the inner ctx,
85
+ // so built-in actions can read live selection state.
86
+ renderContextMenu={renderContextMenu}
87
+ contextMenuActions={contextMenuActions}
88
+ adapter={adapter}
89
+ defaultMenuItems={defaultMenuItems}
90
+ enableInlineRename={enableInlineRename}
91
+ enableDnD={enableDnD}
92
+ canDrop={canDrop}
134
93
  labels={labels}
135
94
  persistKey={persistKey}
136
95
  persistSelection={persistSelection}
@@ -140,7 +99,9 @@ function TreeRoot<T>(props: TreeRootProps<T>) {
140
99
  style={style}
141
100
  enableSearch={enableSearch}
142
101
  enableTypeAhead={enableTypeAhead}
102
+ enableFinderHotkeys={enableFinderHotkeys}
143
103
  renderRow={renderRow}
104
+ actionsRef={actionsRef}
144
105
  />
145
106
  </TreeProvider>
146
107
  );
@@ -151,7 +112,9 @@ interface TreeRootShellProps<T> {
151
112
  style?: React.CSSProperties;
152
113
  enableSearch: boolean;
153
114
  enableTypeAhead: boolean;
115
+ enableFinderHotkeys: boolean;
154
116
  renderRow?: TreeRootProps<T>['renderRow'];
117
+ actionsRef?: React.MutableRefObject<TreeActionsHandle | null>;
155
118
  }
156
119
 
157
120
  function TreeRootShell<T>({
@@ -159,16 +122,53 @@ function TreeRootShell<T>({
159
122
  style,
160
123
  enableSearch,
161
124
  enableTypeAhead,
125
+ enableFinderHotkeys,
162
126
  renderRow,
127
+ actionsRef,
163
128
  }: TreeRootShellProps<T>) {
164
129
  const containerRef = useRef<HTMLDivElement>(null);
165
130
  const ctx = useTreeContext<T>();
166
131
 
167
- // Keyboard navigation (↑↓ ←→ Home/End Enter Esc) scoped via callback ref.
132
+ // Publish the action handle to the outer ref so host code can call
133
+ // refresh / refreshAll after a mutation that originated outside Tree.
134
+ // Effect runs after mount; the ref stays populated until unmount.
135
+ useEffect(() => {
136
+ if (!actionsRef) return;
137
+ actionsRef.current = {
138
+ refresh: ctx.refresh,
139
+ refreshAll: ctx.refreshAll,
140
+ expandAll: ctx.expandAll,
141
+ collapseAll: ctx.collapseAll,
142
+ };
143
+ return () => {
144
+ if (actionsRef.current) actionsRef.current = null;
145
+ };
146
+ }, [
147
+ actionsRef,
148
+ ctx.refresh,
149
+ ctx.refreshAll,
150
+ ctx.expandAll,
151
+ ctx.collapseAll,
152
+ ]);
153
+
154
+ // Keyboard navigation (↑↓ ←→ Home/End Enter Esc Cmd+A, Shift-extend) —
155
+ // scoped via callback ref.
156
+ const isMulti = ctx.selectionMode === 'multiple';
168
157
  const { ref: keyboardRef } = useTreeKeyboard<T>({
169
158
  rows: ctx.flatRows,
170
159
  focusedId: ctx.focused,
171
- onFocus: ctx.setFocus,
160
+ multiSelect: isMulti,
161
+ // Pause container hotkeys while inline rename is active so the
162
+ // user can type freely (TreeRenameInput stops bubbling already, but
163
+ // gating here is the cleaner second line of defence).
164
+ enabled: ctx.renamingId === null,
165
+ onFocus: (id, { extend }) => {
166
+ if (extend && isMulti) {
167
+ ctx.moveSelect(id, { extend: true });
168
+ } else {
169
+ ctx.setFocus(id);
170
+ }
171
+ },
172
172
  onSelect: ctx.select,
173
173
  onActivate: (id) => {
174
174
  // Keyboard Enter / Space is always an explicit action — pin (no preview).
@@ -178,14 +178,41 @@ function TreeRootShell<T>({
178
178
  onExpand: ctx.expand,
179
179
  onCollapse: ctx.collapse,
180
180
  onClearSelection: ctx.clearSelection,
181
+ onSelectAll: ctx.selectAll,
182
+ });
183
+
184
+ // Finder hotkeys (P4) — ⌘⌫, F2, ⌘D, ⌘N, ⌘⇧N, ⌘C/X/V. Bound only when
185
+ // `enableFinderHotkeys` is true; individual shortcuts further gated
186
+ // by adapter method availability inside the hook.
187
+ const { ref: finderHotkeysRef } = useTreeFinderHotkeys<T>({
188
+ enabled: enableFinderHotkeys,
189
+ paused: ctx.renamingId !== null,
190
+ adapter: ctx.adapter,
191
+ labels: ctx.labels,
192
+ selected: ctx.selected,
193
+ focused: ctx.focused,
194
+ getNodeById: ctx.getNodeById,
195
+ getItemName: ctx.getItemName,
196
+ startInlineRename: ctx.inlineRenameEnabled ? ctx.startRename : undefined,
197
+ clipboard: {
198
+ hasItems: !!ctx.clipboard && ctx.clipboard.ids.length > 0,
199
+ cut: ctx.cutToClipboard,
200
+ copy: ctx.copyToClipboard,
201
+ // Hotkey paste targets the currently focused row (or null = root).
202
+ paste: () => {
203
+ const target = ctx.focused ? ctx.getNodeById(ctx.focused) ?? null : null;
204
+ return ctx.pasteFromClipboard(target, 'inside');
205
+ },
206
+ },
181
207
  });
182
208
 
183
209
  const setContainerRef = useCallback(
184
210
  (instance: HTMLDivElement | null) => {
185
211
  containerRef.current = instance;
186
212
  keyboardRef(instance);
213
+ finderHotkeysRef(instance);
187
214
  },
188
- [keyboardRef],
215
+ [keyboardRef, finderHotkeysRef],
189
216
  );
190
217
 
191
218
  // Keep the focused row scrolled into view whenever focus moves (keyboard
@@ -216,7 +243,10 @@ function TreeRootShell<T>({
216
243
  enabled: enableTypeAhead,
217
244
  });
218
245
 
219
- return (
246
+ // Tree body. `ctx.renderContextMenu` is already the final slot —
247
+ // TreeProvider does the declarative→slot merge centrally, so Shell
248
+ // doesn't re-derive it (and no nested provider override is needed).
249
+ const treeBody = (
220
250
  <div
221
251
  ref={setContainerRef}
222
252
  tabIndex={0}
@@ -233,11 +263,20 @@ function TreeRootShell<T>({
233
263
  data-tree-root=""
234
264
  >
235
265
  {enableSearch ? <TreeSearchInput className="mx-2 mt-2" /> : null}
236
- <div className="min-h-0 flex-1 overflow-auto px-1">
266
+ <div className="flex min-h-0 flex-1 flex-col overflow-auto px-1">
237
267
  <TreeContent<T> role="group">{renderRow}</TreeContent>
268
+ {/* Empty-area: catches right-clicks on whitespace below the
269
+ last row + acts as the root drop target for DnD. Always
270
+ rendered — it self-disables when there's nothing to do. */}
271
+ <TreeEmptyArea />
238
272
  </div>
239
273
  </div>
240
274
  );
275
+
276
+ // Wrap in @dnd-kit context only when DnD is active — `TreeDndProvider`
277
+ // short-circuits to a fragment otherwise, so we don't pay the
278
+ // sensor-registration cost.
279
+ return <TreeDndProvider>{treeBody}</TreeDndProvider>;
241
280
  }
242
281
 
243
282
  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
+ });