sh3-core 0.11.4 → 0.11.7
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 +143 -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 +168 -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 +150 -0
- package/dist/actions/MenuButton.svelte.d.ts +10 -0
- package/dist/actions/MenuButton.test.d.ts +1 -0
- package/dist/actions/MenuButton.test.js +125 -0
- package/dist/actions/contextMenuModel.d.ts +10 -0
- package/dist/actions/contextMenuModel.js +44 -9
- package/dist/actions/contextMenuModel.test.js +28 -1
- 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/listeners.d.ts +4 -0
- package/dist/actions/listeners.js +77 -17
- package/dist/actions/listeners.test.js +50 -0
- package/dist/actions/menuBarModel.d.ts +42 -0
- package/dist/actions/menuBarModel.js +110 -0
- package/dist/actions/menuBarModel.test.d.ts +1 -0
- package/dist/actions/menuBarModel.test.js +158 -0
- package/dist/actions/palette-scorer.d.ts +4 -0
- package/dist/actions/palette-scorer.js +5 -0
- package/dist/actions/palette-scorer.test.js +9 -1
- package/dist/actions/paletteModel.d.ts +7 -1
- package/dist/actions/paletteModel.js +26 -1
- package/dist/actions/paletteModel.test.js +43 -0
- package/dist/actions/registry.js +5 -0
- package/dist/actions/registry.test.js +12 -0
- package/dist/actions/types.d.ts +48 -1
- package/dist/actions/types.test.d.ts +1 -0
- package/dist/actions/types.test.js +31 -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/icons.svg +5 -0
- package/dist/documents/backends.d.ts +2 -0
- package/dist/documents/backends.js +55 -0
- package/dist/documents/backends.test.d.ts +1 -1
- package/dist/documents/backends.test.js +69 -1
- package/dist/documents/browse.d.ts +18 -0
- package/dist/documents/browse.js +13 -0
- package/dist/documents/browse.test.js +47 -0
- package/dist/documents/handle.js +23 -0
- package/dist/documents/handle.test.js +51 -0
- package/dist/documents/http-backend.d.ts +1 -0
- package/dist/documents/http-backend.js +19 -0
- package/dist/documents/http-backend.test.js +42 -0
- package/dist/documents/types.d.ts +29 -1
- package/dist/documents/types.js +4 -0
- package/dist/documents/types.test.d.ts +1 -0
- package/dist/documents/types.test.js +20 -0
- package/dist/layout/LayoutRenderer.browser.test.js +196 -0
- package/dist/layout/SlotContainer.svelte +13 -8
- package/dist/layout/SlotDropZone.svelte +44 -9
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-7-fixed-slot-drop-protection-still-accepts-a-strip-drop-into-a-fixed-tabs-node-1.png +0 -0
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-8-same-strip-reorder-keeps-the-active-pane-populated-after-moving-the-second-tab-to-first-1.png +0 -0
- package/dist/layout/ops.d.ts +10 -0
- package/dist/layout/ops.js +30 -2
- package/dist/layout/ops.test.js +111 -1
- package/dist/layout/slotHostPool.svelte.d.ts +7 -1
- package/dist/layout/slotHostPool.svelte.js +27 -8
- 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 +38 -4
- package/dist/shell-shard/shellShard.svelte.js +0 -4
- package/dist/tokens.css +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -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,143 @@
|
|
|
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
|
+
* Visuals:
|
|
19
|
+
* - Reserved leading "check slot": ✓ when item.checked, blank otherwise.
|
|
20
|
+
* - Disabled items: aria-disabled, .sh3-ctx-disabled class, click no-op,
|
|
21
|
+
* keyboard skip.
|
|
22
|
+
* - Submenu parents: trailing ▸, no shortcut hint. The submenu drill
|
|
23
|
+
* itself is wired by the consumer (MenuButton / context-menu listener)
|
|
24
|
+
* via onInvoke — this component just emits the parent's id.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
let { sections, onInvoke, onDismiss }: {
|
|
28
|
+
sections: ActionPanelSection[];
|
|
29
|
+
onInvoke: (id: string) => void;
|
|
30
|
+
onDismiss: () => void;
|
|
31
|
+
} = $props();
|
|
32
|
+
|
|
33
|
+
const flatItems: MenuBarItem[] = $derived(sections.flatMap((s) => s.items));
|
|
34
|
+
let cursor = $state(0);
|
|
35
|
+
|
|
36
|
+
function nextEnabled(start: number, dir: 1 | -1): number {
|
|
37
|
+
if (flatItems.length === 0) return 0;
|
|
38
|
+
let i = start;
|
|
39
|
+
for (let n = 0; n < flatItems.length; n++) {
|
|
40
|
+
i = (i + dir + flatItems.length) % flatItems.length;
|
|
41
|
+
if (!flatItems[i].disabled) return i;
|
|
42
|
+
}
|
|
43
|
+
return start;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function onKeydown(ev: KeyboardEvent) {
|
|
47
|
+
if (ev.key === 'ArrowDown') {
|
|
48
|
+
cursor = nextEnabled(cursor, 1);
|
|
49
|
+
ev.preventDefault();
|
|
50
|
+
} else if (ev.key === 'ArrowUp') {
|
|
51
|
+
cursor = nextEnabled(cursor, -1);
|
|
52
|
+
ev.preventDefault();
|
|
53
|
+
} else if (ev.key === 'Enter') {
|
|
54
|
+
const item = flatItems[cursor];
|
|
55
|
+
if (item && !item.disabled) {
|
|
56
|
+
onInvoke(item.id);
|
|
57
|
+
if (!item.submenu) onDismiss();
|
|
58
|
+
}
|
|
59
|
+
ev.preventDefault();
|
|
60
|
+
} else if (ev.key === 'Escape') {
|
|
61
|
+
onDismiss();
|
|
62
|
+
ev.preventDefault();
|
|
63
|
+
} else if (ev.key.length === 1) {
|
|
64
|
+
const q = ev.key.toLowerCase();
|
|
65
|
+
const start = (cursor + 1) % flatItems.length;
|
|
66
|
+
for (let i = 0; i < flatItems.length; i++) {
|
|
67
|
+
const idx = (start + i) % flatItems.length;
|
|
68
|
+
const item = flatItems[idx];
|
|
69
|
+
if (item.disabled) continue;
|
|
70
|
+
if (item.label.toLowerCase().startsWith(q)) {
|
|
71
|
+
cursor = idx;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function onItemClick(item: MenuBarItem) {
|
|
79
|
+
if (item.disabled) return;
|
|
80
|
+
onInvoke(item.id);
|
|
81
|
+
if (!item.submenu) onDismiss();
|
|
82
|
+
}
|
|
83
|
+
</script>
|
|
84
|
+
|
|
85
|
+
<!-- svelte-ignore a11y_autofocus -->
|
|
86
|
+
<div class="sh3-context-menu" role="menu" tabindex="0" onkeydown={onKeydown} autofocus>
|
|
87
|
+
{#each sections as section, sIdx (section.id)}
|
|
88
|
+
{#if sIdx > 0}<div class="sh3-ctx-sep" role="separator"></div>{/if}
|
|
89
|
+
{#each section.items as item (item.id)}
|
|
90
|
+
{@const globalIdx = flatItems.indexOf(item)}
|
|
91
|
+
<button
|
|
92
|
+
class="sh3-ctx-item"
|
|
93
|
+
class:sh3-ctx-active={globalIdx === cursor}
|
|
94
|
+
class:sh3-ctx-disabled={item.disabled}
|
|
95
|
+
role="menuitem"
|
|
96
|
+
aria-disabled={item.disabled || undefined}
|
|
97
|
+
onpointerenter={() => { if (!item.disabled) cursor = globalIdx; }}
|
|
98
|
+
onclick={() => onItemClick(item)}
|
|
99
|
+
>
|
|
100
|
+
<span class="sh3-ctx-check">{item.checked ? '✓' : ''}</span>
|
|
101
|
+
<span class="sh3-ctx-label">{item.label}</span>
|
|
102
|
+
{#if item.submenu}
|
|
103
|
+
<span class="sh3-ctx-chevron">▸</span>
|
|
104
|
+
{:else if item.shortcut}
|
|
105
|
+
<span class="sh3-ctx-shortcut">{item.shortcut}</span>
|
|
106
|
+
{/if}
|
|
107
|
+
</button>
|
|
108
|
+
{/each}
|
|
109
|
+
{/each}
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<style>
|
|
113
|
+
.sh3-context-menu {
|
|
114
|
+
min-width: 220px;
|
|
115
|
+
background: var(--shell-bg-elevated, #222);
|
|
116
|
+
color: var(--shell-fg, #eee);
|
|
117
|
+
border-radius: 4px;
|
|
118
|
+
padding: 4px 0;
|
|
119
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
|
120
|
+
outline: none;
|
|
121
|
+
}
|
|
122
|
+
.sh3-ctx-item {
|
|
123
|
+
display: flex;
|
|
124
|
+
align-items: center;
|
|
125
|
+
gap: 8px;
|
|
126
|
+
width: 100%;
|
|
127
|
+
padding: 4px 10px;
|
|
128
|
+
background: none;
|
|
129
|
+
border: 0;
|
|
130
|
+
text-align: left;
|
|
131
|
+
color: inherit;
|
|
132
|
+
cursor: default;
|
|
133
|
+
font: inherit;
|
|
134
|
+
}
|
|
135
|
+
.sh3-ctx-active { background: var(--shell-accent, #6ea8fe); color: var(--shell-fg,#e4e6eb); }
|
|
136
|
+
.sh3-ctx-disabled { opacity: 0.45; }
|
|
137
|
+
.sh3-ctx-disabled.sh3-ctx-active { background: transparent; }
|
|
138
|
+
.sh3-ctx-check { display: inline-block; width: 12px; text-align: center; opacity: 0.85; }
|
|
139
|
+
.sh3-ctx-label { flex: 1; }
|
|
140
|
+
.sh3-ctx-chevron { opacity: 0.6; }
|
|
141
|
+
.sh3-ctx-shortcut { opacity: 0.6; font-size: 0.9em; }
|
|
142
|
+
.sh3-ctx-sep { height: 1px; background: rgba(255, 255, 255, 0.1); margin: 4px 0; }
|
|
143
|
+
</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,168 @@
|
|
|
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
|
+
checked: false, disabled: false, submenu: false },
|
|
21
|
+
{ id: 'paste', label: 'Paste', shortcut: 'Ctrl+V', group: 'edit', icon: undefined,
|
|
22
|
+
checked: false, disabled: false, submenu: false },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
], onInvoke: vi.fn(), onDismiss: vi.fn() }, overrides));
|
|
26
|
+
describe('ActionPanel', () => {
|
|
27
|
+
let panel;
|
|
28
|
+
afterEach(() => panel === null || panel === void 0 ? void 0 : panel.cleanup());
|
|
29
|
+
it('renders one button per item', () => {
|
|
30
|
+
panel = mountPanel(baseProps());
|
|
31
|
+
expect(panel.el.querySelectorAll('[role="menuitem"]')).toHaveLength(2);
|
|
32
|
+
});
|
|
33
|
+
it('renders shortcut hint when provided, omits when null', () => {
|
|
34
|
+
panel = mountPanel(baseProps({
|
|
35
|
+
sections: [{
|
|
36
|
+
id: 'g',
|
|
37
|
+
items: [
|
|
38
|
+
{ id: 'a', label: 'A', shortcut: 'Ctrl+A', group: '', icon: undefined,
|
|
39
|
+
checked: false, disabled: false, submenu: false },
|
|
40
|
+
{ id: 'b', label: 'B', shortcut: null, group: '', icon: undefined,
|
|
41
|
+
checked: false, disabled: false, submenu: false },
|
|
42
|
+
],
|
|
43
|
+
}],
|
|
44
|
+
}));
|
|
45
|
+
const shortcuts = panel.el.querySelectorAll('.sh3-ctx-shortcut');
|
|
46
|
+
expect(shortcuts).toHaveLength(1);
|
|
47
|
+
expect(shortcuts[0].textContent).toBe('Ctrl+A');
|
|
48
|
+
});
|
|
49
|
+
it('inserts a separator between distinct sections', () => {
|
|
50
|
+
panel = mountPanel(baseProps({
|
|
51
|
+
sections: [
|
|
52
|
+
{ id: 'g1', items: [{ id: 'a', label: 'A', shortcut: null, group: 'g1', icon: undefined,
|
|
53
|
+
checked: false, disabled: false, submenu: false }] },
|
|
54
|
+
{ id: 'g2', items: [{ id: 'b', label: 'B', shortcut: null, group: 'g2', icon: undefined,
|
|
55
|
+
checked: false, disabled: false, submenu: false }] },
|
|
56
|
+
],
|
|
57
|
+
}));
|
|
58
|
+
expect(panel.el.querySelectorAll('[role="separator"]')).toHaveLength(1);
|
|
59
|
+
});
|
|
60
|
+
it('click on an item calls onInvoke with its id and onDismiss', () => {
|
|
61
|
+
const onInvoke = vi.fn();
|
|
62
|
+
const onDismiss = vi.fn();
|
|
63
|
+
panel = mountPanel(baseProps({ onInvoke, onDismiss }));
|
|
64
|
+
const items = panel.el.querySelectorAll('[role="menuitem"]');
|
|
65
|
+
items[1].click();
|
|
66
|
+
expect(onInvoke).toHaveBeenCalledWith('paste');
|
|
67
|
+
expect(onDismiss).toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
it('ArrowDown moves the cursor and Enter dispatches the focused item', async () => {
|
|
70
|
+
const onInvoke = vi.fn();
|
|
71
|
+
panel = mountPanel(baseProps({ onInvoke }));
|
|
72
|
+
const root = panel.el.querySelector('[role="menu"]');
|
|
73
|
+
root.focus();
|
|
74
|
+
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
|
75
|
+
await tick();
|
|
76
|
+
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
|
77
|
+
expect(onInvoke).toHaveBeenCalledWith('paste');
|
|
78
|
+
});
|
|
79
|
+
it('Escape calls onDismiss', () => {
|
|
80
|
+
const onDismiss = vi.fn();
|
|
81
|
+
panel = mountPanel(baseProps({ onDismiss }));
|
|
82
|
+
const root = panel.el.querySelector('[role="menu"]');
|
|
83
|
+
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
84
|
+
expect(onDismiss).toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
describe('ActionPanel — checked / disabled / submenu visuals', () => {
|
|
88
|
+
let panel;
|
|
89
|
+
afterEach(() => panel === null || panel === void 0 ? void 0 : panel.cleanup());
|
|
90
|
+
it('renders a check glyph for items with checked: true', () => {
|
|
91
|
+
var _a, _b;
|
|
92
|
+
panel = mountPanel({
|
|
93
|
+
sections: [{ id: 'g', items: [
|
|
94
|
+
{ id: 'a', label: 'A', shortcut: null, group: '', icon: undefined,
|
|
95
|
+
checked: true, disabled: false, submenu: false },
|
|
96
|
+
] }],
|
|
97
|
+
onInvoke: vi.fn(),
|
|
98
|
+
onDismiss: vi.fn(),
|
|
99
|
+
});
|
|
100
|
+
expect((_b = (_a = panel.el.querySelector('.sh3-ctx-check')) === null || _a === void 0 ? void 0 : _a.textContent) === null || _b === void 0 ? void 0 : _b.trim())
|
|
101
|
+
.toBe('✓');
|
|
102
|
+
});
|
|
103
|
+
it('reserves the check slot (empty) on items with checked: false', () => {
|
|
104
|
+
var _a;
|
|
105
|
+
panel = mountPanel({
|
|
106
|
+
sections: [{ id: 'g', items: [
|
|
107
|
+
{ id: 'a', label: 'A', shortcut: null, group: '', icon: undefined,
|
|
108
|
+
checked: false, disabled: false, submenu: false },
|
|
109
|
+
] }],
|
|
110
|
+
onInvoke: vi.fn(),
|
|
111
|
+
onDismiss: vi.fn(),
|
|
112
|
+
});
|
|
113
|
+
const slot = panel.el.querySelector('.sh3-ctx-check');
|
|
114
|
+
expect(slot).not.toBeNull();
|
|
115
|
+
expect((_a = slot.textContent) === null || _a === void 0 ? void 0 : _a.trim()).toBe('');
|
|
116
|
+
});
|
|
117
|
+
it('applies aria-disabled and class on disabled items, click is a no-op', () => {
|
|
118
|
+
const onInvoke = vi.fn();
|
|
119
|
+
panel = mountPanel({
|
|
120
|
+
sections: [{ id: 'g', items: [
|
|
121
|
+
{ id: 'a', label: 'A', shortcut: null, group: '', icon: undefined,
|
|
122
|
+
checked: false, disabled: true, submenu: false },
|
|
123
|
+
] }],
|
|
124
|
+
onInvoke,
|
|
125
|
+
onDismiss: vi.fn(),
|
|
126
|
+
});
|
|
127
|
+
const btn = panel.el.querySelector('[role="menuitem"]');
|
|
128
|
+
expect(btn.getAttribute('aria-disabled')).toBe('true');
|
|
129
|
+
expect(btn.classList.contains('sh3-ctx-disabled')).toBe(true);
|
|
130
|
+
btn.click();
|
|
131
|
+
expect(onInvoke).not.toHaveBeenCalled();
|
|
132
|
+
});
|
|
133
|
+
it('keyboard nav skips disabled items', async () => {
|
|
134
|
+
const onInvoke = vi.fn();
|
|
135
|
+
panel = mountPanel({
|
|
136
|
+
sections: [{ id: 'g', items: [
|
|
137
|
+
{ id: 'a', label: 'A', shortcut: null, group: '', icon: undefined,
|
|
138
|
+
checked: false, disabled: false, submenu: false },
|
|
139
|
+
{ id: 'b', label: 'B', shortcut: null, group: '', icon: undefined,
|
|
140
|
+
checked: false, disabled: true, submenu: false },
|
|
141
|
+
{ id: 'c', label: 'C', shortcut: null, group: '', icon: undefined,
|
|
142
|
+
checked: false, disabled: false, submenu: false },
|
|
143
|
+
] }],
|
|
144
|
+
onInvoke,
|
|
145
|
+
onDismiss: vi.fn(),
|
|
146
|
+
});
|
|
147
|
+
const root = panel.el.querySelector('[role="menu"]');
|
|
148
|
+
root.focus();
|
|
149
|
+
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
|
150
|
+
await tick();
|
|
151
|
+
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
|
152
|
+
expect(onInvoke).toHaveBeenCalledWith('c');
|
|
153
|
+
});
|
|
154
|
+
it('renders chevron and suppresses shortcut hint for submenu parents', () => {
|
|
155
|
+
var _a, _b;
|
|
156
|
+
panel = mountPanel({
|
|
157
|
+
sections: [{ id: 'g', items: [
|
|
158
|
+
{ id: 'p', label: 'P', shortcut: 'Ctrl+P', group: '', icon: undefined,
|
|
159
|
+
checked: false, disabled: false, submenu: true },
|
|
160
|
+
] }],
|
|
161
|
+
onInvoke: vi.fn(),
|
|
162
|
+
onDismiss: vi.fn(),
|
|
163
|
+
});
|
|
164
|
+
expect((_b = (_a = panel.el.querySelector('.sh3-ctx-chevron')) === null || _a === void 0 ? void 0 : _a.textContent) === null || _b === void 0 ? void 0 : _b.trim())
|
|
165
|
+
.toBe('▸');
|
|
166
|
+
expect(panel.el.querySelector('.sh3-ctx-shortcut')).toBeNull();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -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} />
|