sh3-core 0.11.6 → 0.11.8

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.
Files changed (59) hide show
  1. package/dist/actions/ActionPanel.svelte +49 -11
  2. package/dist/actions/ActionPanel.test.js +94 -6
  3. package/dist/actions/MenuButton.svelte +60 -14
  4. package/dist/actions/MenuButton.svelte.d.ts +3 -2
  5. package/dist/actions/MenuButton.test.js +38 -1
  6. package/dist/actions/contextMenuModel.d.ts +12 -1
  7. package/dist/actions/contextMenuModel.js +62 -13
  8. package/dist/actions/contextMenuModel.test.js +72 -20
  9. package/dist/actions/listeners.d.ts +6 -0
  10. package/dist/actions/listeners.js +124 -20
  11. package/dist/actions/listeners.test.js +98 -6
  12. package/dist/actions/menuBarModel.d.ts +14 -0
  13. package/dist/actions/menuBarModel.js +43 -0
  14. package/dist/actions/menuBarModel.test.js +75 -1
  15. package/dist/actions/palette-scorer.d.ts +4 -0
  16. package/dist/actions/palette-scorer.js +5 -0
  17. package/dist/actions/palette-scorer.test.js +9 -1
  18. package/dist/actions/paletteModel.d.ts +7 -1
  19. package/dist/actions/paletteModel.js +26 -1
  20. package/dist/actions/paletteModel.test.js +43 -0
  21. package/dist/actions/registry.js +5 -0
  22. package/dist/actions/registry.test.js +12 -0
  23. package/dist/actions/scope-helpers.d.ts +6 -0
  24. package/dist/actions/scope-helpers.js +10 -0
  25. package/dist/actions/scope-helpers.test.js +24 -1
  26. package/dist/actions/types.d.ts +41 -1
  27. package/dist/actions/types.test.d.ts +1 -0
  28. package/dist/actions/types.test.js +31 -0
  29. package/dist/assets/icons.svg +5 -0
  30. package/dist/documents/backends.d.ts +2 -0
  31. package/dist/documents/backends.js +55 -0
  32. package/dist/documents/backends.test.d.ts +1 -1
  33. package/dist/documents/backends.test.js +69 -1
  34. package/dist/documents/browse.d.ts +33 -0
  35. package/dist/documents/browse.js +20 -0
  36. package/dist/documents/browse.test.js +88 -0
  37. package/dist/documents/handle.js +26 -1
  38. package/dist/documents/handle.test.js +74 -0
  39. package/dist/documents/http-backend.d.ts +1 -0
  40. package/dist/documents/http-backend.js +19 -0
  41. package/dist/documents/http-backend.test.js +42 -0
  42. package/dist/documents/types.d.ts +29 -1
  43. package/dist/documents/types.js +4 -0
  44. package/dist/documents/types.test.d.ts +1 -0
  45. package/dist/documents/types.test.js +20 -0
  46. package/dist/layout/LayoutRenderer.browser.test.js +196 -0
  47. package/dist/layout/SlotContainer.svelte +13 -8
  48. package/dist/layout/SlotDropZone.svelte +44 -9
  49. 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
  50. 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
  51. package/dist/layout/ops.d.ts +10 -0
  52. package/dist/layout/ops.js +30 -2
  53. package/dist/layout/ops.test.js +111 -1
  54. package/dist/layout/slotHostPool.svelte.d.ts +7 -1
  55. package/dist/layout/slotHostPool.svelte.js +27 -8
  56. package/dist/sh3core-shard/sh3coreShard.svelte.js +18 -4
  57. package/dist/version.d.ts +1 -1
  58. package/dist/version.js +1 -1
  59. package/package.json +2 -1
@@ -14,6 +14,14 @@
14
14
  * rendering, hover/focus state, keyboard nav, click-dispatch. Does NOT
15
15
  * own: positioning, backdrop, the popover surface itself — those stay
16
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.
17
25
  */
18
26
 
19
27
  let { sections, onInvoke, onDismiss }: {
@@ -25,17 +33,28 @@
25
33
  const flatItems: MenuBarItem[] = $derived(sections.flatMap((s) => s.items));
26
34
  let cursor = $state(0);
27
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
+
28
46
  function onKeydown(ev: KeyboardEvent) {
29
47
  if (ev.key === 'ArrowDown') {
30
- cursor = (cursor + 1) % flatItems.length;
48
+ cursor = nextEnabled(cursor, 1);
31
49
  ev.preventDefault();
32
50
  } else if (ev.key === 'ArrowUp') {
33
- cursor = (cursor - 1 + flatItems.length) % flatItems.length;
51
+ cursor = nextEnabled(cursor, -1);
34
52
  ev.preventDefault();
35
53
  } else if (ev.key === 'Enter') {
36
- if (flatItems[cursor]) {
37
- onInvoke(flatItems[cursor].id);
38
- onDismiss();
54
+ const item = flatItems[cursor];
55
+ if (item && !item.disabled) {
56
+ onInvoke(item.id);
57
+ if (!item.submenu) onDismiss();
39
58
  }
40
59
  ev.preventDefault();
41
60
  } else if (ev.key === 'Escape') {
@@ -46,13 +65,21 @@
46
65
  const start = (cursor + 1) % flatItems.length;
47
66
  for (let i = 0; i < flatItems.length; i++) {
48
67
  const idx = (start + i) % flatItems.length;
49
- if (flatItems[idx].label.toLowerCase().startsWith(q)) {
68
+ const item = flatItems[idx];
69
+ if (item.disabled) continue;
70
+ if (item.label.toLowerCase().startsWith(q)) {
50
71
  cursor = idx;
51
72
  break;
52
73
  }
53
74
  }
54
75
  }
55
76
  }
77
+
78
+ function onItemClick(item: MenuBarItem) {
79
+ if (item.disabled) return;
80
+ onInvoke(item.id);
81
+ if (!item.submenu) onDismiss();
82
+ }
56
83
  </script>
57
84
 
58
85
  <!-- svelte-ignore a11y_autofocus -->
@@ -64,12 +91,19 @@
64
91
  <button
65
92
  class="sh3-ctx-item"
66
93
  class:sh3-ctx-active={globalIdx === cursor}
94
+ class:sh3-ctx-disabled={item.disabled}
67
95
  role="menuitem"
68
- onpointerenter={() => { cursor = globalIdx; }}
69
- onclick={() => { onInvoke(item.id); onDismiss(); }}
96
+ aria-disabled={item.disabled || undefined}
97
+ onpointerenter={() => { if (!item.disabled) cursor = globalIdx; }}
98
+ onclick={() => onItemClick(item)}
70
99
  >
100
+ <span class="sh3-ctx-check">{item.checked ? '✓' : ''}</span>
71
101
  <span class="sh3-ctx-label">{item.label}</span>
72
- {#if item.shortcut}<span class="sh3-ctx-shortcut">{item.shortcut}</span>{/if}
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}
73
107
  </button>
74
108
  {/each}
75
109
  {/each}
@@ -77,7 +111,7 @@
77
111
 
78
112
  <style>
79
113
  .sh3-context-menu {
80
- min-width: 200px;
114
+ min-width: 220px;
81
115
  background: var(--shell-bg-elevated, #222);
82
116
  color: var(--shell-fg, #eee);
83
117
  border-radius: 4px;
@@ -88,7 +122,7 @@
88
122
  .sh3-ctx-item {
89
123
  display: flex;
90
124
  align-items: center;
91
- gap: 16px;
125
+ gap: 8px;
92
126
  width: 100%;
93
127
  padding: 4px 10px;
94
128
  background: none;
@@ -99,7 +133,11 @@
99
133
  font: inherit;
100
134
  }
101
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; }
102
139
  .sh3-ctx-label { flex: 1; }
140
+ .sh3-ctx-chevron { opacity: 0.6; }
103
141
  .sh3-ctx-shortcut { opacity: 0.6; font-size: 0.9em; }
104
142
  .sh3-ctx-sep { height: 1px; background: rgba(255, 255, 255, 0.1); margin: 4px 0; }
105
143
  </style>
@@ -16,8 +16,10 @@ const baseProps = (overrides = {}) => (Object.assign({ sections: [
16
16
  {
17
17
  id: 'group:edit',
18
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 },
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 },
21
23
  ],
22
24
  },
23
25
  ], onInvoke: vi.fn(), onDismiss: vi.fn() }, overrides));
@@ -33,8 +35,10 @@ describe('ActionPanel', () => {
33
35
  sections: [{
34
36
  id: 'g',
35
37
  items: [
36
- { id: 'a', label: 'A', shortcut: 'Ctrl+A', group: '', icon: undefined },
37
- { id: 'b', label: 'B', shortcut: null, group: '', icon: undefined },
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 },
38
42
  ],
39
43
  }],
40
44
  }));
@@ -45,8 +49,10 @@ describe('ActionPanel', () => {
45
49
  it('inserts a separator between distinct sections', () => {
46
50
  panel = mountPanel(baseProps({
47
51
  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 }] },
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 }] },
50
56
  ],
51
57
  }));
52
58
  expect(panel.el.querySelectorAll('[role="separator"]')).toHaveLength(1);
@@ -78,3 +84,85 @@ describe('ActionPanel', () => {
78
84
  expect(onDismiss).toHaveBeenCalled();
79
85
  });
80
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
+ });
@@ -4,12 +4,20 @@
4
4
  * containing an ActionPanel rendered with this container's items.
5
5
  * Items are passed in (the parent MenuBar resolves them via
6
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.
7
13
  */
14
+ import { mount, unmount, type Component } from 'svelte';
8
15
  import { popupManager } from '../overlays/popup';
16
+ import type { PopupHandle } from '../overlays/types';
9
17
  import ActionPanel, { type ActionPanelSection } from './ActionPanel.svelte';
10
18
  import { listActions } from './registry';
11
- import { getLiveDispatcherState } from './state.svelte';
12
- import type { MenuBarItem } from './menuBarModel';
19
+ import { getLiveDispatcherState, type DispatcherState } from './state.svelte';
20
+ import { resolveSubmenuItems, type MenuBarItem } from './menuBarModel';
13
21
  import type { MenuContainer } from '../apps/types';
14
22
 
15
23
  let { container, items }: {
@@ -33,10 +41,55 @@
33
41
  return order.map((k) => ({ id: `group:${k || '_default'}`, items: buckets.get(k)! }));
34
42
  }
35
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
+
36
89
  function openPopup() {
37
90
  if (!buttonEl) return;
38
91
  const state = getLiveDispatcherState();
39
- popupManager.show(
92
+ const handle = popupManager.show(
40
93
  ActionPanel,
41
94
  { anchor: buttonEl, placement: 'bottom-start' },
42
95
  {
@@ -44,18 +97,11 @@
44
97
  onInvoke: (id: string) => {
45
98
  const entry = listActions().find((e) => e.action.id === id);
46
99
  if (!entry) 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);
100
+ if (entry.action.submenu === true) {
101
+ openSubmenu(id, state, handle);
102
+ return;
58
103
  }
104
+ dispatchLeaf(id, state, handle);
59
105
  },
60
106
  onDismiss: () => popupManager.close(),
61
107
  },
@@ -1,9 +1,10 @@
1
- import type { MenuBarItem } from './menuBarModel';
1
+ import { type Component } from 'svelte';
2
+ import { type MenuBarItem } from './menuBarModel';
2
3
  import type { MenuContainer } from '../apps/types';
3
4
  type $$ComponentProps = {
4
5
  container: MenuContainer;
5
6
  items: MenuBarItem[];
6
7
  };
7
- declare const MenuButton: import("svelte").Component<$$ComponentProps, {}, "">;
8
+ declare const MenuButton: Component<$$ComponentProps, {}, "">;
8
9
  type MenuButton = ReturnType<typeof MenuButton>;
9
10
  export default MenuButton;
@@ -73,7 +73,8 @@ describe('MenuButton', () => {
73
73
  props: {
74
74
  container: { id: 'file', label: 'File' },
75
75
  items: [
76
- { id: 'open', label: 'Open', shortcut: 'Ctrl+O', group: '', icon: undefined },
76
+ { id: 'open', label: 'Open', shortcut: 'Ctrl+O', group: '', icon: undefined,
77
+ checked: false, disabled: false, submenu: false },
77
78
  ],
78
79
  },
79
80
  });
@@ -86,3 +87,39 @@ describe('MenuButton', () => {
86
87
  expect(items[0].textContent).toContain('Open');
87
88
  });
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
+ });
@@ -1,10 +1,15 @@
1
1
  import type { ActionEntry } from './registry';
2
2
  import { type DispatcherState, type TierName } from './dispatcher.svelte';
3
+ import type { AtomicScope } from './types';
3
4
  export interface MenuItem {
4
5
  id: string;
5
6
  label: string;
6
7
  shortcut: string | null;
7
8
  group: string;
9
+ icon: string | undefined;
10
+ checked: boolean;
11
+ disabled: boolean;
12
+ submenu: boolean;
8
13
  }
9
14
  export interface MenuTier {
10
15
  tier: TierName;
@@ -13,4 +18,10 @@ export interface MenuTier {
13
18
  export interface ContextMenuModel {
14
19
  tiers: MenuTier[];
15
20
  }
16
- export declare function buildContextMenuModel(entries: ActionEntry[], state: DispatcherState): ContextMenuModel;
21
+ export declare function buildContextMenuModel(entries: ActionEntry[], state: DispatcherState, anchor: AtomicScope): ContextMenuModel;
22
+ /**
23
+ * Active children of a context-menu submenu parent. Single flat list,
24
+ * de-duplicated by id, in registration order. The same anchor filter that
25
+ * admitted the parent is applied to children.
26
+ */
27
+ export declare function buildContextMenuSubmenu(entries: ActionEntry[], state: DispatcherState, parentId: string, anchor: AtomicScope): MenuItem[];
@@ -1,12 +1,42 @@
1
1
  /*
2
2
  * Pure model layer for the context menu: takes the action registry +
3
- * dispatcher state, returns a tiered, deduplicated, shortcut-annotated
4
- * item list the Svelte component renders without further logic.
3
+ * dispatcher state + an anchor scope, returns a tier-grouped, deduplicated,
4
+ * flag-annotated item list the Svelte component renders without further
5
+ * logic. Items pass iff their declared scope list contains the anchor;
6
+ * `app`/`home` anchors additionally require the existing owner-shard
7
+ * activation guard.
5
8
  */
6
- import { TIER_ORDER } from './dispatcher.svelte';
9
+ import { TIER_ORDER, isScopeActive, } from './dispatcher.svelte';
7
10
  import { effectiveShortcut } from './bindings';
8
- import { scopeToTier, innermostActiveScope } from './scope-helpers';
9
- export function buildContextMenuModel(entries, state) {
11
+ import { scopeToTier, innermostActiveScope, scopeEquals, normalizeScope, } from './scope-helpers';
12
+ function evalFlag(v) {
13
+ if (v === undefined)
14
+ return false;
15
+ return typeof v === 'function' ? !!v() : !!v;
16
+ }
17
+ function toMenuItem(entry, state) {
18
+ var _a;
19
+ return {
20
+ id: entry.action.id,
21
+ label: entry.action.label,
22
+ shortcut: effectiveShortcut(entry.action, state.bindings, state.platform),
23
+ group: (_a = entry.action.group) !== null && _a !== void 0 ? _a : '',
24
+ icon: entry.action.icon,
25
+ checked: evalFlag(entry.action.checked),
26
+ disabled: evalFlag(entry.action.disabled),
27
+ submenu: entry.action.submenu === true,
28
+ };
29
+ }
30
+ function matchesAnchor(action, ownerShardId, anchor, state) {
31
+ const inList = normalizeScope(action.scope).some((s) => scopeEquals(s, anchor));
32
+ if (!inList)
33
+ return false;
34
+ if (anchor === 'app' || anchor === 'home') {
35
+ return isScopeActive(anchor, state, ownerShardId);
36
+ }
37
+ return true;
38
+ }
39
+ export function buildContextMenuModel(entries, state, anchor) {
10
40
  var _a;
11
41
  const byTier = {
12
42
  element: [], focus: [], view: [], app: [], home: [],
@@ -15,18 +45,17 @@ export function buildContextMenuModel(entries, state) {
15
45
  for (const entry of entries) {
16
46
  if (!entry.action.contextItem)
17
47
  continue;
48
+ if (entry.action.submenuOf !== undefined)
49
+ continue;
18
50
  if (seen.has(entry.action.id))
19
51
  continue;
20
- const winning = innermostActiveScope(entry.action.scope, state, entry.ownerShardId);
21
- if (!winning)
52
+ if (!matchesAnchor(entry.action, entry.ownerShardId, anchor, state))
22
53
  continue;
54
+ // Tier is determined by the anchor itself; the per-action winning scope
55
+ // is preserved purely for any future tier-aware rendering.
56
+ const winning = (_a = innermostActiveScope(entry.action.scope, state, entry.ownerShardId)) !== null && _a !== void 0 ? _a : anchor;
23
57
  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
- });
58
+ byTier[scopeToTier(winning)].push(toMenuItem(entry, state));
30
59
  }
31
60
  return {
32
61
  tiers: TIER_ORDER
@@ -34,3 +63,23 @@ export function buildContextMenuModel(entries, state) {
34
63
  .filter((t) => t.items.length > 0),
35
64
  };
36
65
  }
66
+ /**
67
+ * Active children of a context-menu submenu parent. Single flat list,
68
+ * de-duplicated by id, in registration order. The same anchor filter that
69
+ * admitted the parent is applied to children.
70
+ */
71
+ export function buildContextMenuSubmenu(entries, state, parentId, anchor) {
72
+ const out = [];
73
+ const seen = new Set();
74
+ for (const entry of entries) {
75
+ if (entry.action.submenuOf !== parentId)
76
+ continue;
77
+ if (seen.has(entry.action.id))
78
+ continue;
79
+ if (!matchesAnchor(entry.action, entry.ownerShardId, anchor, state))
80
+ continue;
81
+ seen.add(entry.action.id);
82
+ out.push(toMenuItem(entry, state));
83
+ }
84
+ return out;
85
+ }
@@ -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),
@@ -9,36 +9,88 @@ describe('buildContextMenuModel', () => {
9
9
  it('returns only actions with contextItem: true', () => {
10
10
  const entries = [
11
11
  mkEntry({ id: 'a', scope: 'home', contextItem: true, label: 'A' }),
12
- mkEntry({ id: 'b', scope: 'home', label: 'B' }), // no contextItem
12
+ mkEntry({ id: 'b', scope: 'home', label: 'B' }),
13
13
  ];
14
- const model = buildContextMenuModel(entries, mkState());
14
+ const model = buildContextMenuModel(entries, mkState(), 'home');
15
15
  expect(model.tiers.flatMap((t) => t.items).map((i) => i.id)).toEqual(['a']);
16
16
  });
17
- it('groups by scope tier in innermost-first order with separators', () => {
18
- const state = mkState({
19
- activeAppId: 'app.a',
20
- activeAppRequiredShards: new Set(['shard.x']),
21
- selection: { type: 'orb', ref: 1, ownerShardId: 'shard.x' },
22
- });
17
+ it('admits only actions whose scope list contains the anchor', () => {
18
+ const state = mkState({ mountedViewIds: new Set(['editor']), focusedViewId: 'editor' });
19
+ const entries = [
20
+ mkEntry({ id: 'view-only', scope: 'focus:editor', contextItem: true, label: 'V' }),
21
+ mkEntry({ id: 'home-only', scope: 'home', contextItem: true, label: 'H' }),
22
+ ];
23
+ const model = buildContextMenuModel(entries, state, 'focus:editor');
24
+ expect(model.tiers.flatMap((t) => t.items).map((i) => i.id)).toEqual(['view-only']);
25
+ });
26
+ it('admits a multi-scope action when the anchor is one of its scopes', () => {
27
+ const state = mkState({ mountedViewIds: new Set(['editor']), focusedViewId: 'editor' });
28
+ const entries = [
29
+ mkEntry({ id: 'multi', scope: ['focus:editor', 'app'], contextItem: true, label: 'M' }),
30
+ ];
31
+ const model = buildContextMenuModel(entries, state, 'focus:editor');
32
+ expect(model.tiers.flatMap((t) => t.items).map((i) => i.id)).toEqual(['multi']);
33
+ });
34
+ it('matches element scopes by element-type equality', () => {
23
35
  const entries = [
24
- mkEntry({ id: 'el', scope: { element: 'orb' }, contextItem: true, label: 'Dup' }),
25
- mkEntry({ id: 'ap', scope: 'app', contextItem: true, label: 'Undo' }),
36
+ mkEntry({ id: 'cell', scope: { element: 'cell' }, contextItem: true, label: 'C' }),
37
+ mkEntry({ id: 'row', scope: { element: 'row' }, contextItem: true, label: 'R' }),
26
38
  ];
27
- const model = buildContextMenuModel(entries, state);
28
- expect(model.tiers.map((t) => t.tier)).toEqual(['element', 'app']);
29
- expect(model.tiers[0].items[0].id).toBe('el');
39
+ const model = buildContextMenuModel(entries, mkState(), { element: 'cell' });
40
+ expect(model.tiers.flatMap((t) => t.items).map((i) => i.id)).toEqual(['cell']);
30
41
  });
31
- it('de-duplicates multi-scope action to innermost tier', () => {
42
+ it('app anchor honors the owner-shard guard', () => {
32
43
  const state = mkState({
33
44
  activeAppId: 'app.a',
34
45
  activeAppRequiredShards: new Set(['shard.x']),
35
- autostartShards: new Set(['shard.x']),
36
46
  });
37
47
  const entries = [
38
- mkEntry({ id: 'p', scope: ['home', 'app'], contextItem: true, label: 'P' }),
48
+ mkEntry({ id: 'mine', scope: 'app', contextItem: true, label: 'M' }, 'shard.x'),
49
+ mkEntry({ id: 'foreign', scope: 'app', contextItem: true, label: 'F' }, 'shard.y'),
50
+ ];
51
+ const model = buildContextMenuModel(entries, state, 'app');
52
+ expect(model.tiers.flatMap((t) => t.items).map((i) => i.id)).toEqual(['mine']);
53
+ });
54
+ it('home anchor only admits actions when no app is active', () => {
55
+ const noApp = mkState();
56
+ const withApp = mkState({ activeAppId: 'app.a' });
57
+ const entries = [
58
+ mkEntry({ id: 'h', scope: 'home', contextItem: true, label: 'H' }),
59
+ ];
60
+ expect(buildContextMenuModel(entries, noApp, 'home').tiers).toHaveLength(1);
61
+ expect(buildContextMenuModel(entries, withApp, 'home').tiers).toHaveLength(0);
62
+ });
63
+ it('flags checked / disabled / submenu and excludes children', () => {
64
+ const entries = [
65
+ mkEntry({ id: 'p', label: 'P', scope: 'home', contextItem: true, submenu: true }),
66
+ mkEntry({ id: 'c', label: 'C', scope: 'home', contextItem: true, submenuOf: 'p' }),
67
+ mkEntry({ id: 't', label: 'T', scope: 'home', contextItem: true, checked: true }),
68
+ mkEntry({ id: 'd', label: 'D', scope: 'home', contextItem: true, disabled: () => true }),
69
+ ];
70
+ const model = buildContextMenuModel(entries, mkState(), 'home');
71
+ const homeItems = model.tiers.find((t) => t.tier === 'home').items;
72
+ expect(homeItems.map((i) => i.id)).toEqual(['p', 't', 'd']);
73
+ expect(homeItems[0].submenu).toBe(true);
74
+ expect(homeItems[1].checked).toBe(true);
75
+ expect(homeItems[2].disabled).toBe(true);
76
+ });
77
+ it('returns an empty model when no action matches the anchor', () => {
78
+ const entries = [
79
+ mkEntry({ id: 'x', scope: 'home', contextItem: true, label: 'X' }),
80
+ ];
81
+ const model = buildContextMenuModel(entries, mkState(), 'focus:nope');
82
+ expect(model.tiers).toEqual([]);
83
+ });
84
+ });
85
+ describe('buildContextMenuSubmenu', () => {
86
+ it('returns children of the parent that match the anchor', () => {
87
+ const entries = [
88
+ mkEntry({ id: 'p', label: 'P', scope: 'home', contextItem: true, submenu: true }),
89
+ mkEntry({ id: 'p.a', label: 'A', scope: 'home', submenuOf: 'p' }),
90
+ mkEntry({ id: 'p.b', label: 'B', scope: 'home', submenuOf: 'p' }),
91
+ mkEntry({ id: 'p.c', label: 'C', scope: 'app', submenuOf: 'p' }),
39
92
  ];
40
- const model = buildContextMenuModel(entries, state);
41
- expect(model.tiers).toHaveLength(1);
42
- expect(model.tiers[0].tier).toBe('app');
93
+ const items = buildContextMenuSubmenu(entries, mkState(), 'p', 'home');
94
+ expect(items.map((i) => i.id)).toEqual(['p.a', 'p.b']);
43
95
  });
44
96
  });