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.
- package/dist/Shell.svelte +12 -31
- package/dist/__test__/reset.js +6 -0
- package/dist/actions/CommandPalette.svelte +68 -0
- package/dist/actions/CommandPalette.svelte.d.ts +11 -0
- package/dist/actions/ContextMenu.svelte +97 -0
- package/dist/actions/ContextMenu.svelte.d.ts +9 -0
- package/dist/actions/bindings-store.d.ts +8 -0
- package/dist/actions/bindings-store.js +27 -0
- package/dist/actions/bindings-store.test.d.ts +1 -0
- package/dist/actions/bindings-store.test.js +25 -0
- package/dist/actions/bindings.d.ts +4 -0
- package/dist/actions/bindings.js +17 -0
- package/dist/actions/bindings.test.d.ts +1 -0
- package/dist/actions/bindings.test.js +30 -0
- package/dist/actions/contextMenuModel.d.ts +16 -0
- package/dist/actions/contextMenuModel.js +71 -0
- package/dist/actions/contextMenuModel.test.d.ts +1 -0
- package/dist/actions/contextMenuModel.test.js +44 -0
- package/dist/actions/dispatcher.svelte.d.ts +34 -0
- package/dist/actions/dispatcher.svelte.js +117 -0
- package/dist/actions/dispatcher.test.d.ts +1 -0
- package/dist/actions/dispatcher.test.js +155 -0
- package/dist/actions/listeners.d.ts +11 -0
- package/dist/actions/listeners.js +180 -0
- package/dist/actions/listeners.test.d.ts +1 -0
- package/dist/actions/listeners.test.js +149 -0
- package/dist/actions/palette-scorer.d.ts +11 -0
- package/dist/actions/palette-scorer.js +49 -0
- package/dist/actions/palette-scorer.test.d.ts +1 -0
- package/dist/actions/palette-scorer.test.js +40 -0
- package/dist/actions/paletteModel.d.ts +4 -0
- package/dist/actions/paletteModel.js +40 -0
- package/dist/actions/paletteModel.test.d.ts +1 -0
- package/dist/actions/paletteModel.test.js +33 -0
- package/dist/actions/registry.d.ts +10 -0
- package/dist/actions/registry.js +36 -0
- package/dist/actions/registry.test.d.ts +1 -0
- package/dist/actions/registry.test.js +49 -0
- package/dist/actions/selection.svelte.d.ts +8 -0
- package/dist/actions/selection.svelte.js +44 -0
- package/dist/actions/selection.test.d.ts +1 -0
- package/dist/actions/selection.test.js +51 -0
- package/dist/actions/shardContext.test.d.ts +1 -0
- package/dist/actions/shardContext.test.js +41 -0
- package/dist/actions/shellActions.test.d.ts +1 -0
- package/dist/actions/shellActions.test.js +22 -0
- package/dist/actions/shortcuts.d.ts +5 -0
- package/dist/actions/shortcuts.js +87 -0
- package/dist/actions/shortcuts.test.d.ts +1 -0
- package/dist/actions/shortcuts.test.js +49 -0
- package/dist/actions/state.svelte.d.ts +16 -0
- package/dist/actions/state.svelte.js +76 -0
- package/dist/actions/state.test.d.ts +1 -0
- package/dist/actions/state.test.js +40 -0
- package/dist/actions/syncMountedViewIds.test.d.ts +1 -0
- package/dist/actions/syncMountedViewIds.test.js +97 -0
- package/dist/actions/types.d.ts +56 -0
- package/dist/actions/types.js +7 -0
- package/dist/api.d.ts +2 -2
- package/dist/api.js +1 -1
- package/dist/apps/lifecycle.js +13 -3
- package/dist/createShell.js +4 -1
- package/dist/host.js +6 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/layout/inspection.d.ts +11 -1
- package/dist/layout/inspection.js +13 -1
- package/dist/layout/ops-locate.test.d.ts +1 -0
- package/dist/layout/ops-locate.test.js +103 -0
- package/dist/layout/ops.d.ts +8 -0
- package/dist/layout/ops.js +27 -0
- package/dist/layout/slotHostPool.svelte.js +24 -0
- package/dist/layout/slotHostPool.test.js +14 -0
- package/dist/layout/types.d.ts +7 -0
- package/dist/overlays/FloatFrame.svelte +23 -11
- package/dist/overlays/ModalFrame.svelte +9 -1
- package/dist/overlays/ModalFrame.svelte.d.ts +1 -0
- package/dist/overlays/__test__/DummyFrame.svelte +6 -0
- package/dist/overlays/__test__/DummyFrame.svelte.d.ts +6 -0
- package/dist/overlays/float.d.ts +6 -0
- package/dist/overlays/float.js +24 -9
- package/dist/overlays/float.test.js +175 -0
- package/dist/overlays/floatDismiss.d.ts +8 -0
- package/dist/overlays/floatDismiss.js +68 -0
- package/dist/overlays/modal.js +5 -1
- package/dist/overlays/modal.test.d.ts +1 -0
- package/dist/overlays/modal.test.js +55 -0
- package/dist/overlays/popup.d.ts +2 -0
- package/dist/overlays/popup.js +24 -4
- package/dist/overlays/popup.test.d.ts +1 -0
- package/dist/overlays/popup.test.js +95 -0
- package/dist/overlays/types.d.ts +17 -1
- package/dist/primitives/Button.svelte +144 -0
- package/dist/primitives/Button.svelte.d.ts +18 -0
- package/dist/primitives/icon-context.d.ts +15 -0
- package/dist/primitives/icon-context.js +29 -0
- package/dist/sh3core-shard/sh3coreShard.svelte.js +50 -0
- package/dist/shards/activate.svelte.js +14 -0
- package/dist/shards/types.d.ts +19 -0
- package/dist/shards/types.js +5 -4
- package/dist/shell-shard/locateSlot.test.d.ts +1 -0
- package/dist/shell-shard/locateSlot.test.js +101 -0
- package/dist/shell-shard/shellShard.svelte.d.ts +7 -0
- package/dist/shell-shard/shellShard.svelte.js +34 -1
- package/dist/shellRuntime.svelte.d.ts +19 -0
- package/dist/shellRuntime.svelte.js +30 -0
- package/dist/tokens.css +11 -1
- package/dist/verbs/types.d.ts +9 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/apps/terminal/manifest.d.ts +0 -8
- package/dist/apps/terminal/manifest.js +0 -14
- package/dist/apps/terminal/terminal-app.d.ts +0 -7
- 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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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"
|
|
@@ -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>
|
package/dist/overlays/float.d.ts
CHANGED
|
@@ -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;
|
package/dist/overlays/float.js
CHANGED
|
@@ -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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
+
}
|
package/dist/overlays/modal.js
CHANGED
|
@@ -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
|
|
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
|
+
});
|
package/dist/overlays/popup.d.ts
CHANGED
package/dist/overlays/popup.js
CHANGED
|
@@ -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
|
|
13
|
-
* itself relative to the anchor's
|
|
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
|
|
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 {};
|