sh3-core 0.11.6 → 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.
Files changed (56) 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 +10 -0
  7. package/dist/actions/contextMenuModel.js +44 -9
  8. package/dist/actions/contextMenuModel.test.js +28 -1
  9. package/dist/actions/listeners.d.ts +4 -0
  10. package/dist/actions/listeners.js +77 -17
  11. package/dist/actions/listeners.test.js +50 -0
  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/types.d.ts +40 -1
  24. package/dist/actions/types.test.d.ts +1 -0
  25. package/dist/actions/types.test.js +31 -0
  26. package/dist/assets/icons.svg +5 -0
  27. package/dist/documents/backends.d.ts +2 -0
  28. package/dist/documents/backends.js +55 -0
  29. package/dist/documents/backends.test.d.ts +1 -1
  30. package/dist/documents/backends.test.js +69 -1
  31. package/dist/documents/browse.d.ts +18 -0
  32. package/dist/documents/browse.js +13 -0
  33. package/dist/documents/browse.test.js +47 -0
  34. package/dist/documents/handle.js +23 -0
  35. package/dist/documents/handle.test.js +51 -0
  36. package/dist/documents/http-backend.d.ts +1 -0
  37. package/dist/documents/http-backend.js +19 -0
  38. package/dist/documents/http-backend.test.js +42 -0
  39. package/dist/documents/types.d.ts +29 -1
  40. package/dist/documents/types.js +4 -0
  41. package/dist/documents/types.test.d.ts +1 -0
  42. package/dist/documents/types.test.js +20 -0
  43. package/dist/layout/LayoutRenderer.browser.test.js +196 -0
  44. package/dist/layout/SlotContainer.svelte +13 -8
  45. package/dist/layout/SlotDropZone.svelte +44 -9
  46. 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
  47. 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
  48. package/dist/layout/ops.d.ts +10 -0
  49. package/dist/layout/ops.js +30 -2
  50. package/dist/layout/ops.test.js +111 -1
  51. package/dist/layout/slotHostPool.svelte.d.ts +7 -1
  52. package/dist/layout/slotHostPool.svelte.js +27 -8
  53. package/dist/sh3core-shard/sh3coreShard.svelte.js +18 -4
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +1 -1
  56. 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
+ });
@@ -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, shortcut-annotated
4
- * item list the Svelte component renders without further logic.
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
- export function buildContextMenuModel(entries, state) {
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
+ });
@@ -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;