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,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 {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { mount, unmount, tick } from 'svelte';
|
|
3
|
+
import MenuBar from './MenuBar.svelte';
|
|
4
|
+
import { registerLayerRoot, unregisterLayerRoot } from '../overlays/roots';
|
|
5
|
+
import { __resetPopupManagerForTest } from '../overlays/popup';
|
|
6
|
+
import { setActiveApp, __resetDispatcherStateForTest, } from './state.svelte';
|
|
7
|
+
import { registerAction, __resetActionsRegistryForTest, } from './registry';
|
|
8
|
+
import { __resetContributionsForTest } from '../contributions/registry';
|
|
9
|
+
import { registerApp, __resetAppRegistryForTest, } from '../apps/registry.svelte';
|
|
10
|
+
let layerRoot;
|
|
11
|
+
let host;
|
|
12
|
+
let cmp = null;
|
|
13
|
+
function makeApp(id, menus) {
|
|
14
|
+
return {
|
|
15
|
+
manifest: {
|
|
16
|
+
id, label: id, version: '0.0.0',
|
|
17
|
+
requiredShards: ['shard.x'], layoutVersion: 1,
|
|
18
|
+
menus,
|
|
19
|
+
},
|
|
20
|
+
initialLayout: { type: 'leaf', viewId: 'shard.x:v' },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.stubGlobal('innerWidth', 2000);
|
|
25
|
+
vi.stubGlobal('innerHeight', 2000);
|
|
26
|
+
layerRoot = document.createElement('div');
|
|
27
|
+
layerRoot.style.position = 'relative';
|
|
28
|
+
document.body.appendChild(layerRoot);
|
|
29
|
+
registerLayerRoot('popup', layerRoot);
|
|
30
|
+
host = document.createElement('div');
|
|
31
|
+
document.body.appendChild(host);
|
|
32
|
+
});
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
if (cmp) {
|
|
35
|
+
unmount(cmp);
|
|
36
|
+
cmp = null;
|
|
37
|
+
}
|
|
38
|
+
host.remove();
|
|
39
|
+
__resetPopupManagerForTest();
|
|
40
|
+
unregisterLayerRoot('popup');
|
|
41
|
+
layerRoot.remove();
|
|
42
|
+
__resetActionsRegistryForTest();
|
|
43
|
+
__resetContributionsForTest();
|
|
44
|
+
__resetAppRegistryForTest();
|
|
45
|
+
__resetDispatcherStateForTest();
|
|
46
|
+
vi.unstubAllGlobals();
|
|
47
|
+
});
|
|
48
|
+
describe('MenuBar', () => {
|
|
49
|
+
it('renders an empty menubar when no app is active', async () => {
|
|
50
|
+
cmp = mount(MenuBar, { target: host, props: {} });
|
|
51
|
+
await tick();
|
|
52
|
+
// The wrapper always renders (it occupies a grid cell in the shell
|
|
53
|
+
// tabbar so user chrome stays anchored right). When no app is active
|
|
54
|
+
// it's just empty.
|
|
55
|
+
const bar = host.querySelector('.sh3-menubar');
|
|
56
|
+
expect(bar).not.toBeNull();
|
|
57
|
+
expect(bar.querySelectorAll('.sh3-menubar-button')).toHaveLength(0);
|
|
58
|
+
});
|
|
59
|
+
it('renders one button per declared container when an app is active', async () => {
|
|
60
|
+
registerApp(makeApp('app.a', [
|
|
61
|
+
{ id: 'file', label: 'File' },
|
|
62
|
+
{ id: 'help', label: 'Help' },
|
|
63
|
+
]));
|
|
64
|
+
registerAction({
|
|
65
|
+
id: 'open', label: 'Open', scope: 'app',
|
|
66
|
+
menuItem: 'file', run: () => { },
|
|
67
|
+
}, 'shard.x');
|
|
68
|
+
registerAction({
|
|
69
|
+
id: 'docs', label: 'Docs', scope: 'app',
|
|
70
|
+
menuItem: 'help', run: () => { },
|
|
71
|
+
}, 'shard.x');
|
|
72
|
+
setActiveApp('app.a', new Set(['shard.x']));
|
|
73
|
+
cmp = mount(MenuBar, { target: host, props: {} });
|
|
74
|
+
await tick();
|
|
75
|
+
const buttons = host.querySelectorAll('.sh3-menubar-button');
|
|
76
|
+
expect(buttons).toHaveLength(2);
|
|
77
|
+
expect(buttons[0].textContent).toContain('File');
|
|
78
|
+
expect(buttons[1].textContent).toContain('Help');
|
|
79
|
+
});
|
|
80
|
+
it('falls back to DEFAULT_MENU_CONTAINERS when manifest.menus is absent', async () => {
|
|
81
|
+
registerApp(makeApp('app.b'));
|
|
82
|
+
registerAction({
|
|
83
|
+
id: 'q', label: 'Quit', scope: 'app',
|
|
84
|
+
menuItem: 'file', run: () => { },
|
|
85
|
+
}, 'shard.x');
|
|
86
|
+
setActiveApp('app.b', new Set(['shard.x']));
|
|
87
|
+
cmp = mount(MenuBar, { target: host, props: {} });
|
|
88
|
+
await tick();
|
|
89
|
+
const buttons = host.querySelectorAll('.sh3-menubar-button');
|
|
90
|
+
expect(buttons).toHaveLength(1);
|
|
91
|
+
expect(buttons[0].textContent).toContain('File');
|
|
92
|
+
});
|
|
93
|
+
it('hides containers whose item list is empty', async () => {
|
|
94
|
+
registerApp(makeApp('app.c', [
|
|
95
|
+
{ id: 'file', label: 'File' },
|
|
96
|
+
{ id: 'edit', label: 'Edit' },
|
|
97
|
+
]));
|
|
98
|
+
registerAction({
|
|
99
|
+
id: 'open', label: 'Open', scope: 'app',
|
|
100
|
+
menuItem: 'file', run: () => { },
|
|
101
|
+
}, 'shard.x');
|
|
102
|
+
setActiveApp('app.c', new Set(['shard.x']));
|
|
103
|
+
cmp = mount(MenuBar, { target: host, props: {} });
|
|
104
|
+
await tick();
|
|
105
|
+
const buttons = host.querySelectorAll('.sh3-menubar-button');
|
|
106
|
+
expect(buttons).toHaveLength(1);
|
|
107
|
+
expect(buttons[0].textContent).toContain('File');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* MenuButton — one menu bar container. A button that opens a popup
|
|
4
|
+
* containing an ActionPanel rendered with this container's items.
|
|
5
|
+
* Items are passed in (the parent MenuBar resolves them via
|
|
6
|
+
* menuBarModel.resolveMenuItems).
|
|
7
|
+
*
|
|
8
|
+
* Submenu drill: when an invoked row's action has `submenu: true`, the
|
|
9
|
+
* button mounts a sibling ActionPanel inside the same popup host (rather
|
|
10
|
+
* than dispatching). popupManager is non-stacking by design, so we
|
|
11
|
+
* intentionally do NOT call popupManager.show again — outside-click on
|
|
12
|
+
* the parent host still closes the entire stack at once.
|
|
13
|
+
*/
|
|
14
|
+
import { mount, unmount, type Component } from 'svelte';
|
|
15
|
+
import { popupManager } from '../overlays/popup';
|
|
16
|
+
import type { PopupHandle } from '../overlays/types';
|
|
17
|
+
import ActionPanel, { type ActionPanelSection } from './ActionPanel.svelte';
|
|
18
|
+
import { listActions } from './registry';
|
|
19
|
+
import { getLiveDispatcherState, type DispatcherState } from './state.svelte';
|
|
20
|
+
import { resolveSubmenuItems, type MenuBarItem } from './menuBarModel';
|
|
21
|
+
import type { MenuContainer } from '../apps/types';
|
|
22
|
+
|
|
23
|
+
let { container, items }: {
|
|
24
|
+
container: MenuContainer;
|
|
25
|
+
items: MenuBarItem[];
|
|
26
|
+
} = $props();
|
|
27
|
+
|
|
28
|
+
let buttonEl: HTMLButtonElement | undefined = $state();
|
|
29
|
+
|
|
30
|
+
function makeSections(list: MenuBarItem[]): ActionPanelSection[] {
|
|
31
|
+
const buckets = new Map<string, MenuBarItem[]>();
|
|
32
|
+
const order: string[] = [];
|
|
33
|
+
for (const item of list) {
|
|
34
|
+
const key = item.group || '';
|
|
35
|
+
if (!buckets.has(key)) {
|
|
36
|
+
buckets.set(key, []);
|
|
37
|
+
order.push(key);
|
|
38
|
+
}
|
|
39
|
+
buckets.get(key)!.push(item);
|
|
40
|
+
}
|
|
41
|
+
return order.map((k) => ({ id: `group:${k || '_default'}`, items: buckets.get(k)! }));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function dispatchLeaf(id: string, state: DispatcherState, handle: PopupHandle) {
|
|
45
|
+
const entry = listActions().find((e) => e.action.id === id);
|
|
46
|
+
if (!entry || typeof entry.action.run !== 'function') return;
|
|
47
|
+
try {
|
|
48
|
+
void entry.action.run({
|
|
49
|
+
action: { id, label: entry.action.label },
|
|
50
|
+
appId: state.activeAppId,
|
|
51
|
+
viewId: state.focusedViewId ?? undefined,
|
|
52
|
+
selection: state.selection ?? undefined,
|
|
53
|
+
invokedVia: 'palette',
|
|
54
|
+
dispatch: () => {},
|
|
55
|
+
});
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.error(`[sh3] menu-bar action "${id}" threw:`, err);
|
|
58
|
+
}
|
|
59
|
+
handle.close();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function openSubmenu(parentId: string, state: DispatcherState, handle: PopupHandle) {
|
|
63
|
+
const root = document.querySelector('.sh3-popup-host') as HTMLElement | null;
|
|
64
|
+
if (!root) return;
|
|
65
|
+
const sub = document.createElement('div');
|
|
66
|
+
sub.className = 'sh3-popup-submenu';
|
|
67
|
+
sub.style.position = 'absolute';
|
|
68
|
+
sub.style.pointerEvents = 'auto';
|
|
69
|
+
const activeRow = root.querySelector('.sh3-ctx-active') as HTMLElement | null;
|
|
70
|
+
const anchorRect = (activeRow ?? root).getBoundingClientRect();
|
|
71
|
+
sub.style.left = `${anchorRect.right + 2}px`;
|
|
72
|
+
sub.style.top = `${anchorRect.top}px`;
|
|
73
|
+
root.appendChild(sub);
|
|
74
|
+
|
|
75
|
+
const subItems = resolveSubmenuItems(listActions(), state, parentId);
|
|
76
|
+
const subCmp = mount(ActionPanel as unknown as Component<Record<string, unknown>>, {
|
|
77
|
+
target: sub,
|
|
78
|
+
props: {
|
|
79
|
+
sections: makeSections(subItems),
|
|
80
|
+
onInvoke: (cid: string) => dispatchLeaf(cid, state, handle),
|
|
81
|
+
onDismiss: () => {
|
|
82
|
+
unmount(subCmp);
|
|
83
|
+
sub.remove();
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function openPopup() {
|
|
90
|
+
if (!buttonEl) return;
|
|
91
|
+
const state = getLiveDispatcherState();
|
|
92
|
+
const handle = popupManager.show(
|
|
93
|
+
ActionPanel,
|
|
94
|
+
{ anchor: buttonEl, placement: 'bottom-start' },
|
|
95
|
+
{
|
|
96
|
+
sections: makeSections(items),
|
|
97
|
+
onInvoke: (id: string) => {
|
|
98
|
+
const entry = listActions().find((e) => e.action.id === id);
|
|
99
|
+
if (!entry) return;
|
|
100
|
+
if (entry.action.submenu === true) {
|
|
101
|
+
openSubmenu(id, state, handle);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
dispatchLeaf(id, state, handle);
|
|
105
|
+
},
|
|
106
|
+
onDismiss: () => popupManager.close(),
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const iconPosition = $derived(container.iconPosition ?? 'before');
|
|
112
|
+
</script>
|
|
113
|
+
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
class="sh3-menubar-button"
|
|
117
|
+
bind:this={buttonEl}
|
|
118
|
+
onclick={openPopup}
|
|
119
|
+
>
|
|
120
|
+
{#if container.icon && iconPosition === 'before'}
|
|
121
|
+
<span class="sh3-menubar-icon" data-icon={container.icon}></span>
|
|
122
|
+
{/if}
|
|
123
|
+
<span class="sh3-menubar-label">{container.label}</span>
|
|
124
|
+
{#if container.icon && iconPosition === 'after'}
|
|
125
|
+
<span class="sh3-menubar-icon" data-icon={container.icon}></span>
|
|
126
|
+
{/if}
|
|
127
|
+
</button>
|
|
128
|
+
|
|
129
|
+
<style>
|
|
130
|
+
.sh3-menubar-button {
|
|
131
|
+
display: inline-flex;
|
|
132
|
+
align-items: center;
|
|
133
|
+
gap: 6px;
|
|
134
|
+
padding: 0 var(--shell-pad-md);
|
|
135
|
+
height: 100%;
|
|
136
|
+
background: transparent;
|
|
137
|
+
color: var(--shell-fg);
|
|
138
|
+
border: 0;
|
|
139
|
+
font: inherit;
|
|
140
|
+
cursor: default;
|
|
141
|
+
}
|
|
142
|
+
.sh3-menubar-button:hover {
|
|
143
|
+
background: var(--shell-bg-elevated);
|
|
144
|
+
}
|
|
145
|
+
.sh3-menubar-icon {
|
|
146
|
+
width: 14px;
|
|
147
|
+
height: 14px;
|
|
148
|
+
display: inline-block;
|
|
149
|
+
}
|
|
150
|
+
</style>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Component } from 'svelte';
|
|
2
|
+
import { type MenuBarItem } from './menuBarModel';
|
|
3
|
+
import type { MenuContainer } from '../apps/types';
|
|
4
|
+
type $$ComponentProps = {
|
|
5
|
+
container: MenuContainer;
|
|
6
|
+
items: MenuBarItem[];
|
|
7
|
+
};
|
|
8
|
+
declare const MenuButton: Component<$$ComponentProps, {}, "">;
|
|
9
|
+
type MenuButton = ReturnType<typeof MenuButton>;
|
|
10
|
+
export default MenuButton;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { mount, unmount, tick } from 'svelte';
|
|
3
|
+
import MenuButton from './MenuButton.svelte';
|
|
4
|
+
import { registerLayerRoot, unregisterLayerRoot } from '../overlays/roots';
|
|
5
|
+
import { __resetPopupManagerForTest } from '../overlays/popup';
|
|
6
|
+
let layerRoot;
|
|
7
|
+
let host;
|
|
8
|
+
let cmp = null;
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.stubGlobal('innerWidth', 2000);
|
|
11
|
+
vi.stubGlobal('innerHeight', 2000);
|
|
12
|
+
layerRoot = document.createElement('div');
|
|
13
|
+
layerRoot.style.position = 'relative';
|
|
14
|
+
document.body.appendChild(layerRoot);
|
|
15
|
+
registerLayerRoot('popup', layerRoot);
|
|
16
|
+
host = document.createElement('div');
|
|
17
|
+
document.body.appendChild(host);
|
|
18
|
+
});
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
if (cmp) {
|
|
21
|
+
unmount(cmp);
|
|
22
|
+
cmp = null;
|
|
23
|
+
}
|
|
24
|
+
host.remove();
|
|
25
|
+
__resetPopupManagerForTest();
|
|
26
|
+
unregisterLayerRoot('popup');
|
|
27
|
+
layerRoot.remove();
|
|
28
|
+
vi.unstubAllGlobals();
|
|
29
|
+
});
|
|
30
|
+
describe('MenuButton', () => {
|
|
31
|
+
it('renders the container label', () => {
|
|
32
|
+
cmp = mount(MenuButton, {
|
|
33
|
+
target: host,
|
|
34
|
+
props: {
|
|
35
|
+
container: { id: 'file', label: 'File' },
|
|
36
|
+
items: [],
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
expect(host.textContent).toContain('File');
|
|
40
|
+
});
|
|
41
|
+
it('renders icon before label when iconPosition is "before" (default)', () => {
|
|
42
|
+
cmp = mount(MenuButton, {
|
|
43
|
+
target: host,
|
|
44
|
+
props: {
|
|
45
|
+
container: { id: 'file', label: 'File', icon: 'folder' },
|
|
46
|
+
items: [],
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
const btn = host.querySelector('button');
|
|
50
|
+
const children = Array.from(btn.children);
|
|
51
|
+
const iconIdx = children.findIndex((c) => c.classList.contains('sh3-menubar-icon'));
|
|
52
|
+
const labelIdx = children.findIndex((c) => c.classList.contains('sh3-menubar-label'));
|
|
53
|
+
expect(iconIdx).toBeGreaterThanOrEqual(0);
|
|
54
|
+
expect(iconIdx).toBeLessThan(labelIdx);
|
|
55
|
+
});
|
|
56
|
+
it('renders icon after label when iconPosition is "after"', () => {
|
|
57
|
+
cmp = mount(MenuButton, {
|
|
58
|
+
target: host,
|
|
59
|
+
props: {
|
|
60
|
+
container: { id: 'file', label: 'File', icon: 'folder', iconPosition: 'after' },
|
|
61
|
+
items: [],
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
const btn = host.querySelector('button');
|
|
65
|
+
const children = Array.from(btn.children);
|
|
66
|
+
const iconIdx = children.findIndex((c) => c.classList.contains('sh3-menubar-icon'));
|
|
67
|
+
const labelIdx = children.findIndex((c) => c.classList.contains('sh3-menubar-label'));
|
|
68
|
+
expect(labelIdx).toBeLessThan(iconIdx);
|
|
69
|
+
});
|
|
70
|
+
it('clicking the button opens a popup with ActionPanel mounted', async () => {
|
|
71
|
+
cmp = mount(MenuButton, {
|
|
72
|
+
target: host,
|
|
73
|
+
props: {
|
|
74
|
+
container: { id: 'file', label: 'File' },
|
|
75
|
+
items: [
|
|
76
|
+
{ id: 'open', label: 'Open', shortcut: 'Ctrl+O', group: '', icon: undefined,
|
|
77
|
+
checked: false, disabled: false, submenu: false },
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
await tick();
|
|
82
|
+
const btn = host.querySelector('button');
|
|
83
|
+
btn.click();
|
|
84
|
+
await tick();
|
|
85
|
+
const items = layerRoot.querySelectorAll('[role="menuitem"]');
|
|
86
|
+
expect(items.length).toBe(1);
|
|
87
|
+
expect(items[0].textContent).toContain('Open');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe('MenuButton — submenu drill', () => {
|
|
91
|
+
it('clicking a submenu-parent row opens a nested submenu listing children', async () => {
|
|
92
|
+
const { registerAction, __resetActionsRegistryForTest } = await import('./registry');
|
|
93
|
+
const { __resetContributionsForTest } = await import('../contributions/registry');
|
|
94
|
+
__resetContributionsForTest();
|
|
95
|
+
__resetActionsRegistryForTest();
|
|
96
|
+
registerAction({ id: 'p', label: 'Launch app', scope: 'home', menuItem: 'file', submenu: true }, 'shard.x');
|
|
97
|
+
registerAction({ id: 'p.a', label: 'guml', scope: 'home', submenuOf: 'p', run: () => { } }, 'shard.x');
|
|
98
|
+
registerAction({ id: 'p.b', label: 'svg', scope: 'home', submenuOf: 'p', run: () => { } }, 'shard.x');
|
|
99
|
+
cmp = mount(MenuButton, {
|
|
100
|
+
target: host,
|
|
101
|
+
props: {
|
|
102
|
+
container: { id: 'file', label: 'File' },
|
|
103
|
+
items: [
|
|
104
|
+
{ id: 'p', label: 'Launch app', shortcut: null, group: '', icon: undefined,
|
|
105
|
+
checked: false, disabled: false, submenu: true },
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
await tick();
|
|
110
|
+
host.querySelector('button').click();
|
|
111
|
+
await tick();
|
|
112
|
+
// Parent panel mounted.
|
|
113
|
+
expect(layerRoot.querySelectorAll('.sh3-context-menu')).toHaveLength(1);
|
|
114
|
+
// Click the parent row.
|
|
115
|
+
const parentRow = layerRoot.querySelector('[role="menuitem"]');
|
|
116
|
+
parentRow.click();
|
|
117
|
+
await tick();
|
|
118
|
+
// Now expect a second .sh3-context-menu — the submenu — and it lists children.
|
|
119
|
+
const panels = layerRoot.querySelectorAll('.sh3-context-menu');
|
|
120
|
+
expect(panels.length).toBe(2);
|
|
121
|
+
const submenuItems = [...panels[1].querySelectorAll('[role="menuitem"] .sh3-ctx-label')]
|
|
122
|
+
.map((n) => n.textContent);
|
|
123
|
+
expect(submenuItems).toEqual(['guml', 'svg']);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -5,6 +5,10 @@ export interface MenuItem {
|
|
|
5
5
|
label: string;
|
|
6
6
|
shortcut: string | null;
|
|
7
7
|
group: string;
|
|
8
|
+
icon: string | undefined;
|
|
9
|
+
checked: boolean;
|
|
10
|
+
disabled: boolean;
|
|
11
|
+
submenu: boolean;
|
|
8
12
|
}
|
|
9
13
|
export interface MenuTier {
|
|
10
14
|
tier: TierName;
|
|
@@ -14,3 +18,9 @@ export interface ContextMenuModel {
|
|
|
14
18
|
tiers: MenuTier[];
|
|
15
19
|
}
|
|
16
20
|
export declare function buildContextMenuModel(entries: ActionEntry[], state: DispatcherState): ContextMenuModel;
|
|
21
|
+
/**
|
|
22
|
+
* Active children of a context-menu submenu parent. Single flat list,
|
|
23
|
+
* de-duplicated by id, in registration order. No tier grouping inside
|
|
24
|
+
* a submenu popup — one continuous list, separators driven by `group`.
|
|
25
|
+
*/
|
|
26
|
+
export declare function buildContextMenuSubmenu(entries: ActionEntry[], state: DispatcherState, parentId: string): MenuItem[];
|
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* Pure model layer for the context menu: takes the action registry +
|
|
3
|
-
* dispatcher state, returns a tiered, deduplicated,
|
|
4
|
-
*
|
|
3
|
+
* dispatcher state, returns a tiered, deduplicated, flag-annotated item
|
|
4
|
+
* list the Svelte component renders without further logic.
|
|
5
5
|
*/
|
|
6
6
|
import { TIER_ORDER } from './dispatcher.svelte';
|
|
7
7
|
import { effectiveShortcut } from './bindings';
|
|
8
8
|
import { scopeToTier, innermostActiveScope } from './scope-helpers';
|
|
9
|
-
|
|
9
|
+
function evalFlag(v) {
|
|
10
|
+
if (v === undefined)
|
|
11
|
+
return false;
|
|
12
|
+
return typeof v === 'function' ? !!v() : !!v;
|
|
13
|
+
}
|
|
14
|
+
function toMenuItem(entry, state) {
|
|
10
15
|
var _a;
|
|
16
|
+
return {
|
|
17
|
+
id: entry.action.id,
|
|
18
|
+
label: entry.action.label,
|
|
19
|
+
shortcut: effectiveShortcut(entry.action, state.bindings, state.platform),
|
|
20
|
+
group: (_a = entry.action.group) !== null && _a !== void 0 ? _a : '',
|
|
21
|
+
icon: entry.action.icon,
|
|
22
|
+
checked: evalFlag(entry.action.checked),
|
|
23
|
+
disabled: evalFlag(entry.action.disabled),
|
|
24
|
+
submenu: entry.action.submenu === true,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function buildContextMenuModel(entries, state) {
|
|
11
28
|
const byTier = {
|
|
12
29
|
element: [], focus: [], view: [], app: [], home: [],
|
|
13
30
|
};
|
|
@@ -15,18 +32,15 @@ export function buildContextMenuModel(entries, state) {
|
|
|
15
32
|
for (const entry of entries) {
|
|
16
33
|
if (!entry.action.contextItem)
|
|
17
34
|
continue;
|
|
35
|
+
if (entry.action.submenuOf !== undefined)
|
|
36
|
+
continue;
|
|
18
37
|
if (seen.has(entry.action.id))
|
|
19
38
|
continue;
|
|
20
39
|
const winning = innermostActiveScope(entry.action.scope, state, entry.ownerShardId);
|
|
21
40
|
if (!winning)
|
|
22
41
|
continue;
|
|
23
42
|
seen.add(entry.action.id);
|
|
24
|
-
byTier[scopeToTier(winning)].push(
|
|
25
|
-
id: entry.action.id,
|
|
26
|
-
label: entry.action.label,
|
|
27
|
-
shortcut: effectiveShortcut(entry.action, state.bindings, state.platform),
|
|
28
|
-
group: (_a = entry.action.group) !== null && _a !== void 0 ? _a : '',
|
|
29
|
-
});
|
|
43
|
+
byTier[scopeToTier(winning)].push(toMenuItem(entry, state));
|
|
30
44
|
}
|
|
31
45
|
return {
|
|
32
46
|
tiers: TIER_ORDER
|
|
@@ -34,3 +48,24 @@ export function buildContextMenuModel(entries, state) {
|
|
|
34
48
|
.filter((t) => t.items.length > 0),
|
|
35
49
|
};
|
|
36
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Active children of a context-menu submenu parent. Single flat list,
|
|
53
|
+
* de-duplicated by id, in registration order. No tier grouping inside
|
|
54
|
+
* a submenu popup — one continuous list, separators driven by `group`.
|
|
55
|
+
*/
|
|
56
|
+
export function buildContextMenuSubmenu(entries, state, parentId) {
|
|
57
|
+
const out = [];
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
if (entry.action.submenuOf !== parentId)
|
|
61
|
+
continue;
|
|
62
|
+
if (seen.has(entry.action.id))
|
|
63
|
+
continue;
|
|
64
|
+
const winning = innermostActiveScope(entry.action.scope, state, entry.ownerShardId);
|
|
65
|
+
if (!winning)
|
|
66
|
+
continue;
|
|
67
|
+
seen.add(entry.action.id);
|
|
68
|
+
out.push(toMenuItem(entry, state));
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { buildContextMenuModel } from './contextMenuModel';
|
|
2
|
+
import { buildContextMenuModel, buildContextMenuSubmenu } from './contextMenuModel';
|
|
3
3
|
const mkEntry = (a, owner = 'shard.x') => ({
|
|
4
4
|
ownerShardId: owner,
|
|
5
5
|
action: Object.assign({ id: 'a', label: 'A', scope: 'home', run: () => { } }, a),
|
|
@@ -42,3 +42,30 @@ describe('buildContextMenuModel', () => {
|
|
|
42
42
|
expect(model.tiers[0].tier).toBe('app');
|
|
43
43
|
});
|
|
44
44
|
});
|
|
45
|
+
describe('buildContextMenuModel — extended fields', () => {
|
|
46
|
+
it('flags checked / disabled / submenu and excludes children', () => {
|
|
47
|
+
const entries = [
|
|
48
|
+
mkEntry({ id: 'p', label: 'P', scope: 'home', contextItem: true, submenu: true }),
|
|
49
|
+
mkEntry({ id: 'c', label: 'C', scope: 'home', contextItem: true, submenuOf: 'p' }),
|
|
50
|
+
mkEntry({ id: 't', label: 'T', scope: 'home', contextItem: true, checked: true }),
|
|
51
|
+
mkEntry({ id: 'd', label: 'D', scope: 'home', contextItem: true, disabled: () => true }),
|
|
52
|
+
];
|
|
53
|
+
const model = buildContextMenuModel(entries, mkState());
|
|
54
|
+
const homeItems = model.tiers.find((t) => t.tier === 'home').items;
|
|
55
|
+
expect(homeItems.map((i) => i.id)).toEqual(['p', 't', 'd']);
|
|
56
|
+
expect(homeItems[0].submenu).toBe(true);
|
|
57
|
+
expect(homeItems[1].checked).toBe(true);
|
|
58
|
+
expect(homeItems[2].disabled).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe('buildContextMenuSubmenu', () => {
|
|
62
|
+
it('returns active children of a parent', () => {
|
|
63
|
+
const entries = [
|
|
64
|
+
mkEntry({ id: 'p', label: 'P', scope: 'home', contextItem: true, submenu: true }),
|
|
65
|
+
mkEntry({ id: 'p.a', label: 'A', scope: 'home', submenuOf: 'p' }),
|
|
66
|
+
mkEntry({ id: 'p.b', label: 'B', scope: 'home', submenuOf: 'p' }),
|
|
67
|
+
];
|
|
68
|
+
const items = buildContextMenuSubmenu(entries, mkState(), 'p');
|
|
69
|
+
expect(items.map((i) => i.id)).toEqual(['p.a', 'p.b']);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { DEFAULT_MENU_CONTAINERS } from './defaultMenuContainers';
|
|
3
|
+
describe('DEFAULT_MENU_CONTAINERS', () => {
|
|
4
|
+
it('declares the canonical five containers in fixed order', () => {
|
|
5
|
+
expect(DEFAULT_MENU_CONTAINERS.map((c) => c.id))
|
|
6
|
+
.toEqual(['file', 'edit', 'view', 'window', 'help']);
|
|
7
|
+
});
|
|
8
|
+
it('declares matching labels', () => {
|
|
9
|
+
expect(DEFAULT_MENU_CONTAINERS.map((c) => c.label))
|
|
10
|
+
.toEqual(['File', 'Edit', 'View', 'Window', 'Help']);
|
|
11
|
+
});
|
|
12
|
+
it('declares no icons by default (text-only fallback)', () => {
|
|
13
|
+
for (const c of DEFAULT_MENU_CONTAINERS) {
|
|
14
|
+
expect(c.icon).toBeUndefined();
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
it('is frozen — mutating throws or is silently dropped', () => {
|
|
18
|
+
expect(() => {
|
|
19
|
+
// @ts-expect-error — testing runtime immutability of a readonly constant
|
|
20
|
+
DEFAULT_MENU_CONTAINERS.push({ id: 'x', label: 'X' });
|
|
21
|
+
}).toThrow();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -4,6 +4,10 @@ export interface OpenContextMenuOpts {
|
|
|
4
4
|
}
|
|
5
5
|
export interface OpenPaletteOpts {
|
|
6
6
|
prefill?: string;
|
|
7
|
+
/** Restrict candidates to children of the given submenu parent (sub-palette drill). */
|
|
8
|
+
filter?: {
|
|
9
|
+
submenuOf?: string;
|
|
10
|
+
};
|
|
7
11
|
}
|
|
8
12
|
export declare function attachGlobalListeners(): void;
|
|
9
13
|
export declare function detachGlobalListeners(): void;
|