@djangocfg/ui-tools 2.1.413 → 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.
Files changed (113) hide show
  1. package/dist/file-icon/index.d.cts +1 -1
  2. package/dist/file-icon/index.d.ts +1 -1
  3. package/dist/slots-ClRpIzoh.d.cts +88 -0
  4. package/dist/slots-ClRpIzoh.d.ts +88 -0
  5. package/dist/tree/index.cjs +1994 -276
  6. package/dist/tree/index.cjs.map +1 -1
  7. package/dist/tree/index.d.cts +717 -72
  8. package/dist/tree/index.d.ts +717 -72
  9. package/dist/tree/index.mjs +1984 -279
  10. package/dist/tree/index.mjs.map +1 -1
  11. package/package.json +10 -6
  12. package/src/tools/chat/README.md +111 -1
  13. package/src/tools/chat/composer/Composer.tsx +138 -17
  14. package/src/tools/chat/composer/ComposerRichTextarea.tsx +25 -0
  15. package/src/tools/chat/composer/index.ts +22 -0
  16. package/src/tools/chat/composer/slash/README.md +187 -0
  17. package/src/tools/chat/composer/slash/SlashHighlightTextarea.tsx +144 -0
  18. package/src/tools/chat/composer/slash/SlashMenu.tsx +142 -0
  19. package/src/tools/chat/composer/slash/SlashToken.tsx +57 -0
  20. package/src/tools/chat/composer/slash/index.ts +44 -0
  21. package/src/tools/chat/composer/slash/labels.ts +19 -0
  22. package/src/tools/chat/composer/slash/state.ts +168 -0
  23. package/src/tools/chat/composer/slash/types.ts +64 -0
  24. package/src/tools/chat/composer/slash/useSlashCommands.ts +204 -0
  25. package/src/tools/chat/composer/types.ts +8 -0
  26. package/src/tools/chat/shell/SuggestedPrompts.tsx +194 -0
  27. package/src/tools/chat/shell/index.ts +6 -0
  28. package/src/tools/data/Listbox/lazy.tsx +1 -1
  29. package/src/tools/data/Masonry/lazy.tsx +1 -1
  30. package/src/tools/data/Timeline/lazy.tsx +1 -1
  31. package/src/tools/data/Tree/FinderTree.tsx +42 -0
  32. package/src/tools/data/Tree/README.md +337 -208
  33. package/src/tools/data/Tree/TreeDndProvider.tsx +137 -0
  34. package/src/tools/data/Tree/TreeRoot.tsx +170 -55
  35. package/src/tools/data/Tree/__tests__/dnd.test.ts +160 -0
  36. package/src/tools/data/Tree/__tests__/keyboard.test.ts +137 -0
  37. package/src/tools/data/Tree/__tests__/renameUtils.test.ts +52 -0
  38. package/src/tools/data/Tree/__tests__/selection.test.ts +227 -0
  39. package/src/tools/data/Tree/components/TreeDropIndicator.tsx +65 -0
  40. package/src/tools/data/Tree/components/TreeEmptyArea.tsx +160 -0
  41. package/src/tools/data/Tree/components/TreeRenameInput.tsx +114 -0
  42. package/src/tools/data/Tree/components/TreeRow.tsx +92 -8
  43. package/src/tools/data/Tree/components/index.ts +6 -0
  44. package/src/tools/data/Tree/context/TreeContext.tsx +204 -363
  45. package/src/tools/data/Tree/context/TreeContextValue.ts +139 -0
  46. package/src/tools/data/Tree/context/async-children/collect-ids.ts +27 -0
  47. package/src/tools/data/Tree/context/async-children/index.ts +8 -0
  48. package/src/tools/data/Tree/context/async-children/use-async-children.ts +157 -0
  49. package/src/tools/data/Tree/context/clipboard/index.ts +4 -0
  50. package/src/tools/data/Tree/context/clipboard/use-clipboard.ts +115 -0
  51. package/src/tools/data/Tree/context/dnd/index.ts +8 -0
  52. package/src/tools/data/Tree/context/dnd/use-dnd.ts +194 -0
  53. package/src/tools/data/Tree/context/expansion/index.ts +4 -0
  54. package/src/tools/data/Tree/context/expansion/use-expansion.ts +55 -0
  55. package/src/tools/data/Tree/context/hooks.ts +68 -1
  56. package/src/tools/data/Tree/context/index.ts +3 -0
  57. package/src/tools/data/Tree/context/menu/builtin-actions.ts +357 -0
  58. package/src/tools/data/Tree/context/menu/index.ts +10 -0
  59. package/src/tools/data/Tree/context/menu/use-resolved-menu.ts +127 -0
  60. package/src/tools/data/Tree/context/persist/index.ts +4 -0
  61. package/src/tools/data/Tree/context/persist/use-persist-sync.ts +74 -0
  62. package/src/tools/data/Tree/context/rename/index.ts +4 -0
  63. package/src/tools/data/Tree/context/rename/use-rename.ts +113 -0
  64. package/src/tools/data/Tree/context/selection/index.ts +4 -0
  65. package/src/tools/data/Tree/context/selection/use-selection.ts +146 -0
  66. package/src/tools/data/Tree/context/state/index.ts +6 -0
  67. package/src/tools/data/Tree/context/state/initial.ts +41 -0
  68. package/src/tools/data/Tree/context/state/reducer.ts +76 -0
  69. package/src/tools/data/Tree/context/state/types.ts +46 -0
  70. package/src/tools/data/Tree/data/clipboard.ts +33 -0
  71. package/src/tools/data/Tree/data/dnd.ts +123 -0
  72. package/src/tools/data/Tree/data/finderShortcuts.ts +67 -0
  73. package/src/tools/data/Tree/data/index.ts +19 -0
  74. package/src/tools/data/Tree/data/renameUtils.ts +51 -0
  75. package/src/tools/data/Tree/data/selection.ts +157 -0
  76. package/src/tools/data/Tree/hooks/finder-hotkeys/build-ctx.ts +48 -0
  77. package/src/tools/data/Tree/hooks/finder-hotkeys/index.ts +8 -0
  78. package/src/tools/data/Tree/hooks/finder-hotkeys/use-tree-finder-hotkeys.ts +166 -0
  79. package/src/tools/data/Tree/hooks/index.ts +23 -4
  80. package/src/tools/data/Tree/hooks/keyboard/activation.ts +27 -0
  81. package/src/tools/data/Tree/hooks/keyboard/arrow-nav.ts +26 -0
  82. package/src/tools/data/Tree/hooks/keyboard/expand-collapse.ts +54 -0
  83. package/src/tools/data/Tree/hooks/keyboard/index.ts +10 -0
  84. package/src/tools/data/Tree/hooks/keyboard/types.ts +39 -0
  85. package/src/tools/data/Tree/hooks/keyboard/use-tree-keyboard.ts +196 -0
  86. package/src/tools/data/Tree/hooks/type-ahead/index.ts +5 -0
  87. package/src/tools/data/Tree/hooks/type-ahead/match-prefix.ts +42 -0
  88. package/src/tools/data/Tree/hooks/{useTreeTypeAhead.ts → type-ahead/use-tree-type-ahead.ts} +8 -19
  89. package/src/tools/data/Tree/index.tsx +25 -2
  90. package/src/tools/data/Tree/types/activation.ts +30 -0
  91. package/src/tools/data/Tree/types/adapter.ts +70 -0
  92. package/src/tools/data/Tree/types/index.ts +27 -0
  93. package/src/tools/data/Tree/types/labels.ts +97 -0
  94. package/src/tools/data/Tree/types/loader.ts +9 -0
  95. package/src/tools/data/Tree/types/node.ts +38 -0
  96. package/src/tools/data/Tree/types/root-props.ts +142 -0
  97. package/src/tools/data/Tree/types/selection.ts +3 -0
  98. package/src/tools/data/Tree/types/slots.ts +64 -0
  99. package/src/tools/dev/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/ResponseBody.tsx +1 -1
  100. package/src/tools/dev/OpenapiViewer/components/shared/ResponsePanel/PrettyView.tsx +1 -1
  101. package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +85 -0
  102. package/src/tools/forms/MarkdownEditor/index.ts +1 -0
  103. package/src/tools/forms/MarkdownEditor/lazy.tsx +6 -0
  104. package/src/tools/forms/MarkdownEditor/slash/SlashCommandNode.ts +162 -0
  105. package/src/tools/forms/MarkdownEditor/slash/index.ts +4 -0
  106. package/src/tools/forms/MarkdownEditor/slash/syncSlashNode.ts +97 -0
  107. package/src/tools/forms/MarkdownEditor/slash/types.ts +13 -0
  108. package/src/tools/forms/MarkdownEditor/styles.css +18 -0
  109. package/src/tools/index.ts +2 -2
  110. package/dist/types-j2vhn4Kv.d.cts +0 -241
  111. package/dist/types-j2vhn4Kv.d.ts +0 -241
  112. package/src/tools/data/Tree/hooks/useTreeKeyboard.ts +0 -171
  113. 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
+ }