sh3-core 0.10.4 → 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__/fixtures.js +1 -0
- 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/LayoutRenderer.browser.test.js +78 -0
- package/dist/layout/LayoutRenderer.svelte +1 -0
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-6-fixed-slots-freezes-the-handle-adjacent-to-a-fixed-pane--dblclick-does-not-collapse-1.png +0 -0
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-6-fixed-slots-hides-the-collapse-widget-on-a-fixed-pane-1.png +0 -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 +15 -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/ResizableSplitter.svelte +38 -3
- package/dist/primitives/ResizableSplitter.svelte.d.ts +7 -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
package/dist/layout/ops.js
CHANGED
|
@@ -93,6 +93,33 @@ export function findTabInTree(tree, slotId) {
|
|
|
93
93
|
}
|
|
94
94
|
return null;
|
|
95
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Locate the root a slot currently lives under in the given tree. Handles
|
|
98
|
+
* both tab entries and bare slot leaves, docked first then each float's
|
|
99
|
+
* content. Returns null when the slot is not present anywhere. Pure —
|
|
100
|
+
* takes the tree as input so callers with an in-hand tree (and tests)
|
|
101
|
+
* don't need the layout store.
|
|
102
|
+
*/
|
|
103
|
+
export function locateSlotIn(tree, slotId) {
|
|
104
|
+
if (containsSlot(tree.docked, slotId))
|
|
105
|
+
return { kind: 'docked' };
|
|
106
|
+
for (const f of tree.floats) {
|
|
107
|
+
if (containsSlot(f.content, slotId))
|
|
108
|
+
return { kind: 'float', floatId: f.id };
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
function containsSlot(node, slotId) {
|
|
113
|
+
if (node.type === 'slot')
|
|
114
|
+
return node.slotId === slotId;
|
|
115
|
+
if (node.type === 'tabs')
|
|
116
|
+
return node.tabs.some((t) => t.slotId === slotId);
|
|
117
|
+
for (const child of node.children) {
|
|
118
|
+
if (containsSlot(child, slotId))
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
96
123
|
// ---------- Tab removal ----------------------------------------------------
|
|
97
124
|
/**
|
|
98
125
|
* Remove a tab from its current location, returning the removed entry
|
|
@@ -33,6 +33,8 @@
|
|
|
33
33
|
* phase 6 has no view that needs it.
|
|
34
34
|
*/
|
|
35
35
|
import { getView, __addViewRegistrationListener } from '../shards/registry';
|
|
36
|
+
import { locateSlotIn } from './ops';
|
|
37
|
+
import { activeLayout } from './store.svelte';
|
|
36
38
|
const pool = new Map();
|
|
37
39
|
const pendingDestroy = new Set();
|
|
38
40
|
/**
|
|
@@ -59,6 +61,9 @@ function onViewRegistered(viewId, factory) {
|
|
|
59
61
|
setDirty(dirty) {
|
|
60
62
|
dirtyState[slotId] = dirty;
|
|
61
63
|
},
|
|
64
|
+
location() {
|
|
65
|
+
return locateSlotIn(activeLayout(), slotId);
|
|
66
|
+
},
|
|
62
67
|
};
|
|
63
68
|
queueMicrotask(() => {
|
|
64
69
|
var _a, _b;
|
|
@@ -129,6 +134,8 @@ function createHost(slotId, viewId, label, meta) {
|
|
|
129
134
|
const host = document.createElement('div');
|
|
130
135
|
host.className = 'slot-host';
|
|
131
136
|
host.dataset.slotId = slotId;
|
|
137
|
+
if (viewId)
|
|
138
|
+
host.setAttribute('data-sh3-view', viewId);
|
|
132
139
|
// Position:absolute inset:0 so the host fills whichever wrapper it is
|
|
133
140
|
// attached to. The wrapper is what the layout engine sizes; the host
|
|
134
141
|
// just tracks it. Styles are set inline (not in a class) so consumers
|
|
@@ -163,6 +170,9 @@ function createHost(slotId, viewId, label, meta) {
|
|
|
163
170
|
setDirty(dirty) {
|
|
164
171
|
dirtyState[slotId] = dirty;
|
|
165
172
|
},
|
|
173
|
+
location() {
|
|
174
|
+
return locateSlotIn(activeLayout(), slotId);
|
|
175
|
+
},
|
|
166
176
|
};
|
|
167
177
|
entry.handle = factory === null || factory === void 0 ? void 0 : factory.mount(host, ctx);
|
|
168
178
|
if (((_a = entry.handle) === null || _a === void 0 ? void 0 : _a.closable) || slotId.startsWith('float:')) {
|
|
@@ -201,6 +211,20 @@ export function acquireSlotHost(slotId, viewId, label, meta) {
|
|
|
201
211
|
entry = createHost(slotId, viewId, label, meta);
|
|
202
212
|
pool.set(slotId, entry);
|
|
203
213
|
}
|
|
214
|
+
else if (entry.viewId !== viewId) {
|
|
215
|
+
// viewId on an existing slot should be stable — the pool does not support
|
|
216
|
+
// swapping views for the same slotId. Keep the focus-tracking attribute
|
|
217
|
+
// in sync with whatever we have and warn; the underlying view handle will
|
|
218
|
+
// not be reconstructed.
|
|
219
|
+
console.warn(`[sh3] acquireSlotHost("${slotId}") called with viewId "${viewId}" ` +
|
|
220
|
+
`but existing pooled entry has viewId "${entry.viewId}". Attribute synced; ` +
|
|
221
|
+
`view handle unchanged.`);
|
|
222
|
+
entry.viewId = viewId;
|
|
223
|
+
if (viewId)
|
|
224
|
+
entry.host.setAttribute('data-sh3-view', viewId);
|
|
225
|
+
else
|
|
226
|
+
entry.host.removeAttribute('data-sh3-view');
|
|
227
|
+
}
|
|
204
228
|
entry.refcount++;
|
|
205
229
|
return entry.host;
|
|
206
230
|
}
|
|
@@ -102,3 +102,17 @@ describe('slotHostPool — D.5 root swap preserves app slots', () => {
|
|
|
102
102
|
expect(teardown).not.toHaveBeenCalled();
|
|
103
103
|
});
|
|
104
104
|
});
|
|
105
|
+
// ─── D.6 ─────────────────────────────────────────────────────────────────────
|
|
106
|
+
describe('slotHostPool — D.6 data-sh3-view attribute', () => {
|
|
107
|
+
beforeEach(resetFramework);
|
|
108
|
+
it('pooled host has data-sh3-view when viewId is set', () => {
|
|
109
|
+
const host = acquireSlotHost('slot-1', 'editor', 'Editor');
|
|
110
|
+
expect(host.getAttribute('data-sh3-view')).toBe('editor');
|
|
111
|
+
releaseSlotHost('slot-1');
|
|
112
|
+
});
|
|
113
|
+
it('pooled host has no data-sh3-view when viewId is null', () => {
|
|
114
|
+
const host = acquireSlotHost('slot-2', null, 'Empty');
|
|
115
|
+
expect(host.hasAttribute('data-sh3-view')).toBe(false);
|
|
116
|
+
releaseSlotHost('slot-2');
|
|
117
|
+
});
|
|
118
|
+
});
|
package/dist/layout/types.d.ts
CHANGED
|
@@ -21,6 +21,14 @@ export interface SplitNode {
|
|
|
21
21
|
pinned?: SizeMode[];
|
|
22
22
|
/** Per-child collapsed state. Omitted means all expanded. */
|
|
23
23
|
collapsed?: boolean[];
|
|
24
|
+
/**
|
|
25
|
+
* Per-child fixed flag. A fixed child has no collapse widget and the
|
|
26
|
+
* resize handles on either side of it are frozen (non-interactive,
|
|
27
|
+
* rendered thinner). A non-fixed child whose every neighbor is fixed
|
|
28
|
+
* also loses its collapse widget — there's nowhere for the freed
|
|
29
|
+
* space to be used.
|
|
30
|
+
*/
|
|
31
|
+
fixed?: boolean[];
|
|
24
32
|
/** Ordered child nodes. Length must equal `sizes` length. */
|
|
25
33
|
children: LayoutNode[];
|
|
26
34
|
}
|
|
@@ -107,6 +115,13 @@ export interface FloatEntry {
|
|
|
107
115
|
};
|
|
108
116
|
/** Optional human-readable title; defaults to the active view's label. */
|
|
109
117
|
title?: string;
|
|
118
|
+
/**
|
|
119
|
+
* When true, this float dismisses on any pointerdown outside its frame,
|
|
120
|
+
* renders its content as a raw slot (no tab-strip handle, not dockable),
|
|
121
|
+
* and hides chrome when `title` is unset. See
|
|
122
|
+
* docs/superpowers/specs/2026-04-21-dismissable-float-design.md.
|
|
123
|
+
*/
|
|
124
|
+
dismissable?: boolean;
|
|
110
125
|
}
|
|
111
126
|
/**
|
|
112
127
|
* Root shape of a workspace layout. The docked tree is the primary
|
|
@@ -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
|
+
});
|