@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
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { edgeRowId, nextRowId, prevRowId } from '../hooks/keyboard/arrow-nav';
|
|
4
|
+
import { resolveActivate } from '../hooks/keyboard/activation';
|
|
5
|
+
import {
|
|
6
|
+
resolveLeftArrow,
|
|
7
|
+
resolveRightArrow,
|
|
8
|
+
} from '../hooks/keyboard/expand-collapse';
|
|
9
|
+
import type { FlatRow } from '../types';
|
|
10
|
+
|
|
11
|
+
function row(
|
|
12
|
+
id: string,
|
|
13
|
+
overrides: Partial<FlatRow<unknown>> = {},
|
|
14
|
+
): FlatRow<unknown> {
|
|
15
|
+
return {
|
|
16
|
+
node: { id, data: undefined },
|
|
17
|
+
level: 0,
|
|
18
|
+
parentId: null,
|
|
19
|
+
isFolder: false,
|
|
20
|
+
isExpanded: false,
|
|
21
|
+
isLoading: false,
|
|
22
|
+
hasError: false,
|
|
23
|
+
posInSet: 1,
|
|
24
|
+
setSize: 1,
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const rows = ['a', 'b', 'c'].map((id) => row(id));
|
|
30
|
+
|
|
31
|
+
// ----------------------------------------------------------------------
|
|
32
|
+
// arrow-nav
|
|
33
|
+
// ----------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
describe('nextRowId / prevRowId', () => {
|
|
36
|
+
it('moves one row forward / back', () => {
|
|
37
|
+
expect(nextRowId(rows, 0)).toBe('b');
|
|
38
|
+
expect(prevRowId(rows, 2)).toBe('b');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('clamps at edges', () => {
|
|
42
|
+
expect(nextRowId(rows, 2)).toBe('c'); // last → last
|
|
43
|
+
expect(prevRowId(rows, 0)).toBe('a'); // first → first
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns null on empty list', () => {
|
|
47
|
+
expect(nextRowId([], 0)).toBeNull();
|
|
48
|
+
expect(prevRowId([], 0)).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('edgeRowId', () => {
|
|
53
|
+
it('picks first / last row', () => {
|
|
54
|
+
expect(edgeRowId(rows, 'first')).toBe('a');
|
|
55
|
+
expect(edgeRowId(rows, 'last')).toBe('c');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns null on empty list', () => {
|
|
59
|
+
expect(edgeRowId([], 'first')).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ----------------------------------------------------------------------
|
|
64
|
+
// expand-collapse
|
|
65
|
+
// ----------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
describe('resolveRightArrow', () => {
|
|
68
|
+
it('expands a collapsed folder', () => {
|
|
69
|
+
const f = row('f', { isFolder: true, isExpanded: false });
|
|
70
|
+
expect(resolveRightArrow(f, [f], 0)).toEqual({ kind: 'expand', id: 'f' });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('jumps to first child on an expanded folder', () => {
|
|
74
|
+
const f = row('f', { isFolder: true, isExpanded: true });
|
|
75
|
+
const c = row('f/c', { parentId: 'f', level: 1 });
|
|
76
|
+
expect(resolveRightArrow(f, [f, c], 0)).toEqual({ kind: 'focus', id: 'f/c' });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('no-op for an expanded folder with no children visible', () => {
|
|
80
|
+
const f = row('f', { isFolder: true, isExpanded: true });
|
|
81
|
+
expect(resolveRightArrow(f, [f], 0)).toEqual({ kind: 'noop' });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('no-op for a leaf', () => {
|
|
85
|
+
expect(resolveRightArrow(row('a'), rows, 0)).toEqual({ kind: 'noop' });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('no-op when current is null', () => {
|
|
89
|
+
expect(resolveRightArrow(null, rows, 0)).toEqual({ kind: 'noop' });
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('resolveLeftArrow', () => {
|
|
94
|
+
it('collapses an expanded folder', () => {
|
|
95
|
+
const f = row('f', { isFolder: true, isExpanded: true });
|
|
96
|
+
expect(resolveLeftArrow(f)).toEqual({ kind: 'collapse', id: 'f' });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('focuses parent for a leaf with a parent', () => {
|
|
100
|
+
const c = row('f/c', { parentId: 'f' });
|
|
101
|
+
expect(resolveLeftArrow(c)).toEqual({ kind: 'focus', id: 'f' });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('no-op for a root leaf', () => {
|
|
105
|
+
expect(resolveLeftArrow(row('root'))).toEqual({ kind: 'noop' });
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ----------------------------------------------------------------------
|
|
110
|
+
// activation
|
|
111
|
+
// ----------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
describe('resolveActivate', () => {
|
|
114
|
+
it('toggles a folder — flags `willExpand` from current state', () => {
|
|
115
|
+
const collapsed = row('f', { isFolder: true, isExpanded: false });
|
|
116
|
+
expect(resolveActivate(collapsed)).toEqual({
|
|
117
|
+
kind: 'toggle-folder',
|
|
118
|
+
id: 'f',
|
|
119
|
+
willExpand: true,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const expanded = row('f', { isFolder: true, isExpanded: true });
|
|
123
|
+
expect(resolveActivate(expanded)).toEqual({
|
|
124
|
+
kind: 'toggle-folder',
|
|
125
|
+
id: 'f',
|
|
126
|
+
willExpand: false,
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('activates a leaf', () => {
|
|
131
|
+
expect(resolveActivate(row('a'))).toEqual({ kind: 'activate-leaf', id: 'a' });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('no-op for null', () => {
|
|
135
|
+
expect(resolveActivate(null)).toEqual({ kind: 'noop' });
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { autoSelectRange, splitFileName } from '../data/renameUtils';
|
|
4
|
+
|
|
5
|
+
describe('splitFileName', () => {
|
|
6
|
+
it('splits a regular file name into base + ext', () => {
|
|
7
|
+
expect(splitFileName('foo.txt')).toEqual({ base: 'foo', ext: '.txt' });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('returns whole name as base when there is no dot', () => {
|
|
11
|
+
expect(splitFileName('readme')).toEqual({ base: 'readme', ext: '' });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('treats dotfiles as base-only', () => {
|
|
15
|
+
expect(splitFileName('.env')).toEqual({ base: '.env', ext: '' });
|
|
16
|
+
expect(splitFileName('.gitignore')).toEqual({ base: '.gitignore', ext: '' });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('splits a dotfile with an extension', () => {
|
|
20
|
+
expect(splitFileName('.env.local')).toEqual({ base: '.env', ext: '.local' });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('uses the last dot for multi-dot names', () => {
|
|
24
|
+
expect(splitFileName('foo.bar.baz')).toEqual({ base: 'foo.bar', ext: '.baz' });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('handles the empty string', () => {
|
|
28
|
+
expect(splitFileName('')).toEqual({ base: '', ext: '' });
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('autoSelectRange', () => {
|
|
33
|
+
it('selects only the base for a regular file', () => {
|
|
34
|
+
expect(autoSelectRange('foo.txt', false)).toEqual([0, 3]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('selects the entire name for a folder', () => {
|
|
38
|
+
expect(autoSelectRange('foo.txt', true)).toEqual([0, 7]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('selects the full dotfile name (no extension)', () => {
|
|
42
|
+
expect(autoSelectRange('.env', false)).toEqual([0, 4]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('selects only base for a dotfile with extension', () => {
|
|
46
|
+
expect(autoSelectRange('.env.local', false)).toEqual([0, 4]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('selects [0, 0] for empty name', () => {
|
|
50
|
+
expect(autoSelectRange('', false)).toEqual([0, 0]);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
computeRange,
|
|
5
|
+
selectionFromClick,
|
|
6
|
+
selectionFromMove,
|
|
7
|
+
selectionSelectAll,
|
|
8
|
+
selectionClear,
|
|
9
|
+
type SelectionState,
|
|
10
|
+
} from '../data/selection';
|
|
11
|
+
import type { FlatRow } from '../types';
|
|
12
|
+
|
|
13
|
+
// =====================================================================
|
|
14
|
+
// Test fixtures
|
|
15
|
+
// =====================================================================
|
|
16
|
+
|
|
17
|
+
function row(id: string): FlatRow<unknown> {
|
|
18
|
+
return {
|
|
19
|
+
node: { id, data: undefined },
|
|
20
|
+
level: 0,
|
|
21
|
+
parentId: null,
|
|
22
|
+
isFolder: false,
|
|
23
|
+
isExpanded: false,
|
|
24
|
+
isLoading: false,
|
|
25
|
+
hasError: false,
|
|
26
|
+
posInSet: 1,
|
|
27
|
+
setSize: 1,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const rows = ['a', 'b', 'c', 'd', 'e'].map(row);
|
|
32
|
+
|
|
33
|
+
const emptyState: SelectionState = {
|
|
34
|
+
selected: new Set(),
|
|
35
|
+
anchor: null,
|
|
36
|
+
focused: null,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// =====================================================================
|
|
40
|
+
// computeRange
|
|
41
|
+
// =====================================================================
|
|
42
|
+
|
|
43
|
+
describe('computeRange', () => {
|
|
44
|
+
it('returns ids between anchor and target (inclusive)', () => {
|
|
45
|
+
expect(computeRange(rows, 'b', 'd')).toEqual(['b', 'c', 'd']);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('works when target is before anchor', () => {
|
|
49
|
+
expect(computeRange(rows, 'd', 'b')).toEqual(['b', 'c', 'd']);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns a single id when from === to', () => {
|
|
53
|
+
expect(computeRange(rows, 'c', 'c')).toEqual(['c']);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('returns empty array when endpoint is missing', () => {
|
|
57
|
+
expect(computeRange(rows, 'b', 'missing')).toEqual([]);
|
|
58
|
+
expect(computeRange(rows, 'missing', 'b')).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('returns empty array when from or to is null', () => {
|
|
62
|
+
expect(computeRange(rows, null, 'b')).toEqual([]);
|
|
63
|
+
expect(computeRange(rows, 'b', null)).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// =====================================================================
|
|
68
|
+
// selectionFromClick
|
|
69
|
+
// =====================================================================
|
|
70
|
+
|
|
71
|
+
describe('selectionFromClick (single-select mode)', () => {
|
|
72
|
+
it('collapses any modifier combo to one id when multi is false', () => {
|
|
73
|
+
const next = selectionFromClick(
|
|
74
|
+
{ selected: new Set(['a', 'b']), anchor: 'a', focused: 'b' },
|
|
75
|
+
rows,
|
|
76
|
+
'c',
|
|
77
|
+
{ shift: true, meta: true },
|
|
78
|
+
false,
|
|
79
|
+
);
|
|
80
|
+
expect([...next.selected]).toEqual(['c']);
|
|
81
|
+
expect(next.anchor).toBe('c');
|
|
82
|
+
expect(next.focused).toBe('c');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('selectionFromClick (multi-select mode)', () => {
|
|
87
|
+
it('plain click replaces selection and resets anchor', () => {
|
|
88
|
+
const next = selectionFromClick(
|
|
89
|
+
{ selected: new Set(['a', 'b']), anchor: 'a', focused: 'b' },
|
|
90
|
+
rows,
|
|
91
|
+
'd',
|
|
92
|
+
{ shift: false, meta: false },
|
|
93
|
+
true,
|
|
94
|
+
);
|
|
95
|
+
expect([...next.selected]).toEqual(['d']);
|
|
96
|
+
expect(next.anchor).toBe('d');
|
|
97
|
+
expect(next.focused).toBe('d');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('meta-click toggles a single id and updates anchor', () => {
|
|
101
|
+
const next = selectionFromClick(
|
|
102
|
+
{ selected: new Set(['a', 'b']), anchor: 'a', focused: 'b' },
|
|
103
|
+
rows,
|
|
104
|
+
'c',
|
|
105
|
+
{ shift: false, meta: true },
|
|
106
|
+
true,
|
|
107
|
+
);
|
|
108
|
+
expect([...next.selected].sort()).toEqual(['a', 'b', 'c']);
|
|
109
|
+
expect(next.anchor).toBe('c');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('meta-click removes an already-selected id', () => {
|
|
113
|
+
const next = selectionFromClick(
|
|
114
|
+
{ selected: new Set(['a', 'b']), anchor: 'a', focused: 'b' },
|
|
115
|
+
rows,
|
|
116
|
+
'b',
|
|
117
|
+
{ shift: false, meta: true },
|
|
118
|
+
true,
|
|
119
|
+
);
|
|
120
|
+
expect([...next.selected]).toEqual(['a']);
|
|
121
|
+
expect(next.anchor).toBe('b');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('shift-click selects range from anchor to clicked row', () => {
|
|
125
|
+
const next = selectionFromClick(
|
|
126
|
+
{ selected: new Set(['b']), anchor: 'b', focused: 'b' },
|
|
127
|
+
rows,
|
|
128
|
+
'd',
|
|
129
|
+
{ shift: true, meta: false },
|
|
130
|
+
true,
|
|
131
|
+
);
|
|
132
|
+
expect([...next.selected]).toEqual(['b', 'c', 'd']);
|
|
133
|
+
expect(next.anchor).toBe('b'); // anchor stays put
|
|
134
|
+
expect(next.focused).toBe('d');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('shift-click without anchor falls back to focused', () => {
|
|
138
|
+
const next = selectionFromClick(
|
|
139
|
+
{ selected: new Set(['c']), anchor: null, focused: 'c' },
|
|
140
|
+
rows,
|
|
141
|
+
'e',
|
|
142
|
+
{ shift: true, meta: false },
|
|
143
|
+
true,
|
|
144
|
+
);
|
|
145
|
+
expect([...next.selected]).toEqual(['c', 'd', 'e']);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('shift+meta unions the range with existing selection', () => {
|
|
149
|
+
const next = selectionFromClick(
|
|
150
|
+
{ selected: new Set(['a']), anchor: 'b', focused: 'b' },
|
|
151
|
+
rows,
|
|
152
|
+
'd',
|
|
153
|
+
{ shift: true, meta: true },
|
|
154
|
+
true,
|
|
155
|
+
);
|
|
156
|
+
expect([...next.selected].sort()).toEqual(['a', 'b', 'c', 'd']);
|
|
157
|
+
expect(next.anchor).toBe('b');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// =====================================================================
|
|
162
|
+
// selectionFromMove
|
|
163
|
+
// =====================================================================
|
|
164
|
+
|
|
165
|
+
describe('selectionFromMove', () => {
|
|
166
|
+
it('non-extend move collapses to single id', () => {
|
|
167
|
+
const next = selectionFromMove(
|
|
168
|
+
{ selected: new Set(['a', 'b']), anchor: 'a', focused: 'b' },
|
|
169
|
+
rows,
|
|
170
|
+
'c',
|
|
171
|
+
false,
|
|
172
|
+
true,
|
|
173
|
+
);
|
|
174
|
+
expect([...next.selected]).toEqual(['c']);
|
|
175
|
+
expect(next.anchor).toBe('c');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('extend move with anchor recomputes range', () => {
|
|
179
|
+
const next = selectionFromMove(
|
|
180
|
+
{ selected: new Set(['b']), anchor: 'b', focused: 'b' },
|
|
181
|
+
rows,
|
|
182
|
+
'd',
|
|
183
|
+
true,
|
|
184
|
+
true,
|
|
185
|
+
);
|
|
186
|
+
expect([...next.selected]).toEqual(['b', 'c', 'd']);
|
|
187
|
+
expect(next.anchor).toBe('b');
|
|
188
|
+
expect(next.focused).toBe('d');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('extend without multi-select treats it as a plain move', () => {
|
|
192
|
+
const next = selectionFromMove(emptyState, rows, 'c', true, false);
|
|
193
|
+
expect([...next.selected]).toEqual(['c']);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// =====================================================================
|
|
198
|
+
// selectionSelectAll + selectionClear
|
|
199
|
+
// =====================================================================
|
|
200
|
+
|
|
201
|
+
describe('selectionSelectAll', () => {
|
|
202
|
+
it('selects every visible id and anchors the first row', () => {
|
|
203
|
+
const next = selectionSelectAll(rows, 'c');
|
|
204
|
+
expect([...next.selected]).toEqual(['a', 'b', 'c', 'd', 'e']);
|
|
205
|
+
expect(next.anchor).toBe('a');
|
|
206
|
+
expect(next.focused).toBe('c');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('returns empty state for an empty row list', () => {
|
|
210
|
+
const next = selectionSelectAll([], null);
|
|
211
|
+
expect(next.selected.size).toBe(0);
|
|
212
|
+
expect(next.anchor).toBeNull();
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('selectionClear', () => {
|
|
217
|
+
it('drops selection and anchor but keeps focused', () => {
|
|
218
|
+
const next = selectionClear({
|
|
219
|
+
selected: new Set(['a', 'b']),
|
|
220
|
+
anchor: 'a',
|
|
221
|
+
focused: 'b',
|
|
222
|
+
});
|
|
223
|
+
expect(next.selected.size).toBe(0);
|
|
224
|
+
expect(next.anchor).toBeNull();
|
|
225
|
+
expect(next.focused).toBe('b');
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
4
|
+
|
|
5
|
+
import type { TreeMovePosition } from '../types';
|
|
6
|
+
|
|
7
|
+
export interface TreeDropIndicatorProps {
|
|
8
|
+
position: TreeMovePosition;
|
|
9
|
+
/** Indent in pixels — keeps the line aligned with the row's text. */
|
|
10
|
+
indent: number;
|
|
11
|
+
/** Render a "rejected" style (red wash) when the drop is forbidden. */
|
|
12
|
+
invalid?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Visual hint for an in-progress drag-and-drop. Three modes:
|
|
17
|
+
*
|
|
18
|
+
* - `before` / `after` → a 2px horizontal line above / below the row
|
|
19
|
+
* - `inside` → a translucent primary wash filling the row
|
|
20
|
+
*
|
|
21
|
+
* Indents the line by the row's depth so it visually aligns with the
|
|
22
|
+
* target's content (rather than running edge-to-edge).
|
|
23
|
+
*
|
|
24
|
+
* Positioned absolutely — the parent `TreeRow` provides `position: relative`.
|
|
25
|
+
*/
|
|
26
|
+
export function TreeDropIndicator({
|
|
27
|
+
position,
|
|
28
|
+
indent,
|
|
29
|
+
invalid = false,
|
|
30
|
+
}: TreeDropIndicatorProps) {
|
|
31
|
+
if (position === 'inside') {
|
|
32
|
+
return (
|
|
33
|
+
<span
|
|
34
|
+
aria-hidden
|
|
35
|
+
data-tree-drop="inside"
|
|
36
|
+
className={cn(
|
|
37
|
+
'pointer-events-none absolute inset-0 rounded-sm ring-1',
|
|
38
|
+
invalid
|
|
39
|
+
? 'bg-destructive/10 ring-destructive/40'
|
|
40
|
+
: 'bg-primary/10 ring-primary/40',
|
|
41
|
+
)}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const isBefore = position === 'before';
|
|
47
|
+
return (
|
|
48
|
+
<span
|
|
49
|
+
aria-hidden
|
|
50
|
+
data-tree-drop={position}
|
|
51
|
+
style={{ paddingLeft: indent }}
|
|
52
|
+
className={cn(
|
|
53
|
+
'pointer-events-none absolute right-0 left-0 h-px',
|
|
54
|
+
isBefore ? 'top-0' : 'bottom-0',
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
<span
|
|
58
|
+
className={cn(
|
|
59
|
+
'block h-0.5 rounded-full',
|
|
60
|
+
invalid ? 'bg-destructive/70' : 'bg-primary',
|
|
61
|
+
)}
|
|
62
|
+
/>
|
|
63
|
+
</span>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import { useDroppable } from '@dnd-kit/core';
|
|
5
|
+
import {
|
|
6
|
+
ContextMenu,
|
|
7
|
+
ContextMenuContent,
|
|
8
|
+
ContextMenuItem,
|
|
9
|
+
ContextMenuSeparator,
|
|
10
|
+
ContextMenuShortcut,
|
|
11
|
+
ContextMenuTrigger,
|
|
12
|
+
} from '@djangocfg/ui-core/components';
|
|
13
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
14
|
+
|
|
15
|
+
import { useTreeContext } from '../context/TreeContext';
|
|
16
|
+
import {
|
|
17
|
+
buildDefaultMenuItems,
|
|
18
|
+
type BuiltinActionContext,
|
|
19
|
+
} from '../context/menu';
|
|
20
|
+
import { TREE_ROOT_DROP_ID } from '../data/dnd';
|
|
21
|
+
import type { TreeContextMenuItem } from '../types';
|
|
22
|
+
|
|
23
|
+
export interface TreeEmptyAreaProps {
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Fills the remaining vertical space below `<TreeContent>` so the user
|
|
29
|
+
* can right-click "into nothing" to get a Finder/Explorer-style empty
|
|
30
|
+
* area menu (paste / new file / new folder at root), and so DnD has a
|
|
31
|
+
* root drop target.
|
|
32
|
+
*
|
|
33
|
+
* Built-in actions are derived the same way as for rows — through
|
|
34
|
+
* `buildDefaultMenuItems`, with `targetNode = null` (root) and
|
|
35
|
+
* `selectedNodes = []` (nothing under the right-click). Items whose
|
|
36
|
+
* `available()` predicate fails are simply skipped, so a tree without
|
|
37
|
+
* `adapter.createFile/Folder` and without a clipboard payload shows
|
|
38
|
+
* no menu at all.
|
|
39
|
+
*/
|
|
40
|
+
export function TreeEmptyArea({ className }: TreeEmptyAreaProps) {
|
|
41
|
+
const ctx = useTreeContext();
|
|
42
|
+
const {
|
|
43
|
+
adapter,
|
|
44
|
+
labels,
|
|
45
|
+
getItemName,
|
|
46
|
+
clipboard,
|
|
47
|
+
cutToClipboard,
|
|
48
|
+
copyToClipboard,
|
|
49
|
+
pasteFromClipboard,
|
|
50
|
+
startRename,
|
|
51
|
+
inlineRenameEnabled,
|
|
52
|
+
dnd,
|
|
53
|
+
} = ctx;
|
|
54
|
+
|
|
55
|
+
// Root drop target — receives drops that miss every row. The shared
|
|
56
|
+
// `TreeDndProvider.onDragMove` detects when `e.over.id === ROOT_DROP_ID`
|
|
57
|
+
// and pushes `{ id: null, position: 'inside' }` into `dnd.dropTarget`,
|
|
58
|
+
// so this component doesn't need an `onPointerMove` of its own.
|
|
59
|
+
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
|
|
60
|
+
id: TREE_ROOT_DROP_ID,
|
|
61
|
+
disabled: !dnd.active,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Resolve menu items every render — they depend on clipboard state
|
|
65
|
+
// (paste shows/hides) and on whether the adapter exposes
|
|
66
|
+
// createFile / createFolder.
|
|
67
|
+
const items = useMemo<TreeContextMenuItem<unknown>[] | null>(() => {
|
|
68
|
+
if (!adapter) return null;
|
|
69
|
+
const builtinCtx: BuiltinActionContext<unknown> = {
|
|
70
|
+
adapter,
|
|
71
|
+
labels,
|
|
72
|
+
selectedNodes: [],
|
|
73
|
+
targetNode: null,
|
|
74
|
+
getName: getItemName,
|
|
75
|
+
startInlineRename: inlineRenameEnabled ? startRename : undefined,
|
|
76
|
+
clipboard: {
|
|
77
|
+
hasItems: !!clipboard && clipboard.ids.length > 0,
|
|
78
|
+
cut: cutToClipboard,
|
|
79
|
+
copy: copyToClipboard,
|
|
80
|
+
paste: () => pasteFromClipboard(null, 'inside'),
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
return buildDefaultMenuItems(builtinCtx);
|
|
84
|
+
}, [
|
|
85
|
+
adapter,
|
|
86
|
+
labels,
|
|
87
|
+
getItemName,
|
|
88
|
+
inlineRenameEnabled,
|
|
89
|
+
startRename,
|
|
90
|
+
clipboard,
|
|
91
|
+
cutToClipboard,
|
|
92
|
+
copyToClipboard,
|
|
93
|
+
pasteFromClipboard,
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
const surface = (
|
|
97
|
+
<div
|
|
98
|
+
ref={dnd.active ? setDroppableRef : undefined}
|
|
99
|
+
data-tree-empty-area=""
|
|
100
|
+
data-drop-active={dnd.active && isOver ? 'true' : undefined}
|
|
101
|
+
className={cn(
|
|
102
|
+
// Soaks up the remaining vertical space inside the scroll
|
|
103
|
+
// container so right-click on whitespace lands here, not on
|
|
104
|
+
// the scroll viewport.
|
|
105
|
+
'min-h-[2.5rem] flex-1',
|
|
106
|
+
dnd.active && isOver && 'bg-primary/5 rounded-sm ring-1 ring-primary/30',
|
|
107
|
+
className,
|
|
108
|
+
)}
|
|
109
|
+
/>
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// No items and no DnD overlap → render plain spacer. Wrapping in a
|
|
113
|
+
// `<ContextMenu>` with zero items would still capture right-clicks
|
|
114
|
+
// and show an empty popover — that's worse than browser default.
|
|
115
|
+
if (!items || items.length === 0) {
|
|
116
|
+
return surface;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<ContextMenu>
|
|
121
|
+
<ContextMenuTrigger asChild>{surface}</ContextMenuTrigger>
|
|
122
|
+
<ContextMenuContent>
|
|
123
|
+
{items.map((item, idx) => {
|
|
124
|
+
if (item === 'separator') {
|
|
125
|
+
return <ContextMenuSeparator key={`sep-${idx}`} />;
|
|
126
|
+
}
|
|
127
|
+
const Icon = item.icon;
|
|
128
|
+
return (
|
|
129
|
+
<ContextMenuItem
|
|
130
|
+
key={item.id}
|
|
131
|
+
disabled={item.disabled}
|
|
132
|
+
variant={item.destructive ? 'destructive' : undefined}
|
|
133
|
+
// Pass a synthetic "no-target" row props — the built-in
|
|
134
|
+
// action handlers we surface here (new-file / new-folder
|
|
135
|
+
// / paste) don't read row-specific fields anyway.
|
|
136
|
+
onSelect={() =>
|
|
137
|
+
item.onSelect({
|
|
138
|
+
node: undefined as never,
|
|
139
|
+
level: 0,
|
|
140
|
+
isSelected: false,
|
|
141
|
+
isExpanded: false,
|
|
142
|
+
isFocused: false,
|
|
143
|
+
isFolder: false,
|
|
144
|
+
isLoading: false,
|
|
145
|
+
isMatchingSearch: false,
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
>
|
|
149
|
+
{Icon ? <Icon /> : null}
|
|
150
|
+
{item.label}
|
|
151
|
+
{item.shortcut ? (
|
|
152
|
+
<ContextMenuShortcut>{item.shortcut}</ContextMenuShortcut>
|
|
153
|
+
) : null}
|
|
154
|
+
</ContextMenuItem>
|
|
155
|
+
);
|
|
156
|
+
})}
|
|
157
|
+
</ContextMenuContent>
|
|
158
|
+
</ContextMenu>
|
|
159
|
+
);
|
|
160
|
+
}
|