@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.
- package/dist/audio-player/index.cjs +2099 -0
- package/dist/audio-player/index.cjs.map +1 -0
- package/dist/audio-player/index.css +65 -0
- package/dist/audio-player/index.css.map +1 -0
- package/dist/audio-player/index.d.cts +174 -0
- package/dist/audio-player/index.d.ts +174 -0
- package/dist/audio-player/index.mjs +2076 -0
- package/dist/audio-player/index.mjs.map +1 -0
- package/dist/composer-registry/index.cjs +45 -0
- package/dist/composer-registry/index.cjs.map +1 -0
- package/dist/composer-registry/index.d.cts +73 -0
- package/dist/composer-registry/index.d.ts +73 -0
- package/dist/composer-registry/index.mjs +39 -0
- package/dist/composer-registry/index.mjs.map +1 -0
- 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 +2019 -279
- package/dist/tree/index.cjs.map +1 -1
- package/dist/tree/index.d.cts +731 -72
- package/dist/tree/index.d.ts +731 -72
- package/dist/tree/index.mjs +2009 -282
- package/dist/tree/index.mjs.map +1 -1
- package/package.json +18 -9
- package/src/tools/chat/README.md +111 -1
- package/src/tools/chat/composer/Composer.tsx +146 -25
- 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/context/ChatProvider.tsx +13 -78
- package/src/tools/chat/hooks/useAutoFocusOnStreamEnd.ts +12 -15
- package/src/tools/chat/hooks/useFocusOnEmptyClick.ts +4 -5
- package/src/tools/chat/launcher/header/ChatHeader.tsx +14 -19
- package/src/tools/chat/launcher/header/ChatHeaderActionButton.tsx +8 -12
- 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 +111 -72
- 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 +103 -8
- package/src/tools/data/Tree/components/index.ts +6 -0
- package/src/tools/data/Tree/context/TreeContext.tsx +223 -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 +11 -0
- package/src/tools/data/Tree/context/menu/render.tsx +75 -0
- package/src/tools/data/Tree/context/menu/use-resolved-menu.ts +141 -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 +26 -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 +158 -0
- package/src/tools/data/Tree/types/selection.ts +3 -0
- package/src/tools/data/Tree/types/slots.ts +64 -0
- package/src/tools/dev/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +6 -9
- package/src/tools/dev/OpenapiViewer/components/DocsLayout/index.tsx +2 -4
- 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/src/tools/input/SpeechRecognition/widgets/VoiceComposerSlot.tsx +11 -12
- package/src/tools/integration/ComposerRegistry/index.ts +105 -0
- package/src/tools/media/AudioPlayer/Player.tsx +2 -0
- package/src/tools/media/AudioPlayer/PlayerShell.tsx +37 -22
- package/src/tools/media/AudioPlayer/lazy.tsx +30 -42
- package/src/tools/media/AudioPlayer/parts/Controls/IconButton.tsx +10 -11
- package/src/tools/media/AudioPlayer/parts/Controls/VolumeControl.tsx +52 -115
- package/src/tools/media/AudioPlayer/types.ts +15 -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
|
+
}
|
|
@@ -1,69 +1,20 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useCallback, useEffect,
|
|
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 {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|