sh3-core 0.17.0 → 0.17.2
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/Sh3.svelte +48 -35
- package/dist/__screenshots__/handheld.browser.test.ts/handheld-viewport-flip-e2e-viewport-override-flips-chrome-and-body-branches-1.png +0 -0
- package/dist/actions/listActionsFromEntries.test.js +29 -0
- package/dist/actions/listActive.js +2 -0
- package/dist/actions/listeners.js +4 -0
- package/dist/actions/programmatic-dispatch.svelte.test.js +9 -2
- package/dist/actions/types.d.ts +8 -0
- package/dist/api.d.ts +4 -1
- package/dist/chrome/CompactChrome.svelte +96 -0
- package/dist/chrome/CompactChrome.svelte.d.ts +3 -0
- package/dist/chrome/CompactChrome.svelte.test.d.ts +1 -0
- package/dist/chrome/CompactChrome.svelte.test.js +67 -0
- package/dist/chrome/MenuSheet.svelte +224 -0
- package/dist/chrome/MenuSheet.svelte.d.ts +7 -0
- package/dist/chrome/MenuSheet.svelte.test.d.ts +1 -0
- package/dist/chrome/MenuSheet.svelte.test.js +46 -0
- package/dist/handheld.browser.test.d.ts +1 -0
- package/dist/handheld.browser.test.js +90 -0
- package/dist/layout/LayoutRenderer.svelte +12 -1
- package/dist/layout/LayoutRenderer.svelte.d.ts +2 -1
- package/dist/layout/compact/CompactRenderer.svelte +53 -0
- package/dist/layout/compact/CompactRenderer.svelte.d.ts +3 -0
- package/dist/layout/compact/CompactRenderer.svelte.test.d.ts +1 -0
- package/dist/layout/compact/CompactRenderer.svelte.test.js +76 -0
- package/dist/layout/compact/derive.d.ts +3 -0
- package/dist/layout/compact/derive.js +155 -0
- package/dist/layout/compact/derive.test.d.ts +1 -0
- package/dist/layout/compact/derive.test.js +160 -0
- package/dist/layout/compact/drawerStore.svelte.d.ts +21 -0
- package/dist/layout/compact/drawerStore.svelte.js +75 -0
- package/dist/layout/compact/drawerStore.svelte.test.d.ts +1 -0
- package/dist/layout/compact/drawerStore.svelte.test.js +43 -0
- package/dist/layout/compact/resolveRole.d.ts +6 -0
- package/dist/layout/compact/resolveRole.js +13 -0
- package/dist/layout/compact/resolveRole.test.d.ts +1 -0
- package/dist/layout/compact/resolveRole.test.js +18 -0
- package/dist/layout/compact/types.d.ts +27 -0
- package/dist/layout/compact/types.js +15 -0
- package/dist/layout/presets.compactVariant.test.d.ts +1 -0
- package/dist/layout/presets.compactVariant.test.js +27 -0
- package/dist/layout/presets.d.ts +12 -0
- package/dist/layout/presets.js +16 -0
- package/dist/layout/store.drawers.svelte.test.d.ts +1 -0
- package/dist/layout/store.drawers.svelte.test.js +49 -0
- package/dist/layout/store.schemaVersion.test.d.ts +1 -0
- package/dist/layout/store.schemaVersion.test.js +35 -0
- package/dist/layout/store.svelte.js +52 -2
- package/dist/layout/types.d.ts +43 -1
- package/dist/layout/types.js +1 -1
- package/dist/overlays/DrawerSurface.svelte +141 -0
- package/dist/overlays/DrawerSurface.svelte.d.ts +12 -0
- package/dist/overlays/DrawerSurface.svelte.test.d.ts +1 -0
- package/dist/overlays/DrawerSurface.svelte.test.js +67 -0
- package/dist/overlays/OverlayRoots.svelte +12 -9
- package/dist/overlays/types.d.ts +1 -1
- package/dist/sh3Api/headless.js +9 -1
- package/dist/sh3Api/headless.svelte.test.js +45 -1
- package/dist/sh3Runtime.svelte.d.ts +36 -0
- package/dist/sh3Runtime.svelte.js +33 -0
- package/dist/shards/types.d.ts +9 -1
- package/dist/tokens.css +3 -2
- package/dist/verbs/types.d.ts +5 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/viewport/classify.d.ts +8 -0
- package/dist/viewport/classify.js +20 -0
- package/dist/viewport/classify.test.d.ts +1 -0
- package/dist/viewport/classify.test.js +32 -0
- package/dist/viewport/store.browser.test.d.ts +1 -0
- package/dist/viewport/store.browser.test.js +33 -0
- package/dist/viewport/store.svelte.d.ts +9 -0
- package/dist/viewport/store.svelte.js +71 -0
- package/dist/viewport/store.svelte.test.d.ts +1 -0
- package/dist/viewport/store.svelte.test.js +54 -0
- package/dist/viewport/types.d.ts +9 -0
- package/dist/viewport/types.js +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* DOM smoke for MenuSheet — verifies open/closed rendering. The
|
|
3
|
+
* container/item resolution is exercised via the same model functions
|
|
4
|
+
* MenuBar uses; their unit tests cover the resolution semantics so
|
|
5
|
+
* this test only asserts the wrapper structure.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
8
|
+
import { mount, unmount, flushSync } from 'svelte';
|
|
9
|
+
import MenuSheet from './MenuSheet.svelte';
|
|
10
|
+
const MenuSheetAny = MenuSheet;
|
|
11
|
+
let mounted = null;
|
|
12
|
+
let host = null;
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
if (mounted) {
|
|
15
|
+
unmount(mounted);
|
|
16
|
+
mounted = null;
|
|
17
|
+
}
|
|
18
|
+
if (host) {
|
|
19
|
+
host.remove();
|
|
20
|
+
host = null;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
describe('MenuSheet (dom)', () => {
|
|
24
|
+
it('renders nothing when closed', () => {
|
|
25
|
+
host = document.createElement('div');
|
|
26
|
+
document.body.appendChild(host);
|
|
27
|
+
mounted = mount(MenuSheetAny, {
|
|
28
|
+
target: host,
|
|
29
|
+
props: { open: false, onClose: () => { } },
|
|
30
|
+
});
|
|
31
|
+
flushSync();
|
|
32
|
+
expect(host.querySelector('[data-sh3-region="menu-sheet"]')).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
it('renders a sheet with a Cancel button when open', () => {
|
|
35
|
+
host = document.createElement('div');
|
|
36
|
+
document.body.appendChild(host);
|
|
37
|
+
mounted = mount(MenuSheetAny, {
|
|
38
|
+
target: host,
|
|
39
|
+
props: { open: true, onClose: () => { } },
|
|
40
|
+
});
|
|
41
|
+
flushSync();
|
|
42
|
+
const sheet = host.querySelector('[data-sh3-region="menu-sheet"]');
|
|
43
|
+
expect(sheet).not.toBeNull();
|
|
44
|
+
expect(sheet.querySelector('.cancel').textContent).toContain('Cancel');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Handheld flip e2e — verifies slot survival across viewport flips
|
|
3
|
+
* using the real browser's re-parent semantics + slot host pool.
|
|
4
|
+
*
|
|
5
|
+
* The proof rides on slot id stability: if the same slot id is requested
|
|
6
|
+
* by both the docked LayoutRenderer (desktop) and the compact-mode
|
|
7
|
+
* DrawerSurface, the host pool keeps the host alive across the swap and
|
|
8
|
+
* the same DOM element is reused. We stamp a unique data attribute on
|
|
9
|
+
* the mounted view DOM, flip viewport classes, and assert the same
|
|
10
|
+
* element is still findable after each flip.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
13
|
+
import { tick } from 'svelte';
|
|
14
|
+
import { resetFramework } from './__test__/reset';
|
|
15
|
+
import { renderWithShell } from './__test__/render';
|
|
16
|
+
import { registerApp } from './apps/registry.svelte';
|
|
17
|
+
import { launchApp } from './apps/lifecycle';
|
|
18
|
+
import { registerView } from './shards/registry';
|
|
19
|
+
import { makeApp, makeAppManifest } from './__test__/fixtures';
|
|
20
|
+
import { sh3 } from './sh3Runtime.svelte';
|
|
21
|
+
import Sh3 from './Sh3.svelte';
|
|
22
|
+
function settle(ms = 50) {
|
|
23
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
24
|
+
}
|
|
25
|
+
function cleanupDOM() {
|
|
26
|
+
document.querySelectorAll('.sh3-sh3-host').forEach((h) => h.remove());
|
|
27
|
+
}
|
|
28
|
+
describe('handheld viewport flip e2e', () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
cleanupDOM();
|
|
31
|
+
resetFramework();
|
|
32
|
+
sh3.viewport.override(null);
|
|
33
|
+
});
|
|
34
|
+
it('viewport override flips chrome and body branches', async () => {
|
|
35
|
+
// Stable per-mount stamp so we can recognise the same DOM element.
|
|
36
|
+
let stampedEl = null;
|
|
37
|
+
registerView('test:body', {
|
|
38
|
+
mount: (el) => {
|
|
39
|
+
el.dataset.testStamp = 'body-' + Math.random().toString(36).slice(2);
|
|
40
|
+
stampedEl = el;
|
|
41
|
+
return { unmount: () => { } };
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
registerView('test:sb', {
|
|
45
|
+
mount: (el) => {
|
|
46
|
+
el.dataset.testStamp = 'sb-' + Math.random().toString(36).slice(2);
|
|
47
|
+
return { unmount: () => { } };
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
registerApp(makeApp({
|
|
51
|
+
manifest: makeAppManifest({ id: 'flip-app', label: 'Flip' }),
|
|
52
|
+
initialLayout: {
|
|
53
|
+
type: 'split', direction: 'horizontal', sizes: [0.3, 0.7],
|
|
54
|
+
children: [
|
|
55
|
+
{ type: 'slot', slotId: 'sb', viewId: 'test:sb', role: 'sidebar' },
|
|
56
|
+
{ type: 'slot', slotId: 'body', viewId: 'test:body', role: 'body' },
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
}));
|
|
60
|
+
// Pin desktop before mounting so the gate's first render is desktop.
|
|
61
|
+
sh3.viewport.override('desktop');
|
|
62
|
+
renderWithShell(Sh3, {});
|
|
63
|
+
await launchApp('flip-app');
|
|
64
|
+
await tick();
|
|
65
|
+
await settle();
|
|
66
|
+
expect(document.querySelector('[data-sh3-region="tabbar"]')).not.toBeNull();
|
|
67
|
+
expect(document.querySelector('[data-sh3-region="compact-chrome"]')).toBeNull();
|
|
68
|
+
expect(stampedEl).not.toBeNull();
|
|
69
|
+
const initialStamp = stampedEl.dataset.testStamp;
|
|
70
|
+
// Flip to compact.
|
|
71
|
+
sh3.viewport.override('compact');
|
|
72
|
+
await tick();
|
|
73
|
+
await settle();
|
|
74
|
+
expect(document.querySelector('[data-sh3-region="compact-chrome"]')).not.toBeNull();
|
|
75
|
+
expect(document.querySelector('[data-sh3-region="tabbar"]')).toBeNull();
|
|
76
|
+
expect(document.querySelector('[data-sh3-region="compact-body"]')).not.toBeNull();
|
|
77
|
+
// Body slot host survived the swap — query the test stamp.
|
|
78
|
+
const survivors = document.querySelectorAll('[data-test-stamp]');
|
|
79
|
+
const compactStamps = Array.from(survivors).map((el) => el.dataset.testStamp);
|
|
80
|
+
expect(compactStamps).toContain(initialStamp);
|
|
81
|
+
// Flip back to desktop.
|
|
82
|
+
sh3.viewport.override('desktop');
|
|
83
|
+
await tick();
|
|
84
|
+
await settle();
|
|
85
|
+
expect(document.querySelector('[data-sh3-region="tabbar"]')).not.toBeNull();
|
|
86
|
+
const finalStamps = Array.from(document.querySelectorAll('[data-test-stamp]')).map((el) => el.dataset.testStamp);
|
|
87
|
+
// Slot was not remounted across two flips — original stamp survives.
|
|
88
|
+
expect(finalStamps).toContain(initialStamp);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -47,7 +47,8 @@
|
|
|
47
47
|
let {
|
|
48
48
|
path = [],
|
|
49
49
|
rootRef = { kind: 'docked' } as TreeRootRef,
|
|
50
|
-
|
|
50
|
+
rootOverride,
|
|
51
|
+
}: { path?: number[]; rootRef?: TreeRootRef; rootOverride?: LayoutNode } = $props();
|
|
51
52
|
|
|
52
53
|
/**
|
|
53
54
|
* Resolve the current node by walking `layoutStore.root` along the
|
|
@@ -55,8 +56,18 @@
|
|
|
55
56
|
* layout mutates. If the path becomes invalid mid-mutation (a
|
|
56
57
|
* cleanup pass can collapse nodes out from under a recursive
|
|
57
58
|
* renderer), we render null.
|
|
59
|
+
*
|
|
60
|
+
* `rootOverride`: compact-mode entry point. CompactRenderer derives a
|
|
61
|
+
* body-only sub-tree from the canonical layout and passes it here as
|
|
62
|
+
* the root. The recursive `<Self>` calls below DO NOT propagate the
|
|
63
|
+
* override — they keep the path-only contract so structural mutations
|
|
64
|
+
* still go through `layoutStore.root` for the (rare) cases where the
|
|
65
|
+
* derived bodyRoot shares object identity with the source.
|
|
58
66
|
*/
|
|
59
67
|
const node = $derived.by(() => {
|
|
68
|
+
if (rootOverride !== undefined) {
|
|
69
|
+
return nodeAtPath(rootOverride, path);
|
|
70
|
+
}
|
|
60
71
|
let rootNode: LayoutNode | null;
|
|
61
72
|
if (rootRef.kind === 'docked') {
|
|
62
73
|
rootNode = layoutStore.root;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { TreeRootRef } from './types';
|
|
1
|
+
import type { LayoutNode, TreeRootRef } from './types';
|
|
2
2
|
type $$ComponentProps = {
|
|
3
3
|
path?: number[];
|
|
4
4
|
rootRef?: TreeRootRef;
|
|
5
|
+
rootOverride?: LayoutNode;
|
|
5
6
|
};
|
|
6
7
|
declare const LayoutRenderer: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
8
|
type LayoutRenderer = ReturnType<typeof LayoutRenderer>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Compact rendering wrapper. Reads the active layout tree from
|
|
4
|
+
* layoutStore, runs derive() to get the CompactRendering, renders the
|
|
5
|
+
* bodyRoot via LayoutRenderer (with rootOverride), and mounts a
|
|
6
|
+
* DrawerSurface for each non-null anchor.
|
|
7
|
+
*
|
|
8
|
+
* Drawer state lives in drawerStore (Sh3.drawers backing). The surfaces
|
|
9
|
+
* paint above the body content via their own absolute-positioned
|
|
10
|
+
* frames; the drawer overlay layer (--sh3-z-layer-drawers) sits above
|
|
11
|
+
* the docked content so the surfaces stack correctly.
|
|
12
|
+
*
|
|
13
|
+
* View-default role lookup is intentionally omitted in v1 — derive()
|
|
14
|
+
* reads slot.role / tab.role directly. Apps that want non-body slots
|
|
15
|
+
* tag them at authoring time. View-default fall-through ships when the
|
|
16
|
+
* registry exposes a pre-mount lookup (deferred from this PR).
|
|
17
|
+
*/
|
|
18
|
+
import { layoutStore } from '../store.svelte';
|
|
19
|
+
import { drawerStore } from './drawerStore.svelte';
|
|
20
|
+
import { derive } from './derive';
|
|
21
|
+
import LayoutRenderer from '../LayoutRenderer.svelte';
|
|
22
|
+
import DrawerSurface from '../../overlays/DrawerSurface.svelte';
|
|
23
|
+
import type { DrawerAnchor } from './types';
|
|
24
|
+
|
|
25
|
+
const rendering = $derived(derive(layoutStore.root));
|
|
26
|
+
|
|
27
|
+
const anchors: DrawerAnchor[] = ['left', 'right', 'top'];
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<div class="compact-body" data-sh3-region="compact-body">
|
|
31
|
+
<LayoutRenderer rootOverride={rendering.bodyRoot} />
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
{#each anchors as anchor (anchor)}
|
|
35
|
+
{@const spec = rendering.drawers[anchor]}
|
|
36
|
+
{#if spec}
|
|
37
|
+
<DrawerSurface
|
|
38
|
+
{anchor}
|
|
39
|
+
{spec}
|
|
40
|
+
open={drawerStore.state[anchor].open}
|
|
41
|
+
activeSlotId={drawerStore.state[anchor].activeSlotId}
|
|
42
|
+
onClose={() => drawerStore.close(anchor)}
|
|
43
|
+
onActivate={(slotId) => drawerStore.activate(anchor, slotId)}
|
|
44
|
+
/>
|
|
45
|
+
{/if}
|
|
46
|
+
{/each}
|
|
47
|
+
|
|
48
|
+
<style>
|
|
49
|
+
.compact-body {
|
|
50
|
+
position: absolute;
|
|
51
|
+
inset: 0;
|
|
52
|
+
}
|
|
53
|
+
</style>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* DOM smoke for CompactRenderer. With a sidebar+body+inspector tree
|
|
3
|
+
* attached as the active layout, the wrapper renders one drawer surface
|
|
4
|
+
* per anchor (open=false, but the drawer-region lookup happens on open
|
|
5
|
+
* — here we just verify the wrapper accepts the tree and renders the
|
|
6
|
+
* body container plus the LayoutRenderer for bodyRoot).
|
|
7
|
+
*
|
|
8
|
+
* Slot-survival on viewport flip is covered by the browser e2e in T21.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
11
|
+
import { mount, unmount, flushSync } from 'svelte';
|
|
12
|
+
import CompactRenderer from './CompactRenderer.svelte';
|
|
13
|
+
import { drawerStore } from './drawerStore.svelte';
|
|
14
|
+
import { __resetLayoutStoreForTest, attachApp, detachApp, switchToApp } from '../store.svelte';
|
|
15
|
+
function fakeApp() {
|
|
16
|
+
return {
|
|
17
|
+
manifest: { id: 'compact-test-app', layoutVersion: 5 },
|
|
18
|
+
initialLayout: {
|
|
19
|
+
type: 'split', direction: 'horizontal', sizes: [0.2, 0.6, 0.2],
|
|
20
|
+
children: [
|
|
21
|
+
{ type: 'slot', slotId: 'sb', viewId: 'v:sb', role: 'sidebar' },
|
|
22
|
+
{ type: 'slot', slotId: 'body', viewId: 'v:body', role: 'body' },
|
|
23
|
+
{ type: 'slot', slotId: 'ins', viewId: 'v:ins', role: 'inspector' },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
let mounted = null;
|
|
29
|
+
let host = null;
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
if (mounted) {
|
|
32
|
+
unmount(mounted);
|
|
33
|
+
mounted = null;
|
|
34
|
+
}
|
|
35
|
+
if (host) {
|
|
36
|
+
host.remove();
|
|
37
|
+
host = null;
|
|
38
|
+
}
|
|
39
|
+
detachApp();
|
|
40
|
+
});
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
__resetLayoutStoreForTest();
|
|
43
|
+
drawerStore.__reset();
|
|
44
|
+
});
|
|
45
|
+
describe('CompactRenderer (dom)', () => {
|
|
46
|
+
it('renders the compact-body region for the bodyRoot', () => {
|
|
47
|
+
attachApp(fakeApp());
|
|
48
|
+
switchToApp();
|
|
49
|
+
flushSync();
|
|
50
|
+
host = document.createElement('div');
|
|
51
|
+
host.style.width = '400px';
|
|
52
|
+
host.style.height = '600px';
|
|
53
|
+
host.style.position = 'relative';
|
|
54
|
+
document.body.appendChild(host);
|
|
55
|
+
mounted = mount(CompactRenderer, { target: host });
|
|
56
|
+
flushSync();
|
|
57
|
+
expect(host.querySelector('[data-sh3-region="compact-body"]')).not.toBeNull();
|
|
58
|
+
});
|
|
59
|
+
it('opening left drawer renders one drawer-region with sidebar label', () => {
|
|
60
|
+
attachApp(fakeApp());
|
|
61
|
+
switchToApp();
|
|
62
|
+
flushSync();
|
|
63
|
+
host = document.createElement('div');
|
|
64
|
+
host.style.width = '400px';
|
|
65
|
+
host.style.height = '600px';
|
|
66
|
+
host.style.position = 'relative';
|
|
67
|
+
document.body.appendChild(host);
|
|
68
|
+
mounted = mount(CompactRenderer, { target: host });
|
|
69
|
+
flushSync();
|
|
70
|
+
drawerStore.open('left');
|
|
71
|
+
flushSync();
|
|
72
|
+
const drawer = host.querySelector('[data-sh3-region="drawer"][data-sh3-anchor="left"]');
|
|
73
|
+
expect(drawer).not.toBeNull();
|
|
74
|
+
expect(drawer.querySelector('header').textContent).toContain('sb');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* derive — pure transform from a canonical LayoutNode tree to a
|
|
3
|
+
* CompactRendering. See ./types.ts for the output shape.
|
|
4
|
+
*
|
|
5
|
+
* Anchor inference (from spec §4):
|
|
6
|
+
* - Horizontal split: first non-body subtree → 'left', last → 'right'.
|
|
7
|
+
* - Vertical split: first non-body subtree → 'top'.
|
|
8
|
+
* - Otherwise: sidebar → 'left', inspector → 'right'.
|
|
9
|
+
*
|
|
10
|
+
* Once an outer split has assigned an anchor for a subtree, descent
|
|
11
|
+
* locks that anchor — inner splits don't retag (a vertical split
|
|
12
|
+
* inside a left-anchored subtree keeps both children on the left).
|
|
13
|
+
*
|
|
14
|
+
* Note: this transform reads slot.role / tab.role only. View-level
|
|
15
|
+
* defaultRole resolution happens at the call site via resolveRole(),
|
|
16
|
+
* which materializes a tree with effective roles before passing to
|
|
17
|
+
* derive(). See layout/compact/CompactRenderer.svelte.
|
|
18
|
+
*/
|
|
19
|
+
import { EMPTY_BODY } from './types';
|
|
20
|
+
function effectiveRole(role) {
|
|
21
|
+
return role !== null && role !== void 0 ? role : 'body';
|
|
22
|
+
}
|
|
23
|
+
function collectSlots(node) {
|
|
24
|
+
if (node.type === 'slot') {
|
|
25
|
+
return [{
|
|
26
|
+
slotId: node.slotId,
|
|
27
|
+
viewId: node.viewId,
|
|
28
|
+
label: node.slotId,
|
|
29
|
+
role: effectiveRole(node.role),
|
|
30
|
+
}];
|
|
31
|
+
}
|
|
32
|
+
if (node.type === 'tabs') {
|
|
33
|
+
return node.tabs.map((t) => ({
|
|
34
|
+
slotId: t.slotId,
|
|
35
|
+
viewId: t.viewId,
|
|
36
|
+
label: t.label,
|
|
37
|
+
icon: t.icon,
|
|
38
|
+
role: effectiveRole(t.role),
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
return node.children.flatMap(collectSlots);
|
|
42
|
+
}
|
|
43
|
+
function hasAnyBody(node) {
|
|
44
|
+
return collectSlots(node).some((s) => s.role === 'body');
|
|
45
|
+
}
|
|
46
|
+
function stripNonBody(node) {
|
|
47
|
+
if (node.type === 'slot') {
|
|
48
|
+
return effectiveRole(node.role) === 'body' ? node : null;
|
|
49
|
+
}
|
|
50
|
+
if (node.type === 'tabs') {
|
|
51
|
+
const bodyTabs = node.tabs.filter((t) => effectiveRole(t.role) === 'body');
|
|
52
|
+
if (bodyTabs.length === 0)
|
|
53
|
+
return null;
|
|
54
|
+
if (bodyTabs.length === node.tabs.length)
|
|
55
|
+
return node;
|
|
56
|
+
return {
|
|
57
|
+
type: 'tabs',
|
|
58
|
+
activeTab: Math.min(node.activeTab, bodyTabs.length - 1),
|
|
59
|
+
tabs: bodyTabs,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// split
|
|
63
|
+
const survivors = [];
|
|
64
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
65
|
+
const stripped = stripNonBody(node.children[i]);
|
|
66
|
+
if (stripped)
|
|
67
|
+
survivors.push({ child: stripped, size: node.sizes[i] });
|
|
68
|
+
}
|
|
69
|
+
if (survivors.length === 0)
|
|
70
|
+
return null;
|
|
71
|
+
if (survivors.length === 1)
|
|
72
|
+
return survivors[0].child;
|
|
73
|
+
return {
|
|
74
|
+
type: 'split',
|
|
75
|
+
direction: node.direction,
|
|
76
|
+
sizes: survivors.map((s) => s.size),
|
|
77
|
+
children: survivors.map((s) => s.child),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function defaultAnchor(role) {
|
|
81
|
+
return role === 'inspector' ? 'right' : 'left';
|
|
82
|
+
}
|
|
83
|
+
function partitionDrawers(node) {
|
|
84
|
+
const buckets = { left: [], right: [], top: [] };
|
|
85
|
+
function walk(n, hint) {
|
|
86
|
+
if (n.type === 'slot') {
|
|
87
|
+
const role = effectiveRole(n.role);
|
|
88
|
+
if (role === 'body')
|
|
89
|
+
return;
|
|
90
|
+
const anchor = hint !== null && hint !== void 0 ? hint : defaultAnchor(role);
|
|
91
|
+
buckets[anchor].push({
|
|
92
|
+
slotId: n.slotId, viewId: n.viewId, label: n.slotId, role,
|
|
93
|
+
});
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (n.type === 'tabs') {
|
|
97
|
+
for (const t of n.tabs) {
|
|
98
|
+
const role = effectiveRole(t.role);
|
|
99
|
+
if (role === 'body')
|
|
100
|
+
continue;
|
|
101
|
+
const anchor = hint !== null && hint !== void 0 ? hint : defaultAnchor(role);
|
|
102
|
+
buckets[anchor].push({
|
|
103
|
+
slotId: t.slotId, viewId: t.viewId, label: t.label, icon: t.icon, role,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (n.direction === 'horizontal') {
|
|
109
|
+
for (let i = 0; i < n.children.length; i++) {
|
|
110
|
+
const child = n.children[i];
|
|
111
|
+
let nextHint = hint;
|
|
112
|
+
if (hint === null && !hasAnyBody(child)) {
|
|
113
|
+
if (i === 0)
|
|
114
|
+
nextHint = 'left';
|
|
115
|
+
else if (i === n.children.length - 1)
|
|
116
|
+
nextHint = 'right';
|
|
117
|
+
}
|
|
118
|
+
walk(child, nextHint);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
for (let i = 0; i < n.children.length; i++) {
|
|
123
|
+
const child = n.children[i];
|
|
124
|
+
let nextHint = hint;
|
|
125
|
+
if (hint === null && i === 0 && !hasAnyBody(child))
|
|
126
|
+
nextHint = 'top';
|
|
127
|
+
walk(child, nextHint);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
walk(node, null);
|
|
132
|
+
return buckets;
|
|
133
|
+
}
|
|
134
|
+
function toSpec(slots) {
|
|
135
|
+
if (slots.length === 0)
|
|
136
|
+
return null;
|
|
137
|
+
return {
|
|
138
|
+
slots: slots.map((s) => ({
|
|
139
|
+
slotId: s.slotId, viewId: s.viewId, label: s.label, icon: s.icon, role: s.role,
|
|
140
|
+
})),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
export function derive(tree) {
|
|
144
|
+
const stripped = stripNonBody(tree);
|
|
145
|
+
const bodyRoot = stripped !== null && stripped !== void 0 ? stripped : EMPTY_BODY;
|
|
146
|
+
const buckets = partitionDrawers(tree);
|
|
147
|
+
return {
|
|
148
|
+
bodyRoot,
|
|
149
|
+
drawers: {
|
|
150
|
+
left: toSpec(buckets.left),
|
|
151
|
+
right: toSpec(buckets.right),
|
|
152
|
+
top: toSpec(buckets.top),
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { derive } from './derive';
|
|
3
|
+
describe('derive', () => {
|
|
4
|
+
describe('body partition', () => {
|
|
5
|
+
it('all-body tree → bodyRoot identical, all drawers null', () => {
|
|
6
|
+
const tree = {
|
|
7
|
+
type: 'split',
|
|
8
|
+
direction: 'horizontal',
|
|
9
|
+
sizes: [1, 1],
|
|
10
|
+
children: [
|
|
11
|
+
{ type: 'slot', slotId: 'a', viewId: 'view:a', role: 'body' },
|
|
12
|
+
{ type: 'slot', slotId: 'b', viewId: 'view:b', role: 'body' },
|
|
13
|
+
],
|
|
14
|
+
};
|
|
15
|
+
const result = derive(tree);
|
|
16
|
+
expect(result.bodyRoot).toEqual(tree);
|
|
17
|
+
expect(result.drawers.left).toBeNull();
|
|
18
|
+
expect(result.drawers.right).toBeNull();
|
|
19
|
+
expect(result.drawers.top).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
it('zero-body tree → bodyRoot is empty placeholder', () => {
|
|
22
|
+
var _a, _b;
|
|
23
|
+
const tree = {
|
|
24
|
+
type: 'split', direction: 'horizontal', sizes: [1, 1],
|
|
25
|
+
children: [
|
|
26
|
+
{ type: 'slot', slotId: 'a', viewId: 'view:a', role: 'sidebar' },
|
|
27
|
+
{ type: 'slot', slotId: 'b', viewId: 'view:b', role: 'inspector' },
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
const result = derive(tree);
|
|
31
|
+
expect(result.bodyRoot.type).toBe('slot');
|
|
32
|
+
expect(result.bodyRoot.slotId).toBe('__sh3core__:compact:empty');
|
|
33
|
+
expect((_a = result.drawers.left) === null || _a === void 0 ? void 0 : _a.slots.map((s) => s.slotId)).toEqual(['a']);
|
|
34
|
+
expect((_b = result.drawers.right) === null || _b === void 0 ? void 0 : _b.slots.map((s) => s.slotId)).toEqual(['b']);
|
|
35
|
+
});
|
|
36
|
+
it('single body wrapped in split collapses to bare body slot', () => {
|
|
37
|
+
const tree = {
|
|
38
|
+
type: 'split', direction: 'horizontal', sizes: [0.2, 0.8],
|
|
39
|
+
children: [
|
|
40
|
+
{ type: 'slot', slotId: 'side', viewId: 'view:side', role: 'sidebar' },
|
|
41
|
+
{ type: 'slot', slotId: 'body', viewId: 'view:body', role: 'body' },
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
const result = derive(tree);
|
|
45
|
+
expect(result.bodyRoot.type).toBe('slot');
|
|
46
|
+
expect(result.bodyRoot.slotId).toBe('body');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe('anchor inference', () => {
|
|
50
|
+
it('first horizontal child → left, last → right', () => {
|
|
51
|
+
var _a, _b;
|
|
52
|
+
const tree = {
|
|
53
|
+
type: 'split', direction: 'horizontal', sizes: [0.2, 0.6, 0.2],
|
|
54
|
+
children: [
|
|
55
|
+
{ type: 'slot', slotId: 'sb', viewId: 'v:sb', role: 'sidebar' },
|
|
56
|
+
{ type: 'slot', slotId: 'body', viewId: 'v:body', role: 'body' },
|
|
57
|
+
{ type: 'slot', slotId: 'ins', viewId: 'v:ins', role: 'inspector' },
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
const result = derive(tree);
|
|
61
|
+
expect((_a = result.drawers.left) === null || _a === void 0 ? void 0 : _a.slots.map((s) => s.slotId)).toEqual(['sb']);
|
|
62
|
+
expect((_b = result.drawers.right) === null || _b === void 0 ? void 0 : _b.slots.map((s) => s.slotId)).toEqual(['ins']);
|
|
63
|
+
expect(result.drawers.top).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
it('top of vertical split → top anchor', () => {
|
|
66
|
+
var _a;
|
|
67
|
+
const tree = {
|
|
68
|
+
type: 'split', direction: 'vertical', sizes: [0.3, 0.7],
|
|
69
|
+
children: [
|
|
70
|
+
{ type: 'slot', slotId: 'tools', viewId: 'v:tools', role: 'sidebar' },
|
|
71
|
+
{ type: 'slot', slotId: 'body', viewId: 'v:body', role: 'body' },
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
const result = derive(tree);
|
|
75
|
+
expect((_a = result.drawers.top) === null || _a === void 0 ? void 0 : _a.slots.map((s) => s.slotId)).toEqual(['tools']);
|
|
76
|
+
expect(result.drawers.left).toBeNull();
|
|
77
|
+
expect(result.drawers.right).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
it('inspector with no horizontal anchor defaults to right', () => {
|
|
80
|
+
var _a;
|
|
81
|
+
const tree = {
|
|
82
|
+
type: 'slot', slotId: 'lone', viewId: 'v:lone', role: 'inspector',
|
|
83
|
+
};
|
|
84
|
+
const result = derive(tree);
|
|
85
|
+
expect((_a = result.drawers.right) === null || _a === void 0 ? void 0 : _a.slots.map((s) => s.slotId)).toEqual(['lone']);
|
|
86
|
+
expect(result.drawers.left).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
it('sidebar with no horizontal anchor defaults to left', () => {
|
|
89
|
+
var _a;
|
|
90
|
+
const tree = {
|
|
91
|
+
type: 'slot', slotId: 'lone', viewId: 'v:lone', role: 'sidebar',
|
|
92
|
+
};
|
|
93
|
+
const result = derive(tree);
|
|
94
|
+
expect((_a = result.drawers.left) === null || _a === void 0 ? void 0 : _a.slots.map((s) => s.slotId)).toEqual(['lone']);
|
|
95
|
+
expect(result.drawers.right).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe('multi-slot drawers', () => {
|
|
99
|
+
it('two sidebars on the left render as one drawer with two slots', () => {
|
|
100
|
+
var _a;
|
|
101
|
+
const tree = {
|
|
102
|
+
type: 'split', direction: 'horizontal', sizes: [0.3, 0.7],
|
|
103
|
+
children: [
|
|
104
|
+
{
|
|
105
|
+
type: 'split', direction: 'vertical', sizes: [0.5, 0.5],
|
|
106
|
+
children: [
|
|
107
|
+
{ type: 'slot', slotId: 'sb-top', viewId: 'v:sb-top', role: 'sidebar' },
|
|
108
|
+
{ type: 'slot', slotId: 'sb-bot', viewId: 'v:sb-bot', role: 'sidebar' },
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
{ type: 'slot', slotId: 'body', viewId: 'v:body', role: 'body' },
|
|
112
|
+
],
|
|
113
|
+
};
|
|
114
|
+
const result = derive(tree);
|
|
115
|
+
expect((_a = result.drawers.left) === null || _a === void 0 ? void 0 : _a.slots.map((s) => s.slotId)).toEqual(['sb-top', 'sb-bot']);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
describe('default role', () => {
|
|
119
|
+
it('untagged slot defaults to body', () => {
|
|
120
|
+
const tree = { type: 'slot', slotId: 'x', viewId: 'v:x' };
|
|
121
|
+
const result = derive(tree);
|
|
122
|
+
expect(result.bodyRoot).toEqual(tree);
|
|
123
|
+
expect(result.drawers.left).toBeNull();
|
|
124
|
+
expect(result.drawers.right).toBeNull();
|
|
125
|
+
expect(result.drawers.top).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
describe('tabs nodes', () => {
|
|
129
|
+
it('tabs of body slots stay in body root', () => {
|
|
130
|
+
const tree = {
|
|
131
|
+
type: 'tabs', activeTab: 0,
|
|
132
|
+
tabs: [
|
|
133
|
+
{ slotId: 't1', viewId: 'v:t1', label: 'Tab 1', role: 'body' },
|
|
134
|
+
{ slotId: 't2', viewId: 'v:t2', label: 'Tab 2', role: 'body' },
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
const result = derive(tree);
|
|
138
|
+
expect(result.bodyRoot.type).toBe('tabs');
|
|
139
|
+
});
|
|
140
|
+
it('tabs with mixed roles split body tabs from sidebar slots', () => {
|
|
141
|
+
var _a;
|
|
142
|
+
const tree = {
|
|
143
|
+
type: 'split', direction: 'horizontal', sizes: [0.2, 0.8],
|
|
144
|
+
children: [
|
|
145
|
+
{ type: 'slot', slotId: 'sb', viewId: 'v:sb', role: 'sidebar' },
|
|
146
|
+
{
|
|
147
|
+
type: 'tabs', activeTab: 0,
|
|
148
|
+
tabs: [
|
|
149
|
+
{ slotId: 't1', viewId: 'v:t1', label: 'Tab 1', role: 'body' },
|
|
150
|
+
{ slotId: 't2', viewId: 'v:t2', label: 'Tab 2', role: 'body' },
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
};
|
|
155
|
+
const result = derive(tree);
|
|
156
|
+
expect(result.bodyRoot.type).toBe('tabs');
|
|
157
|
+
expect((_a = result.drawers.left) === null || _a === void 0 ? void 0 : _a.slots.map((s) => s.slotId)).toEqual(['sb']);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { DrawerAnchor, DrawerStateMap } from './types';
|
|
2
|
+
export declare const drawerStore: {
|
|
3
|
+
readonly state: DrawerStateMap;
|
|
4
|
+
open(anchor: DrawerAnchor): void;
|
|
5
|
+
close(anchor: DrawerAnchor): void;
|
|
6
|
+
toggle(anchor: DrawerAnchor): void;
|
|
7
|
+
activate(anchor: DrawerAnchor, slotId: string): void;
|
|
8
|
+
/**
|
|
9
|
+
* Bind a write-through callback. layoutStore calls this when the active
|
|
10
|
+
* preset/viewport changes; the callback persists state into the
|
|
11
|
+
* AppLayoutBlob.drawers field. Pass null to unbind.
|
|
12
|
+
*/
|
|
13
|
+
__setWriteThrough(cb: ((next: DrawerStateMap) => void) | null): void;
|
|
14
|
+
/**
|
|
15
|
+
* Replace the current state from a persisted snapshot. Used by
|
|
16
|
+
* layoutStore on preset/viewport-class change.
|
|
17
|
+
*/
|
|
18
|
+
__hydrate(snapshot: DrawerStateMap): void;
|
|
19
|
+
/** Test-only reset. */
|
|
20
|
+
__reset(): void;
|
|
21
|
+
};
|