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
package/dist/Sh3.svelte
CHANGED
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
import { startServerSideStream } from './keys/revocation-bus.svelte';
|
|
27
27
|
import BrandSlot from './BrandSlot.svelte';
|
|
28
28
|
import MenuBar from './actions/MenuBar.svelte';
|
|
29
|
+
import CompactChrome from './chrome/CompactChrome.svelte';
|
|
30
|
+
import CompactRenderer from './layout/compact/CompactRenderer.svelte';
|
|
31
|
+
import { sh3 } from './sh3Runtime.svelte';
|
|
29
32
|
|
|
30
33
|
const authenticated = $derived(isAuthenticated());
|
|
31
34
|
const user = $derived(getUser());
|
|
@@ -34,6 +37,8 @@
|
|
|
34
37
|
// getActiveApp() here: returnToHome() keeps the app warm (activeApp.id
|
|
35
38
|
// stays set) and only flips the layout store's activeRoot back to 'home'.
|
|
36
39
|
const onHome = $derived(getActiveRoot() === 'home');
|
|
40
|
+
// Reactive viewport class — drives the compact-vs-desktop chrome/body fork.
|
|
41
|
+
const viewportClass = $derived(sh3.viewport.current.class);
|
|
37
42
|
|
|
38
43
|
// Keep the actions dispatcher's `mountedViewIds` set in sync with the
|
|
39
44
|
// live layout tree, so `view:<viewId>` scope checks (context menu,
|
|
@@ -60,45 +65,53 @@
|
|
|
60
65
|
});
|
|
61
66
|
</script>
|
|
62
67
|
|
|
63
|
-
<div class="sh3">
|
|
64
|
-
|
|
65
|
-
<
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
68
|
+
<div class="sh3" data-sh3-viewport={viewportClass}>
|
|
69
|
+
{#if viewportClass === 'desktop'}
|
|
70
|
+
<header class="sh3-tabbar" data-sh3-region="tabbar">
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
class="sh3-tabbar-home-button"
|
|
74
|
+
onclick={() => returnToHome()}
|
|
75
|
+
disabled={onHome}
|
|
76
|
+
title="Home"
|
|
77
|
+
>
|
|
78
|
+
<svg class="sh3-tabbar-home-icon" aria-hidden="true">
|
|
79
|
+
<use href="{iconsUrl}#house" />
|
|
80
|
+
</svg>
|
|
81
|
+
</button>
|
|
82
|
+
<BrandSlot />
|
|
83
|
+
<MenuBar />
|
|
84
|
+
{#if authenticated && user}
|
|
85
|
+
<div class="sh3-tabbar-user">
|
|
86
|
+
<span class="sh3-tabbar-user-name">{user.displayName}</span>
|
|
87
|
+
<span class="sh3-tabbar-tag">{elevated ? 'admin' : 'user'}</span>
|
|
88
|
+
{#if !isLocalOwner()}
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
class="sh3-tabbar-signout"
|
|
92
|
+
onclick={() => logout()}
|
|
93
|
+
title="Sign out"
|
|
94
|
+
>
|
|
95
|
+
<svg class="sh3-tabbar-signout-icon" aria-hidden="true">
|
|
96
|
+
<use href="{iconsUrl}#log-out" />
|
|
97
|
+
</svg>
|
|
98
|
+
</button>
|
|
99
|
+
{/if}
|
|
100
|
+
</div>
|
|
101
|
+
{/if}
|
|
102
|
+
</header>
|
|
103
|
+
{:else}
|
|
104
|
+
<CompactChrome />
|
|
105
|
+
{/if}
|
|
97
106
|
|
|
98
107
|
<GuestBanner />
|
|
99
108
|
|
|
100
109
|
<main class="sh3-content" data-sh3-region="content" data-sh3-layer="0">
|
|
101
|
-
|
|
110
|
+
{#if viewportClass === 'desktop'}
|
|
111
|
+
<LayoutRenderer />
|
|
112
|
+
{:else}
|
|
113
|
+
<CompactRenderer />
|
|
114
|
+
{/if}
|
|
102
115
|
</main>
|
|
103
116
|
|
|
104
117
|
<footer class="sh3-statusbar" data-sh3-region="statusbar">
|
|
Binary file
|
|
@@ -75,4 +75,33 @@ describe('listActionsFromEntries', () => {
|
|
|
75
75
|
expect(out).toHaveLength(1);
|
|
76
76
|
expect(out[0].ownerShardId).toBe('shard.a');
|
|
77
77
|
});
|
|
78
|
+
it('passes through submenu=true on a parent descriptor', () => {
|
|
79
|
+
const entries = [{
|
|
80
|
+
ownerShardId: 'shard.x',
|
|
81
|
+
action: { id: 'p', label: 'P', scope: 'home', submenu: true },
|
|
82
|
+
}];
|
|
83
|
+
const out = listActionsFromEntries(entries, mkState());
|
|
84
|
+
expect(out[0].submenu).toBe(true);
|
|
85
|
+
expect(out[0].submenuOf).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
it('passes through submenuOf on a child descriptor', () => {
|
|
88
|
+
const entries = [
|
|
89
|
+
{
|
|
90
|
+
ownerShardId: 'shard.x',
|
|
91
|
+
action: {
|
|
92
|
+
id: 'p:dark', label: 'Dark', scope: 'home',
|
|
93
|
+
submenuOf: 'p', run: () => { },
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
const out = listActionsFromEntries(entries, mkState());
|
|
98
|
+
expect(out[0].submenuOf).toBe('p');
|
|
99
|
+
expect(out[0].submenu).toBeUndefined();
|
|
100
|
+
});
|
|
101
|
+
it('omits submenu and submenuOf on plain actions', () => {
|
|
102
|
+
const entries = [mkEntry({ id: 'plain', scope: 'home' })];
|
|
103
|
+
const out = listActionsFromEntries(entries, mkState());
|
|
104
|
+
expect(out[0].submenu).toBeUndefined();
|
|
105
|
+
expect(out[0].submenuOf).toBeUndefined();
|
|
106
|
+
});
|
|
78
107
|
});
|
|
@@ -50,6 +50,8 @@ export function listActionsFromEntries(entries, state) {
|
|
|
50
50
|
ownerShardId: entry.ownerShardId,
|
|
51
51
|
paletteItem: entry.action.paletteItem !== false,
|
|
52
52
|
contextItem: entry.action.contextItem !== false,
|
|
53
|
+
submenu: entry.action.submenu,
|
|
54
|
+
submenuOf: entry.action.submenuOf,
|
|
53
55
|
active,
|
|
54
56
|
};
|
|
55
57
|
if (active) {
|
|
@@ -94,6 +94,10 @@ export function dispatchActionProgrammatic(actionId, _opts) {
|
|
|
94
94
|
const state = getLiveDispatcherState();
|
|
95
95
|
const desc = listActionsFromEntries(entries, state).find((d) => d.id === actionId);
|
|
96
96
|
if (!desc || !desc.active) {
|
|
97
|
+
if (entry.action.submenu === true &&
|
|
98
|
+
typeof entry.action.run !== 'function') {
|
|
99
|
+
return Promise.reject(new Error(`action "${actionId}" is a submenu parent — list children with listActions({ submenuOf: "${actionId}" })`));
|
|
100
|
+
}
|
|
97
101
|
return Promise.reject(new Error(`action "${actionId}" is not active in current scope`));
|
|
98
102
|
}
|
|
99
103
|
// run is guaranteed non-null by `desc.active === true`.
|
|
@@ -34,11 +34,18 @@ describe('dispatchActionProgrammatic', () => {
|
|
|
34
34
|
await expect(dispatchActionProgrammatic('d')).rejects.toThrow(/action "d" is not active/);
|
|
35
35
|
expect(run).not.toHaveBeenCalled();
|
|
36
36
|
});
|
|
37
|
-
it('rejects on a submenu parent
|
|
37
|
+
it('rejects on a submenu parent with the parent-specific message', async () => {
|
|
38
38
|
registerAction({
|
|
39
39
|
id: 's', label: 'S', scope: 'home', submenu: true,
|
|
40
40
|
}, 'shard.x');
|
|
41
|
-
await expect(dispatchActionProgrammatic('s')).rejects.toThrow(/action "s" is
|
|
41
|
+
await expect(dispatchActionProgrammatic('s')).rejects.toThrow(/action "s" is a submenu parent — list children with listActions\(\{ submenuOf: "s" \}\)/);
|
|
42
|
+
});
|
|
43
|
+
it('still uses the generic inactive message for non-submenu inactive actions', async () => {
|
|
44
|
+
registerAction({
|
|
45
|
+
id: 'gated2', label: 'G', scope: 'app', run: () => { },
|
|
46
|
+
}, 'shard.x');
|
|
47
|
+
// No active app -> 'app' scope is inactive.
|
|
48
|
+
await expect(dispatchActionProgrammatic('gated2')).rejects.toThrow(/action "gated2" is not active/);
|
|
42
49
|
});
|
|
43
50
|
it('invokes run with invokedVia="programmatic" on happy path', async () => {
|
|
44
51
|
let captured = null;
|
package/dist/actions/types.d.ts
CHANGED
|
@@ -148,6 +148,10 @@ export interface ActiveActionDescriptor {
|
|
|
148
148
|
ownerShardId: string;
|
|
149
149
|
paletteItem: boolean;
|
|
150
150
|
contextItem: boolean;
|
|
151
|
+
/** True when this action is a submenu parent (children opened by drill). */
|
|
152
|
+
submenu?: true;
|
|
153
|
+
/** Parent action id when this action is a submenu child. */
|
|
154
|
+
submenuOf?: string;
|
|
151
155
|
}
|
|
152
156
|
/**
|
|
153
157
|
* Read-only snapshot describing one action in the registry. Produced by
|
|
@@ -183,6 +187,10 @@ export interface ActionDescriptor {
|
|
|
183
187
|
ownerShardId: string;
|
|
184
188
|
paletteItem: boolean;
|
|
185
189
|
contextItem: boolean;
|
|
190
|
+
/** True when this action is a submenu parent (children opened by drill). */
|
|
191
|
+
submenu?: true;
|
|
192
|
+
/** Parent action id when this action is a submenu child. */
|
|
193
|
+
submenuOf?: string;
|
|
186
194
|
/** True when `runAction(id)` would dispatch right now. */
|
|
187
195
|
active: boolean;
|
|
188
196
|
}
|
package/dist/api.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export { sh3 } from './sh3Runtime.svelte';
|
|
2
|
-
export type { Sh3 } from './sh3Runtime.svelte';
|
|
2
|
+
export type { Sh3, Sh3Drawers, Sh3Viewport } from './sh3Runtime.svelte';
|
|
3
|
+
export type { ViewportClass, ViewportInfo } from './viewport/types';
|
|
4
|
+
export type { SlotRole } from './layout/types';
|
|
5
|
+
export type { DrawerAnchor, DrawerSpec, DrawerState, DrawerStateMap, CompactRendering, } from './layout/compact/types';
|
|
3
6
|
export type { Shard, ShardManifest, SourceShard, SourceShardManifest, ShardContext, ViewDeclaration, ViewFactory, ViewHandle, MountContext, } from './shards/types';
|
|
4
7
|
export type { LayoutNode, SplitNode, TabsNode, SlotNode, TabEntry, SplitDirection, SizeMode, LayoutTree, FloatEntry, LayoutPreset, CanonicalPreset, TreeRootRef as SlotLocation, } from './layout/types';
|
|
5
8
|
export type { ActionDescriptor, ActiveActionDescriptor, BindingSource, AtomicScope, ActionScope, } from './actions/types';
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Top app bar for compact mode. Three-column grid:
|
|
4
|
+
* leading — one button per non-null drawer anchor (read from
|
|
5
|
+
* the active CompactRendering)
|
|
6
|
+
* title — active app name
|
|
7
|
+
* trailing — palette button + overflow (menu sheet) button
|
|
8
|
+
*
|
|
9
|
+
* MenuSheet handles the overflow menu; this component owns the open
|
|
10
|
+
* state and renders MenuSheet conditionally.
|
|
11
|
+
*/
|
|
12
|
+
import { sh3 } from '../sh3Runtime.svelte';
|
|
13
|
+
import { layoutStore } from '../layout/store.svelte';
|
|
14
|
+
import { derive } from '../layout/compact/derive';
|
|
15
|
+
import { getLiveDispatcherState } from '../actions/state.svelte';
|
|
16
|
+
import { getRegisteredApp } from '../apps/registry.svelte';
|
|
17
|
+
import MenuSheet from './MenuSheet.svelte';
|
|
18
|
+
import type { DrawerAnchor } from '../layout/compact/types';
|
|
19
|
+
|
|
20
|
+
const rendering = $derived(derive(layoutStore.root));
|
|
21
|
+
const dispatcher = $derived(getLiveDispatcherState());
|
|
22
|
+
const title = $derived.by(() => {
|
|
23
|
+
const id = dispatcher.activeAppId;
|
|
24
|
+
if (!id) return 'SH3';
|
|
25
|
+
return getRegisteredApp(id)?.manifest.label ?? id;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
let menuOpen = $state(false);
|
|
29
|
+
|
|
30
|
+
function toggleDrawer(anchor: DrawerAnchor) {
|
|
31
|
+
sh3.drawers.toggle(anchor);
|
|
32
|
+
}
|
|
33
|
+
function openPalette() {
|
|
34
|
+
sh3.actions.openPalette();
|
|
35
|
+
}
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<header class="sh3-compact-chrome" data-sh3-region="compact-chrome">
|
|
39
|
+
<div class="leading">
|
|
40
|
+
{#if rendering.drawers.left}
|
|
41
|
+
<button onclick={() => toggleDrawer('left')} aria-label="Toggle left drawer" data-sh3-anchor="left">≡</button>
|
|
42
|
+
{/if}
|
|
43
|
+
{#if rendering.drawers.right}
|
|
44
|
+
<button onclick={() => toggleDrawer('right')} aria-label="Toggle right drawer" data-sh3-anchor="right">▣</button>
|
|
45
|
+
{/if}
|
|
46
|
+
{#if rendering.drawers.top}
|
|
47
|
+
<button onclick={() => toggleDrawer('top')} aria-label="Toggle top drawer" data-sh3-anchor="top">⫶</button>
|
|
48
|
+
{/if}
|
|
49
|
+
</div>
|
|
50
|
+
<div class="title">{title}</div>
|
|
51
|
+
<div class="trailing">
|
|
52
|
+
<button onclick={openPalette} aria-label="Open command palette">⌘</button>
|
|
53
|
+
<button onclick={() => (menuOpen = true)} aria-label="Open menu">⋯</button>
|
|
54
|
+
</div>
|
|
55
|
+
</header>
|
|
56
|
+
|
|
57
|
+
<MenuSheet open={menuOpen} onClose={() => (menuOpen = false)} />
|
|
58
|
+
|
|
59
|
+
<style>
|
|
60
|
+
.sh3-compact-chrome {
|
|
61
|
+
display: grid;
|
|
62
|
+
grid-template-columns: auto 1fr auto;
|
|
63
|
+
align-items: center;
|
|
64
|
+
height: var(--sh3-tabbar-height);
|
|
65
|
+
padding: 0 var(--sh3-pad-sm);
|
|
66
|
+
gap: var(--sh3-pad-sm);
|
|
67
|
+
background: var(--sh3-grad-bg-elevated, var(--sh3-bg-elevated));
|
|
68
|
+
border-bottom: 1px solid var(--sh3-border);
|
|
69
|
+
color: var(--sh3-fg);
|
|
70
|
+
}
|
|
71
|
+
.leading,
|
|
72
|
+
.trailing {
|
|
73
|
+
display: inline-flex;
|
|
74
|
+
gap: var(--sh3-pad-xs);
|
|
75
|
+
}
|
|
76
|
+
button {
|
|
77
|
+
width: 40px;
|
|
78
|
+
height: 40px;
|
|
79
|
+
font-size: var(--sh3-font-lg);
|
|
80
|
+
border: none;
|
|
81
|
+
background: none;
|
|
82
|
+
cursor: pointer;
|
|
83
|
+
border-radius: var(--sh3-radius-sm);
|
|
84
|
+
color: var(--sh3-fg);
|
|
85
|
+
}
|
|
86
|
+
button:active {
|
|
87
|
+
background: var(--sh3-bg-sunken);
|
|
88
|
+
}
|
|
89
|
+
.title {
|
|
90
|
+
font-weight: 600;
|
|
91
|
+
text-align: center;
|
|
92
|
+
overflow: hidden;
|
|
93
|
+
text-overflow: ellipsis;
|
|
94
|
+
white-space: nowrap;
|
|
95
|
+
}
|
|
96
|
+
</style>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* DOM smoke for CompactChrome — verifies the toolbar renders the
|
|
3
|
+
* expected leading drawer toggles based on the active layout's
|
|
4
|
+
* derived rendering, plus the trailing palette + overflow buttons.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
7
|
+
import { mount, unmount, flushSync } from 'svelte';
|
|
8
|
+
import CompactChrome from './CompactChrome.svelte';
|
|
9
|
+
import { __resetLayoutStoreForTest, attachApp, detachApp, switchToApp, } from '../layout/store.svelte';
|
|
10
|
+
import { drawerStore } from '../layout/compact/drawerStore.svelte';
|
|
11
|
+
const CompactChromeAny = CompactChrome;
|
|
12
|
+
function fakeApp() {
|
|
13
|
+
return {
|
|
14
|
+
manifest: { id: 'cc-app', label: 'CC App', layoutVersion: 5 },
|
|
15
|
+
initialLayout: {
|
|
16
|
+
type: 'split', direction: 'horizontal', sizes: [0.2, 0.6, 0.2],
|
|
17
|
+
children: [
|
|
18
|
+
{ type: 'slot', slotId: 'sb', viewId: 'v:sb', role: 'sidebar' },
|
|
19
|
+
{ type: 'slot', slotId: 'body', viewId: 'v:body', role: 'body' },
|
|
20
|
+
{ type: 'slot', slotId: 'ins', viewId: 'v:ins', role: 'inspector' },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
let mounted = null;
|
|
26
|
+
let host = null;
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
if (mounted) {
|
|
29
|
+
unmount(mounted);
|
|
30
|
+
mounted = null;
|
|
31
|
+
}
|
|
32
|
+
if (host) {
|
|
33
|
+
host.remove();
|
|
34
|
+
host = null;
|
|
35
|
+
}
|
|
36
|
+
detachApp();
|
|
37
|
+
});
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
__resetLayoutStoreForTest();
|
|
40
|
+
drawerStore.__reset();
|
|
41
|
+
});
|
|
42
|
+
describe('CompactChrome (dom)', () => {
|
|
43
|
+
it('renders a leading toggle for each present drawer anchor', () => {
|
|
44
|
+
attachApp(fakeApp());
|
|
45
|
+
switchToApp();
|
|
46
|
+
flushSync();
|
|
47
|
+
host = document.createElement('div');
|
|
48
|
+
document.body.appendChild(host);
|
|
49
|
+
mounted = mount(CompactChromeAny, { target: host });
|
|
50
|
+
flushSync();
|
|
51
|
+
const leading = host.querySelectorAll('.leading button');
|
|
52
|
+
expect(leading.length).toBe(2);
|
|
53
|
+
expect(host.querySelector('.leading button[data-sh3-anchor="left"]')).not.toBeNull();
|
|
54
|
+
expect(host.querySelector('.leading button[data-sh3-anchor="right"]')).not.toBeNull();
|
|
55
|
+
});
|
|
56
|
+
it('renders palette + overflow buttons in the trailing section', () => {
|
|
57
|
+
attachApp(fakeApp());
|
|
58
|
+
switchToApp();
|
|
59
|
+
flushSync();
|
|
60
|
+
host = document.createElement('div');
|
|
61
|
+
document.body.appendChild(host);
|
|
62
|
+
mounted = mount(CompactChromeAny, { target: host });
|
|
63
|
+
flushSync();
|
|
64
|
+
const trailing = host.querySelectorAll('.trailing button');
|
|
65
|
+
expect(trailing.length).toBe(2);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Touch-friendly replacement for MenuBar — bottom-anchored sheet with
|
|
4
|
+
* collapsible sections per menu container. Tapping a submenu parent
|
|
5
|
+
* expands its children inline (no nested popover stack — see
|
|
6
|
+
* docs/superpowers/specs/2026-05-09-action-submenu-discoverability-design.md).
|
|
7
|
+
*
|
|
8
|
+
* Reads the same dispatcher state and registry as MenuBar:
|
|
9
|
+
* resolveMenuContainers(activeAppId, declared)
|
|
10
|
+
* resolveMenuItems(entries, dispatcherState, containerId)
|
|
11
|
+
* resolveSubmenuItems(entries, dispatcherState, parentId)
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
resolveMenuContainers,
|
|
15
|
+
resolveMenuItems,
|
|
16
|
+
resolveSubmenuItems,
|
|
17
|
+
type MenuBarItem,
|
|
18
|
+
} from '../actions/menuBarModel';
|
|
19
|
+
import { listActions } from '../actions/registry';
|
|
20
|
+
import { getLiveDispatcherState } from '../actions/state.svelte';
|
|
21
|
+
import { getRegisteredApp } from '../apps/registry.svelte';
|
|
22
|
+
import { resolveLabel } from '../actions/types';
|
|
23
|
+
|
|
24
|
+
let { open, onClose }: { open: boolean; onClose: () => void } = $props();
|
|
25
|
+
|
|
26
|
+
const dispatcher = $derived(getLiveDispatcherState());
|
|
27
|
+
const activeAppId = $derived(dispatcher.activeAppId);
|
|
28
|
+
const declaredMenus = $derived.by(() => {
|
|
29
|
+
if (!activeAppId) return undefined;
|
|
30
|
+
return getRegisteredApp(activeAppId)?.manifest.menus;
|
|
31
|
+
});
|
|
32
|
+
const containers = $derived(resolveMenuContainers(activeAppId, declaredMenus));
|
|
33
|
+
const containerItems = $derived.by(() => {
|
|
34
|
+
const out: { containerId: string; label: string; items: MenuBarItem[] }[] = [];
|
|
35
|
+
const entries = listActions();
|
|
36
|
+
for (const c of containers) {
|
|
37
|
+
const items = resolveMenuItems(entries, dispatcher, c.id);
|
|
38
|
+
if (items.length > 0) out.push({ containerId: c.id, label: c.label, items });
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
let expanded = $state(new Set<string>());
|
|
44
|
+
let expandedSubmenu = $state(new Set<string>());
|
|
45
|
+
|
|
46
|
+
function toggleContainer(id: string) {
|
|
47
|
+
const next = new Set(expanded);
|
|
48
|
+
if (next.has(id)) next.delete(id);
|
|
49
|
+
else next.add(id);
|
|
50
|
+
expanded = next;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function toggleSubmenu(id: string) {
|
|
54
|
+
const next = new Set(expandedSubmenu);
|
|
55
|
+
if (next.has(id)) next.delete(id);
|
|
56
|
+
else next.add(id);
|
|
57
|
+
expandedSubmenu = next;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function invoke(itemId: string) {
|
|
61
|
+
const entry = listActions().find((e) => e.action.id === itemId);
|
|
62
|
+
if (!entry || typeof entry.action.run !== 'function') return;
|
|
63
|
+
try {
|
|
64
|
+
void entry.action.run({
|
|
65
|
+
action: { id: itemId, label: resolveLabel(entry.action) },
|
|
66
|
+
appId: dispatcher.activeAppId,
|
|
67
|
+
viewId: dispatcher.focusedViewId ?? undefined,
|
|
68
|
+
selection: dispatcher.selection ?? undefined,
|
|
69
|
+
invokedVia: 'palette',
|
|
70
|
+
dispatch: () => {},
|
|
71
|
+
});
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.error(`[sh3] menu-sheet action "${itemId}" threw:`, err);
|
|
74
|
+
}
|
|
75
|
+
onClose();
|
|
76
|
+
}
|
|
77
|
+
</script>
|
|
78
|
+
|
|
79
|
+
{#if open}
|
|
80
|
+
<div
|
|
81
|
+
class="backdrop"
|
|
82
|
+
onclick={onClose}
|
|
83
|
+
onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}
|
|
84
|
+
role="presentation"
|
|
85
|
+
></div>
|
|
86
|
+
<div class="sheet" role="dialog" aria-label="Menu" data-sh3-region="menu-sheet">
|
|
87
|
+
<div class="scroll">
|
|
88
|
+
{#each containerItems as { containerId, label, items } (containerId)}
|
|
89
|
+
<button
|
|
90
|
+
class="container"
|
|
91
|
+
aria-expanded={expanded.has(containerId)}
|
|
92
|
+
onclick={() => toggleContainer(containerId)}
|
|
93
|
+
>
|
|
94
|
+
<span class="caret" class:open={expanded.has(containerId)}>▸</span>
|
|
95
|
+
<span class="label">{label}</span>
|
|
96
|
+
</button>
|
|
97
|
+
{#if expanded.has(containerId)}
|
|
98
|
+
<div class="items">
|
|
99
|
+
{#each items as item (item.id)}
|
|
100
|
+
{#if item.submenu}
|
|
101
|
+
<button
|
|
102
|
+
class="item submenu"
|
|
103
|
+
aria-expanded={expandedSubmenu.has(item.id)}
|
|
104
|
+
disabled={item.disabled}
|
|
105
|
+
onclick={() => toggleSubmenu(item.id)}
|
|
106
|
+
>
|
|
107
|
+
<span class="caret" class:open={expandedSubmenu.has(item.id)}>▸</span>
|
|
108
|
+
<span class="label">{item.label}</span>
|
|
109
|
+
</button>
|
|
110
|
+
{#if expandedSubmenu.has(item.id)}
|
|
111
|
+
<div class="subitems">
|
|
112
|
+
{#each resolveSubmenuItems(listActions(), dispatcher, item.id) as sub (sub.id)}
|
|
113
|
+
<button
|
|
114
|
+
class="item child"
|
|
115
|
+
disabled={sub.disabled}
|
|
116
|
+
onclick={() => invoke(sub.id)}
|
|
117
|
+
>
|
|
118
|
+
<span class="label">{sub.label}</span>
|
|
119
|
+
{#if sub.shortcut}
|
|
120
|
+
<span class="shortcut">{sub.shortcut}</span>
|
|
121
|
+
{/if}
|
|
122
|
+
</button>
|
|
123
|
+
{/each}
|
|
124
|
+
</div>
|
|
125
|
+
{/if}
|
|
126
|
+
{:else}
|
|
127
|
+
<button
|
|
128
|
+
class="item"
|
|
129
|
+
disabled={item.disabled}
|
|
130
|
+
onclick={() => invoke(item.id)}
|
|
131
|
+
>
|
|
132
|
+
<span class="label">{item.label}</span>
|
|
133
|
+
{#if item.shortcut}
|
|
134
|
+
<span class="shortcut">{item.shortcut}</span>
|
|
135
|
+
{/if}
|
|
136
|
+
</button>
|
|
137
|
+
{/if}
|
|
138
|
+
{/each}
|
|
139
|
+
</div>
|
|
140
|
+
{/if}
|
|
141
|
+
{/each}
|
|
142
|
+
</div>
|
|
143
|
+
<button class="cancel" onclick={onClose}>Cancel</button>
|
|
144
|
+
</div>
|
|
145
|
+
{/if}
|
|
146
|
+
|
|
147
|
+
<style>
|
|
148
|
+
.backdrop {
|
|
149
|
+
position: absolute;
|
|
150
|
+
inset: 0;
|
|
151
|
+
background: var(--sh3-overlay-backdrop, rgba(0, 0, 0, 0.35));
|
|
152
|
+
pointer-events: auto;
|
|
153
|
+
z-index: var(--sh3-z-layer-4);
|
|
154
|
+
}
|
|
155
|
+
.sheet {
|
|
156
|
+
position: absolute;
|
|
157
|
+
left: 0;
|
|
158
|
+
right: 0;
|
|
159
|
+
bottom: 0;
|
|
160
|
+
max-height: 70vh;
|
|
161
|
+
display: flex;
|
|
162
|
+
flex-direction: column;
|
|
163
|
+
background: var(--sh3-bg);
|
|
164
|
+
color: var(--sh3-fg);
|
|
165
|
+
border-top: 1px solid var(--sh3-border);
|
|
166
|
+
box-shadow: var(--sh3-shadow-md, 0 -4px 16px rgba(0, 0, 0, 0.2));
|
|
167
|
+
pointer-events: auto;
|
|
168
|
+
z-index: var(--sh3-z-layer-4);
|
|
169
|
+
}
|
|
170
|
+
.scroll {
|
|
171
|
+
flex: 1;
|
|
172
|
+
min-height: 0;
|
|
173
|
+
overflow: auto;
|
|
174
|
+
padding: var(--sh3-pad-sm) 0;
|
|
175
|
+
}
|
|
176
|
+
.container {
|
|
177
|
+
display: flex;
|
|
178
|
+
align-items: center;
|
|
179
|
+
gap: var(--sh3-pad-sm);
|
|
180
|
+
width: 100%;
|
|
181
|
+
padding: var(--sh3-pad-sm) var(--sh3-pad-md);
|
|
182
|
+
border: none;
|
|
183
|
+
background: none;
|
|
184
|
+
color: var(--sh3-fg);
|
|
185
|
+
font-weight: 600;
|
|
186
|
+
text-align: left;
|
|
187
|
+
cursor: pointer;
|
|
188
|
+
}
|
|
189
|
+
.container:active { background: var(--sh3-bg-sunken); }
|
|
190
|
+
.items { padding-left: var(--sh3-pad-md); }
|
|
191
|
+
.subitems { padding-left: var(--sh3-pad-md); }
|
|
192
|
+
.item {
|
|
193
|
+
display: flex;
|
|
194
|
+
align-items: center;
|
|
195
|
+
gap: var(--sh3-pad-sm);
|
|
196
|
+
width: 100%;
|
|
197
|
+
padding: var(--sh3-pad-sm) var(--sh3-pad-md);
|
|
198
|
+
border: none;
|
|
199
|
+
background: none;
|
|
200
|
+
color: var(--sh3-fg);
|
|
201
|
+
text-align: left;
|
|
202
|
+
cursor: pointer;
|
|
203
|
+
}
|
|
204
|
+
.item:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
205
|
+
.item:active:not(:disabled) { background: var(--sh3-bg-sunken); }
|
|
206
|
+
.item.child { padding-left: calc(var(--sh3-pad-md) * 2); }
|
|
207
|
+
.label { flex: 1; }
|
|
208
|
+
.shortcut { color: var(--sh3-fg-muted); font-family: var(--sh3-font-mono); }
|
|
209
|
+
.caret {
|
|
210
|
+
display: inline-block;
|
|
211
|
+
width: 1em;
|
|
212
|
+
transition: transform 120ms;
|
|
213
|
+
}
|
|
214
|
+
.caret.open { transform: rotate(90deg); }
|
|
215
|
+
.cancel {
|
|
216
|
+
padding: var(--sh3-pad-md);
|
|
217
|
+
border: none;
|
|
218
|
+
border-top: 1px solid var(--sh3-border);
|
|
219
|
+
background: var(--sh3-bg-elevated);
|
|
220
|
+
color: var(--sh3-fg);
|
|
221
|
+
font-weight: 600;
|
|
222
|
+
cursor: pointer;
|
|
223
|
+
}
|
|
224
|
+
</style>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|