@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.
- package/dist/file-icon/index.d.cts +1 -1
- package/dist/file-icon/index.d.ts +1 -1
- package/dist/slots-ClRpIzoh.d.cts +88 -0
- package/dist/slots-ClRpIzoh.d.ts +88 -0
- package/dist/tree/index.cjs +1994 -276
- package/dist/tree/index.cjs.map +1 -1
- package/dist/tree/index.d.cts +717 -72
- package/dist/tree/index.d.ts +717 -72
- package/dist/tree/index.mjs +1984 -279
- package/dist/tree/index.mjs.map +1 -1
- package/package.json +10 -6
- package/src/tools/chat/README.md +111 -1
- package/src/tools/chat/composer/Composer.tsx +138 -17
- package/src/tools/chat/composer/ComposerRichTextarea.tsx +25 -0
- package/src/tools/chat/composer/index.ts +22 -0
- package/src/tools/chat/composer/slash/README.md +187 -0
- package/src/tools/chat/composer/slash/SlashHighlightTextarea.tsx +144 -0
- package/src/tools/chat/composer/slash/SlashMenu.tsx +142 -0
- package/src/tools/chat/composer/slash/SlashToken.tsx +57 -0
- package/src/tools/chat/composer/slash/index.ts +44 -0
- package/src/tools/chat/composer/slash/labels.ts +19 -0
- package/src/tools/chat/composer/slash/state.ts +168 -0
- package/src/tools/chat/composer/slash/types.ts +64 -0
- package/src/tools/chat/composer/slash/useSlashCommands.ts +204 -0
- package/src/tools/chat/composer/types.ts +8 -0
- package/src/tools/chat/shell/SuggestedPrompts.tsx +194 -0
- package/src/tools/chat/shell/index.ts +6 -0
- package/src/tools/data/Listbox/lazy.tsx +1 -1
- package/src/tools/data/Masonry/lazy.tsx +1 -1
- package/src/tools/data/Timeline/lazy.tsx +1 -1
- package/src/tools/data/Tree/FinderTree.tsx +42 -0
- package/src/tools/data/Tree/README.md +337 -208
- package/src/tools/data/Tree/TreeDndProvider.tsx +137 -0
- package/src/tools/data/Tree/TreeRoot.tsx +170 -55
- package/src/tools/data/Tree/__tests__/dnd.test.ts +160 -0
- package/src/tools/data/Tree/__tests__/keyboard.test.ts +137 -0
- package/src/tools/data/Tree/__tests__/renameUtils.test.ts +52 -0
- package/src/tools/data/Tree/__tests__/selection.test.ts +227 -0
- package/src/tools/data/Tree/components/TreeDropIndicator.tsx +65 -0
- package/src/tools/data/Tree/components/TreeEmptyArea.tsx +160 -0
- package/src/tools/data/Tree/components/TreeRenameInput.tsx +114 -0
- package/src/tools/data/Tree/components/TreeRow.tsx +92 -8
- package/src/tools/data/Tree/components/index.ts +6 -0
- package/src/tools/data/Tree/context/TreeContext.tsx +204 -363
- package/src/tools/data/Tree/context/TreeContextValue.ts +139 -0
- package/src/tools/data/Tree/context/async-children/collect-ids.ts +27 -0
- package/src/tools/data/Tree/context/async-children/index.ts +8 -0
- package/src/tools/data/Tree/context/async-children/use-async-children.ts +157 -0
- package/src/tools/data/Tree/context/clipboard/index.ts +4 -0
- package/src/tools/data/Tree/context/clipboard/use-clipboard.ts +115 -0
- package/src/tools/data/Tree/context/dnd/index.ts +8 -0
- package/src/tools/data/Tree/context/dnd/use-dnd.ts +194 -0
- package/src/tools/data/Tree/context/expansion/index.ts +4 -0
- package/src/tools/data/Tree/context/expansion/use-expansion.ts +55 -0
- package/src/tools/data/Tree/context/hooks.ts +68 -1
- package/src/tools/data/Tree/context/index.ts +3 -0
- package/src/tools/data/Tree/context/menu/builtin-actions.ts +357 -0
- package/src/tools/data/Tree/context/menu/index.ts +10 -0
- package/src/tools/data/Tree/context/menu/use-resolved-menu.ts +127 -0
- package/src/tools/data/Tree/context/persist/index.ts +4 -0
- package/src/tools/data/Tree/context/persist/use-persist-sync.ts +74 -0
- package/src/tools/data/Tree/context/rename/index.ts +4 -0
- package/src/tools/data/Tree/context/rename/use-rename.ts +113 -0
- package/src/tools/data/Tree/context/selection/index.ts +4 -0
- package/src/tools/data/Tree/context/selection/use-selection.ts +146 -0
- package/src/tools/data/Tree/context/state/index.ts +6 -0
- package/src/tools/data/Tree/context/state/initial.ts +41 -0
- package/src/tools/data/Tree/context/state/reducer.ts +76 -0
- package/src/tools/data/Tree/context/state/types.ts +46 -0
- package/src/tools/data/Tree/data/clipboard.ts +33 -0
- package/src/tools/data/Tree/data/dnd.ts +123 -0
- package/src/tools/data/Tree/data/finderShortcuts.ts +67 -0
- package/src/tools/data/Tree/data/index.ts +19 -0
- package/src/tools/data/Tree/data/renameUtils.ts +51 -0
- package/src/tools/data/Tree/data/selection.ts +157 -0
- package/src/tools/data/Tree/hooks/finder-hotkeys/build-ctx.ts +48 -0
- package/src/tools/data/Tree/hooks/finder-hotkeys/index.ts +8 -0
- package/src/tools/data/Tree/hooks/finder-hotkeys/use-tree-finder-hotkeys.ts +166 -0
- package/src/tools/data/Tree/hooks/index.ts +23 -4
- package/src/tools/data/Tree/hooks/keyboard/activation.ts +27 -0
- package/src/tools/data/Tree/hooks/keyboard/arrow-nav.ts +26 -0
- package/src/tools/data/Tree/hooks/keyboard/expand-collapse.ts +54 -0
- package/src/tools/data/Tree/hooks/keyboard/index.ts +10 -0
- package/src/tools/data/Tree/hooks/keyboard/types.ts +39 -0
- package/src/tools/data/Tree/hooks/keyboard/use-tree-keyboard.ts +196 -0
- package/src/tools/data/Tree/hooks/type-ahead/index.ts +5 -0
- package/src/tools/data/Tree/hooks/type-ahead/match-prefix.ts +42 -0
- package/src/tools/data/Tree/hooks/{useTreeTypeAhead.ts → type-ahead/use-tree-type-ahead.ts} +8 -19
- package/src/tools/data/Tree/index.tsx +25 -2
- package/src/tools/data/Tree/types/activation.ts +30 -0
- package/src/tools/data/Tree/types/adapter.ts +70 -0
- package/src/tools/data/Tree/types/index.ts +27 -0
- package/src/tools/data/Tree/types/labels.ts +97 -0
- package/src/tools/data/Tree/types/loader.ts +9 -0
- package/src/tools/data/Tree/types/node.ts +38 -0
- package/src/tools/data/Tree/types/root-props.ts +142 -0
- package/src/tools/data/Tree/types/selection.ts +3 -0
- package/src/tools/data/Tree/types/slots.ts +64 -0
- package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +85 -0
- package/src/tools/forms/MarkdownEditor/index.ts +1 -0
- package/src/tools/forms/MarkdownEditor/lazy.tsx +6 -0
- package/src/tools/forms/MarkdownEditor/slash/SlashCommandNode.ts +162 -0
- package/src/tools/forms/MarkdownEditor/slash/index.ts +4 -0
- package/src/tools/forms/MarkdownEditor/slash/syncSlashNode.ts +97 -0
- package/src/tools/forms/MarkdownEditor/slash/types.ts +13 -0
- package/src/tools/forms/MarkdownEditor/styles.css +18 -0
- package/dist/types-j2vhn4Kv.d.cts +0 -241
- package/dist/types-j2vhn4Kv.d.ts +0 -241
- package/src/tools/data/Tree/hooks/useTreeKeyboard.ts +0 -171
- 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 {
|
|
19
|
-
|
|
20
|
+
import {
|
|
21
|
+
useTreeKeyboard,
|
|
22
|
+
useTreeTypeAhead,
|
|
23
|
+
useTreeFinderHotkeys,
|
|
24
|
+
} from './hooks';
|
|
20
25
|
import type {
|
|
21
|
-
|
|
26
|
+
TreeContextMenuItem,
|
|
22
27
|
TreeContextMenuSlot,
|
|
23
28
|
TreeRootProps,
|
|
24
29
|
TreeRowRenderProps,
|
|
25
30
|
} from './types';
|
|
26
31
|
|
|
27
32
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
{item.shortcut
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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) —
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|