sh3-core 0.19.3 → 0.19.5
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/api.d.ts +4 -0
- package/dist/api.js +3 -0
- package/dist/chrome/CompactChrome.svelte +34 -1
- package/dist/chrome/CompactChrome.svelte.test.js +4 -2
- package/dist/chrome/FloatsSheet.svelte +236 -0
- package/dist/chrome/FloatsSheet.svelte.d.ts +7 -0
- package/dist/chrome/FloatsSheet.svelte.test.d.ts +1 -0
- package/dist/chrome/FloatsSheet.svelte.test.js +155 -0
- package/dist/layout/compact/CompactRenderer.svelte +8 -2
- package/dist/layout/compact/rootStore.svelte.d.ts +20 -0
- package/dist/layout/compact/rootStore.svelte.js +59 -0
- package/dist/layout/compact/rootStore.svelte.test.d.ts +1 -0
- package/dist/layout/compact/rootStore.svelte.test.js +54 -0
- package/dist/layout/floats.d.ts +27 -0
- package/dist/layout/floats.js +20 -0
- package/dist/layout/floats.test.js +34 -1
- package/dist/layout/inspection.js +25 -2
- package/dist/layout/inspection.svelte.test.js +49 -0
- package/dist/overlays/FloatLayer.svelte +12 -1
- package/dist/overlays/float.d.ts +7 -0
- package/dist/overlays/float.js +76 -6
- package/dist/overlays/float.test.js +170 -0
- package/dist/primitives/widgets/DocumentFilePicker.d.ts +25 -0
- package/dist/primitives/widgets/DocumentFilePicker.js +74 -0
- package/dist/primitives/widgets/DocumentFilePicker.svelte +144 -0
- package/dist/primitives/widgets/DocumentFilePicker.svelte.d.ts +18 -0
- package/dist/primitives/widgets/DocumentOpener.svelte +36 -0
- package/dist/primitives/widgets/DocumentOpener.svelte.d.ts +17 -0
- package/dist/primitives/widgets/DocumentSaver.svelte +36 -0
- package/dist/primitives/widgets/DocumentSaver.svelte.d.ts +17 -0
- package/dist/primitives/widgets/_DocumentBrowser.svelte +337 -0
- package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +11 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/api.d.ts
CHANGED
|
@@ -87,5 +87,9 @@ export { default as Select } from './primitives/widgets/Select.svelte';
|
|
|
87
87
|
export type { SelectOption } from './primitives/widgets/Select';
|
|
88
88
|
export { default as AppPicker } from './primitives/widgets/AppPicker.svelte';
|
|
89
89
|
export { default as UserPicker } from './primitives/widgets/UserPicker.svelte';
|
|
90
|
+
export { default as DocumentFilePicker } from './primitives/widgets/DocumentFilePicker.svelte';
|
|
91
|
+
export { default as DocumentOpener } from './primitives/widgets/DocumentOpener.svelte';
|
|
92
|
+
export { default as DocumentSaver } from './primitives/widgets/DocumentSaver.svelte';
|
|
93
|
+
export type { OpenerValue, SaverValue } from './primitives/widgets/DocumentFilePicker';
|
|
90
94
|
export type { FieldKind, FieldAddress, FieldView, ControllableFieldDescriptor, ImperativeFieldDescriptor, ElementRefFieldDescriptor, ReadonlyFieldDescriptor, FieldsApi, DecorationHandle, } from './fields/types';
|
|
91
95
|
export { fieldAddressToString, fieldAddressFromString } from './fields/address';
|
package/dist/api.js
CHANGED
|
@@ -97,4 +97,7 @@ export { default as FilePicker } from './primitives/widgets/FilePicker.svelte';
|
|
|
97
97
|
export { default as Select } from './primitives/widgets/Select.svelte';
|
|
98
98
|
export { default as AppPicker } from './primitives/widgets/AppPicker.svelte';
|
|
99
99
|
export { default as UserPicker } from './primitives/widgets/UserPicker.svelte';
|
|
100
|
+
export { default as DocumentFilePicker } from './primitives/widgets/DocumentFilePicker.svelte';
|
|
101
|
+
export { default as DocumentOpener } from './primitives/widgets/DocumentOpener.svelte';
|
|
102
|
+
export { default as DocumentSaver } from './primitives/widgets/DocumentSaver.svelte';
|
|
100
103
|
export { fieldAddressToString, fieldAddressFromString } from './fields/address';
|
|
@@ -12,14 +12,23 @@
|
|
|
12
12
|
import { sh3 } from '../sh3Runtime.svelte';
|
|
13
13
|
import { layoutStore, getActiveRoot } from '../layout/store.svelte';
|
|
14
14
|
import { derive } from '../layout/compact/derive';
|
|
15
|
+
import {
|
|
16
|
+
compactRootStore,
|
|
17
|
+
resolveCompactBodyRoot,
|
|
18
|
+
} from '../layout/compact/rootStore.svelte';
|
|
15
19
|
import { getLiveDispatcherState } from '../actions/state.svelte';
|
|
16
20
|
import { getRegisteredApp } from '../apps/registry.svelte';
|
|
17
21
|
import { returnToHome } from '../apps/lifecycle';
|
|
18
22
|
import Button from '../primitives/Button.svelte';
|
|
19
23
|
import MenuSheet from './MenuSheet.svelte';
|
|
24
|
+
import FloatsSheet from './FloatsSheet.svelte';
|
|
20
25
|
import type { DrawerAnchor } from '../layout/compact/types';
|
|
21
26
|
|
|
22
|
-
|
|
27
|
+
// Drawer toggles re-derive against whatever is currently the body —
|
|
28
|
+
// when the user is on a float, its role-tagged slots drive the chrome,
|
|
29
|
+
// not the docked tree's.
|
|
30
|
+
const bodyRoot = $derived(resolveCompactBodyRoot());
|
|
31
|
+
const rendering = $derived(derive(bodyRoot));
|
|
23
32
|
const dispatcher = $derived(getLiveDispatcherState());
|
|
24
33
|
const onHome = $derived(getActiveRoot() === 'home');
|
|
25
34
|
const appLabel = $derived.by(() => {
|
|
@@ -47,13 +56,28 @@
|
|
|
47
56
|
return bestLabel;
|
|
48
57
|
});
|
|
49
58
|
|
|
59
|
+
const floatTitle = $derived.by(() => {
|
|
60
|
+
const cur = compactRootStore.current;
|
|
61
|
+
if (cur.kind !== 'float') return null;
|
|
62
|
+
const f = layoutStore.tree.floats.find((x) => x.id === cur.floatId);
|
|
63
|
+
if (!f) return null;
|
|
64
|
+
if (f.title) return f.title;
|
|
65
|
+
if (f.content.type === 'tabs') {
|
|
66
|
+
const t = f.content.tabs[f.content.activeTab] ?? f.content.tabs[0];
|
|
67
|
+
return t?.label ?? null;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
});
|
|
71
|
+
|
|
50
72
|
const title = $derived.by(() => {
|
|
73
|
+
if (floatTitle) return floatTitle;
|
|
51
74
|
const carouselLabel = topmostCarouselLabel;
|
|
52
75
|
if (carouselLabel) return `${appLabel} › ${carouselLabel}`;
|
|
53
76
|
return appLabel;
|
|
54
77
|
});
|
|
55
78
|
|
|
56
79
|
let menuOpen = $state(false);
|
|
80
|
+
let floatsOpen = $state(false);
|
|
57
81
|
|
|
58
82
|
function toggleDrawer(anchor: DrawerAnchor) {
|
|
59
83
|
sh3.drawers.toggle(anchor);
|
|
@@ -92,11 +116,20 @@
|
|
|
92
116
|
<div class="title">{title}</div>
|
|
93
117
|
<div class="trailing">
|
|
94
118
|
<Button variant="icon" icon="command" ariaLabel="Open command palette" title="Open command palette" onclick={openPalette} />
|
|
119
|
+
<Button
|
|
120
|
+
variant="icon"
|
|
121
|
+
icon="layers"
|
|
122
|
+
ariaLabel="Floats"
|
|
123
|
+
title="Floats"
|
|
124
|
+
pressed={floatsOpen}
|
|
125
|
+
onclick={() => { floatsOpen = !floatsOpen; }}
|
|
126
|
+
/>
|
|
95
127
|
<Button variant="icon" icon="ellipsis-vertical" ariaLabel="Open menu" title="Open menu" onclick={() => { menuOpen = true; }} />
|
|
96
128
|
</div>
|
|
97
129
|
</header>
|
|
98
130
|
|
|
99
131
|
<MenuSheet open={menuOpen} onClose={() => (menuOpen = false)} />
|
|
132
|
+
<FloatsSheet open={floatsOpen} onClose={() => (floatsOpen = false)} />
|
|
100
133
|
|
|
101
134
|
<style>
|
|
102
135
|
.sh3-compact-chrome {
|
|
@@ -65,7 +65,7 @@ describe('CompactChrome (dom)', () => {
|
|
|
65
65
|
expect(host.querySelector('.leading [data-sh3-anchor="right"] button')).not.toBeNull();
|
|
66
66
|
expect(host.querySelector('.leading [data-sh3-anchor="top"] button')).toBeNull();
|
|
67
67
|
});
|
|
68
|
-
it('renders palette + overflow buttons in the trailing section', () => {
|
|
68
|
+
it('renders palette + floats + overflow buttons in the trailing section', () => {
|
|
69
69
|
attachApp(fakeApp());
|
|
70
70
|
switchToApp();
|
|
71
71
|
flushSync();
|
|
@@ -74,7 +74,9 @@ describe('CompactChrome (dom)', () => {
|
|
|
74
74
|
mounted = mount(CompactChromeAny, { target: host });
|
|
75
75
|
flushSync();
|
|
76
76
|
const trailing = host.querySelectorAll('.trailing button');
|
|
77
|
-
expect(trailing.length).toBe(
|
|
77
|
+
expect(trailing.length).toBe(3);
|
|
78
|
+
const labels = Array.from(trailing).map((b) => b.getAttribute('aria-label'));
|
|
79
|
+
expect(labels).toEqual(['Open command palette', 'Floats', 'Open menu']);
|
|
78
80
|
});
|
|
79
81
|
});
|
|
80
82
|
describe('CompactChrome — home button', () => {
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* FloatsSheet — bottom-anchored navigation sheet for compact mode.
|
|
4
|
+
*
|
|
5
|
+
* Lists the active-layout entry plus one row per non-dismissable float.
|
|
6
|
+
* Tapping a row calls compactRootStore.setRoot(...) and closes the sheet.
|
|
7
|
+
* Dismissable pickers (anchored popovers) are excluded — they aren't
|
|
8
|
+
* "places to go", they're transient overlays.
|
|
9
|
+
*
|
|
10
|
+
* The active-layout row label tracks the active app (or "Home" when no
|
|
11
|
+
* app is attached). The current row is marked with data-current="true"
|
|
12
|
+
* for styling.
|
|
13
|
+
*/
|
|
14
|
+
import { layoutStore, getActiveRoot } from '../layout/store.svelte';
|
|
15
|
+
import { compactRootStore } from '../layout/compact/rootStore.svelte';
|
|
16
|
+
import { floatManager } from '../overlays/float';
|
|
17
|
+
import { getLiveDispatcherState } from '../actions/state.svelte';
|
|
18
|
+
import { getRegisteredApp } from '../apps/registry.svelte';
|
|
19
|
+
import type { FloatEntry } from '../layout/types';
|
|
20
|
+
|
|
21
|
+
let { open, onClose }: { open: boolean; onClose: () => void } = $props();
|
|
22
|
+
|
|
23
|
+
const dispatcher = $derived(getLiveDispatcherState());
|
|
24
|
+
const dockedLabel = $derived.by(() => {
|
|
25
|
+
if (getActiveRoot() === 'home') return 'Home';
|
|
26
|
+
const id = dispatcher.activeAppId;
|
|
27
|
+
if (!id) return 'Home';
|
|
28
|
+
return getRegisteredApp(id)?.manifest.label ?? id;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function floatLabel(f: FloatEntry): string {
|
|
32
|
+
if (f.title) return f.title;
|
|
33
|
+
if (f.content.type === 'tabs') {
|
|
34
|
+
const t = f.content.tabs[f.content.activeTab] ?? f.content.tabs[0];
|
|
35
|
+
if (t) return t.label;
|
|
36
|
+
}
|
|
37
|
+
return f.id;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const rows = $derived.by(() => {
|
|
41
|
+
const out: { id: 'docked' | string; label: string }[] = [
|
|
42
|
+
{ id: 'docked', label: dockedLabel },
|
|
43
|
+
];
|
|
44
|
+
for (const f of layoutStore.tree.floats) {
|
|
45
|
+
if (f.dismissable) continue;
|
|
46
|
+
out.push({ id: f.id, label: floatLabel(f) });
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function isCurrent(rowId: 'docked' | string): boolean {
|
|
52
|
+
const cur = compactRootStore.current;
|
|
53
|
+
if (rowId === 'docked') return cur.kind === 'docked';
|
|
54
|
+
return cur.kind === 'float' && cur.floatId === rowId;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function activate(rowId: 'docked' | string): void {
|
|
58
|
+
if (rowId === 'docked') {
|
|
59
|
+
compactRootStore.reset();
|
|
60
|
+
} else {
|
|
61
|
+
compactRootStore.setRoot({ kind: 'float', floatId: rowId });
|
|
62
|
+
}
|
|
63
|
+
onClose();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ----- swipe-to-close --------------------------------------------------
|
|
67
|
+
// Horizontal pointer drag on a float row past 40% of its width closes the
|
|
68
|
+
// float. The active-layout row is non-swipeable. Document-level pointer
|
|
69
|
+
// listeners survive the pointer leaving the row mid-drag, mirroring the
|
|
70
|
+
// float-frame drag pattern.
|
|
71
|
+
const SWIPE_THRESHOLD = 0.4;
|
|
72
|
+
let swipingId = $state<string | null>(null);
|
|
73
|
+
let swipeDx = $state(0);
|
|
74
|
+
let swipeStartX = 0;
|
|
75
|
+
let swipePointerId: number | null = null;
|
|
76
|
+
let swipeRowEl: HTMLElement | null = null;
|
|
77
|
+
|
|
78
|
+
function rowOffset(rowId: 'docked' | string): number {
|
|
79
|
+
return swipingId === rowId ? swipeDx : 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function rowTransition(rowId: 'docked' | string): string {
|
|
83
|
+
return swipingId === rowId ? 'none' : 'transform 160ms ease-out';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function onRowPointerDown(e: PointerEvent, rowId: 'docked' | string): void {
|
|
87
|
+
if (rowId === 'docked') return;
|
|
88
|
+
if (e.button !== 0) return;
|
|
89
|
+
if (swipingId !== null) return;
|
|
90
|
+
swipingId = rowId;
|
|
91
|
+
swipeStartX = e.clientX;
|
|
92
|
+
swipeDx = 0;
|
|
93
|
+
swipePointerId = e.pointerId;
|
|
94
|
+
// Prefer e.currentTarget; fall back to a DOM query so synthetic events
|
|
95
|
+
// (vitest dispatchEvent) that may not populate currentTarget still
|
|
96
|
+
// resolve the row width for the threshold check.
|
|
97
|
+
swipeRowEl =
|
|
98
|
+
(e.currentTarget as HTMLElement | null) ??
|
|
99
|
+
(document.querySelector(`[data-sh3-floats-row="${rowId}"]`) as HTMLElement | null);
|
|
100
|
+
document.addEventListener('pointermove', onSwipeMove);
|
|
101
|
+
document.addEventListener('pointerup', onSwipeUp);
|
|
102
|
+
document.addEventListener('pointercancel', onSwipeCancel);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function onSwipeMove(e: PointerEvent): void {
|
|
106
|
+
if (e.pointerId !== swipePointerId) return;
|
|
107
|
+
swipeDx = e.clientX - swipeStartX;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function endSwipe(): void {
|
|
111
|
+
document.removeEventListener('pointermove', onSwipeMove);
|
|
112
|
+
document.removeEventListener('pointerup', onSwipeUp);
|
|
113
|
+
document.removeEventListener('pointercancel', onSwipeCancel);
|
|
114
|
+
swipingId = null;
|
|
115
|
+
swipePointerId = null;
|
|
116
|
+
swipeStartX = 0;
|
|
117
|
+
swipeDx = 0;
|
|
118
|
+
swipeRowEl = null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function onSwipeUp(e: PointerEvent): void {
|
|
122
|
+
if (e.pointerId !== swipePointerId) return;
|
|
123
|
+
const id = swipingId;
|
|
124
|
+
const width = swipeRowEl?.clientWidth ?? 0;
|
|
125
|
+
const dx = swipeDx;
|
|
126
|
+
endSwipe();
|
|
127
|
+
if (id && id !== 'docked' && width > 0 && Math.abs(dx) >= width * SWIPE_THRESHOLD) {
|
|
128
|
+
floatManager.close(id);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function onSwipeCancel(e: PointerEvent): void {
|
|
133
|
+
if (e.pointerId !== swipePointerId) return;
|
|
134
|
+
endSwipe();
|
|
135
|
+
}
|
|
136
|
+
</script>
|
|
137
|
+
|
|
138
|
+
{#if open}
|
|
139
|
+
<div
|
|
140
|
+
class="backdrop"
|
|
141
|
+
onclick={onClose}
|
|
142
|
+
onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}
|
|
143
|
+
role="presentation"
|
|
144
|
+
></div>
|
|
145
|
+
<div class="sheet" role="dialog" aria-label="Floats" data-sh3-region="floats-sheet">
|
|
146
|
+
<div class="scroll">
|
|
147
|
+
{#each rows as row (row.id)}
|
|
148
|
+
<button
|
|
149
|
+
class="row"
|
|
150
|
+
data-sh3-floats-row={row.id}
|
|
151
|
+
data-current={isCurrent(row.id) ? 'true' : 'false'}
|
|
152
|
+
onclick={() => activate(row.id)}
|
|
153
|
+
onpointerdown={(e) => onRowPointerDown(e, row.id)}
|
|
154
|
+
style:transform="translateX({rowOffset(row.id)}px)"
|
|
155
|
+
style:transition={rowTransition(row.id)}
|
|
156
|
+
>
|
|
157
|
+
<span class="label">{row.label}</span>
|
|
158
|
+
{#if row.id === 'docked'}
|
|
159
|
+
<span class="kind">layout</span>
|
|
160
|
+
{:else}
|
|
161
|
+
<span class="kind">float</span>
|
|
162
|
+
{/if}
|
|
163
|
+
</button>
|
|
164
|
+
{/each}
|
|
165
|
+
</div>
|
|
166
|
+
<button class="cancel" onclick={onClose}>Cancel</button>
|
|
167
|
+
</div>
|
|
168
|
+
{/if}
|
|
169
|
+
|
|
170
|
+
<style>
|
|
171
|
+
.backdrop {
|
|
172
|
+
position: absolute;
|
|
173
|
+
inset: 0;
|
|
174
|
+
background: var(--sh3-overlay-backdrop, rgba(0, 0, 0, 0.35));
|
|
175
|
+
pointer-events: auto;
|
|
176
|
+
z-index: var(--sh3-z-layer-4);
|
|
177
|
+
}
|
|
178
|
+
.sheet {
|
|
179
|
+
position: absolute;
|
|
180
|
+
left: 0;
|
|
181
|
+
right: 0;
|
|
182
|
+
bottom: 0;
|
|
183
|
+
max-height: 70vh;
|
|
184
|
+
display: flex;
|
|
185
|
+
flex-direction: column;
|
|
186
|
+
background: var(--sh3-bg);
|
|
187
|
+
color: var(--sh3-fg);
|
|
188
|
+
border-top: 1px solid var(--sh3-border);
|
|
189
|
+
box-shadow: var(--sh3-shadow-md, 0 -4px 16px rgba(0, 0, 0, 0.2));
|
|
190
|
+
pointer-events: auto;
|
|
191
|
+
z-index: var(--sh3-z-layer-4);
|
|
192
|
+
}
|
|
193
|
+
.scroll {
|
|
194
|
+
flex: 1;
|
|
195
|
+
min-height: 0;
|
|
196
|
+
overflow: auto;
|
|
197
|
+
padding: var(--sh3-pad-sm) 0;
|
|
198
|
+
}
|
|
199
|
+
.row {
|
|
200
|
+
display: flex;
|
|
201
|
+
align-items: center;
|
|
202
|
+
gap: var(--sh3-pad-sm);
|
|
203
|
+
width: 100%;
|
|
204
|
+
padding: var(--sh3-pad-md);
|
|
205
|
+
border: none;
|
|
206
|
+
background: none;
|
|
207
|
+
color: var(--sh3-fg);
|
|
208
|
+
text-align: left;
|
|
209
|
+
cursor: pointer;
|
|
210
|
+
/* Suppress browser-claimed horizontal pan so swipe-to-close survives
|
|
211
|
+
past the system scroll-claim threshold on Android/iOS. */
|
|
212
|
+
touch-action: pan-y;
|
|
213
|
+
user-select: none;
|
|
214
|
+
}
|
|
215
|
+
.row[data-current='true'] {
|
|
216
|
+
background: var(--sh3-bg-sunken);
|
|
217
|
+
font-weight: 600;
|
|
218
|
+
}
|
|
219
|
+
.row:active { background: var(--sh3-bg-sunken); }
|
|
220
|
+
.label { flex: 1; }
|
|
221
|
+
.kind {
|
|
222
|
+
color: var(--sh3-fg-muted);
|
|
223
|
+
font-size: 11px;
|
|
224
|
+
text-transform: uppercase;
|
|
225
|
+
letter-spacing: 0.05em;
|
|
226
|
+
}
|
|
227
|
+
.cancel {
|
|
228
|
+
padding: var(--sh3-pad-md);
|
|
229
|
+
border: none;
|
|
230
|
+
border-top: 1px solid var(--sh3-border);
|
|
231
|
+
background: var(--sh3-bg-elevated);
|
|
232
|
+
color: var(--sh3-fg);
|
|
233
|
+
font-weight: 600;
|
|
234
|
+
cursor: pointer;
|
|
235
|
+
}
|
|
236
|
+
</style>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { tick } from 'svelte';
|
|
3
|
+
import { renderWithShell } from '../__test__/render';
|
|
4
|
+
import { resetFramework } from '../__test__/reset';
|
|
5
|
+
import { __resetLayoutStoreForTest, layoutStore, } from '../layout/store.svelte';
|
|
6
|
+
import { compactRootStore, __resetCompactRootStoreForTest, } from '../layout/compact/rootStore.svelte';
|
|
7
|
+
import FloatsSheet from './FloatsSheet.svelte';
|
|
8
|
+
function makeFloat(id, title) {
|
|
9
|
+
return {
|
|
10
|
+
id,
|
|
11
|
+
content: {
|
|
12
|
+
type: 'tabs',
|
|
13
|
+
tabs: [{ slotId: `s-${id}`, viewId: 'v', label: title }],
|
|
14
|
+
activeTab: 0,
|
|
15
|
+
},
|
|
16
|
+
position: { x: 0, y: 0 },
|
|
17
|
+
size: { w: 200, h: 200 },
|
|
18
|
+
title,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
describe('FloatsSheet', () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
resetFramework();
|
|
24
|
+
__resetCompactRootStoreForTest();
|
|
25
|
+
__resetLayoutStoreForTest();
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
document.body.innerHTML = '';
|
|
29
|
+
});
|
|
30
|
+
it('renders the active-layout row even when no floats exist', async () => {
|
|
31
|
+
const { container } = renderWithShell(FloatsSheet, {
|
|
32
|
+
open: true,
|
|
33
|
+
onClose: () => { },
|
|
34
|
+
});
|
|
35
|
+
await tick();
|
|
36
|
+
const rows = container.querySelectorAll('[data-sh3-floats-row]');
|
|
37
|
+
expect(rows.length).toBe(1);
|
|
38
|
+
expect(rows[0].getAttribute('data-sh3-floats-row')).toBe('docked');
|
|
39
|
+
});
|
|
40
|
+
it('lists one row per non-dismissable float, excluding pickers', async () => {
|
|
41
|
+
layoutStore.tree.floats.push(makeFloat('f-1', 'Notes'));
|
|
42
|
+
layoutStore.tree.floats.push(makeFloat('f-2', 'Editor'));
|
|
43
|
+
layoutStore.tree.floats.push(Object.assign(Object.assign({}, makeFloat('f-3', 'Picker')), { dismissable: true }));
|
|
44
|
+
const { container } = renderWithShell(FloatsSheet, {
|
|
45
|
+
open: true,
|
|
46
|
+
onClose: () => { },
|
|
47
|
+
});
|
|
48
|
+
await tick();
|
|
49
|
+
const rows = container.querySelectorAll('[data-sh3-floats-row]');
|
|
50
|
+
expect(rows.length).toBe(3); // docked + 2 non-dismissable
|
|
51
|
+
const ids = Array.from(rows).map((r) => r.getAttribute('data-sh3-floats-row'));
|
|
52
|
+
expect(ids).toEqual(['docked', 'f-1', 'f-2']);
|
|
53
|
+
});
|
|
54
|
+
it('tapping a float row sets compactRootStore + calls onClose', async () => {
|
|
55
|
+
layoutStore.tree.floats.push(makeFloat('f-9', 'Notes'));
|
|
56
|
+
let closed = false;
|
|
57
|
+
const { container } = renderWithShell(FloatsSheet, {
|
|
58
|
+
open: true,
|
|
59
|
+
onClose: () => { closed = true; },
|
|
60
|
+
});
|
|
61
|
+
await tick();
|
|
62
|
+
const row = container.querySelector('[data-sh3-floats-row="f-9"]');
|
|
63
|
+
row.click();
|
|
64
|
+
expect(compactRootStore.current).toEqual({ kind: 'float', floatId: 'f-9' });
|
|
65
|
+
expect(closed).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
it('tapping the docked row resets compactRootStore + calls onClose', async () => {
|
|
68
|
+
layoutStore.tree.floats.push(makeFloat('f-10', 'Notes'));
|
|
69
|
+
compactRootStore.setRoot({ kind: 'float', floatId: 'f-10' });
|
|
70
|
+
let closed = false;
|
|
71
|
+
const { container } = renderWithShell(FloatsSheet, {
|
|
72
|
+
open: true,
|
|
73
|
+
onClose: () => { closed = true; },
|
|
74
|
+
});
|
|
75
|
+
await tick();
|
|
76
|
+
const row = container.querySelector('[data-sh3-floats-row="docked"]');
|
|
77
|
+
row.click();
|
|
78
|
+
expect(compactRootStore.current).toEqual({ kind: 'docked' });
|
|
79
|
+
expect(closed).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
it('marks the current row with data-current="true"', async () => {
|
|
82
|
+
layoutStore.tree.floats.push(makeFloat('f-11', 'Notes'));
|
|
83
|
+
compactRootStore.setRoot({ kind: 'float', floatId: 'f-11' });
|
|
84
|
+
const { container } = renderWithShell(FloatsSheet, {
|
|
85
|
+
open: true,
|
|
86
|
+
onClose: () => { },
|
|
87
|
+
});
|
|
88
|
+
await tick();
|
|
89
|
+
const cur = container.querySelector('[data-current="true"]');
|
|
90
|
+
expect(cur === null || cur === void 0 ? void 0 : cur.getAttribute('data-sh3-floats-row')).toBe('f-11');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
import { floatManager, bindFloatStore, __resetFloatManagerForTest, } from '../overlays/float';
|
|
94
|
+
function fakePointer(type, x, y, id = 1) {
|
|
95
|
+
const ev = new Event(type, { bubbles: true, cancelable: true });
|
|
96
|
+
Object.assign(ev, { pointerId: id, clientX: x, clientY: y, pointerType: 'touch', button: 0 });
|
|
97
|
+
return ev;
|
|
98
|
+
}
|
|
99
|
+
describe('FloatsSheet — swipe to close', () => {
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
resetFramework();
|
|
102
|
+
__resetCompactRootStoreForTest();
|
|
103
|
+
__resetLayoutStoreForTest();
|
|
104
|
+
__resetFloatManagerForTest();
|
|
105
|
+
bindFloatStore(layoutStore.floats, () => ({ w: 360, h: 740 }));
|
|
106
|
+
});
|
|
107
|
+
afterEach(() => {
|
|
108
|
+
document.body.innerHTML = '';
|
|
109
|
+
});
|
|
110
|
+
it('swiping a float row past 40% width closes that float', async () => {
|
|
111
|
+
const id = floatManager.open('test:view', { title: 'Notes' });
|
|
112
|
+
expect(layoutStore.floats.find((f) => f.id === id)).toBeTruthy();
|
|
113
|
+
const { container } = renderWithShell(FloatsSheet, {
|
|
114
|
+
open: true,
|
|
115
|
+
onClose: () => { },
|
|
116
|
+
});
|
|
117
|
+
await tick();
|
|
118
|
+
const row = container.querySelector(`[data-sh3-floats-row="${id}"]`);
|
|
119
|
+
Object.defineProperty(row, 'clientWidth', { configurable: true, value: 360 });
|
|
120
|
+
row.dispatchEvent(fakePointer('pointerdown', 10, 10, 1));
|
|
121
|
+
document.dispatchEvent(fakePointer('pointermove', 200, 10, 1));
|
|
122
|
+
document.dispatchEvent(fakePointer('pointerup', 200, 10, 1));
|
|
123
|
+
await tick();
|
|
124
|
+
expect(layoutStore.floats.find((f) => f.id === id)).toBeUndefined();
|
|
125
|
+
});
|
|
126
|
+
it('does not let the docked row be swiped (no throw, no state change)', async () => {
|
|
127
|
+
const { container } = renderWithShell(FloatsSheet, {
|
|
128
|
+
open: true,
|
|
129
|
+
onClose: () => { },
|
|
130
|
+
});
|
|
131
|
+
await tick();
|
|
132
|
+
const row = container.querySelector('[data-sh3-floats-row="docked"]');
|
|
133
|
+
Object.defineProperty(row, 'clientWidth', { configurable: true, value: 360 });
|
|
134
|
+
row.dispatchEvent(fakePointer('pointerdown', 10, 10, 2));
|
|
135
|
+
document.dispatchEvent(fakePointer('pointermove', 300, 10, 2));
|
|
136
|
+
document.dispatchEvent(fakePointer('pointerup', 300, 10, 2));
|
|
137
|
+
await tick();
|
|
138
|
+
expect(compactRootStore.current).toEqual({ kind: 'docked' });
|
|
139
|
+
});
|
|
140
|
+
it('swiping less than 40% width does not close', async () => {
|
|
141
|
+
const id = floatManager.open('test:view', { title: 'Notes' });
|
|
142
|
+
const { container } = renderWithShell(FloatsSheet, {
|
|
143
|
+
open: true,
|
|
144
|
+
onClose: () => { },
|
|
145
|
+
});
|
|
146
|
+
await tick();
|
|
147
|
+
const row = container.querySelector(`[data-sh3-floats-row="${id}"]`);
|
|
148
|
+
Object.defineProperty(row, 'clientWidth', { configurable: true, value: 360 });
|
|
149
|
+
row.dispatchEvent(fakePointer('pointerdown', 10, 10, 3));
|
|
150
|
+
document.dispatchEvent(fakePointer('pointermove', 50, 10, 3));
|
|
151
|
+
document.dispatchEvent(fakePointer('pointerup', 50, 10, 3));
|
|
152
|
+
await tick();
|
|
153
|
+
expect(layoutStore.floats.find((f) => f.id === id)).toBeTruthy();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -15,14 +15,20 @@
|
|
|
15
15
|
* tag them at authoring time. View-default fall-through ships when the
|
|
16
16
|
* registry exposes a pre-mount lookup (deferred from this PR).
|
|
17
17
|
*/
|
|
18
|
-
import { layoutStore } from '../store.svelte';
|
|
19
18
|
import { drawerStore } from './drawerStore.svelte';
|
|
20
19
|
import { derive } from './derive';
|
|
20
|
+
import { resolveCompactBodyRoot } from './rootStore.svelte';
|
|
21
21
|
import LayoutRenderer from '../LayoutRenderer.svelte';
|
|
22
22
|
import DrawerSurface from '../../overlays/DrawerSurface.svelte';
|
|
23
23
|
import type { DrawerAnchor } from './types';
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
// Compact body shows whatever compactRootStore currently points at —
|
|
26
|
+
// the docked tree by default, or one float when the user navigates
|
|
27
|
+
// there via the FloatsSheet. Drawer derivation runs on the same root,
|
|
28
|
+
// so a float carrying role-tagged sidebar slots gets the same drawer
|
|
29
|
+
// chrome the docked tree gets.
|
|
30
|
+
const bodyRoot = $derived(resolveCompactBodyRoot());
|
|
31
|
+
const rendering = $derived(derive(bodyRoot));
|
|
26
32
|
|
|
27
33
|
const anchors: DrawerAnchor[] = ['left', 'right', 'top'];
|
|
28
34
|
</script>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { LayoutNode } from '../types';
|
|
2
|
+
export type CompactRoot = {
|
|
3
|
+
kind: 'docked';
|
|
4
|
+
} | {
|
|
5
|
+
kind: 'float';
|
|
6
|
+
floatId: string;
|
|
7
|
+
};
|
|
8
|
+
export declare const compactRootStore: {
|
|
9
|
+
readonly current: CompactRoot;
|
|
10
|
+
setRoot(r: CompactRoot): void;
|
|
11
|
+
reset(): void;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the LayoutNode the compact body should render. Returns
|
|
15
|
+
* `layoutStore.root` when current is docked OR when the referenced float
|
|
16
|
+
* has been removed (self-heal: also resets `current` to docked).
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolveCompactBodyRoot(): LayoutNode;
|
|
19
|
+
/** Test-only reset. Not exported from src/index.ts. */
|
|
20
|
+
export declare function __resetCompactRootStoreForTest(): void;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* compactRootStore — selects which LayoutNode the compact body shows.
|
|
3
|
+
*
|
|
4
|
+
* In compact mode the user sees exactly one root at a time: either the
|
|
5
|
+
* docked tree (the "active layout"), or the content of one float. This
|
|
6
|
+
* module is the source of truth for that selection. Desktop never reads it.
|
|
7
|
+
*
|
|
8
|
+
* Reset triggers (called from existing modules):
|
|
9
|
+
* - bindFloatStore (active tree changed)
|
|
10
|
+
* - closeFloat(id) when current.floatId === id
|
|
11
|
+
* - resolveCompactBodyRoot self-heal when the referenced float is gone
|
|
12
|
+
*
|
|
13
|
+
* Set triggers:
|
|
14
|
+
* - floatManager.open in compact + non-dismissable
|
|
15
|
+
* - focusView / focusTab in compact when the target lives in a float
|
|
16
|
+
* - FloatsSheet row tap
|
|
17
|
+
*/
|
|
18
|
+
import { layoutStore } from '../store.svelte';
|
|
19
|
+
let current = $state({ kind: 'docked' });
|
|
20
|
+
export const compactRootStore = {
|
|
21
|
+
get current() {
|
|
22
|
+
return current;
|
|
23
|
+
},
|
|
24
|
+
setRoot(r) {
|
|
25
|
+
if (r.kind === 'float') {
|
|
26
|
+
const exists = layoutStore.tree.floats.some((f) => f.id === r.floatId);
|
|
27
|
+
if (!exists) {
|
|
28
|
+
throw new Error(`compactRootStore.setRoot: float id "${r.floatId}" is not in the active tree`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
current = r;
|
|
32
|
+
},
|
|
33
|
+
reset() {
|
|
34
|
+
current = { kind: 'docked' };
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the LayoutNode the compact body should render. Returns
|
|
39
|
+
* `layoutStore.root` when current is docked OR when the referenced float
|
|
40
|
+
* has been removed (self-heal: also resets `current` to docked).
|
|
41
|
+
*/
|
|
42
|
+
export function resolveCompactBodyRoot() {
|
|
43
|
+
// Snapshot locally — narrowing on a module-level $state binding is lost
|
|
44
|
+
// across function calls because the narrowed-out branch could in theory
|
|
45
|
+
// reassign before the next read.
|
|
46
|
+
const cur = current;
|
|
47
|
+
if (cur.kind === 'docked')
|
|
48
|
+
return layoutStore.root;
|
|
49
|
+
const entry = layoutStore.tree.floats.find((f) => f.id === cur.floatId);
|
|
50
|
+
if (!entry) {
|
|
51
|
+
current = { kind: 'docked' };
|
|
52
|
+
return layoutStore.root;
|
|
53
|
+
}
|
|
54
|
+
return entry.content;
|
|
55
|
+
}
|
|
56
|
+
/** Test-only reset. Not exported from src/index.ts. */
|
|
57
|
+
export function __resetCompactRootStoreForTest() {
|
|
58
|
+
current = { kind: 'docked' };
|
|
59
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|