sh3-core 0.10.5 → 0.11.2

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 (115) hide show
  1. package/dist/Shell.svelte +12 -31
  2. package/dist/__test__/reset.js +6 -0
  3. package/dist/actions/CommandPalette.svelte +68 -0
  4. package/dist/actions/CommandPalette.svelte.d.ts +11 -0
  5. package/dist/actions/ContextMenu.svelte +97 -0
  6. package/dist/actions/ContextMenu.svelte.d.ts +9 -0
  7. package/dist/actions/bindings-store.d.ts +8 -0
  8. package/dist/actions/bindings-store.js +27 -0
  9. package/dist/actions/bindings-store.test.d.ts +1 -0
  10. package/dist/actions/bindings-store.test.js +25 -0
  11. package/dist/actions/bindings.d.ts +4 -0
  12. package/dist/actions/bindings.js +17 -0
  13. package/dist/actions/bindings.test.d.ts +1 -0
  14. package/dist/actions/bindings.test.js +30 -0
  15. package/dist/actions/contextMenuModel.d.ts +16 -0
  16. package/dist/actions/contextMenuModel.js +71 -0
  17. package/dist/actions/contextMenuModel.test.d.ts +1 -0
  18. package/dist/actions/contextMenuModel.test.js +44 -0
  19. package/dist/actions/dispatcher.svelte.d.ts +34 -0
  20. package/dist/actions/dispatcher.svelte.js +117 -0
  21. package/dist/actions/dispatcher.test.d.ts +1 -0
  22. package/dist/actions/dispatcher.test.js +155 -0
  23. package/dist/actions/listeners.d.ts +11 -0
  24. package/dist/actions/listeners.js +180 -0
  25. package/dist/actions/listeners.test.d.ts +1 -0
  26. package/dist/actions/listeners.test.js +149 -0
  27. package/dist/actions/palette-scorer.d.ts +11 -0
  28. package/dist/actions/palette-scorer.js +49 -0
  29. package/dist/actions/palette-scorer.test.d.ts +1 -0
  30. package/dist/actions/palette-scorer.test.js +40 -0
  31. package/dist/actions/paletteModel.d.ts +4 -0
  32. package/dist/actions/paletteModel.js +40 -0
  33. package/dist/actions/paletteModel.test.d.ts +1 -0
  34. package/dist/actions/paletteModel.test.js +33 -0
  35. package/dist/actions/registry.d.ts +10 -0
  36. package/dist/actions/registry.js +36 -0
  37. package/dist/actions/registry.test.d.ts +1 -0
  38. package/dist/actions/registry.test.js +49 -0
  39. package/dist/actions/selection.svelte.d.ts +8 -0
  40. package/dist/actions/selection.svelte.js +44 -0
  41. package/dist/actions/selection.test.d.ts +1 -0
  42. package/dist/actions/selection.test.js +51 -0
  43. package/dist/actions/shardContext.test.d.ts +1 -0
  44. package/dist/actions/shardContext.test.js +41 -0
  45. package/dist/actions/shellActions.test.d.ts +1 -0
  46. package/dist/actions/shellActions.test.js +22 -0
  47. package/dist/actions/shortcuts.d.ts +5 -0
  48. package/dist/actions/shortcuts.js +87 -0
  49. package/dist/actions/shortcuts.test.d.ts +1 -0
  50. package/dist/actions/shortcuts.test.js +49 -0
  51. package/dist/actions/state.svelte.d.ts +16 -0
  52. package/dist/actions/state.svelte.js +76 -0
  53. package/dist/actions/state.test.d.ts +1 -0
  54. package/dist/actions/state.test.js +40 -0
  55. package/dist/actions/syncMountedViewIds.test.d.ts +1 -0
  56. package/dist/actions/syncMountedViewIds.test.js +97 -0
  57. package/dist/actions/types.d.ts +56 -0
  58. package/dist/actions/types.js +7 -0
  59. package/dist/api.d.ts +2 -2
  60. package/dist/api.js +1 -1
  61. package/dist/apps/lifecycle.js +13 -3
  62. package/dist/createShell.js +4 -1
  63. package/dist/host.js +6 -3
  64. package/dist/index.d.ts +2 -0
  65. package/dist/index.js +2 -0
  66. package/dist/layout/inspection.d.ts +11 -1
  67. package/dist/layout/inspection.js +13 -1
  68. package/dist/layout/ops-locate.test.d.ts +1 -0
  69. package/dist/layout/ops-locate.test.js +103 -0
  70. package/dist/layout/ops.d.ts +8 -0
  71. package/dist/layout/ops.js +27 -0
  72. package/dist/layout/slotHostPool.svelte.js +24 -0
  73. package/dist/layout/slotHostPool.test.js +14 -0
  74. package/dist/layout/types.d.ts +7 -0
  75. package/dist/overlays/FloatFrame.svelte +23 -11
  76. package/dist/overlays/ModalFrame.svelte +9 -1
  77. package/dist/overlays/ModalFrame.svelte.d.ts +1 -0
  78. package/dist/overlays/__test__/DummyFrame.svelte +6 -0
  79. package/dist/overlays/__test__/DummyFrame.svelte.d.ts +6 -0
  80. package/dist/overlays/float.d.ts +6 -0
  81. package/dist/overlays/float.js +24 -9
  82. package/dist/overlays/float.test.js +175 -0
  83. package/dist/overlays/floatDismiss.d.ts +8 -0
  84. package/dist/overlays/floatDismiss.js +68 -0
  85. package/dist/overlays/modal.js +5 -1
  86. package/dist/overlays/modal.test.d.ts +1 -0
  87. package/dist/overlays/modal.test.js +55 -0
  88. package/dist/overlays/popup.d.ts +2 -0
  89. package/dist/overlays/popup.js +24 -4
  90. package/dist/overlays/popup.test.d.ts +1 -0
  91. package/dist/overlays/popup.test.js +95 -0
  92. package/dist/overlays/types.d.ts +17 -1
  93. package/dist/primitives/Button.svelte +144 -0
  94. package/dist/primitives/Button.svelte.d.ts +18 -0
  95. package/dist/primitives/icon-context.d.ts +15 -0
  96. package/dist/primitives/icon-context.js +29 -0
  97. package/dist/sh3core-shard/sh3coreShard.svelte.js +50 -0
  98. package/dist/shards/activate.svelte.js +14 -0
  99. package/dist/shards/types.d.ts +19 -0
  100. package/dist/shards/types.js +5 -4
  101. package/dist/shell-shard/locateSlot.test.d.ts +1 -0
  102. package/dist/shell-shard/locateSlot.test.js +101 -0
  103. package/dist/shell-shard/shellShard.svelte.d.ts +7 -0
  104. package/dist/shell-shard/shellShard.svelte.js +34 -1
  105. package/dist/shellRuntime.svelte.d.ts +19 -0
  106. package/dist/shellRuntime.svelte.js +30 -0
  107. package/dist/tokens.css +11 -1
  108. package/dist/verbs/types.d.ts +9 -0
  109. package/dist/version.d.ts +1 -1
  110. package/dist/version.js +1 -1
  111. package/package.json +1 -1
  112. package/dist/apps/terminal/manifest.d.ts +0 -8
  113. package/dist/apps/terminal/manifest.js +0 -14
  114. package/dist/apps/terminal/terminal-app.d.ts +0 -7
  115. package/dist/apps/terminal/terminal-app.js +0 -14
@@ -18,6 +18,7 @@
18
18
  <script lang="ts">
19
19
  import LayoutRenderer from '../layout/LayoutRenderer.svelte';
20
20
  import { floatManager } from './float';
21
+ import { registerDismissableFrame, unregisterDismissableFrame } from './floatDismiss';
21
22
  import type { FloatEntry } from '../layout/types';
22
23
 
23
24
  interface Props {
@@ -27,6 +28,14 @@
27
28
 
28
29
  let dragging = $state(false);
29
30
  let dragOffset = { x: 0, y: 0 };
31
+ let frameEl: HTMLDivElement | undefined = $state();
32
+
33
+ $effect(() => {
34
+ if (!entry.dismissable) return;
35
+ if (!frameEl) return;
36
+ registerDismissableFrame(entry.id, frameEl);
37
+ return () => unregisterDismissableFrame(entry.id);
38
+ });
30
39
 
31
40
  function onHeaderPointerDown(e: PointerEvent): void {
32
41
  if (e.button !== 0) return;
@@ -67,6 +76,7 @@
67
76
  <!-- svelte-ignore a11y_click_events_have_key_events -->
68
77
  <div
69
78
  class="sh3-float-frame"
79
+ bind:this={frameEl}
70
80
  style:left="{entry.position.x}px"
71
81
  style:top="{entry.position.y}px"
72
82
  style:width="{entry.size.w}px"
@@ -76,17 +86,19 @@
76
86
  aria-label={entry.title ?? 'Float panel'}
77
87
  tabindex="-1"
78
88
  >
79
- <!-- svelte-ignore a11y_no_static_element_interactions -->
80
- <header
81
- class="sh3-float-header"
82
- onpointerdown={onHeaderPointerDown}
83
- onpointermove={onHeaderPointerMove}
84
- onpointerup={onHeaderPointerUp}
85
- onpointercancel={onHeaderPointerUp}
86
- >
87
- <span class="sh3-float-title">{entry.title ?? entry.content.type}</span>
88
- <button class="sh3-float-close" onclick={onClose} aria-label="Close float">×</button>
89
- </header>
89
+ {#if entry.title}
90
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
91
+ <header
92
+ class="sh3-float-header"
93
+ onpointerdown={onHeaderPointerDown}
94
+ onpointermove={onHeaderPointerMove}
95
+ onpointerup={onHeaderPointerUp}
96
+ onpointercancel={onHeaderPointerUp}
97
+ >
98
+ <span class="sh3-float-title">{entry.title}</span>
99
+ <button class="sh3-float-close" onclick={onClose} aria-label="Close float">×</button>
100
+ </header>
101
+ {/if}
90
102
  <div class="sh3-float-body">
91
103
  <LayoutRenderer rootRef={{ kind: 'float', floatId: entry.id }} path={[]} />
92
104
  </div>
@@ -34,11 +34,13 @@
34
34
  contentProps,
35
35
  close,
36
36
  boxStyle,
37
+ onBackdropClick,
37
38
  }: {
38
39
  Content: Component<Record<string, unknown>>;
39
40
  contentProps: Record<string, unknown>;
40
41
  close: () => void;
41
42
  boxStyle?: string;
43
+ onBackdropClick?: () => void;
42
44
  } = $props();
43
45
 
44
46
  let box: HTMLDivElement;
@@ -47,9 +49,15 @@
47
49
  if (!box) return;
48
50
  return createFocusTrap(box);
49
51
  });
52
+
53
+ function handleFrameClick(ev: MouseEvent): void {
54
+ if (!onBackdropClick) return;
55
+ if (ev.target !== ev.currentTarget) return; // descendant click — let it through
56
+ onBackdropClick();
57
+ }
50
58
  </script>
51
59
 
52
- <div class="modal-frame" role="presentation">
60
+ <div class="modal-frame" role="presentation" onclick={handleFrameClick}>
53
61
  <div
54
62
  class="modal-box"
55
63
  role="dialog"
@@ -4,6 +4,7 @@ type $$ComponentProps = {
4
4
  contentProps: Record<string, unknown>;
5
5
  close: () => void;
6
6
  boxStyle?: string;
7
+ onBackdropClick?: () => void;
7
8
  };
8
9
  declare const ModalFrame: Component<$$ComponentProps, {}, "">;
9
10
  type ModalFrame = ReturnType<typeof ModalFrame>;
@@ -0,0 +1,6 @@
1
+ <script lang="ts">
2
+ // Minimal component for tests that need a popupManager.show() target.
3
+ // Popup infrastructure passes `close` as a prop; we accept it but don't use it here.
4
+ let { close: _close }: { close: () => void } = $props();
5
+ </script>
6
+ <div class="dummy-popup-content" style="width:10px;height:10px"></div>
@@ -0,0 +1,6 @@
1
+ type $$ComponentProps = {
2
+ close: () => void;
3
+ };
4
+ declare const DummyFrame: import("svelte").Component<$$ComponentProps, {}, "">;
5
+ type DummyFrame = ReturnType<typeof DummyFrame>;
6
+ export default DummyFrame;
@@ -9,6 +9,12 @@ export interface FloatOptions {
9
9
  size?: Size;
10
10
  /** Instance data threaded to the view factory via `MountContext.meta`. */
11
11
  meta?: Record<string, unknown>;
12
+ /**
13
+ * When true, the float dismisses on any pointerdown outside its frame,
14
+ * is not dockable, and renders without a header when `title` is unset.
15
+ * See docs/superpowers/specs/2026-04-21-dismissable-float-design.md.
16
+ */
17
+ dismissable?: boolean;
12
18
  }
13
19
  export interface FloatManager {
14
20
  open(viewId: string, options?: FloatOptions): string;
@@ -73,15 +73,28 @@ function openFloat(viewId, options = {}) {
73
73
  // docked tree. The TabsNode's tab strip appears at the top of the
74
74
  // float body; the frame header still moves the float as a whole.
75
75
  const slotId = mintFloatSlotId(viewId);
76
- const label = (_a = options.title) !== null && _a !== void 0 ? _a : viewId;
77
- const tab = { slotId, viewId, label };
78
- if (options.meta)
79
- tab.meta = options.meta;
80
- const content = {
81
- type: 'tabs',
82
- tabs: [tab],
83
- activeTab: 0,
84
- };
76
+ let content;
77
+ if (options.dismissable) {
78
+ // Picker float: render the view directly as a leaf slot. No tab strip,
79
+ // no tabs wrapper, no drag-to-dock handle. Note: options.meta cannot
80
+ // thread through a bare SlotNode — only TabEntry carries meta.
81
+ content = {
82
+ type: 'slot',
83
+ slotId,
84
+ viewId,
85
+ };
86
+ }
87
+ else {
88
+ const label = (_a = options.title) !== null && _a !== void 0 ? _a : viewId;
89
+ const tab = { slotId, viewId, label };
90
+ if (options.meta)
91
+ tab.meta = options.meta;
92
+ content = {
93
+ type: 'tabs',
94
+ tabs: [tab],
95
+ activeTab: 0,
96
+ };
97
+ }
85
98
  const computedMin = computeMinSize(content);
86
99
  const size = (_b = options.size) !== null && _b !== void 0 ? _b : maxSize(DEFAULT_SIZE, computedMin);
87
100
  const position = (_c = options.position) !== null && _c !== void 0 ? _c : cascadePosition(store, getTreeBounds());
@@ -92,6 +105,8 @@ function openFloat(viewId, options = {}) {
92
105
  size,
93
106
  title: options.title,
94
107
  };
108
+ if (options.dismissable)
109
+ entry.dismissable = true;
95
110
  store.push(entry);
96
111
  return id;
97
112
  }
@@ -54,6 +54,31 @@ describe('floatManager', () => {
54
54
  expect(tabs.tabs[0].meta).toBeUndefined();
55
55
  }
56
56
  });
57
+ it('open() persists options.dismissable onto the FloatEntry', () => {
58
+ const id = floatManager.open('test:view', { dismissable: true });
59
+ const f = floatManager.list().find((e) => e.id === id);
60
+ expect(f.dismissable).toBe(true);
61
+ });
62
+ it('open() without options.dismissable leaves FloatEntry.dismissable undefined', () => {
63
+ const id = floatManager.open('test:view');
64
+ const f = floatManager.list().find((e) => e.id === id);
65
+ expect(f.dismissable).toBeUndefined();
66
+ });
67
+ it('open() with dismissable=true places content as a raw slot (no tabs wrap)', () => {
68
+ const id = floatManager.open('test:view', { dismissable: true });
69
+ const f = floatManager.list().find((e) => e.id === id);
70
+ expect(f.content.type).toBe('slot');
71
+ if (f.content.type === 'slot') {
72
+ expect(f.content.viewId).toBe('test:view');
73
+ expect(typeof f.content.slotId).toBe('string');
74
+ expect(f.content.slotId.length).toBeGreaterThan(0);
75
+ }
76
+ });
77
+ it('open() without dismissable still wraps content in a TabsNode', () => {
78
+ const id = floatManager.open('test:view');
79
+ const f = floatManager.list().find((e) => e.id === id);
80
+ expect(f.content.type).toBe('tabs');
81
+ });
57
82
  });
58
83
  // ---------------------------------------------------------------------------
59
84
  // DOM tests — floatManager + FloatLayer.svelte in happy-dom
@@ -62,6 +87,7 @@ import { renderWithShell } from '../__test__/render';
62
87
  import FloatLayer from './FloatLayer.svelte';
63
88
  import { tick } from 'svelte';
64
89
  import { resetFramework } from '../__test__/reset';
90
+ import { __isDismissListenerAttachedForTest } from './floatDismiss';
65
91
  /**
66
92
  * Wire the floatManager to the same FloatEntry[] that FloatLayer reads
67
93
  * (layoutStore.floats → HOME_TREE.floats). Without this binding, the
@@ -136,3 +162,152 @@ describe('floats — F.3 close button removes float', () => {
136
162
  expect(container.querySelector('[role="dialog"][aria-label="Closeable"]')).toBeNull();
137
163
  });
138
164
  });
165
+ // ---------------------------------------------------------------------------
166
+ // F.4 — header conditional on title
167
+ // ---------------------------------------------------------------------------
168
+ describe('floats — F.4 header conditional on title', () => {
169
+ beforeEach(() => {
170
+ resetFramework();
171
+ bindManagerToStore();
172
+ });
173
+ it('renders no header when title is not provided', async () => {
174
+ const { container } = renderWithShell(FloatLayer, {});
175
+ floatManager.open('test:view', { dismissable: true });
176
+ await tick();
177
+ const frame = container.querySelector('[role="dialog"]');
178
+ expect(frame).toBeTruthy();
179
+ expect(frame.querySelector('.sh3-float-header')).toBeNull();
180
+ expect(frame.querySelector('.sh3-float-close')).toBeNull();
181
+ });
182
+ it('renders the header with close button when title is provided', async () => {
183
+ const { container } = renderWithShell(FloatLayer, {});
184
+ floatManager.open('test:view', { dismissable: true, title: 'Picker' });
185
+ await tick();
186
+ const frame = container.querySelector('[role="dialog"][aria-label="Picker"]');
187
+ expect(frame).toBeTruthy();
188
+ expect(frame.querySelector('.sh3-float-header')).not.toBeNull();
189
+ expect(frame.querySelector('button[aria-label="Close float"]')).not.toBeNull();
190
+ });
191
+ });
192
+ // ---------------------------------------------------------------------------
193
+ // F.5a — dismiss-listener lifecycle
194
+ // ---------------------------------------------------------------------------
195
+ describe('floats — F.5a dismiss-listener lifecycle', () => {
196
+ beforeEach(() => {
197
+ resetFramework();
198
+ bindManagerToStore();
199
+ });
200
+ it('attaches the document listener when a dismissable frame mounts', async () => {
201
+ renderWithShell(FloatLayer, {});
202
+ expect(__isDismissListenerAttachedForTest()).toBe(false);
203
+ floatManager.open('test:view', { dismissable: true });
204
+ await tick();
205
+ expect(__isDismissListenerAttachedForTest()).toBe(true);
206
+ });
207
+ it('detaches the document listener when the last dismissable frame unmounts', async () => {
208
+ renderWithShell(FloatLayer, {});
209
+ const id = floatManager.open('test:view', { dismissable: true });
210
+ await tick();
211
+ expect(__isDismissListenerAttachedForTest()).toBe(true);
212
+ floatManager.close(id);
213
+ await tick();
214
+ expect(__isDismissListenerAttachedForTest()).toBe(false);
215
+ });
216
+ it('does not attach the listener for non-dismissable floats', async () => {
217
+ renderWithShell(FloatLayer, {});
218
+ floatManager.open('test:view', { title: 'Regular' });
219
+ await tick();
220
+ expect(__isDismissListenerAttachedForTest()).toBe(false);
221
+ });
222
+ });
223
+ // ---------------------------------------------------------------------------
224
+ // F.5b — outside-pointerdown dismisses picker
225
+ // ---------------------------------------------------------------------------
226
+ describe('floats — F.5b outside-pointerdown dismisses picker', () => {
227
+ beforeEach(() => {
228
+ resetFramework();
229
+ bindManagerToStore();
230
+ });
231
+ function pointerDown(el) {
232
+ el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
233
+ }
234
+ it('closes a dismissable float when pointerdown fires outside its frame', async () => {
235
+ const { container } = renderWithShell(FloatLayer, {});
236
+ const id = floatManager.open('test:view', { dismissable: true, title: 'Picker' });
237
+ await tick();
238
+ expect(floatManager.list().some((f) => f.id === id)).toBe(true);
239
+ pointerDown(document.body);
240
+ await tick();
241
+ expect(floatManager.list().some((f) => f.id === id)).toBe(false);
242
+ expect(container.querySelector('[role="dialog"][aria-label="Picker"]')).toBeNull();
243
+ });
244
+ it('does not close a dismissable float when pointerdown fires inside its frame', async () => {
245
+ const { container } = renderWithShell(FloatLayer, {});
246
+ const id = floatManager.open('test:view', { dismissable: true, title: 'Picker' });
247
+ await tick();
248
+ const frame = container.querySelector('[role="dialog"][aria-label="Picker"]');
249
+ expect(frame).toBeTruthy();
250
+ pointerDown(frame);
251
+ await tick();
252
+ expect(floatManager.list().some((f) => f.id === id)).toBe(true);
253
+ });
254
+ it('does not close a non-dismissable float on outside pointerdown', async () => {
255
+ renderWithShell(FloatLayer, {});
256
+ const id = floatManager.open('test:view', { title: 'Regular' });
257
+ await tick();
258
+ pointerDown(document.body);
259
+ await tick();
260
+ expect(floatManager.list().some((f) => f.id === id)).toBe(true);
261
+ });
262
+ });
263
+ // ---------------------------------------------------------------------------
264
+ // F.6 — multi-picker interaction
265
+ // ---------------------------------------------------------------------------
266
+ describe('floats — F.6 multi-picker interaction', () => {
267
+ beforeEach(() => {
268
+ resetFramework();
269
+ bindManagerToStore();
270
+ });
271
+ function pointerDown(el) {
272
+ el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
273
+ }
274
+ it('clicking inside picker A closes picker B but keeps A', async () => {
275
+ const { container } = renderWithShell(FloatLayer, {});
276
+ const a = floatManager.open('test:view', { dismissable: true, title: 'A' });
277
+ const b = floatManager.open('test:view', { dismissable: true, title: 'B' });
278
+ await tick();
279
+ const frameA = container.querySelector('[role="dialog"][aria-label="A"]');
280
+ expect(frameA).toBeTruthy();
281
+ pointerDown(frameA);
282
+ await tick();
283
+ const ids = floatManager.list().map((f) => f.id);
284
+ expect(ids).toContain(a);
285
+ expect(ids).not.toContain(b);
286
+ });
287
+ it('clicking a persistent float closes every open picker', async () => {
288
+ const { container } = renderWithShell(FloatLayer, {});
289
+ const reg = floatManager.open('test:view', { title: 'Regular' });
290
+ const picker1 = floatManager.open('test:view', { dismissable: true, title: 'P1' });
291
+ const picker2 = floatManager.open('test:view', { dismissable: true, title: 'P2' });
292
+ await tick();
293
+ const frameReg = container.querySelector('[role="dialog"][aria-label="Regular"]');
294
+ expect(frameReg).toBeTruthy();
295
+ pointerDown(frameReg);
296
+ await tick();
297
+ const ids = floatManager.list().map((f) => f.id);
298
+ expect(ids).toContain(reg);
299
+ expect(ids).not.toContain(picker1);
300
+ expect(ids).not.toContain(picker2);
301
+ });
302
+ it('closing a picker via its × button fires exactly one close (no double-close from listener)', async () => {
303
+ const { container } = renderWithShell(FloatLayer, {});
304
+ const id = floatManager.open('test:view', { dismissable: true, title: 'Picker' });
305
+ await tick();
306
+ const closeBtn = container.querySelector('[role="dialog"][aria-label="Picker"] button[aria-label="Close float"]');
307
+ expect(closeBtn).toBeTruthy();
308
+ pointerDown(closeBtn);
309
+ closeBtn.click();
310
+ await tick();
311
+ expect(floatManager.list().some((f) => f.id === id)).toBe(false);
312
+ });
313
+ });
@@ -0,0 +1,8 @@
1
+ /** Register a dismissable float's root element. Idempotent per id. */
2
+ export declare function registerDismissableFrame(id: string, el: HTMLElement): void;
3
+ /** Unregister on frame destroy. Idempotent; safe to call for unknown ids. */
4
+ export declare function unregisterDismissableFrame(id: string): void;
5
+ /** Test-only inspector: is the singleton listener currently attached? */
6
+ export declare function __isDismissListenerAttachedForTest(): boolean;
7
+ /** Test-only reset. Clears the registry and detaches the listener. */
8
+ export declare function __resetDismissRegistryForTest(): void;
@@ -0,0 +1,68 @@
1
+ /*
2
+ * Dismiss-listener registry for dismissable ("picker") floats.
3
+ *
4
+ * Each `FloatFrame` whose entry is dismissable registers its root DOM
5
+ * element here on mount and unregisters on destroy. A singleton
6
+ * `pointerdown` listener is installed on `document` as soon as the
7
+ * registry becomes non-empty and removed when it empties.
8
+ *
9
+ * On pointerdown, every registered entry whose element does not contain
10
+ * the event target is closed via floatManager.close. The listener uses
11
+ * capture=true so it runs before any in-view click handlers; the picker's
12
+ * own action handlers fire on `click`/`pointerup` and are unaffected.
13
+ */
14
+ import { floatManager } from './float';
15
+ const registry = new Map();
16
+ let listenerAttached = false;
17
+ function onDocumentPointerDown(event) {
18
+ if (registry.size === 0)
19
+ return;
20
+ const target = event.target;
21
+ if (!target)
22
+ return;
23
+ // Snapshot ids — we mutate the registry (via close → unregister) while iterating.
24
+ const ids = Array.from(registry.keys());
25
+ for (const id of ids) {
26
+ const el = registry.get(id);
27
+ if (!el)
28
+ continue;
29
+ if (!el.contains(target)) {
30
+ floatManager.close(id);
31
+ }
32
+ }
33
+ }
34
+ function ensureListener() {
35
+ if (listenerAttached)
36
+ return;
37
+ if (typeof document === 'undefined')
38
+ return;
39
+ document.addEventListener('pointerdown', onDocumentPointerDown, true);
40
+ listenerAttached = true;
41
+ }
42
+ function teardownListenerIfEmpty() {
43
+ if (!listenerAttached)
44
+ return;
45
+ if (registry.size > 0)
46
+ return;
47
+ document.removeEventListener('pointerdown', onDocumentPointerDown, true);
48
+ listenerAttached = false;
49
+ }
50
+ /** Register a dismissable float's root element. Idempotent per id. */
51
+ export function registerDismissableFrame(id, el) {
52
+ registry.set(id, el);
53
+ ensureListener();
54
+ }
55
+ /** Unregister on frame destroy. Idempotent; safe to call for unknown ids. */
56
+ export function unregisterDismissableFrame(id) {
57
+ registry.delete(id);
58
+ teardownListenerIfEmpty();
59
+ }
60
+ /** Test-only inspector: is the singleton listener currently attached? */
61
+ export function __isDismissListenerAttachedForTest() {
62
+ return listenerAttached;
63
+ }
64
+ /** Test-only reset. Clears the registry and detaches the listener. */
65
+ export function __resetDismissRegistryForTest() {
66
+ registry.clear();
67
+ teardownListenerIfEmpty();
68
+ }
@@ -8,7 +8,10 @@
8
8
  * Semantics (from docs/design/layout.md):
9
9
  * - Modals stack. Opening a second modal pushes it on top of the first.
10
10
  * - Escape pops the topmost modal.
11
- * - Backdrop click does NOT dismiss. Content owns its own close UI.
11
+ * - Backdrop click does NOT dismiss by default. Per-modal opt-in via
12
+ * `ModalOptions.dismissOnBackdrop` (used by the command palette and
13
+ * other picker-style modals where outside-click is the expected
14
+ * dismissal gesture). See ADR-005 for the per-layer policy.
12
15
  * - Each modal has its own focus trap (installed by ModalFrame).
13
16
  *
14
17
  * Implementation notes:
@@ -120,6 +123,7 @@ function openModal(Content, props, options) {
120
123
  contentProps: (props !== null && props !== void 0 ? props : {}),
121
124
  close: handle.close,
122
125
  boxStyle: options === null || options === void 0 ? void 0 : options.boxStyle,
126
+ onBackdropClick: (options === null || options === void 0 ? void 0 : options.dismissOnBackdrop) ? handle.close : undefined,
123
127
  },
124
128
  });
125
129
  entry.host = host;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { tick } from 'svelte';
3
+ import { modalManager } from './modal';
4
+ import { registerLayerRoot, unregisterLayerRoot } from './roots';
5
+ import DummyFrame from './__test__/DummyFrame.svelte';
6
+ function makeLayerRoot() {
7
+ const el = document.createElement('div');
8
+ el.style.position = 'relative';
9
+ document.body.appendChild(el);
10
+ registerLayerRoot('modal', el);
11
+ return el;
12
+ }
13
+ function teardownLayerRoot(el) {
14
+ unregisterLayerRoot('modal');
15
+ el.remove();
16
+ }
17
+ describe('modal — backdrop dismiss policy', () => {
18
+ let layerRoot;
19
+ beforeEach(() => {
20
+ layerRoot = makeLayerRoot();
21
+ });
22
+ afterEach(() => {
23
+ modalManager.closeAll();
24
+ teardownLayerRoot(layerRoot);
25
+ });
26
+ it('default modal: clicking the frame area outside the box does NOT dismiss', async () => {
27
+ modalManager.open(DummyFrame, {});
28
+ await tick();
29
+ const frame = layerRoot.querySelector('.modal-frame');
30
+ expect(frame).not.toBeNull();
31
+ // Click the frame itself (outside the box). target === currentTarget
32
+ // is what the handler keys off.
33
+ frame.dispatchEvent(new MouseEvent('click', { bubbles: true }));
34
+ await tick();
35
+ expect(layerRoot.querySelector('.sh3-modal-host')).not.toBeNull();
36
+ });
37
+ it('opt-in modal: clicking the frame area outside the box closes it', async () => {
38
+ modalManager.open(DummyFrame, {}, { dismissOnBackdrop: true });
39
+ await tick();
40
+ const frame = layerRoot.querySelector('.modal-frame');
41
+ expect(frame).not.toBeNull();
42
+ frame.dispatchEvent(new MouseEvent('click', { bubbles: true }));
43
+ await tick();
44
+ expect(layerRoot.querySelector('.sh3-modal-host')).toBeNull();
45
+ });
46
+ it('opt-in modal: clicks on the dialog box itself do NOT dismiss', async () => {
47
+ modalManager.open(DummyFrame, {}, { dismissOnBackdrop: true });
48
+ await tick();
49
+ const box = layerRoot.querySelector('.modal-box');
50
+ expect(box).not.toBeNull();
51
+ box.dispatchEvent(new MouseEvent('click', { bubbles: true }));
52
+ await tick();
53
+ expect(layerRoot.querySelector('.sh3-modal-host')).not.toBeNull();
54
+ });
55
+ });
@@ -7,3 +7,5 @@ export interface PopupManager {
7
7
  close(): void;
8
8
  }
9
9
  export declare const popupManager: PopupManager;
10
+ /** @internal — test helper only. Closes any active popup and resets state. */
11
+ export declare function __resetPopupManagerForTest(): void;
@@ -9,8 +9,8 @@
9
9
  * - Popups do NOT stack. Opening a second popup dismisses the first.
10
10
  * - Clicking outside the popup dismisses it.
11
11
  * - Pressing Escape dismisses it.
12
- * - The caller provides an HTMLElement anchor; the popup positions
13
- * itself relative to the anchor's current viewport rect.
12
+ * - The caller provides a PopupAnchor (HTMLElement or {x,y} point);
13
+ * the popup positions itself relative to the anchor's viewport rect.
14
14
  *
15
15
  * Implementation notes:
16
16
  * - The manager keeps at most one active entry. show() closes any
@@ -28,6 +28,17 @@
28
28
  import { mount, unmount } from 'svelte';
29
29
  import PopupFrame from './PopupFrame.svelte';
30
30
  import { getLayerRoot } from './roots';
31
+ /**
32
+ * Convert a PopupAnchor to a DOMRect.
33
+ * - HTMLElement: uses its live bounding rect.
34
+ * - { x, y } virtual point: zero-size rect at the viewport coordinates so
35
+ * PopupFrame places itself at bottom-start of the cursor position.
36
+ */
37
+ function anchorRect(anchor) {
38
+ if (anchor instanceof HTMLElement)
39
+ return anchor.getBoundingClientRect();
40
+ return new DOMRect(anchor.x, anchor.y, 0, 0);
41
+ }
31
42
  let current = null;
32
43
  function onDocumentPointerDown(e) {
33
44
  if (!current)
@@ -75,7 +86,7 @@ function showPopup(Content, options, props) {
75
86
  host.style.inset = '0';
76
87
  host.style.pointerEvents = 'none'; // only the frame captures pointer events
77
88
  root.appendChild(host);
78
- const anchorRect = options.anchor.getBoundingClientRect();
89
+ const rect = anchorRect(options.anchor);
79
90
  const entry = {};
80
91
  const handle = {
81
92
  close: () => removeEntry(entry),
@@ -85,7 +96,7 @@ function showPopup(Content, options, props) {
85
96
  props: {
86
97
  Content: Content,
87
98
  contentProps: (props !== null && props !== void 0 ? props : {}),
88
- anchorRect,
99
+ anchorRect: rect,
89
100
  close: handle.close,
90
101
  },
91
102
  });
@@ -106,3 +117,12 @@ export const popupManager = {
106
117
  show: showPopup,
107
118
  close: closeCurrent,
108
119
  };
120
+ /** @internal — test helper only. Closes any active popup and resets state. */
121
+ export function __resetPopupManagerForTest() {
122
+ if (current) {
123
+ removeDismissListeners();
124
+ unmount(current.frame);
125
+ current.host.remove();
126
+ current = null;
127
+ }
128
+ }
@@ -0,0 +1 @@
1
+ export {};