sh3-core 0.7.1 → 0.7.5
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/Shell.svelte +3 -2
- package/dist/__test__/fixtures.d.ts +12 -0
- package/dist/__test__/fixtures.js +62 -0
- package/dist/__test__/render.d.ts +3 -0
- package/dist/__test__/render.js +11 -0
- package/dist/__test__/reset.d.ts +14 -0
- package/dist/__test__/reset.js +34 -0
- package/dist/__test__/setup-dom.d.ts +1 -0
- package/dist/__test__/setup-dom.js +26 -0
- package/dist/__test__/smoke.test.d.ts +1 -0
- package/dist/__test__/smoke.test.js +28 -0
- package/dist/api.d.ts +4 -0
- package/dist/apps/lifecycle.js +27 -10
- package/dist/apps/lifecycle.test.d.ts +1 -0
- package/dist/apps/lifecycle.test.js +260 -0
- package/dist/apps/registry.svelte.d.ts +2 -0
- package/dist/apps/registry.svelte.js +5 -0
- package/dist/apps/types.d.ts +17 -0
- package/dist/contract.d.ts +10 -0
- package/dist/contract.js +10 -0
- package/dist/layout/LayoutRenderer.browser.test.d.ts +1 -0
- package/dist/layout/LayoutRenderer.browser.test.js +274 -0
- package/dist/layout/LayoutRenderer.svelte +2 -1
- package/dist/layout/LayoutRenderer.test.d.ts +1 -0
- package/dist/layout/LayoutRenderer.test.js +143 -0
- package/dist/layout/SlotContainer.svelte +8 -2
- package/dist/layout/SlotDropZone.svelte +19 -0
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-1-drag-tab-between-groups-moves-a-tab-from-one-tabs-group-to-another-1.png +0 -0
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-2-drag-tab-to-quadrant-creates-a-split-when-dropping-a-tab-on-a-quadrant-drop-zone-1.png +0 -0
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-3-splitter-drag-updates-split-sizes-when-the-splitter-handle-is-dragged-1.png +0 -0
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-4-close-policy-removes-closable-tabs--keeps-non-closable--and-awaits-canClose-1.png +0 -0
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-5-splitter-collapse-toggle-toggles-collapsed-i--on-double-click-1.png +0 -0
- package/dist/layout/drag.svelte.d.ts +5 -0
- package/dist/layout/drag.svelte.js +15 -0
- package/dist/layout/inspection.js +58 -7
- package/dist/layout/ops.js +25 -6
- package/dist/layout/ops.test.js +51 -1
- package/dist/layout/slotHostPool.svelte.d.ts +16 -1
- package/dist/layout/slotHostPool.svelte.js +124 -6
- package/dist/layout/slotHostPool.test.d.ts +1 -0
- package/dist/layout/slotHostPool.test.js +104 -0
- package/dist/layout/store.svelte.d.ts +22 -0
- package/dist/layout/store.svelte.js +80 -18
- package/dist/layout/tree-walk.d.ts +2 -0
- package/dist/layout/tree-walk.js +1 -1
- package/dist/layout/types.d.ts +5 -0
- package/dist/overlays/FloatFrame.svelte +1 -0
- package/dist/overlays/float.d.ts +2 -0
- package/dist/overlays/float.js +4 -1
- package/dist/overlays/float.test.js +102 -1
- package/dist/primitives/ResizableSplitter.svelte +2 -0
- package/dist/primitives/TabbedPanel.svelte +4 -0
- package/dist/primitives/TabbedPanel.svelte.d.ts +2 -0
- package/dist/shards/activate.svelte.d.ts +6 -0
- package/dist/shards/activate.svelte.js +10 -0
- package/dist/shards/registry.d.ts +4 -0
- package/dist/shards/registry.js +18 -0
- package/dist/shards/types.d.ts +6 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +9 -1
package/dist/contract.js
CHANGED
|
@@ -17,12 +17,22 @@ export const contract = {
|
|
|
17
17
|
* listed in shardImports or hostImports is illegal. */
|
|
18
18
|
packagePrefix: 'sh3-core',
|
|
19
19
|
shard: {
|
|
20
|
+
/** Fields external authors must declare. */
|
|
21
|
+
sourceRequiredFields: ['id', 'label', 'views'],
|
|
22
|
+
/** Fields the framework stamps at load time — must NOT appear in source. */
|
|
23
|
+
runtimeRequiredFields: ['version'],
|
|
24
|
+
/** Union of both — the full runtime shape. Kept for backward compat. */
|
|
20
25
|
requiredFields: ['id', 'label', 'version', 'views'],
|
|
21
26
|
views: {
|
|
22
27
|
requiredFields: ['id', 'label'],
|
|
23
28
|
},
|
|
24
29
|
},
|
|
25
30
|
app: {
|
|
31
|
+
/** Fields external authors must declare. */
|
|
32
|
+
sourceRequiredFields: ['id', 'label', 'requiredShards', 'layoutVersion'],
|
|
33
|
+
/** Fields the framework stamps at load time — must NOT appear in source. */
|
|
34
|
+
runtimeRequiredFields: ['version'],
|
|
35
|
+
/** Union of both — the full runtime shape. Kept for backward compat. */
|
|
26
36
|
requiredFields: ['id', 'label', 'version', 'requiredShards', 'layoutVersion'],
|
|
27
37
|
},
|
|
28
38
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { page } from '@vitest/browser/context';
|
|
3
|
+
import { resetFramework } from '../__test__/reset';
|
|
4
|
+
import { renderWithShell } from '../__test__/render';
|
|
5
|
+
import LayoutRenderer from './LayoutRenderer.svelte';
|
|
6
|
+
import { registerApp } from '../apps/registry.svelte';
|
|
7
|
+
import { launchApp } from '../apps/lifecycle';
|
|
8
|
+
import { registerView } from '../shards/registry';
|
|
9
|
+
import { makeApp, makeAppManifest, makeSplitNode, makeTabsNode, makeTabEntry, makeSlotNode, makeTree, } from '../__test__/fixtures';
|
|
10
|
+
import { layoutStore } from './store.svelte';
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Utilities
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
function stubView() {
|
|
15
|
+
registerView('test:view', {
|
|
16
|
+
mount: (el) => {
|
|
17
|
+
el.textContent = 'stub';
|
|
18
|
+
return { unmount: () => { el.textContent = ''; } };
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/** Settle microtasks + a short macrotask gap. */
|
|
23
|
+
function settle(ms = 50) {
|
|
24
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
25
|
+
}
|
|
26
|
+
/** Remove all shell host elements from the body (cleanup between tests). */
|
|
27
|
+
function cleanupDOM() {
|
|
28
|
+
const hosts = document.querySelectorAll('.sh3-shell-host');
|
|
29
|
+
hosts.forEach((h) => h.remove());
|
|
30
|
+
}
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// E.1 — drag tab between groups
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
describe('LayoutRenderer browser — E.1 drag tab between groups', () => {
|
|
35
|
+
beforeEach(() => { cleanupDOM(); resetFramework(); });
|
|
36
|
+
it('moves a tab from one tabs group to another', async () => {
|
|
37
|
+
stubView();
|
|
38
|
+
registerApp(makeApp({
|
|
39
|
+
manifest: makeAppManifest({ id: 'e1' }),
|
|
40
|
+
initialLayout: [
|
|
41
|
+
{
|
|
42
|
+
name: 'default',
|
|
43
|
+
tree: makeTree(makeSplitNode([
|
|
44
|
+
makeTabsNode([makeTabEntry({ slotId: 'L1', label: 'L1' })]),
|
|
45
|
+
makeTabsNode([makeTabEntry({ slotId: 'R1', label: 'R1' })]),
|
|
46
|
+
])),
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
}));
|
|
50
|
+
await launchApp('e1');
|
|
51
|
+
renderWithShell(LayoutRenderer, { path: [] });
|
|
52
|
+
// Wait for initial render + slot-host pool microtasks
|
|
53
|
+
await settle(30);
|
|
54
|
+
// Get tabs by role. There are two tabs: L1 (active) and R1.
|
|
55
|
+
const allTabs = document.querySelectorAll('[role="tab"]');
|
|
56
|
+
if (allTabs.length < 2)
|
|
57
|
+
throw new Error(`expected 2 tabs, got ${allTabs.length}`);
|
|
58
|
+
// Get both tab strips (role="tablist") — left and right.
|
|
59
|
+
const strips = document.querySelectorAll('[role="tablist"]');
|
|
60
|
+
if (strips.length < 2)
|
|
61
|
+
throw new Error(`expected 2 tab strips, got ${strips.length}`);
|
|
62
|
+
const srcTab = allTabs[0]; // L1 (in left strip)
|
|
63
|
+
const rightStrip = strips[1]; // right tab strip
|
|
64
|
+
const srcRect = srcTab.getBoundingClientRect();
|
|
65
|
+
const sx = srcRect.left + srcRect.width / 2;
|
|
66
|
+
const sy = srcRect.top + srcRect.height / 2;
|
|
67
|
+
const tgtRect = rightStrip.getBoundingClientRect();
|
|
68
|
+
const tgtX = tgtRect.left + tgtRect.width / 2;
|
|
69
|
+
const tgtY = tgtRect.top + tgtRect.height / 2;
|
|
70
|
+
const mk = (x, y, type, buttons = 1) => new PointerEvent(type, {
|
|
71
|
+
bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse',
|
|
72
|
+
clientX: x, clientY: y, screenX: x, screenY: y, buttons, button: 0,
|
|
73
|
+
});
|
|
74
|
+
// Start drag on source tab
|
|
75
|
+
srcTab.dispatchEvent(mk(sx, sy, 'pointerdown'));
|
|
76
|
+
// Cross the threshold (DRAG_THRESHOLD_PX = 4)
|
|
77
|
+
window.dispatchEvent(mk(sx + 2, sy, 'pointermove'));
|
|
78
|
+
window.dispatchEvent(mk(sx + 6, sy, 'pointermove'));
|
|
79
|
+
// Dispatch pointermove ON the right strip so TabbedPanel.onStripPointerMove fires
|
|
80
|
+
// and calls dragController.onStripHover → setDropTarget({ kind: 'strip' })
|
|
81
|
+
rightStrip.dispatchEvent(mk(tgtX, tgtY, 'pointermove'));
|
|
82
|
+
// Commit
|
|
83
|
+
window.dispatchEvent(mk(tgtX, tgtY, 'pointerup', 0));
|
|
84
|
+
await settle();
|
|
85
|
+
// After the drag, cleanupTree runs and removes the empty left group.
|
|
86
|
+
// The split collapses to just the right tabs node as root. Root is `tabs`
|
|
87
|
+
// with both L1 and R1 in it.
|
|
88
|
+
const root = layoutStore.root;
|
|
89
|
+
if ((root === null || root === void 0 ? void 0 : root.type) === 'split') {
|
|
90
|
+
// Cleanup didn't collapse — check left is empty, right has 2
|
|
91
|
+
const leftTabs = root.children[0];
|
|
92
|
+
const rightTabs = root.children[1];
|
|
93
|
+
expect(leftTabs.type === 'tabs' ? leftTabs.tabs.length : -1).toBe(0);
|
|
94
|
+
expect(rightTabs.type === 'tabs' ? rightTabs.tabs.length : -1).toBe(2);
|
|
95
|
+
}
|
|
96
|
+
else if ((root === null || root === void 0 ? void 0 : root.type) === 'tabs') {
|
|
97
|
+
// Cleanup collapsed the split — the lone right tabs group is now root.
|
|
98
|
+
// Both tabs should be present.
|
|
99
|
+
expect(root.tabs.length).toBe(2);
|
|
100
|
+
const slotIds = root.tabs.map((t) => t.slotId);
|
|
101
|
+
expect(slotIds).toContain('L1');
|
|
102
|
+
expect(slotIds).toContain('R1');
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
throw new Error(`expected split or tabs root, got: ${root === null || root === void 0 ? void 0 : root.type}`);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// E.2 — drag tab to quadrant drop zone
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
describe('LayoutRenderer browser — E.2 drag tab to quadrant', () => {
|
|
113
|
+
beforeEach(() => { cleanupDOM(); resetFramework(); });
|
|
114
|
+
it('creates a split when dropping a tab on a quadrant drop zone', async () => {
|
|
115
|
+
stubView();
|
|
116
|
+
registerApp(makeApp({
|
|
117
|
+
manifest: makeAppManifest({ id: 'e2' }),
|
|
118
|
+
initialLayout: [
|
|
119
|
+
{
|
|
120
|
+
name: 'default',
|
|
121
|
+
tree: makeTree(makeTabsNode([
|
|
122
|
+
makeTabEntry({ slotId: 'X', label: 'X' }),
|
|
123
|
+
makeTabEntry({ slotId: 'Y', label: 'Y' }),
|
|
124
|
+
])),
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
}));
|
|
128
|
+
await launchApp('e2');
|
|
129
|
+
renderWithShell(LayoutRenderer, { path: [] });
|
|
130
|
+
await settle(30);
|
|
131
|
+
// Get the X tab (first tab = active one)
|
|
132
|
+
const tabs = document.querySelectorAll('[role="tab"]');
|
|
133
|
+
if (tabs.length < 1)
|
|
134
|
+
throw new Error('no tabs found');
|
|
135
|
+
const srcTab = tabs[0]; // X (active)
|
|
136
|
+
const srcRect = srcTab.getBoundingClientRect();
|
|
137
|
+
const sx = srcRect.left + srcRect.width / 2;
|
|
138
|
+
const sy = srcRect.top + srcRect.height / 2;
|
|
139
|
+
const zone = document.querySelector('.slot-drop-zone');
|
|
140
|
+
if (!zone)
|
|
141
|
+
throw new Error('no .slot-drop-zone found');
|
|
142
|
+
const zRect = zone.getBoundingClientRect();
|
|
143
|
+
// Right quadrant center
|
|
144
|
+
const tgtX = zRect.left + zRect.width * 0.75;
|
|
145
|
+
const tgtY = zRect.top + zRect.height / 2;
|
|
146
|
+
const mk = (x, y, type, buttons = 1) => new PointerEvent(type, {
|
|
147
|
+
bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse',
|
|
148
|
+
clientX: x, clientY: y, screenX: x, screenY: y, buttons, button: 0,
|
|
149
|
+
});
|
|
150
|
+
srcTab.dispatchEvent(mk(sx, sy, 'pointerdown'));
|
|
151
|
+
window.dispatchEvent(mk(sx + 2, sy, 'pointermove'));
|
|
152
|
+
window.dispatchEvent(mk(sx + 6, sy, 'pointermove'));
|
|
153
|
+
// Fire pointermove on the zone element so SlotDropZone.onMove runs
|
|
154
|
+
zone.dispatchEvent(mk(tgtX, tgtY, 'pointermove'));
|
|
155
|
+
// Commit via global pointerup
|
|
156
|
+
window.dispatchEvent(mk(tgtX, tgtY, 'pointerup', 0));
|
|
157
|
+
await settle();
|
|
158
|
+
const root = layoutStore.root;
|
|
159
|
+
expect(root === null || root === void 0 ? void 0 : root.type).toBe('split');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// E.3 — splitter drag updates sizes
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
describe('LayoutRenderer browser — E.3 splitter drag', () => {
|
|
166
|
+
beforeEach(() => { cleanupDOM(); resetFramework(); });
|
|
167
|
+
it('updates split.sizes when the splitter handle is dragged', async () => {
|
|
168
|
+
stubView();
|
|
169
|
+
registerApp(makeApp({
|
|
170
|
+
manifest: makeAppManifest({ id: 'e3' }),
|
|
171
|
+
initialLayout: [
|
|
172
|
+
{
|
|
173
|
+
name: 'default',
|
|
174
|
+
tree: makeTree(makeSplitNode([makeSlotNode('a', 'test:view'), makeSlotNode('b', 'test:view')])),
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
}));
|
|
178
|
+
await launchApp('e3');
|
|
179
|
+
renderWithShell(LayoutRenderer, { path: [] });
|
|
180
|
+
await settle(30);
|
|
181
|
+
const root0 = layoutStore.root;
|
|
182
|
+
const size0 = (root0 === null || root0 === void 0 ? void 0 : root0.type) === 'split' ? root0.sizes[0] : -1;
|
|
183
|
+
// Get handle element directly from DOM (data-testid)
|
|
184
|
+
const handleEl = document.querySelector('[data-testid="splitter-handle-0"]');
|
|
185
|
+
if (!handleEl)
|
|
186
|
+
throw new Error('splitter handle not in DOM');
|
|
187
|
+
const box = handleEl.getBoundingClientRect();
|
|
188
|
+
const hx = box.left + box.width / 2;
|
|
189
|
+
const hy = box.top + box.height / 2;
|
|
190
|
+
const dx = 100;
|
|
191
|
+
const mk = (x, y, type, buttons = 1) => new PointerEvent(type, {
|
|
192
|
+
bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse',
|
|
193
|
+
clientX: x, clientY: y, screenX: x, screenY: y, buttons, button: 0,
|
|
194
|
+
});
|
|
195
|
+
// ResizableSplitter uses onpointerdown/onpointermove/onpointerup on the handle.
|
|
196
|
+
handleEl.dispatchEvent(mk(hx, hy, 'pointerdown'));
|
|
197
|
+
handleEl.dispatchEvent(mk(hx + dx / 2, hy, 'pointermove'));
|
|
198
|
+
handleEl.dispatchEvent(mk(hx + dx, hy, 'pointermove'));
|
|
199
|
+
handleEl.dispatchEvent(mk(hx + dx, hy, 'pointerup', 0));
|
|
200
|
+
await settle();
|
|
201
|
+
const root1 = layoutStore.root;
|
|
202
|
+
const size1 = (root1 === null || root1 === void 0 ? void 0 : root1.type) === 'split' ? root1.sizes[0] : -1;
|
|
203
|
+
expect(size1).not.toBe(size0);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// E.4 — tab close policy
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
import { setSlotClosableForTest, setSlotCanCloseForTest } from './slotHostPool.svelte';
|
|
210
|
+
describe('LayoutRenderer browser — E.4 close policy', () => {
|
|
211
|
+
beforeEach(() => { cleanupDOM(); resetFramework(); });
|
|
212
|
+
it('removes closable tabs, keeps non-closable, and awaits canClose', async () => {
|
|
213
|
+
stubView();
|
|
214
|
+
setSlotClosableForTest('free', true);
|
|
215
|
+
setSlotClosableForTest('pinned', false);
|
|
216
|
+
setSlotClosableForTest('guarded', true);
|
|
217
|
+
setSlotCanCloseForTest('guarded', async () => false);
|
|
218
|
+
registerApp(makeApp({
|
|
219
|
+
manifest: makeAppManifest({ id: 'e4' }),
|
|
220
|
+
initialLayout: [
|
|
221
|
+
{
|
|
222
|
+
name: 'default',
|
|
223
|
+
tree: makeTree(makeTabsNode([
|
|
224
|
+
makeTabEntry({ slotId: 'free', label: 'Free' }),
|
|
225
|
+
makeTabEntry({ slotId: 'pinned', label: 'Pinned' }),
|
|
226
|
+
makeTabEntry({ slotId: 'guarded', label: 'Guarded' }),
|
|
227
|
+
])),
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
}));
|
|
231
|
+
await launchApp('e4');
|
|
232
|
+
renderWithShell(LayoutRenderer, { path: [] });
|
|
233
|
+
await settle(30);
|
|
234
|
+
await page.getByTestId('tab-close-free').click();
|
|
235
|
+
await page.getByTestId('tab-close-guarded').click();
|
|
236
|
+
// Allow async canClose to resolve and layout to update
|
|
237
|
+
await settle(100);
|
|
238
|
+
const root = layoutStore.root;
|
|
239
|
+
if ((root === null || root === void 0 ? void 0 : root.type) !== 'tabs')
|
|
240
|
+
throw new Error('expected tabs root');
|
|
241
|
+
const labels = root.tabs.map((t) => t.label);
|
|
242
|
+
expect(labels).toContain('Pinned');
|
|
243
|
+
expect(labels).toContain('Guarded');
|
|
244
|
+
expect(labels).not.toContain('Free');
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// E.5 — double-click splitter toggles collapse
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
describe('LayoutRenderer browser — E.5 splitter collapse toggle', () => {
|
|
251
|
+
beforeEach(() => { cleanupDOM(); resetFramework(); });
|
|
252
|
+
it('toggles collapsed[i] on double-click', async () => {
|
|
253
|
+
var _a;
|
|
254
|
+
stubView();
|
|
255
|
+
registerApp(makeApp({
|
|
256
|
+
manifest: makeAppManifest({ id: 'e5' }),
|
|
257
|
+
initialLayout: [
|
|
258
|
+
{
|
|
259
|
+
name: 'default',
|
|
260
|
+
tree: makeTree(makeSplitNode([makeSlotNode('a', 'test:view'), makeSlotNode('b', 'test:view')])),
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
}));
|
|
264
|
+
await launchApp('e5');
|
|
265
|
+
renderWithShell(LayoutRenderer, { path: [] });
|
|
266
|
+
await settle(30);
|
|
267
|
+
await page.getByTestId('splitter-handle-0').dblClick();
|
|
268
|
+
await settle(50);
|
|
269
|
+
const root = layoutStore.root;
|
|
270
|
+
if ((root === null || root === void 0 ? void 0 : root.type) !== 'split')
|
|
271
|
+
throw new Error('expected split root');
|
|
272
|
+
expect((_a = root.collapsed) === null || _a === void 0 ? void 0 : _a[0]).toBe(true);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -195,9 +195,10 @@
|
|
|
195
195
|
closable={tabClosable(tabs.tabs)}
|
|
196
196
|
dirty={tabDirty(tabs.tabs)}
|
|
197
197
|
onClose={(i) => handleTabClose(tabs.tabs, i)}
|
|
198
|
+
tabIds={tabs.tabs.map((t) => t.slotId)}
|
|
198
199
|
/>
|
|
199
200
|
{#snippet tabBody(i: number)}
|
|
200
|
-
{@const entry = tabs
|
|
201
|
+
{@const entry = tabs?.tabs[i]}
|
|
201
202
|
{#if entry}
|
|
202
203
|
<div class="tab-slot-wrapper">
|
|
203
204
|
<SlotContainer node={{ type: 'slot', slotId: entry.slotId, viewId: entry.viewId }} label={entry.label} />
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { tick } from 'svelte';
|
|
3
|
+
import LayoutRenderer from './LayoutRenderer.svelte';
|
|
4
|
+
import { resetFramework } from '../__test__/reset';
|
|
5
|
+
import { renderWithShell } from '../__test__/render';
|
|
6
|
+
import { makeApp, makeAppManifest, makeSplitNode, makeTabsNode, makeTabEntry, makeSlotNode, makeTree, } from '../__test__/fixtures';
|
|
7
|
+
import { registerApp } from '../apps/registry.svelte';
|
|
8
|
+
import { registerView } from '../shards/registry';
|
|
9
|
+
import { launchApp } from '../apps/lifecycle';
|
|
10
|
+
function registerStubView() {
|
|
11
|
+
registerView('test:view', {
|
|
12
|
+
mount(container, ctx) {
|
|
13
|
+
const node = document.createElement('div');
|
|
14
|
+
node.dataset.viewFor = ctx.slotId;
|
|
15
|
+
container.appendChild(node);
|
|
16
|
+
return { unmount: () => node.remove() };
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
describe('LayoutRenderer — C.1 split with tabs inside', () => {
|
|
21
|
+
beforeEach(resetFramework);
|
|
22
|
+
it('renders a splitter with two panes and a tab strip', async () => {
|
|
23
|
+
registerStubView();
|
|
24
|
+
registerApp(makeApp({
|
|
25
|
+
manifest: makeAppManifest({ id: 'c1' }),
|
|
26
|
+
initialLayout: [
|
|
27
|
+
{
|
|
28
|
+
name: 'default',
|
|
29
|
+
tree: makeTree(makeSplitNode([
|
|
30
|
+
makeSlotNode('left', 'test:view'),
|
|
31
|
+
makeTabsNode([
|
|
32
|
+
makeTabEntry({ slotId: 'r1', label: 'R1', viewId: 'test:view' }),
|
|
33
|
+
makeTabEntry({ slotId: 'r2', label: 'R2', viewId: 'test:view' }),
|
|
34
|
+
]),
|
|
35
|
+
])),
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
}));
|
|
39
|
+
await launchApp('c1');
|
|
40
|
+
const { container } = renderWithShell(LayoutRenderer, { path: [] });
|
|
41
|
+
await tick();
|
|
42
|
+
expect(container.querySelector('[data-view-for="left"]')).toBeTruthy();
|
|
43
|
+
// TabbedPanel renders buttons with role="tab" inside a div[role="tablist"]
|
|
44
|
+
expect(container.querySelectorAll('[role="tab"]').length).toBeGreaterThanOrEqual(2);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
import { layoutStore } from './store.svelte';
|
|
48
|
+
describe('LayoutRenderer — C.2 live tab insertion', () => {
|
|
49
|
+
beforeEach(resetFramework);
|
|
50
|
+
it('shows a new tab button when a tab entry is pushed into an existing tabs node', async () => {
|
|
51
|
+
registerStubView();
|
|
52
|
+
registerApp(makeApp({
|
|
53
|
+
manifest: makeAppManifest({ id: 'c2' }),
|
|
54
|
+
initialLayout: [
|
|
55
|
+
{
|
|
56
|
+
name: 'default',
|
|
57
|
+
tree: makeTree(makeTabsNode([makeTabEntry({ slotId: 'a', label: 'A', viewId: 'test:view' })])),
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
}));
|
|
61
|
+
await launchApp('c2');
|
|
62
|
+
const { container } = renderWithShell(LayoutRenderer, { path: [] });
|
|
63
|
+
await tick();
|
|
64
|
+
// TabbedPanel renders buttons with role="tab"
|
|
65
|
+
const before = container.querySelectorAll('[role="tab"]').length;
|
|
66
|
+
const root = layoutStore.root;
|
|
67
|
+
if ((root === null || root === void 0 ? void 0 : root.type) !== 'tabs')
|
|
68
|
+
throw new Error('expected tabs root');
|
|
69
|
+
root.tabs.push({ slotId: 'b', viewId: 'test:view', label: 'B' });
|
|
70
|
+
await tick();
|
|
71
|
+
const after = container.querySelectorAll('[role="tab"]').length;
|
|
72
|
+
expect(after).toBe(before + 1);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
import { presetManager } from '../overlays/presets';
|
|
76
|
+
describe('LayoutRenderer — C.3 tree shape swap', () => {
|
|
77
|
+
beforeEach(resetFramework);
|
|
78
|
+
it('does not throw when swapping from tabs-at-root to split-with-tabs-inside', async () => {
|
|
79
|
+
registerStubView();
|
|
80
|
+
registerApp(makeApp({
|
|
81
|
+
manifest: makeAppManifest({ id: 'c3' }),
|
|
82
|
+
initialLayout: [
|
|
83
|
+
{
|
|
84
|
+
name: 'tabs-root',
|
|
85
|
+
tree: makeTree(makeTabsNode([makeTabEntry({ slotId: 't', viewId: 'test:view' })])),
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'split-root',
|
|
89
|
+
tree: makeTree(makeSplitNode([
|
|
90
|
+
makeSlotNode('left', 'test:view'),
|
|
91
|
+
makeTabsNode([makeTabEntry({ slotId: 'r', viewId: 'test:view' })]),
|
|
92
|
+
])),
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
}));
|
|
96
|
+
await launchApp('c3');
|
|
97
|
+
const { container } = renderWithShell(LayoutRenderer, { path: [] });
|
|
98
|
+
await tick();
|
|
99
|
+
expect(() => presetManager.switch('split-root')).not.toThrow();
|
|
100
|
+
await tick();
|
|
101
|
+
expect(container.querySelector('[data-view-for="left"]')).toBeTruthy();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
describe('LayoutRenderer — C.4 tabless preset', () => {
|
|
105
|
+
beforeEach(resetFramework);
|
|
106
|
+
it('renders a slot-only preset without accessing any tabs snippet', async () => {
|
|
107
|
+
registerStubView();
|
|
108
|
+
registerApp(makeApp({
|
|
109
|
+
manifest: makeAppManifest({ id: 'c4' }),
|
|
110
|
+
initialLayout: [
|
|
111
|
+
{ name: 'default', tree: makeTree(makeSlotNode('only', 'test:view')) },
|
|
112
|
+
],
|
|
113
|
+
}));
|
|
114
|
+
await launchApp('c4');
|
|
115
|
+
const { container } = renderWithShell(LayoutRenderer, { path: [] });
|
|
116
|
+
await tick();
|
|
117
|
+
expect(container.querySelector('[data-view-for="only"]')).toBeTruthy();
|
|
118
|
+
// TabbedPanel renders buttons with role="tab" — none should appear for a slot-only tree
|
|
119
|
+
expect(container.querySelector('[role="tab"]')).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
describe('LayoutRenderer — C.5 invalid path', () => {
|
|
123
|
+
beforeEach(resetFramework);
|
|
124
|
+
it('renders nothing when the path no longer resolves to a node', async () => {
|
|
125
|
+
registerStubView();
|
|
126
|
+
registerApp(makeApp({
|
|
127
|
+
manifest: makeAppManifest({ id: 'c5' }),
|
|
128
|
+
initialLayout: [
|
|
129
|
+
{
|
|
130
|
+
name: 'default',
|
|
131
|
+
tree: makeTree(makeSplitNode([makeSlotNode('a', 'test:view'), makeSlotNode('b', 'test:view')])),
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
}));
|
|
135
|
+
await launchApp('c5');
|
|
136
|
+
const { container } = renderWithShell(LayoutRenderer, { path: [99] });
|
|
137
|
+
await tick();
|
|
138
|
+
// LayoutRenderer renders nothing when path doesn't resolve — Svelte leaves
|
|
139
|
+
// a comment anchor node in the container but no visible content.
|
|
140
|
+
expect(container.querySelector('[data-view-for]')).toBeNull();
|
|
141
|
+
expect(container.querySelector('.leaf-slot-wrapper, .splitter, .tabbed-panel')).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -51,7 +51,13 @@
|
|
|
51
51
|
$effect(() => {
|
|
52
52
|
if (!wrapper) return;
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
// Capture slotId at effect-run time so the cleanup closure releases the
|
|
55
|
+
// correct slot even when node.slotId has already changed to a new value
|
|
56
|
+
// by the time Svelte calls the cleanup (which happens after reactive
|
|
57
|
+
// state has been updated). Without this capture, cleanup would call
|
|
58
|
+
// releaseSlotHost with the NEW slotId instead of the old one.
|
|
59
|
+
const currentSlotId = node.slotId;
|
|
60
|
+
const host = acquireSlotHost(currentSlotId, node.viewId, label || node.viewId || currentSlotId);
|
|
55
61
|
wrapper.appendChild(host);
|
|
56
62
|
|
|
57
63
|
// Local observer exists only to drive the placeholder's dims text;
|
|
@@ -67,7 +73,7 @@
|
|
|
67
73
|
|
|
68
74
|
return () => {
|
|
69
75
|
ro.disconnect();
|
|
70
|
-
releaseSlotHost(
|
|
76
|
+
releaseSlotHost(currentSlotId);
|
|
71
77
|
};
|
|
72
78
|
});
|
|
73
79
|
</script>
|
|
@@ -99,6 +99,12 @@
|
|
|
99
99
|
{#if hoveredSide}
|
|
100
100
|
<div class="quad-highlight quad-{hoveredSide}"></div>
|
|
101
101
|
{/if}
|
|
102
|
+
{#if active}
|
|
103
|
+
<div class="quad-target quad-left" data-testid="drop-zone-left"></div>
|
|
104
|
+
<div class="quad-target quad-right" data-testid="drop-zone-right"></div>
|
|
105
|
+
<div class="quad-target quad-top" data-testid="drop-zone-top"></div>
|
|
106
|
+
<div class="quad-target quad-bottom" data-testid="drop-zone-bottom"></div>
|
|
107
|
+
{/if}
|
|
102
108
|
</div>
|
|
103
109
|
|
|
104
110
|
<style>
|
|
@@ -122,4 +128,17 @@
|
|
|
122
128
|
.quad-highlight.quad-right { top: 0; bottom: 0; left: 50%; right: 0; }
|
|
123
129
|
.quad-highlight.quad-top { left: 0; right: 0; top: 0; bottom: 50%; }
|
|
124
130
|
.quad-highlight.quad-bottom { left: 0; right: 0; top: 50%; bottom: 0; }
|
|
131
|
+
|
|
132
|
+
/* Invisible hit-target overlays for each quadrant — present only during a
|
|
133
|
+
drag so they don't intercept normal pointer events. These give Playwright
|
|
134
|
+
(and pointer-based UI) a stable target area per side without relying on
|
|
135
|
+
the hover-driven highlight. */
|
|
136
|
+
.quad-target {
|
|
137
|
+
position: absolute;
|
|
138
|
+
pointer-events: none; /* the parent zone handles pointermove */
|
|
139
|
+
}
|
|
140
|
+
.quad-target.quad-left { top: 0; bottom: 0; left: 0; right: 50%; }
|
|
141
|
+
.quad-target.quad-right { top: 0; bottom: 0; left: 50%; right: 0; }
|
|
142
|
+
.quad-target.quad-top { left: 0; right: 0; top: 0; bottom: 50%; }
|
|
143
|
+
.quad-target.quad-bottom { left: 0; right: 0; top: 50%; bottom: 0; }
|
|
125
144
|
</style>
|
|
@@ -45,4 +45,9 @@ export declare function beginTabDrag(slotId: string, entry: TabEntry, sourceRoot
|
|
|
45
45
|
*/
|
|
46
46
|
export declare function setDropTarget(target: DropTarget): void;
|
|
47
47
|
export declare function clearDropTarget(match?: (target: DropTarget) => boolean): void;
|
|
48
|
+
/**
|
|
49
|
+
* Test-only reset. Returns dragState to its idle initial value and
|
|
50
|
+
* removes any pointer listeners attached during a prior drag.
|
|
51
|
+
*/
|
|
52
|
+
export declare function __resetDragStateForTest(): void;
|
|
48
53
|
export {};
|
|
@@ -230,3 +230,18 @@ export function clearDropTarget(match) {
|
|
|
230
230
|
return;
|
|
231
231
|
dragState.target = null;
|
|
232
232
|
}
|
|
233
|
+
/**
|
|
234
|
+
* Test-only reset. Returns dragState to its idle initial value and
|
|
235
|
+
* removes any pointer listeners attached during a prior drag.
|
|
236
|
+
*/
|
|
237
|
+
export function __resetDragStateForTest() {
|
|
238
|
+
removeGlobalListeners();
|
|
239
|
+
dragState.phase = 'idle';
|
|
240
|
+
dragState.source = null;
|
|
241
|
+
dragState.target = null;
|
|
242
|
+
dragState.pointerX = 0;
|
|
243
|
+
dragState.pointerY = 0;
|
|
244
|
+
clickSuppressedUntil = 0;
|
|
245
|
+
pendingStartX = 0;
|
|
246
|
+
pendingStartY = 0;
|
|
247
|
+
}
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { activeLayout, getActiveRoot } from './store.svelte';
|
|
16
16
|
import { nodeAtPath, findTabBySlotId, removeTabBySlotId, cleanupTree, splitNodeAtPath, } from './ops';
|
|
17
17
|
import { getSlotHandle } from './slotHostPool.svelte';
|
|
18
|
+
import { floatManager } from '../overlays/float';
|
|
18
19
|
/**
|
|
19
20
|
* Read-only snapshot of the currently-rendered layout tree. The return
|
|
20
21
|
* value is the live object — callers MUST NOT mutate it directly;
|
|
@@ -46,16 +47,20 @@ export function spliceIntoActiveLayout(entry) {
|
|
|
46
47
|
* layout. Returns `true` if a matching tab was found and activated.
|
|
47
48
|
*/
|
|
48
49
|
export function focusTab(slotId) {
|
|
49
|
-
const
|
|
50
|
-
|
|
50
|
+
const tree = activeLayout();
|
|
51
|
+
if (focusTabWhere(tree.docked, (entry) => entry.slotId === slotId))
|
|
52
|
+
return true;
|
|
53
|
+
return focusTabInFloats(tree, (entry) => entry.slotId === slotId);
|
|
51
54
|
}
|
|
52
55
|
/**
|
|
53
56
|
* Activate the first tab whose `viewId` matches in the currently-rendered
|
|
54
57
|
* layout. Returns `true` if a matching tab was found and activated.
|
|
55
58
|
*/
|
|
56
59
|
export function focusView(viewId) {
|
|
57
|
-
const
|
|
58
|
-
|
|
60
|
+
const tree = activeLayout();
|
|
61
|
+
if (focusTabWhere(tree.docked, (entry) => entry.viewId === viewId))
|
|
62
|
+
return true;
|
|
63
|
+
return focusTabInFloats(tree, (entry) => entry.viewId === viewId);
|
|
59
64
|
}
|
|
60
65
|
/** Walk the tree looking for a tab entry that satisfies `pred`, activate it. */
|
|
61
66
|
function focusTabWhere(node, pred) {
|
|
@@ -75,6 +80,16 @@ function focusTabWhere(node, pred) {
|
|
|
75
80
|
}
|
|
76
81
|
return false;
|
|
77
82
|
}
|
|
83
|
+
/** Search floats for a matching tab; activate it and raise the float. */
|
|
84
|
+
function focusTabInFloats(tree, pred) {
|
|
85
|
+
for (const floatEntry of tree.floats) {
|
|
86
|
+
if (focusTabWhere(floatEntry.content, pred)) {
|
|
87
|
+
floatManager.focus(floatEntry.id);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
78
93
|
/**
|
|
79
94
|
* Collapse a child of a split node at the given path. Returns true if
|
|
80
95
|
* the split was found and the child was collapsed.
|
|
@@ -115,10 +130,13 @@ function setCollapsed(splitPath, childIndex, value) {
|
|
|
115
130
|
* the sole authority on tree mutations.
|
|
116
131
|
*/
|
|
117
132
|
export async function closeTab(slotId) {
|
|
118
|
-
const
|
|
133
|
+
const tree = activeLayout();
|
|
134
|
+
const root = tree.docked;
|
|
119
135
|
const located = findTabBySlotId(root, slotId);
|
|
120
|
-
|
|
121
|
-
|
|
136
|
+
// Not found in docked tree — check floats.
|
|
137
|
+
if (!located) {
|
|
138
|
+
return closeFloatTab(tree, slotId);
|
|
139
|
+
}
|
|
122
140
|
const handle = getSlotHandle(slotId);
|
|
123
141
|
const closable = handle === null || handle === void 0 ? void 0 : handle.closable;
|
|
124
142
|
// Non-closable: no action.
|
|
@@ -143,6 +161,39 @@ export async function closeTab(slotId) {
|
|
|
143
161
|
cleanupTree(root);
|
|
144
162
|
return true;
|
|
145
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Close a tab that lives inside a float entry. Float tabs are
|
|
166
|
+
* auto-closable (views gain closability when mounted in a float) but
|
|
167
|
+
* guarded canClose() on the view handle is still respected.
|
|
168
|
+
* Closing the last tab in a float removes the entire float.
|
|
169
|
+
*/
|
|
170
|
+
async function closeFloatTab(tree, slotId) {
|
|
171
|
+
for (const entry of tree.floats) {
|
|
172
|
+
const located = findTabBySlotId(entry.content, slotId);
|
|
173
|
+
if (!located)
|
|
174
|
+
continue;
|
|
175
|
+
// Respect guarded canClose() if the view declared one.
|
|
176
|
+
const handle = getSlotHandle(slotId);
|
|
177
|
+
const closable = handle === null || handle === void 0 ? void 0 : handle.closable;
|
|
178
|
+
if (typeof closable === 'object') {
|
|
179
|
+
const allowed = await closable.canClose();
|
|
180
|
+
if (!allowed)
|
|
181
|
+
return false;
|
|
182
|
+
// Re-verify after async gap.
|
|
183
|
+
if (!findTabBySlotId(entry.content, slotId))
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
// Remove the tab from the float's content tree.
|
|
187
|
+
removeTabBySlotId(entry.content, slotId);
|
|
188
|
+
// If the float's content is now empty, remove the entire float.
|
|
189
|
+
const tabs = entry.content.type === 'tabs' ? entry.content : null;
|
|
190
|
+
if (!tabs || tabs.tabs.length === 0) {
|
|
191
|
+
floatManager.close(entry.id);
|
|
192
|
+
}
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
146
197
|
function findFirstTabsNode(node) {
|
|
147
198
|
if (node.type === 'tabs')
|
|
148
199
|
return node;
|