@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,39 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { FlatRow, TreeItemId } from '../../types';
|
|
4
|
+
|
|
5
|
+
export interface UseTreeKeyboardOptions<T> {
|
|
6
|
+
rows: FlatRow<T>[];
|
|
7
|
+
focusedId: TreeItemId | null;
|
|
8
|
+
enabled?: boolean;
|
|
9
|
+
/**
|
|
10
|
+
* `true` when `selectionMode === 'multiple'` — enables shift-extend on
|
|
11
|
+
* arrow keys, Home / End, and `Cmd/Ctrl+A` select-all. Without it the
|
|
12
|
+
* shift-modifier just moves focus.
|
|
13
|
+
*/
|
|
14
|
+
multiSelect?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Move focus to `id`. When `extend` is true and multi-select is enabled,
|
|
17
|
+
* the consumer should extend the selection range from anchor through id.
|
|
18
|
+
*/
|
|
19
|
+
onFocus: (id: TreeItemId, opts: { extend: boolean }) => void;
|
|
20
|
+
onSelect: (id: TreeItemId) => void;
|
|
21
|
+
onActivate: (id: TreeItemId) => void;
|
|
22
|
+
onExpand: (id: TreeItemId) => void;
|
|
23
|
+
onCollapse: (id: TreeItemId) => void;
|
|
24
|
+
onClearSelection: () => void;
|
|
25
|
+
/** Cmd/Ctrl+A — select all visible rows. Ignored if multiSelect is false. */
|
|
26
|
+
onSelectAll?: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface UseTreeKeyboardReturn {
|
|
30
|
+
/** Attach to the tree container. Hotkeys only fire when focus is inside. */
|
|
31
|
+
ref: (instance: HTMLElement | null) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Reduced state shape passed to action helpers — keeps them pure. */
|
|
35
|
+
export interface CurrentRow<T> {
|
|
36
|
+
rows: FlatRow<T>[];
|
|
37
|
+
idx: number;
|
|
38
|
+
current: FlatRow<T> | null;
|
|
39
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef } from 'react';
|
|
4
|
+
import { useHotkey } from '@djangocfg/ui-core/hooks';
|
|
5
|
+
|
|
6
|
+
import { edgeRowId, nextRowId, prevRowId } from './arrow-nav';
|
|
7
|
+
import { resolveLeftArrow, resolveRightArrow } from './expand-collapse';
|
|
8
|
+
import { resolveActivate } from './activation';
|
|
9
|
+
import type {
|
|
10
|
+
CurrentRow,
|
|
11
|
+
UseTreeKeyboardOptions,
|
|
12
|
+
UseTreeKeyboardReturn,
|
|
13
|
+
} from './types';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Standard tree keyboard navigation, scoped to the container ref.
|
|
17
|
+
*
|
|
18
|
+
* - ↑ / ↓ : prev / next visible row (Shift extends range)
|
|
19
|
+
* - Home / End : first / last visible row (Shift extends range)
|
|
20
|
+
* - → / ← : expand-or-jump-to-child / collapse-or-jump-to-parent
|
|
21
|
+
* - Enter / Space : activate (folder → toggle, leaf → onActivate)
|
|
22
|
+
* - Esc : clear selection
|
|
23
|
+
* - Cmd/Ctrl + A : select all (multi-select only)
|
|
24
|
+
*
|
|
25
|
+
* Pure decision-making lives in the sibling helpers (`arrow-nav.ts`,
|
|
26
|
+
* `expand-collapse.ts`, `activation.ts`) so it's unit-testable without
|
|
27
|
+
* a DOM. This file only wires up `useHotkey` bindings and dispatches
|
|
28
|
+
* the helper outcomes back to the consumer's callbacks.
|
|
29
|
+
*/
|
|
30
|
+
export function useTreeKeyboard<T>({
|
|
31
|
+
rows,
|
|
32
|
+
focusedId,
|
|
33
|
+
enabled = true,
|
|
34
|
+
multiSelect = false,
|
|
35
|
+
onFocus,
|
|
36
|
+
onSelect,
|
|
37
|
+
onActivate,
|
|
38
|
+
onExpand,
|
|
39
|
+
onCollapse,
|
|
40
|
+
onClearSelection,
|
|
41
|
+
onSelectAll,
|
|
42
|
+
}: UseTreeKeyboardOptions<T>): UseTreeKeyboardReturn {
|
|
43
|
+
// Keep latest values in refs so the callbacks below stay stable across
|
|
44
|
+
// renders — react-hotkeys-hook re-binds on dep change otherwise.
|
|
45
|
+
const rowsRef = useRef(rows);
|
|
46
|
+
const focusedIdRef = useRef(focusedId);
|
|
47
|
+
rowsRef.current = rows;
|
|
48
|
+
focusedIdRef.current = focusedId;
|
|
49
|
+
|
|
50
|
+
const getCurrent = (): CurrentRow<T> => {
|
|
51
|
+
const r = rowsRef.current;
|
|
52
|
+
const id = focusedIdRef.current;
|
|
53
|
+
const idx = id ? r.findIndex((x) => x.node.id === id) : -1;
|
|
54
|
+
return { rows: r, idx, current: idx >= 0 ? r[idx] : null };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Down / Shift+Down. Plain moves focus, shift extends selection range.
|
|
58
|
+
const refDown = useHotkey(
|
|
59
|
+
['down', 'shift+down'],
|
|
60
|
+
(e) => {
|
|
61
|
+
const { rows: r, idx } = getCurrent();
|
|
62
|
+
const id = nextRowId(r, idx);
|
|
63
|
+
if (id) onFocus(id, { extend: multiSelect && e.shiftKey });
|
|
64
|
+
},
|
|
65
|
+
{ enabled, preventDefault: true, description: 'Next row (Shift extends)' },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const refUp = useHotkey(
|
|
69
|
+
['up', 'shift+up'],
|
|
70
|
+
(e) => {
|
|
71
|
+
const { rows: r, idx } = getCurrent();
|
|
72
|
+
const id = prevRowId(r, idx);
|
|
73
|
+
if (id) onFocus(id, { extend: multiSelect && e.shiftKey });
|
|
74
|
+
},
|
|
75
|
+
{ enabled, preventDefault: true, description: 'Previous row (Shift extends)' },
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const refHome = useHotkey(
|
|
79
|
+
['home', 'shift+home'],
|
|
80
|
+
(e) => {
|
|
81
|
+
const id = edgeRowId(rowsRef.current, 'first');
|
|
82
|
+
if (id) onFocus(id, { extend: multiSelect && e.shiftKey });
|
|
83
|
+
},
|
|
84
|
+
{ enabled, preventDefault: true, description: 'First row (Shift extends)' },
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const refEnd = useHotkey(
|
|
88
|
+
['end', 'shift+end'],
|
|
89
|
+
(e) => {
|
|
90
|
+
const id = edgeRowId(rowsRef.current, 'last');
|
|
91
|
+
if (id) onFocus(id, { extend: multiSelect && e.shiftKey });
|
|
92
|
+
},
|
|
93
|
+
{ enabled, preventDefault: true, description: 'Last row (Shift extends)' },
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const refSelectAll = useHotkey(
|
|
97
|
+
'mod+a',
|
|
98
|
+
() => {
|
|
99
|
+
if (!multiSelect) return;
|
|
100
|
+
onSelectAll?.();
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
enabled: enabled && multiSelect,
|
|
104
|
+
preventDefault: true,
|
|
105
|
+
description: 'Select all visible rows',
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const refRight = useHotkey(
|
|
110
|
+
'right',
|
|
111
|
+
() => {
|
|
112
|
+
const { rows: r, idx, current } = getCurrent();
|
|
113
|
+
const out = resolveRightArrow(current, r, idx);
|
|
114
|
+
switch (out.kind) {
|
|
115
|
+
case 'expand':
|
|
116
|
+
onExpand(out.id);
|
|
117
|
+
return;
|
|
118
|
+
case 'focus':
|
|
119
|
+
onFocus(out.id, { extend: false });
|
|
120
|
+
return;
|
|
121
|
+
case 'noop':
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
{ enabled, preventDefault: true, description: 'Expand / first child' },
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const refLeft = useHotkey(
|
|
129
|
+
'left',
|
|
130
|
+
() => {
|
|
131
|
+
const { current } = getCurrent();
|
|
132
|
+
const out = resolveLeftArrow(current);
|
|
133
|
+
switch (out.kind) {
|
|
134
|
+
case 'collapse':
|
|
135
|
+
onCollapse(out.id);
|
|
136
|
+
return;
|
|
137
|
+
case 'focus':
|
|
138
|
+
onFocus(out.id, { extend: false });
|
|
139
|
+
return;
|
|
140
|
+
case 'noop':
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
{ enabled, preventDefault: true, description: 'Collapse / parent' },
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const refActivate = useHotkey(
|
|
148
|
+
['enter', 'space'],
|
|
149
|
+
() => {
|
|
150
|
+
const { current } = getCurrent();
|
|
151
|
+
const out = resolveActivate(current);
|
|
152
|
+
if (out.kind === 'noop') return;
|
|
153
|
+
onSelect(out.kind === 'activate-leaf' ? out.id : out.id);
|
|
154
|
+
if (out.kind === 'toggle-folder') {
|
|
155
|
+
if (out.willExpand) onExpand(out.id);
|
|
156
|
+
else onCollapse(out.id);
|
|
157
|
+
} else {
|
|
158
|
+
onActivate(out.id);
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
{ enabled, preventDefault: true, description: 'Activate / toggle' },
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const refEscape = useHotkey(
|
|
165
|
+
'escape',
|
|
166
|
+
() => onClearSelection(),
|
|
167
|
+
{ enabled, preventDefault: true, description: 'Clear selection' },
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const ref = useCallback(
|
|
171
|
+
(instance: HTMLElement | null) => {
|
|
172
|
+
refDown(instance);
|
|
173
|
+
refUp(instance);
|
|
174
|
+
refHome(instance);
|
|
175
|
+
refEnd(instance);
|
|
176
|
+
refRight(instance);
|
|
177
|
+
refLeft(instance);
|
|
178
|
+
refActivate(instance);
|
|
179
|
+
refEscape(instance);
|
|
180
|
+
refSelectAll(instance);
|
|
181
|
+
},
|
|
182
|
+
[
|
|
183
|
+
refDown,
|
|
184
|
+
refUp,
|
|
185
|
+
refHome,
|
|
186
|
+
refEnd,
|
|
187
|
+
refRight,
|
|
188
|
+
refLeft,
|
|
189
|
+
refActivate,
|
|
190
|
+
refEscape,
|
|
191
|
+
refSelectAll,
|
|
192
|
+
],
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
return { ref };
|
|
196
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { FlatRow, TreeNode } from '../../types';
|
|
4
|
+
|
|
5
|
+
/** Find the first row whose name starts with `prefix` (case-insensitive). */
|
|
6
|
+
export function findRowByPrefix<T>(
|
|
7
|
+
rows: readonly FlatRow<T>[],
|
|
8
|
+
getName: (node: TreeNode<T>) => string,
|
|
9
|
+
prefix: string,
|
|
10
|
+
): FlatRow<T> | undefined {
|
|
11
|
+
if (prefix.length === 0) return undefined;
|
|
12
|
+
return rows.find((row) => getName(row.node).toLowerCase().startsWith(prefix));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Should this key terminate type-ahead instead of extending the buffer?
|
|
17
|
+
* Navigation, Enter, Tab, Escape — all reset; everything else continues.
|
|
18
|
+
*/
|
|
19
|
+
export function isResetKey(key: string): boolean {
|
|
20
|
+
return (
|
|
21
|
+
key === 'Escape' ||
|
|
22
|
+
key === 'Enter' ||
|
|
23
|
+
key === 'Tab' ||
|
|
24
|
+
key.startsWith('Arrow') ||
|
|
25
|
+
key === 'Home' ||
|
|
26
|
+
key === 'End' ||
|
|
27
|
+
key === 'PageUp' ||
|
|
28
|
+
key === 'PageDown'
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Skip keystrokes that originate inside a text field so type-ahead
|
|
34
|
+
* doesn't yank focus while the user is filling in a form.
|
|
35
|
+
*/
|
|
36
|
+
export function isTypingTarget(target: EventTarget | null): boolean {
|
|
37
|
+
const el = target as HTMLElement | null;
|
|
38
|
+
if (!el) return false;
|
|
39
|
+
const tag = el.tagName;
|
|
40
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
|
41
|
+
return el.isContentEditable === true;
|
|
42
|
+
}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useRef } from 'react';
|
|
4
4
|
|
|
5
|
-
import type { FlatRow, TreeNode } from '
|
|
5
|
+
import type { FlatRow, TreeNode } from '../../types';
|
|
6
|
+
import { findRowByPrefix, isResetKey, isTypingTarget } from './match-prefix';
|
|
6
7
|
|
|
7
8
|
const FLUSH_MS = 600;
|
|
8
9
|
|
|
@@ -56,34 +57,22 @@ export function useTreeTypeAhead<T>({
|
|
|
56
57
|
};
|
|
57
58
|
|
|
58
59
|
const handler = (e: KeyboardEvent) => {
|
|
59
|
-
|
|
60
|
-
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
|
61
|
-
if ((e.target as HTMLElement | null)?.isContentEditable) return;
|
|
60
|
+
if (isTypingTarget(e.target)) return;
|
|
62
61
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
63
|
-
|
|
64
|
-
if (
|
|
65
|
-
e.key === 'Escape' ||
|
|
66
|
-
e.key === 'Enter' ||
|
|
67
|
-
e.key === 'Tab' ||
|
|
68
|
-
e.key.startsWith('Arrow') ||
|
|
69
|
-
e.key === 'Home' ||
|
|
70
|
-
e.key === 'End' ||
|
|
71
|
-
e.key === 'PageUp' ||
|
|
72
|
-
e.key === 'PageDown'
|
|
73
|
-
) {
|
|
62
|
+
if (isResetKey(e.key)) {
|
|
74
63
|
reset();
|
|
75
64
|
return;
|
|
76
65
|
}
|
|
77
|
-
|
|
78
66
|
if (e.key.length !== 1) return;
|
|
79
67
|
|
|
80
68
|
bufferRef.current += e.key.toLowerCase();
|
|
81
69
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
82
70
|
timerRef.current = setTimeout(reset, FLUSH_MS);
|
|
83
71
|
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
getNameRef.current
|
|
72
|
+
const hit = findRowByPrefix(
|
|
73
|
+
rowsRef.current,
|
|
74
|
+
getNameRef.current,
|
|
75
|
+
bufferRef.current,
|
|
87
76
|
);
|
|
88
77
|
if (hit) {
|
|
89
78
|
e.preventDefault();
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
export { TreeRoot, TreeRoot as Tree, default as default } from './TreeRoot';
|
|
19
|
+
export { FinderTree } from './FinderTree';
|
|
19
20
|
|
|
20
21
|
export {
|
|
21
22
|
TreeProvider,
|
|
@@ -26,12 +27,19 @@ export {
|
|
|
26
27
|
useTreeExpansion,
|
|
27
28
|
useTreeFocus,
|
|
28
29
|
useTreeSearch,
|
|
30
|
+
useTreeRename,
|
|
31
|
+
useTreeClipboard,
|
|
32
|
+
useTreeDnd,
|
|
29
33
|
useTreeActions,
|
|
30
34
|
} from './context';
|
|
31
35
|
export type { TreeProviderProps, TreeContextValue } from './context';
|
|
32
36
|
|
|
33
|
-
export { useTreeTypeAhead, useTreeKeyboard } from './hooks';
|
|
34
|
-
export type {
|
|
37
|
+
export { useTreeTypeAhead, useTreeKeyboard, useTreeFinderHotkeys } from './hooks';
|
|
38
|
+
export type {
|
|
39
|
+
UseTreeTypeAheadOptions,
|
|
40
|
+
UseTreeKeyboardOptions,
|
|
41
|
+
UseTreeFinderHotkeysOptions,
|
|
42
|
+
} from './hooks';
|
|
35
43
|
|
|
36
44
|
export {
|
|
37
45
|
TreeContent,
|
|
@@ -44,6 +52,9 @@ export {
|
|
|
44
52
|
TreeSkeleton,
|
|
45
53
|
TreeError,
|
|
46
54
|
TreeIndentGuides,
|
|
55
|
+
TreeRenameInput,
|
|
56
|
+
TreeDropIndicator,
|
|
57
|
+
TreeEmptyArea,
|
|
47
58
|
} from './components';
|
|
48
59
|
export type {
|
|
49
60
|
TreeContentProps,
|
|
@@ -56,6 +67,9 @@ export type {
|
|
|
56
67
|
TreeSkeletonProps,
|
|
57
68
|
TreeErrorProps,
|
|
58
69
|
TreeIndentGuidesProps,
|
|
70
|
+
TreeRenameInputProps,
|
|
71
|
+
TreeDropIndicatorProps,
|
|
72
|
+
TreeEmptyAreaProps,
|
|
59
73
|
} from './components';
|
|
60
74
|
|
|
61
75
|
export {
|
|
@@ -69,6 +83,11 @@ export {
|
|
|
69
83
|
DEFAULT_TREE_APPEARANCE,
|
|
70
84
|
resolveAppearance,
|
|
71
85
|
appearanceToStyle,
|
|
86
|
+
splitFileName,
|
|
87
|
+
autoSelectRange,
|
|
88
|
+
resolveDropZone,
|
|
89
|
+
defaultCanDrop,
|
|
90
|
+
TREE_DND_MIME,
|
|
72
91
|
} from './data';
|
|
73
92
|
export type {
|
|
74
93
|
ChildCache,
|
|
@@ -98,7 +117,11 @@ export type {
|
|
|
98
117
|
TreeContextMenuSlot,
|
|
99
118
|
TreeContextMenuAction,
|
|
100
119
|
TreeContextMenuItem,
|
|
120
|
+
TreeContextMenuActionsContext,
|
|
101
121
|
TreeContextMenuActionsResolver,
|
|
102
122
|
TreeLoadChildren,
|
|
123
|
+
TreeAdapter,
|
|
124
|
+
TreeBuiltinAction,
|
|
125
|
+
TreeMovePosition,
|
|
103
126
|
FlatRow,
|
|
104
127
|
} from './types';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How a node becomes "activated" (i.e. opened) on pointer interaction.
|
|
5
|
+
*
|
|
6
|
+
* - `'single-click'` (default): single click activates a leaf immediately;
|
|
7
|
+
* double-click also activates. Folders always toggle on single click.
|
|
8
|
+
* - `'double-click'`: single click only selects + focuses; double-click is
|
|
9
|
+
* required to activate. Mirrors classic file-manager behaviour.
|
|
10
|
+
* - `'single-click-preview'`: VSCode Explorer / Cursor behaviour. Single
|
|
11
|
+
* click activates with `{ preview: true }` (consumer renders a preview
|
|
12
|
+
* tab); double-click activates with `{ preview: false }` (pinned tab).
|
|
13
|
+
*
|
|
14
|
+
* Folders ignore this setting — they always toggle on single click and
|
|
15
|
+
* never call `onActivate`.
|
|
16
|
+
*/
|
|
17
|
+
export type TreeActivationMode =
|
|
18
|
+
| 'single-click'
|
|
19
|
+
| 'double-click'
|
|
20
|
+
| 'single-click-preview';
|
|
21
|
+
|
|
22
|
+
export interface TreeActivateOptions {
|
|
23
|
+
/**
|
|
24
|
+
* `true` when the activation came from a single click in
|
|
25
|
+
* `'single-click-preview'` mode. `false` for double-click and for
|
|
26
|
+
* non-preview modes. Consumers typically map this to a
|
|
27
|
+
* preview-tab vs pinned-tab distinction.
|
|
28
|
+
*/
|
|
29
|
+
preview: boolean;
|
|
30
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { TreeNode } from './node';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Position of a drop / move relative to the target row. `inside` means
|
|
7
|
+
* "drop into this folder"; `before`/`after` are sibling reorder hints.
|
|
8
|
+
*/
|
|
9
|
+
export type TreeMovePosition = 'before' | 'inside' | 'after';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* CRUD adapter. Every method is optional — Tree only surfaces built-in
|
|
13
|
+
* menu items / hotkeys whose adapter method is defined. So an
|
|
14
|
+
* inspection-only tree just passes `{}` (or no adapter) and gets no
|
|
15
|
+
* destructive menu actions.
|
|
16
|
+
*
|
|
17
|
+
* Dialogs (`alert` / `confirm` / `prompt`) are taken from `window.dialog`
|
|
18
|
+
* exposed by `<DialogProvider />` in `@djangocfg/ui-core`. The host app
|
|
19
|
+
* is expected to mount that provider once at the layout level — Tree
|
|
20
|
+
* never re-implements its own dialogs.
|
|
21
|
+
*/
|
|
22
|
+
export interface TreeAdapter<T = unknown> {
|
|
23
|
+
/** Delete the given nodes. Tree calls `window.dialog.confirm` first. */
|
|
24
|
+
remove?: (nodes: TreeNode<T>[]) => Promise<void>;
|
|
25
|
+
/** Inline rename — node + new name (already non-empty, post-validate). */
|
|
26
|
+
rename?: (node: TreeNode<T>, nextName: string) => Promise<void>;
|
|
27
|
+
/** Create a file under `parent` (null → root). Tree prompts for name. */
|
|
28
|
+
createFile?: (parent: TreeNode<T> | null, name: string) => Promise<void>;
|
|
29
|
+
/** Create a folder under `parent` (null → root). Tree prompts for name. */
|
|
30
|
+
createFolder?: (parent: TreeNode<T> | null, name: string) => Promise<void>;
|
|
31
|
+
/** Duplicate the given nodes in-place. */
|
|
32
|
+
duplicate?: (nodes: TreeNode<T>[]) => Promise<void>;
|
|
33
|
+
/** Move nodes (drag-and-drop, cut+paste). */
|
|
34
|
+
move?: (
|
|
35
|
+
nodes: TreeNode<T>[],
|
|
36
|
+
target: TreeNode<T> | null,
|
|
37
|
+
position: TreeMovePosition,
|
|
38
|
+
) => Promise<void>;
|
|
39
|
+
/** Copy nodes (copy+paste, drop with modifier). */
|
|
40
|
+
copy?: (
|
|
41
|
+
nodes: TreeNode<T>[],
|
|
42
|
+
target: TreeNode<T> | null,
|
|
43
|
+
position: TreeMovePosition,
|
|
44
|
+
) => Promise<void>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Optional name validator. Return a non-empty string to surface as an
|
|
48
|
+
* error via `window.dialog.alert`. Return `null` to accept.
|
|
49
|
+
*/
|
|
50
|
+
validateName?: (
|
|
51
|
+
name: string,
|
|
52
|
+
ctx: { node?: TreeNode<T>; parent?: TreeNode<T> | null },
|
|
53
|
+
) => string | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Built-in action ids. Used by `defaultMenuItems` and the internal
|
|
58
|
+
* built-in action registry. Each id maps 1:1 to an adapter method
|
|
59
|
+
* (plus a few selection helpers).
|
|
60
|
+
*/
|
|
61
|
+
export type TreeBuiltinAction =
|
|
62
|
+
| 'open'
|
|
63
|
+
| 'rename'
|
|
64
|
+
| 'duplicate'
|
|
65
|
+
| 'cut'
|
|
66
|
+
| 'copy'
|
|
67
|
+
| 'paste'
|
|
68
|
+
| 'delete'
|
|
69
|
+
| 'new-file'
|
|
70
|
+
| 'new-folder';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// Public surface — re-exports each types module. External code imports
|
|
4
|
+
// from `'../types'` (or `'@djangocfg/ui-tools/tree'`) and never touches
|
|
5
|
+
// these files directly.
|
|
6
|
+
|
|
7
|
+
export type { TreeItemId, TreeNode, FlatRow } from './node';
|
|
8
|
+
export type { TreeSelectionMode } from './selection';
|
|
9
|
+
export type { TreeActivationMode, TreeActivateOptions } from './activation';
|
|
10
|
+
export type { TreeLoadChildren } from './loader';
|
|
11
|
+
export type { TreeLabels } from './labels';
|
|
12
|
+
export { DEFAULT_TREE_LABELS } from './labels';
|
|
13
|
+
export type {
|
|
14
|
+
TreeRowRenderProps,
|
|
15
|
+
TreeRowSlot,
|
|
16
|
+
TreeContextMenuSlot,
|
|
17
|
+
TreeContextMenuAction,
|
|
18
|
+
TreeContextMenuItem,
|
|
19
|
+
TreeContextMenuActionsContext,
|
|
20
|
+
TreeContextMenuActionsResolver,
|
|
21
|
+
} from './slots';
|
|
22
|
+
export type {
|
|
23
|
+
TreeMovePosition,
|
|
24
|
+
TreeAdapter,
|
|
25
|
+
TreeBuiltinAction,
|
|
26
|
+
} from './adapter';
|
|
27
|
+
export type { TreeRootProps } from './root-props';
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
export interface TreeLabels {
|
|
4
|
+
loading: string;
|
|
5
|
+
empty: string;
|
|
6
|
+
error: string;
|
|
7
|
+
searchPlaceholder: string;
|
|
8
|
+
searchMatches: (count: number) => string;
|
|
9
|
+
ariaLabel: string;
|
|
10
|
+
|
|
11
|
+
// ---- CRUD flow copy (used by built-in adapter actions + dialogs) ----
|
|
12
|
+
|
|
13
|
+
/** Default context-menu item labels. */
|
|
14
|
+
actionOpen: string;
|
|
15
|
+
actionRename: string;
|
|
16
|
+
actionDuplicate: string;
|
|
17
|
+
actionCut: string;
|
|
18
|
+
actionCopy: string;
|
|
19
|
+
actionPaste: string;
|
|
20
|
+
actionDelete: string;
|
|
21
|
+
actionNewFile: string;
|
|
22
|
+
actionNewFolder: string;
|
|
23
|
+
|
|
24
|
+
/** Delete confirmation dialog. */
|
|
25
|
+
confirmDeleteTitle: (count: number) => string;
|
|
26
|
+
confirmDeleteMessage: (names: string[]) => string;
|
|
27
|
+
confirmDeleteOk: string;
|
|
28
|
+
confirmDeleteCancel: string;
|
|
29
|
+
|
|
30
|
+
/** New file prompt. */
|
|
31
|
+
newFileTitle: string;
|
|
32
|
+
newFileMessage: string;
|
|
33
|
+
newFilePlaceholder: string;
|
|
34
|
+
newFileDefault: string;
|
|
35
|
+
|
|
36
|
+
/** New folder prompt. */
|
|
37
|
+
newFolderTitle: string;
|
|
38
|
+
newFolderMessage: string;
|
|
39
|
+
newFolderPlaceholder: string;
|
|
40
|
+
newFolderDefault: string;
|
|
41
|
+
|
|
42
|
+
/** Rename prompt (used when inline rename is unavailable / disabled). */
|
|
43
|
+
renameTitle: string;
|
|
44
|
+
renameMessage: string;
|
|
45
|
+
|
|
46
|
+
/** Name validation. */
|
|
47
|
+
invalidNameEmpty: string;
|
|
48
|
+
|
|
49
|
+
/** Suffix used by the default `duplicate` flow when the consumer's adapter
|
|
50
|
+
* needs a hint name. Receives the source name. */
|
|
51
|
+
duplicateSuffix: (name: string) => string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const DEFAULT_TREE_LABELS: TreeLabels = {
|
|
55
|
+
loading: 'Loading…',
|
|
56
|
+
empty: 'Nothing to show',
|
|
57
|
+
error: 'Failed to load',
|
|
58
|
+
searchPlaceholder: 'Search…',
|
|
59
|
+
searchMatches: (n) => `${n} match${n === 1 ? '' : 'es'}`,
|
|
60
|
+
ariaLabel: 'Tree',
|
|
61
|
+
|
|
62
|
+
actionOpen: 'Open',
|
|
63
|
+
actionRename: 'Rename',
|
|
64
|
+
actionDuplicate: 'Duplicate',
|
|
65
|
+
actionCut: 'Cut',
|
|
66
|
+
actionCopy: 'Copy',
|
|
67
|
+
actionPaste: 'Paste',
|
|
68
|
+
actionDelete: 'Delete',
|
|
69
|
+
actionNewFile: 'New file',
|
|
70
|
+
actionNewFolder: 'New folder',
|
|
71
|
+
|
|
72
|
+
confirmDeleteTitle: (n) =>
|
|
73
|
+
n === 1 ? 'Delete item?' : `Delete ${n} items?`,
|
|
74
|
+
confirmDeleteMessage: (names) =>
|
|
75
|
+
names.length === 1
|
|
76
|
+
? `"${names[0]}" will be removed. This action cannot be undone.`
|
|
77
|
+
: `${names.length} items will be removed. This action cannot be undone.`,
|
|
78
|
+
confirmDeleteOk: 'Delete',
|
|
79
|
+
confirmDeleteCancel: 'Cancel',
|
|
80
|
+
|
|
81
|
+
newFileTitle: 'New file',
|
|
82
|
+
newFileMessage: 'File name',
|
|
83
|
+
newFilePlaceholder: 'untitled.txt',
|
|
84
|
+
newFileDefault: 'untitled.txt',
|
|
85
|
+
|
|
86
|
+
newFolderTitle: 'New folder',
|
|
87
|
+
newFolderMessage: 'Folder name',
|
|
88
|
+
newFolderPlaceholder: 'untitled folder',
|
|
89
|
+
newFolderDefault: 'untitled folder',
|
|
90
|
+
|
|
91
|
+
renameTitle: 'Rename',
|
|
92
|
+
renameMessage: 'New name',
|
|
93
|
+
|
|
94
|
+
invalidNameEmpty: 'Name cannot be empty',
|
|
95
|
+
|
|
96
|
+
duplicateSuffix: (name) => `${name} copy`,
|
|
97
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { TreeNode } from './node';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Async loader: called the first time a folder is expanded with no inline
|
|
7
|
+
* `children`. Result is cached; concurrent expansions are de-duplicated.
|
|
8
|
+
*/
|
|
9
|
+
export type TreeLoadChildren<T> = (node: TreeNode<T>) => Promise<TreeNode<T>[]>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
export type TreeItemId = string;
|
|
4
|
+
|
|
5
|
+
/** A single node in the consumer's tree data. Generic over your payload. */
|
|
6
|
+
export interface TreeNode<T = unknown> {
|
|
7
|
+
id: TreeItemId;
|
|
8
|
+
data: T;
|
|
9
|
+
/** Inline children. Omit (and provide a `loadChildren`) for async loading. */
|
|
10
|
+
children?: TreeNode<T>[];
|
|
11
|
+
/**
|
|
12
|
+
* Set to `true` to mark a node as a folder even when its `children` array
|
|
13
|
+
* is empty (e.g. an unloaded async folder). Default: derived from
|
|
14
|
+
* `Array.isArray(children)`.
|
|
15
|
+
*/
|
|
16
|
+
isFolder?: boolean;
|
|
17
|
+
/** Disable interaction. */
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Internal flat-row representation used by the renderer + keyboard nav.
|
|
23
|
+
* Produced by `flattenTree`; consumed by `TreeRow`, the keyboard hook,
|
|
24
|
+
* and the type-ahead hook.
|
|
25
|
+
*/
|
|
26
|
+
export interface FlatRow<T> {
|
|
27
|
+
node: TreeNode<T>;
|
|
28
|
+
level: number;
|
|
29
|
+
parentId: TreeItemId | null;
|
|
30
|
+
isFolder: boolean;
|
|
31
|
+
isExpanded: boolean;
|
|
32
|
+
isLoading: boolean;
|
|
33
|
+
hasError: boolean;
|
|
34
|
+
/** 1-based position among visible siblings (for `aria-posinset`). */
|
|
35
|
+
posInSet: number;
|
|
36
|
+
/** Count of visible siblings sharing this row's parent (for `aria-setsize`). */
|
|
37
|
+
setSize: number;
|
|
38
|
+
}
|