sh3-core 0.11.2 → 0.11.6
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/BrandSlot.svelte +80 -0
- package/dist/BrandSlot.svelte.d.ts +3 -0
- package/dist/BrandSlot.test.d.ts +1 -0
- package/dist/BrandSlot.test.js +71 -0
- package/dist/Shell.svelte +8 -10
- package/dist/actions/ActionPanel.svelte +105 -0
- package/dist/actions/ActionPanel.svelte.d.ts +13 -0
- package/dist/actions/ActionPanel.test.d.ts +1 -0
- package/dist/actions/ActionPanel.test.js +80 -0
- package/dist/actions/ContextMenu.svelte +17 -85
- package/dist/actions/MenuBar.svelte +57 -0
- package/dist/actions/MenuBar.svelte.d.ts +3 -0
- package/dist/actions/MenuBar.test.d.ts +1 -0
- package/dist/actions/MenuBar.test.js +109 -0
- package/dist/actions/MenuButton.svelte +104 -0
- package/dist/actions/MenuButton.svelte.d.ts +9 -0
- package/dist/actions/MenuButton.test.d.ts +1 -0
- package/dist/actions/MenuButton.test.js +88 -0
- package/dist/actions/bindings.d.ts +10 -1
- package/dist/actions/bindings.js +16 -0
- package/dist/actions/bindings.test.js +23 -1
- package/dist/actions/contextMenuModel.js +5 -40
- package/dist/actions/defaultMenuContainers.d.ts +2 -0
- package/dist/actions/defaultMenuContainers.js +7 -0
- package/dist/actions/defaultMenuContainers.test.d.ts +1 -0
- package/dist/actions/defaultMenuContainers.test.js +23 -0
- package/dist/actions/dispatcher.svelte.js +1 -14
- package/dist/actions/listActive.d.ts +4 -0
- package/dist/actions/listActive.js +42 -0
- package/dist/actions/listActive.test.d.ts +1 -0
- package/dist/actions/listActive.test.js +86 -0
- package/dist/actions/menuBarModel.d.ts +28 -0
- package/dist/actions/menuBarModel.js +67 -0
- package/dist/actions/menuBarModel.test.d.ts +1 -0
- package/dist/actions/menuBarModel.test.js +84 -0
- package/dist/actions/paletteModel.js +10 -21
- package/dist/actions/paletteModel.test.js +16 -0
- package/dist/actions/scope-helpers.d.ts +11 -0
- package/dist/actions/scope-helpers.js +51 -0
- package/dist/actions/scope-helpers.test.d.ts +1 -0
- package/dist/actions/scope-helpers.test.js +62 -0
- package/dist/actions/shellActions.test.js +50 -0
- package/dist/actions/state.svelte.d.ts +12 -0
- package/dist/actions/state.svelte.js +36 -0
- package/dist/actions/state.test.js +26 -1
- package/dist/actions/types.d.ts +49 -0
- package/dist/api.d.ts +5 -0
- package/dist/api.js +6 -0
- package/dist/apps/lifecycle.js +8 -1
- package/dist/apps/lifecycle.test.js +211 -1
- package/dist/apps/registry.svelte.d.ts +17 -1
- package/dist/apps/registry.svelte.js +20 -1
- package/dist/apps/types.d.ts +28 -0
- package/dist/assets/favicon.png +0 -0
- package/dist/assets/favicon.svg +5 -0
- package/dist/color/api.d.ts +38 -0
- package/dist/color/api.js +10 -0
- package/dist/color/native-fallback.test.d.ts +1 -0
- package/dist/color/native-fallback.test.js +43 -0
- package/dist/color/primitive.d.ts +2 -0
- package/dist/color/primitive.js +40 -0
- package/dist/color/primitive.test.d.ts +1 -0
- package/dist/color/primitive.test.js +42 -0
- package/dist/color/shell-api.d.ts +2 -0
- package/dist/color/shell-api.js +11 -0
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -2
- package/dist/layout/store.svelte.d.ts +27 -0
- package/dist/layout/store.svelte.js +63 -0
- package/dist/overlays/ConfirmDialog.svelte +138 -0
- package/dist/overlays/ConfirmDialog.svelte.d.ts +13 -0
- package/dist/overlays/ConfirmDialog.test.d.ts +1 -0
- package/dist/overlays/ConfirmDialog.test.js +123 -0
- package/dist/overlays/FloatFrame.svelte +2 -2
- package/dist/overlays/ToastItem.svelte +3 -3
- package/dist/primitives/base.css +5 -5
- package/dist/sh3core-shard/sh3coreShard.svelte.js +20 -0
- package/dist/shell-shard/shellShard.svelte.js +0 -4
- package/dist/shellRuntime.svelte.d.ts +20 -0
- package/dist/shellRuntime.svelte.js +16 -1
- package/dist/tokens.css +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* BrandSlot — top-bar context indicator. Three states:
|
|
4
|
+
* - 'brand' → renders <span>SH3</span>
|
|
5
|
+
* - 'app' → renders <span>{label}</span>
|
|
6
|
+
* - 'breadcrumb' → renders SH3 + separator + <button>{label}</button>
|
|
7
|
+
*
|
|
8
|
+
* State derives from (activeAppId, breadcrumbAppId). Click on the
|
|
9
|
+
* breadcrumb's app button re-launches the app (existing handler).
|
|
10
|
+
*/
|
|
11
|
+
import { getLiveDispatcherState } from './actions/state.svelte';
|
|
12
|
+
import { launchApp } from './apps/lifecycle';
|
|
13
|
+
import { getBreadcrumbAppId, getRegisteredApp } from './apps/registry.svelte';
|
|
14
|
+
|
|
15
|
+
const activeAppId = $derived(getLiveDispatcherState().activeAppId);
|
|
16
|
+
const breadcrumbId = $derived(getBreadcrumbAppId());
|
|
17
|
+
|
|
18
|
+
const activeLabel = $derived(
|
|
19
|
+
activeAppId ? getRegisteredApp(activeAppId)?.manifest.label ?? activeAppId : null,
|
|
20
|
+
);
|
|
21
|
+
const breadcrumbLabel = $derived(
|
|
22
|
+
breadcrumbId ? getRegisteredApp(breadcrumbId)?.manifest.label ?? breadcrumbId : null,
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const mode: 'brand' | 'app' | 'breadcrumb' = $derived.by(() => {
|
|
26
|
+
if (activeAppId) return 'app';
|
|
27
|
+
if (breadcrumbId) return 'breadcrumb';
|
|
28
|
+
return 'brand';
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function reopen() {
|
|
32
|
+
if (breadcrumbId) void launchApp(breadcrumbId);
|
|
33
|
+
}
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<div class="sh3-brand-slot">
|
|
37
|
+
{#if mode === 'brand'}
|
|
38
|
+
<span class="sh3-brand">SH3</span>
|
|
39
|
+
{:else if mode === 'app'}
|
|
40
|
+
<span class="sh3-brand sh3-brand-app">{activeLabel}</span>
|
|
41
|
+
{:else}
|
|
42
|
+
<span class="sh3-brand">SH3</span>
|
|
43
|
+
<span class="sh3-brand-sep" aria-hidden="true">›</span>
|
|
44
|
+
<button type="button" class="sh3-brand-crumb" onclick={reopen}>
|
|
45
|
+
{breadcrumbLabel}
|
|
46
|
+
</button>
|
|
47
|
+
{/if}
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<style>
|
|
51
|
+
.sh3-brand-slot {
|
|
52
|
+
display: inline-flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
gap: 4px;
|
|
55
|
+
}
|
|
56
|
+
.sh3-brand {
|
|
57
|
+
font-weight: 600;
|
|
58
|
+
color: var(--shell-accent);
|
|
59
|
+
letter-spacing: 0.5px;
|
|
60
|
+
}
|
|
61
|
+
.sh3-brand-app {
|
|
62
|
+
color: var(--shell-fg);
|
|
63
|
+
}
|
|
64
|
+
.sh3-brand-sep {
|
|
65
|
+
color: var(--shell-fg-muted);
|
|
66
|
+
margin: 0 4px;
|
|
67
|
+
}
|
|
68
|
+
.sh3-brand-crumb {
|
|
69
|
+
background: transparent;
|
|
70
|
+
border: 0;
|
|
71
|
+
color: var(--shell-fg);
|
|
72
|
+
font: inherit;
|
|
73
|
+
cursor: pointer;
|
|
74
|
+
padding: 2px 6px;
|
|
75
|
+
border-radius: var(--shell-radius-sm, 3px);
|
|
76
|
+
}
|
|
77
|
+
.sh3-brand-crumb:hover {
|
|
78
|
+
background: var(--shell-bg-elevated);
|
|
79
|
+
}
|
|
80
|
+
</style>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { mount, unmount, tick } from 'svelte';
|
|
3
|
+
// Mock launchApp so we can assert the click handler invokes it.
|
|
4
|
+
// The real launchApp does heavy lifecycle work; we only care that BrandSlot
|
|
5
|
+
// calls it with the right argument.
|
|
6
|
+
vi.mock('./apps/lifecycle', async (orig) => {
|
|
7
|
+
const actual = await orig();
|
|
8
|
+
return Object.assign(Object.assign({}, actual), { launchApp: vi.fn(actual.launchApp) });
|
|
9
|
+
});
|
|
10
|
+
import BrandSlot from './BrandSlot.svelte';
|
|
11
|
+
import { setActiveApp, __resetDispatcherStateForTest } from './actions/state.svelte';
|
|
12
|
+
import { registerApp, __resetAppRegistryForTest, __resetBreadcrumbForTest, breadcrumbApp, } from './apps/registry.svelte';
|
|
13
|
+
import { launchApp } from './apps/lifecycle';
|
|
14
|
+
let host;
|
|
15
|
+
let cmp = null;
|
|
16
|
+
function makeApp(id, label) {
|
|
17
|
+
return {
|
|
18
|
+
manifest: {
|
|
19
|
+
id, label, version: '0.0.0',
|
|
20
|
+
requiredShards: [], layoutVersion: 1,
|
|
21
|
+
},
|
|
22
|
+
initialLayout: { type: 'leaf', viewId: 'x:v' },
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
host = document.createElement('div');
|
|
27
|
+
document.body.appendChild(host);
|
|
28
|
+
__resetBreadcrumbForTest();
|
|
29
|
+
__resetDispatcherStateForTest();
|
|
30
|
+
});
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
if (cmp) {
|
|
33
|
+
unmount(cmp);
|
|
34
|
+
cmp = null;
|
|
35
|
+
}
|
|
36
|
+
host.remove();
|
|
37
|
+
__resetAppRegistryForTest();
|
|
38
|
+
__resetDispatcherStateForTest();
|
|
39
|
+
vi.clearAllMocks();
|
|
40
|
+
});
|
|
41
|
+
describe('BrandSlot', () => {
|
|
42
|
+
it('renders SH3 when no app has launched this session', async () => {
|
|
43
|
+
var _a;
|
|
44
|
+
cmp = mount(BrandSlot, { target: host, props: {} });
|
|
45
|
+
await tick();
|
|
46
|
+
expect((_a = host.textContent) === null || _a === void 0 ? void 0 : _a.trim()).toBe('SH3');
|
|
47
|
+
expect(host.querySelector('button')).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
it('renders [App Name] when an app is currently active', async () => {
|
|
50
|
+
registerApp(makeApp('app.a', 'My App'));
|
|
51
|
+
breadcrumbApp.id = 'app.a';
|
|
52
|
+
setActiveApp('app.a', new Set());
|
|
53
|
+
cmp = mount(BrandSlot, { target: host, props: {} });
|
|
54
|
+
await tick();
|
|
55
|
+
expect(host.textContent).toContain('My App');
|
|
56
|
+
expect(host.textContent).not.toContain('SH3');
|
|
57
|
+
});
|
|
58
|
+
it('renders "SH3 > [App Name]" with the app portion clickable when at home with prior app', async () => {
|
|
59
|
+
registerApp(makeApp('app.a', 'My App'));
|
|
60
|
+
breadcrumbApp.id = 'app.a';
|
|
61
|
+
setActiveApp(null, new Set());
|
|
62
|
+
cmp = mount(BrandSlot, { target: host, props: {} });
|
|
63
|
+
await tick();
|
|
64
|
+
expect(host.textContent).toMatch(/SH3.*My App/);
|
|
65
|
+
const btn = host.querySelector('button');
|
|
66
|
+
expect(btn).not.toBeNull();
|
|
67
|
+
expect(btn.textContent).toContain('My App');
|
|
68
|
+
btn.click();
|
|
69
|
+
expect(launchApp).toHaveBeenCalledWith('app.a');
|
|
70
|
+
});
|
|
71
|
+
});
|
package/dist/Shell.svelte
CHANGED
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
import GuestBanner from './auth/GuestBanner.svelte';
|
|
29
29
|
import ConsentDialog from './keys/ConsentDialog.svelte';
|
|
30
30
|
import { startServerSideStream } from './keys/revocation-bus.svelte';
|
|
31
|
+
import BrandSlot from './BrandSlot.svelte';
|
|
32
|
+
import MenuBar from './actions/MenuBar.svelte';
|
|
31
33
|
|
|
32
34
|
const authenticated = $derived(isAuthenticated());
|
|
33
35
|
const user = $derived(getUser());
|
|
@@ -110,7 +112,8 @@
|
|
|
110
112
|
<use href="{iconsUrl}#house" />
|
|
111
113
|
</svg>
|
|
112
114
|
</button>
|
|
113
|
-
<
|
|
115
|
+
<BrandSlot />
|
|
116
|
+
<MenuBar />
|
|
114
117
|
{#if authenticated && user}
|
|
115
118
|
<div class="shell-tabbar-user">
|
|
116
119
|
<span class="shell-tabbar-user-name">{user.displayName}</span>
|
|
@@ -185,7 +188,8 @@
|
|
|
185
188
|
}
|
|
186
189
|
|
|
187
190
|
.shell-tabbar {
|
|
188
|
-
display:
|
|
191
|
+
display: grid;
|
|
192
|
+
grid-template-columns: auto auto 1fr auto;
|
|
189
193
|
align-items: center;
|
|
190
194
|
gap: var(--shell-pad-md);
|
|
191
195
|
padding: 0 var(--shell-pad-md);
|
|
@@ -193,11 +197,6 @@
|
|
|
193
197
|
border-bottom: 1px solid var(--shell-border);
|
|
194
198
|
user-select: none;
|
|
195
199
|
}
|
|
196
|
-
.shell-tabbar-brand {
|
|
197
|
-
font-weight: 600;
|
|
198
|
-
color: var(--shell-accent);
|
|
199
|
-
letter-spacing: 0.5px;
|
|
200
|
-
}
|
|
201
200
|
|
|
202
201
|
.shell-content {
|
|
203
202
|
position: relative;
|
|
@@ -258,7 +257,6 @@
|
|
|
258
257
|
display: flex;
|
|
259
258
|
align-items: center;
|
|
260
259
|
gap: 6px;
|
|
261
|
-
margin-left: auto;
|
|
262
260
|
}
|
|
263
261
|
.shell-tabbar-user-name {
|
|
264
262
|
font-size: 12px;
|
|
@@ -269,10 +267,10 @@
|
|
|
269
267
|
font-weight: 700;
|
|
270
268
|
text-transform: uppercase;
|
|
271
269
|
letter-spacing: 0.08em;
|
|
272
|
-
color:
|
|
270
|
+
color: var(--shell-fg-on-accent);
|
|
273
271
|
background: var(--shell-accent);
|
|
274
272
|
padding: 1px 6px;
|
|
275
|
-
border-radius:
|
|
273
|
+
border-radius: var(--shell-radius-md);
|
|
276
274
|
}
|
|
277
275
|
.shell-tabbar-signout {
|
|
278
276
|
display: flex;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { MenuBarItem } from './menuBarModel';
|
|
3
|
+
|
|
4
|
+
export interface ActionPanelSection {
|
|
5
|
+
id: string;
|
|
6
|
+
items: MenuBarItem[];
|
|
7
|
+
}
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<script lang="ts">
|
|
11
|
+
/*
|
|
12
|
+
* ActionPanel — shared dropdown body for action lists. Renders sections
|
|
13
|
+
* (each a group of items) with separators between them. Owns: row
|
|
14
|
+
* rendering, hover/focus state, keyboard nav, click-dispatch. Does NOT
|
|
15
|
+
* own: positioning, backdrop, the popover surface itself — those stay
|
|
16
|
+
* with the consumer (ContextMenu, MenuButton, etc.).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
let { sections, onInvoke, onDismiss }: {
|
|
20
|
+
sections: ActionPanelSection[];
|
|
21
|
+
onInvoke: (id: string) => void;
|
|
22
|
+
onDismiss: () => void;
|
|
23
|
+
} = $props();
|
|
24
|
+
|
|
25
|
+
const flatItems: MenuBarItem[] = $derived(sections.flatMap((s) => s.items));
|
|
26
|
+
let cursor = $state(0);
|
|
27
|
+
|
|
28
|
+
function onKeydown(ev: KeyboardEvent) {
|
|
29
|
+
if (ev.key === 'ArrowDown') {
|
|
30
|
+
cursor = (cursor + 1) % flatItems.length;
|
|
31
|
+
ev.preventDefault();
|
|
32
|
+
} else if (ev.key === 'ArrowUp') {
|
|
33
|
+
cursor = (cursor - 1 + flatItems.length) % flatItems.length;
|
|
34
|
+
ev.preventDefault();
|
|
35
|
+
} else if (ev.key === 'Enter') {
|
|
36
|
+
if (flatItems[cursor]) {
|
|
37
|
+
onInvoke(flatItems[cursor].id);
|
|
38
|
+
onDismiss();
|
|
39
|
+
}
|
|
40
|
+
ev.preventDefault();
|
|
41
|
+
} else if (ev.key === 'Escape') {
|
|
42
|
+
onDismiss();
|
|
43
|
+
ev.preventDefault();
|
|
44
|
+
} else if (ev.key.length === 1) {
|
|
45
|
+
const q = ev.key.toLowerCase();
|
|
46
|
+
const start = (cursor + 1) % flatItems.length;
|
|
47
|
+
for (let i = 0; i < flatItems.length; i++) {
|
|
48
|
+
const idx = (start + i) % flatItems.length;
|
|
49
|
+
if (flatItems[idx].label.toLowerCase().startsWith(q)) {
|
|
50
|
+
cursor = idx;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<!-- svelte-ignore a11y_autofocus -->
|
|
59
|
+
<div class="sh3-context-menu" role="menu" tabindex="0" onkeydown={onKeydown} autofocus>
|
|
60
|
+
{#each sections as section, sIdx (section.id)}
|
|
61
|
+
{#if sIdx > 0}<div class="sh3-ctx-sep" role="separator"></div>{/if}
|
|
62
|
+
{#each section.items as item (item.id)}
|
|
63
|
+
{@const globalIdx = flatItems.indexOf(item)}
|
|
64
|
+
<button
|
|
65
|
+
class="sh3-ctx-item"
|
|
66
|
+
class:sh3-ctx-active={globalIdx === cursor}
|
|
67
|
+
role="menuitem"
|
|
68
|
+
onpointerenter={() => { cursor = globalIdx; }}
|
|
69
|
+
onclick={() => { onInvoke(item.id); onDismiss(); }}
|
|
70
|
+
>
|
|
71
|
+
<span class="sh3-ctx-label">{item.label}</span>
|
|
72
|
+
{#if item.shortcut}<span class="sh3-ctx-shortcut">{item.shortcut}</span>{/if}
|
|
73
|
+
</button>
|
|
74
|
+
{/each}
|
|
75
|
+
{/each}
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<style>
|
|
79
|
+
.sh3-context-menu {
|
|
80
|
+
min-width: 200px;
|
|
81
|
+
background: var(--shell-bg-elevated, #222);
|
|
82
|
+
color: var(--shell-fg, #eee);
|
|
83
|
+
border-radius: 4px;
|
|
84
|
+
padding: 4px 0;
|
|
85
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
|
86
|
+
outline: none;
|
|
87
|
+
}
|
|
88
|
+
.sh3-ctx-item {
|
|
89
|
+
display: flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
gap: 16px;
|
|
92
|
+
width: 100%;
|
|
93
|
+
padding: 4px 10px;
|
|
94
|
+
background: none;
|
|
95
|
+
border: 0;
|
|
96
|
+
text-align: left;
|
|
97
|
+
color: inherit;
|
|
98
|
+
cursor: default;
|
|
99
|
+
font: inherit;
|
|
100
|
+
}
|
|
101
|
+
.sh3-ctx-active { background: var(--shell-accent, #6ea8fe); color: var(--shell-fg,#e4e6eb); }
|
|
102
|
+
.sh3-ctx-label { flex: 1; }
|
|
103
|
+
.sh3-ctx-shortcut { opacity: 0.6; font-size: 0.9em; }
|
|
104
|
+
.sh3-ctx-sep { height: 1px; background: rgba(255, 255, 255, 0.1); margin: 4px 0; }
|
|
105
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { MenuBarItem } from './menuBarModel';
|
|
2
|
+
export interface ActionPanelSection {
|
|
3
|
+
id: string;
|
|
4
|
+
items: MenuBarItem[];
|
|
5
|
+
}
|
|
6
|
+
type $$ComponentProps = {
|
|
7
|
+
sections: ActionPanelSection[];
|
|
8
|
+
onInvoke: (id: string) => void;
|
|
9
|
+
onDismiss: () => void;
|
|
10
|
+
};
|
|
11
|
+
declare const ActionPanel: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
12
|
+
type ActionPanel = ReturnType<typeof ActionPanel>;
|
|
13
|
+
export default ActionPanel;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
2
|
+
import { mount, unmount, tick } from 'svelte';
|
|
3
|
+
import ActionPanel from './ActionPanel.svelte';
|
|
4
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
5
|
+
function mountPanel(props) {
|
|
6
|
+
const el = document.createElement('div');
|
|
7
|
+
document.body.appendChild(el);
|
|
8
|
+
const cmp = mount(ActionPanel, { target: el, props });
|
|
9
|
+
return {
|
|
10
|
+
el,
|
|
11
|
+
cmp,
|
|
12
|
+
cleanup: () => { unmount(cmp); el.remove(); },
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
const baseProps = (overrides = {}) => (Object.assign({ sections: [
|
|
16
|
+
{
|
|
17
|
+
id: 'group:edit',
|
|
18
|
+
items: [
|
|
19
|
+
{ id: 'copy', label: 'Copy', shortcut: 'Ctrl+C', group: 'edit', icon: undefined },
|
|
20
|
+
{ id: 'paste', label: 'Paste', shortcut: 'Ctrl+V', group: 'edit', icon: undefined },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
], onInvoke: vi.fn(), onDismiss: vi.fn() }, overrides));
|
|
24
|
+
describe('ActionPanel', () => {
|
|
25
|
+
let panel;
|
|
26
|
+
afterEach(() => panel === null || panel === void 0 ? void 0 : panel.cleanup());
|
|
27
|
+
it('renders one button per item', () => {
|
|
28
|
+
panel = mountPanel(baseProps());
|
|
29
|
+
expect(panel.el.querySelectorAll('[role="menuitem"]')).toHaveLength(2);
|
|
30
|
+
});
|
|
31
|
+
it('renders shortcut hint when provided, omits when null', () => {
|
|
32
|
+
panel = mountPanel(baseProps({
|
|
33
|
+
sections: [{
|
|
34
|
+
id: 'g',
|
|
35
|
+
items: [
|
|
36
|
+
{ id: 'a', label: 'A', shortcut: 'Ctrl+A', group: '', icon: undefined },
|
|
37
|
+
{ id: 'b', label: 'B', shortcut: null, group: '', icon: undefined },
|
|
38
|
+
],
|
|
39
|
+
}],
|
|
40
|
+
}));
|
|
41
|
+
const shortcuts = panel.el.querySelectorAll('.sh3-ctx-shortcut');
|
|
42
|
+
expect(shortcuts).toHaveLength(1);
|
|
43
|
+
expect(shortcuts[0].textContent).toBe('Ctrl+A');
|
|
44
|
+
});
|
|
45
|
+
it('inserts a separator between distinct sections', () => {
|
|
46
|
+
panel = mountPanel(baseProps({
|
|
47
|
+
sections: [
|
|
48
|
+
{ id: 'g1', items: [{ id: 'a', label: 'A', shortcut: null, group: 'g1', icon: undefined }] },
|
|
49
|
+
{ id: 'g2', items: [{ id: 'b', label: 'B', shortcut: null, group: 'g2', icon: undefined }] },
|
|
50
|
+
],
|
|
51
|
+
}));
|
|
52
|
+
expect(panel.el.querySelectorAll('[role="separator"]')).toHaveLength(1);
|
|
53
|
+
});
|
|
54
|
+
it('click on an item calls onInvoke with its id and onDismiss', () => {
|
|
55
|
+
const onInvoke = vi.fn();
|
|
56
|
+
const onDismiss = vi.fn();
|
|
57
|
+
panel = mountPanel(baseProps({ onInvoke, onDismiss }));
|
|
58
|
+
const items = panel.el.querySelectorAll('[role="menuitem"]');
|
|
59
|
+
items[1].click();
|
|
60
|
+
expect(onInvoke).toHaveBeenCalledWith('paste');
|
|
61
|
+
expect(onDismiss).toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
it('ArrowDown moves the cursor and Enter dispatches the focused item', async () => {
|
|
64
|
+
const onInvoke = vi.fn();
|
|
65
|
+
panel = mountPanel(baseProps({ onInvoke }));
|
|
66
|
+
const root = panel.el.querySelector('[role="menu"]');
|
|
67
|
+
root.focus();
|
|
68
|
+
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
|
69
|
+
await tick();
|
|
70
|
+
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
|
71
|
+
expect(onInvoke).toHaveBeenCalledWith('paste');
|
|
72
|
+
});
|
|
73
|
+
it('Escape calls onDismiss', () => {
|
|
74
|
+
const onDismiss = vi.fn();
|
|
75
|
+
panel = mountPanel(baseProps({ onDismiss }));
|
|
76
|
+
const root = panel.el.querySelector('[role="menu"]');
|
|
77
|
+
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
78
|
+
expect(onDismiss).toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
/*
|
|
3
|
-
* ContextMenu — popup-rendered, tier-grouped action list. Receives
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* ContextMenu — popup-rendered, tier-grouped action list. Receives a
|
|
4
|
+
* pre-built model and an onInvoke callback. Dismiss and anchoring are
|
|
5
|
+
* handled by PopupFrame / popupManager. The list rendering, keyboard
|
|
6
|
+
* nav, and click dispatch are delegated to ActionPanel.
|
|
7
7
|
*/
|
|
8
|
-
import type { ContextMenuModel
|
|
8
|
+
import type { ContextMenuModel } from './contextMenuModel';
|
|
9
|
+
import ActionPanel, { type ActionPanelSection } from './ActionPanel.svelte';
|
|
9
10
|
|
|
10
11
|
let { model, onInvoke, onClose }: {
|
|
11
12
|
model: ContextMenuModel;
|
|
@@ -13,85 +14,16 @@
|
|
|
13
14
|
onClose: () => void;
|
|
14
15
|
} = $props();
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
} else if (ev.key === 'Enter') {
|
|
27
|
-
if (flatItems[cursor]) {
|
|
28
|
-
onInvoke(flatItems[cursor].id);
|
|
29
|
-
onClose();
|
|
30
|
-
}
|
|
31
|
-
ev.preventDefault();
|
|
32
|
-
} else if (ev.key === 'Escape') {
|
|
33
|
-
onClose();
|
|
34
|
-
ev.preventDefault();
|
|
35
|
-
} else if (ev.key.length === 1) {
|
|
36
|
-
// type-ahead: jump to next item whose label starts with the pressed character
|
|
37
|
-
const q = ev.key.toLowerCase();
|
|
38
|
-
const start = (cursor + 1) % flatItems.length;
|
|
39
|
-
for (let i = 0; i < flatItems.length; i++) {
|
|
40
|
-
const idx = (start + i) % flatItems.length;
|
|
41
|
-
if (flatItems[idx].label.toLowerCase().startsWith(q)) {
|
|
42
|
-
cursor = idx;
|
|
43
|
-
break;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
}
|
|
17
|
+
// Adapt ContextMenuModel.tiers → ActionPanel.sections. The MenuItem
|
|
18
|
+
// shape from contextMenuModel already carries id/label/shortcut/group;
|
|
19
|
+
// ActionPanel expects an `icon?` field — context menu items don't carry
|
|
20
|
+
// one today, so it stays undefined.
|
|
21
|
+
const sections: ActionPanelSection[] = $derived(
|
|
22
|
+
model.tiers.map((t) => ({
|
|
23
|
+
id: `tier:${t.tier}`,
|
|
24
|
+
items: t.items.map((i) => ({ ...i, icon: undefined })),
|
|
25
|
+
})),
|
|
26
|
+
);
|
|
48
27
|
</script>
|
|
49
28
|
|
|
50
|
-
|
|
51
|
-
<div class="sh3-context-menu" role="menu" tabindex="0" onkeydown={onKeydown} autofocus>
|
|
52
|
-
{#each model.tiers as tier, tIdx (tier.tier)}
|
|
53
|
-
{#if tIdx > 0}<div class="sh3-ctx-sep" role="separator"></div>{/if}
|
|
54
|
-
{#each tier.items as item (item.id)}
|
|
55
|
-
{@const globalIdx = flatItems.indexOf(item)}
|
|
56
|
-
<button
|
|
57
|
-
class="sh3-ctx-item"
|
|
58
|
-
class:sh3-ctx-active={globalIdx === cursor}
|
|
59
|
-
role="menuitem"
|
|
60
|
-
onpointerenter={() => { cursor = globalIdx; }}
|
|
61
|
-
onclick={() => { onInvoke(item.id); onClose(); }}
|
|
62
|
-
>
|
|
63
|
-
<span class="sh3-ctx-label">{item.label}</span>
|
|
64
|
-
{#if item.shortcut}<span class="sh3-ctx-shortcut">{item.shortcut}</span>{/if}
|
|
65
|
-
</button>
|
|
66
|
-
{/each}
|
|
67
|
-
{/each}
|
|
68
|
-
</div>
|
|
69
|
-
|
|
70
|
-
<style>
|
|
71
|
-
.sh3-context-menu {
|
|
72
|
-
min-width: 200px;
|
|
73
|
-
background: var(--shell-bg-elevated, #222);
|
|
74
|
-
color: var(--shell-fg, #eee);
|
|
75
|
-
border-radius: 4px;
|
|
76
|
-
padding: 4px 0;
|
|
77
|
-
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
|
78
|
-
outline: none;
|
|
79
|
-
}
|
|
80
|
-
.sh3-ctx-item {
|
|
81
|
-
display: flex;
|
|
82
|
-
align-items: center;
|
|
83
|
-
gap: 16px;
|
|
84
|
-
width: 100%;
|
|
85
|
-
padding: 4px 10px;
|
|
86
|
-
background: none;
|
|
87
|
-
border: 0;
|
|
88
|
-
text-align: left;
|
|
89
|
-
color: inherit;
|
|
90
|
-
cursor: default;
|
|
91
|
-
font: inherit;
|
|
92
|
-
}
|
|
93
|
-
.sh3-ctx-active { background: var(--shell-accent, #6ea8fe); color: var(--shell-fg,#e4e6eb); }
|
|
94
|
-
.sh3-ctx-label { flex: 1; }
|
|
95
|
-
.sh3-ctx-shortcut { opacity: 0.6; font-size: 0.9em; }
|
|
96
|
-
.sh3-ctx-sep { height: 1px; background: rgba(255, 255, 255, 0.1); margin: 4px 0; }
|
|
97
|
-
</style>
|
|
29
|
+
<ActionPanel {sections} {onInvoke} onDismiss={onClose} />
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* MenuBar — top-bar row of menu containers. Renders one MenuButton per
|
|
4
|
+
* container in the active app's manifest.menus (or DEFAULT_MENU_CONTAINERS
|
|
5
|
+
* fallback). Skips containers whose resolved item list is empty.
|
|
6
|
+
* Renders nothing when no app is active.
|
|
7
|
+
*/
|
|
8
|
+
import MenuButton from './MenuButton.svelte';
|
|
9
|
+
import { resolveMenuContainers, resolveMenuItems } from './menuBarModel';
|
|
10
|
+
import { getLiveDispatcherState } from './state.svelte';
|
|
11
|
+
import { listActions } from './registry';
|
|
12
|
+
import { getRegisteredApp } from '../apps/registry.svelte';
|
|
13
|
+
|
|
14
|
+
// Snapshot the live dispatcher state on each derive — `getLiveDispatcherState`
|
|
15
|
+
// reads from $state internals, so this re-derives whenever activeAppId, focus,
|
|
16
|
+
// bindings, or selection change. Mirrors the listeners.ts call pattern.
|
|
17
|
+
const state = $derived(getLiveDispatcherState());
|
|
18
|
+
const activeAppId = $derived(state.activeAppId);
|
|
19
|
+
|
|
20
|
+
const declaredMenus = $derived.by(() => {
|
|
21
|
+
if (!activeAppId) return undefined;
|
|
22
|
+
return getRegisteredApp(activeAppId)?.manifest.menus;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const containers = $derived(resolveMenuContainers(activeAppId, declaredMenus));
|
|
26
|
+
|
|
27
|
+
// Per-container item lists. Recomputes whenever actions or scope state
|
|
28
|
+
// change — same reactivity model as the palette/context menu.
|
|
29
|
+
const containerItems = $derived.by(() => {
|
|
30
|
+
const out: { container: typeof containers[number]; items: ReturnType<typeof resolveMenuItems> }[] = [];
|
|
31
|
+
const entries = listActions();
|
|
32
|
+
for (const c of containers) {
|
|
33
|
+
const items = resolveMenuItems(entries, state, c.id);
|
|
34
|
+
if (items.length > 0) out.push({ container: c, items });
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
});
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<!--
|
|
41
|
+
Always render the wrapper so it reliably occupies its 1fr grid cell
|
|
42
|
+
in the shell tabbar (and pushes user chrome to the rightmost column).
|
|
43
|
+
Empty when no app is active or no container has visible items.
|
|
44
|
+
-->
|
|
45
|
+
<div class="sh3-menubar" role="menubar">
|
|
46
|
+
{#each containerItems as { container, items } (container.id)}
|
|
47
|
+
<MenuButton {container} {items} />
|
|
48
|
+
{/each}
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<style>
|
|
52
|
+
.sh3-menubar {
|
|
53
|
+
display: inline-flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
height: 100%;
|
|
56
|
+
}
|
|
57
|
+
</style>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|