sh3-core 0.17.2 → 0.19.1
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/Sh3.svelte +59 -4
- package/dist/actions/CommandPalette.svelte +1 -2
- package/dist/actions/listeners.js +12 -1
- package/dist/api.d.ts +4 -0
- package/dist/app/store/storeShard.svelte.js +1 -21
- package/dist/app/store/version.d.ts +11 -0
- package/dist/app/store/version.js +39 -0
- package/dist/app/store/version.test.d.ts +1 -0
- package/dist/app/store/version.test.js +44 -0
- package/dist/apps/lifecycle.d.ts +6 -0
- package/dist/apps/lifecycle.js +5 -2
- package/dist/apps/lifecycle.test.js +30 -0
- package/dist/apps/types.d.ts +12 -0
- package/dist/assets/iconIds.generated.d.ts +1 -1
- package/dist/assets/iconIds.generated.js +5 -0
- package/dist/assets/icons.svg +31 -0
- package/dist/auth/auth.svelte.js +18 -8
- package/dist/auth/types.d.ts +6 -0
- package/dist/chrome/CompactChrome.svelte +54 -20
- package/dist/chrome/CompactChrome.svelte.test.js +112 -5
- package/dist/createShell.d.ts +9 -0
- package/dist/createShell.js +20 -7
- package/dist/createShell.remoteAuth.test.d.ts +1 -0
- package/dist/createShell.remoteAuth.test.js +71 -0
- package/dist/documents/http-backend.js +12 -11
- package/dist/env/client.js +11 -5
- package/dist/files/types.d.ts +106 -0
- package/dist/files/types.js +1 -0
- package/dist/gestures/gestureRegistry.d.ts +6 -0
- package/dist/gestures/gestureRegistry.js +190 -0
- package/dist/gestures/gestureRegistry.test.d.ts +1 -0
- package/dist/gestures/gestureRegistry.test.js +120 -0
- package/dist/gestures/index.d.ts +6 -0
- package/dist/gestures/index.js +12 -0
- package/dist/gestures/pointerClaim.d.ts +7 -0
- package/dist/gestures/pointerClaim.js +36 -0
- package/dist/gestures/pointerClaim.test.d.ts +1 -0
- package/dist/gestures/pointerClaim.test.js +64 -0
- package/dist/gestures/types.d.ts +83 -0
- package/dist/gestures/types.js +1 -0
- package/dist/host-entry.d.ts +1 -0
- package/dist/host-entry.js +1 -0
- package/dist/layout/LayoutRenderer.browser.test.js +15 -3
- package/dist/layout/LayoutRenderer.svelte +16 -3
- package/dist/layout/LayoutRenderer.svelte.d.ts +2 -0
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-3-splitter-drag-updates-split-sizes-when-the-splitter-handle-is-dragged-1.png +0 -0
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-5-splitter-collapse-toggle-toggles-collapsed-i--on-double-click-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-but-keeps-it-on-panes-with-a-non-fixed-neighbor-1.png +0 -0
- package/dist/layout/compact/CarouselTabs.svelte +362 -0
- package/dist/layout/compact/CarouselTabs.svelte.d.ts +10 -0
- package/dist/layout/compact/CarouselTabs.svelte.test.d.ts +1 -0
- package/dist/layout/compact/CarouselTabs.svelte.test.js +300 -0
- package/dist/layout/compact/CompactRenderer.svelte +1 -1
- package/dist/layout/compact/CompactRenderer.svelte.test.js +49 -0
- package/dist/layout/compact/derive.js +2 -0
- package/dist/layout/compact/derive.test.js +37 -0
- package/dist/layout/compact/enrichCarousels.d.ts +8 -0
- package/dist/layout/compact/enrichCarousels.js +44 -0
- package/dist/layout/compact/enrichCarousels.test.d.ts +1 -0
- package/dist/layout/compact/enrichCarousels.test.js +88 -0
- package/dist/layout/compact/types.d.ts +3 -0
- package/dist/layout/drag.svelte.js +13 -0
- package/dist/layout/store.schemaVersion.test.js +2 -2
- package/dist/layout/types.d.ts +9 -1
- package/dist/layout/types.js +1 -1
- package/dist/layout/types.test.d.ts +1 -0
- package/dist/layout/types.test.js +26 -0
- package/dist/overlays/ModalFrame.svelte +3 -1
- package/dist/overlays/ModalFrame.svelte.d.ts +1 -0
- package/dist/overlays/floatDismiss.js +5 -0
- package/dist/overlays/focusTrap.d.ts +11 -1
- package/dist/overlays/focusTrap.js +11 -9
- package/dist/overlays/modal.js +1 -0
- package/dist/overlays/popup.js +4 -0
- package/dist/overlays/types.d.ts +9 -0
- package/dist/primitives/Button.svelte +18 -0
- package/dist/primitives/Button.svelte.d.ts +6 -0
- package/dist/primitives/ResizableSplitter.svelte +71 -11
- package/dist/primitives/ResizableSplitter.svelte.d.ts +8 -0
- package/dist/primitives/ResizableSplitter.svelte.test.d.ts +1 -0
- package/dist/primitives/ResizableSplitter.svelte.test.js +74 -0
- package/dist/server-shard/types.d.ts +2 -1
- package/dist/shards/activate.svelte.js +16 -0
- package/dist/shards/ctx-fetch.test.d.ts +1 -0
- package/dist/shards/ctx-fetch.test.js +136 -0
- package/dist/shards/types.d.ts +29 -0
- package/dist/transport/apiFetch.d.ts +1 -0
- package/dist/transport/apiFetch.js +65 -0
- package/dist/transport/apiFetch.test.d.ts +1 -0
- package/dist/transport/apiFetch.test.js +37 -0
- package/dist/transport/authToken.d.ts +2 -0
- package/dist/transport/authToken.js +53 -0
- package/dist/transport/authToken.test.d.ts +1 -0
- package/dist/transport/authToken.test.js +33 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* DOM smoke for CarouselTabs — verifies static rendering and
|
|
3
|
+
* responsiveness to activeTab mutations. Gesture tests live in
|
|
4
|
+
* the same file (added in Task 5).
|
|
5
|
+
*
|
|
6
|
+
* happy-dom does not compute layout, so we install a deterministic
|
|
7
|
+
* ResizeObserver mock that fires once with the width parsed from the
|
|
8
|
+
* observed element's nearest ancestor that carries `data-test-width`.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
11
|
+
import { mount, unmount, flushSync } from 'svelte';
|
|
12
|
+
import CarouselTabs from './CarouselTabs.svelte';
|
|
13
|
+
import { claim, getOwner, __resetForTest as resetClaims } from '../../gestures/pointerClaim';
|
|
14
|
+
const CarouselTabsAny = CarouselTabs;
|
|
15
|
+
let mounted = null;
|
|
16
|
+
let host = null;
|
|
17
|
+
let originalRO;
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
originalRO = globalThis.ResizeObserver;
|
|
20
|
+
class FakeResizeObserver {
|
|
21
|
+
constructor(cb) { this.callback = cb; }
|
|
22
|
+
observe(target) {
|
|
23
|
+
var _a;
|
|
24
|
+
let el = target;
|
|
25
|
+
let width = 0;
|
|
26
|
+
while (el) {
|
|
27
|
+
const w = (_a = el.dataset) === null || _a === void 0 ? void 0 : _a.testWidth;
|
|
28
|
+
if (w) {
|
|
29
|
+
width = parseInt(w, 10);
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
el = el.parentElement;
|
|
33
|
+
}
|
|
34
|
+
this.callback([{ contentRect: { width, height: 0 }, target }], this);
|
|
35
|
+
}
|
|
36
|
+
unobserve() { }
|
|
37
|
+
disconnect() { }
|
|
38
|
+
}
|
|
39
|
+
globalThis.ResizeObserver = FakeResizeObserver;
|
|
40
|
+
});
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
if (mounted) {
|
|
43
|
+
unmount(mounted);
|
|
44
|
+
mounted = null;
|
|
45
|
+
}
|
|
46
|
+
if (host) {
|
|
47
|
+
host.remove();
|
|
48
|
+
host = null;
|
|
49
|
+
}
|
|
50
|
+
globalThis.ResizeObserver = originalRO;
|
|
51
|
+
resetClaims();
|
|
52
|
+
});
|
|
53
|
+
function makeNode(labels, activeTab = 0, wrap = false) {
|
|
54
|
+
return {
|
|
55
|
+
type: 'tabs',
|
|
56
|
+
tabs: labels.map((l, i) => ({ slotId: `s${i}`, viewId: null, label: l })),
|
|
57
|
+
activeTab,
|
|
58
|
+
wrap,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function mountCarousel(props) {
|
|
62
|
+
host = document.createElement('div');
|
|
63
|
+
host.style.width = '300px';
|
|
64
|
+
host.style.height = '400px';
|
|
65
|
+
host.style.position = 'relative';
|
|
66
|
+
host.dataset.testWidth = '300';
|
|
67
|
+
document.body.appendChild(host);
|
|
68
|
+
mounted = mount(CarouselTabsAny, { target: host, props });
|
|
69
|
+
flushSync();
|
|
70
|
+
}
|
|
71
|
+
describe('CarouselTabs (static)', () => {
|
|
72
|
+
it('renders one slide per tab', () => {
|
|
73
|
+
mountCarousel({ node: makeNode(['A', 'B', 'C']), wrap: false });
|
|
74
|
+
const slides = host.querySelectorAll('[data-sh3-slide]');
|
|
75
|
+
expect(slides.length).toBe(3);
|
|
76
|
+
});
|
|
77
|
+
it('renders pager dots when tab count > 1', () => {
|
|
78
|
+
mountCarousel({ node: makeNode(['A', 'B']), wrap: false });
|
|
79
|
+
const dots = host.querySelectorAll('[data-sh3-pager-dot]');
|
|
80
|
+
expect(dots.length).toBe(2);
|
|
81
|
+
});
|
|
82
|
+
it('marks the active dot via aria-current="true"', () => {
|
|
83
|
+
mountCarousel({ node: makeNode(['A', 'B', 'C'], 1), wrap: false });
|
|
84
|
+
const dots = host.querySelectorAll('[data-sh3-pager-dot]');
|
|
85
|
+
expect(dots[0].getAttribute('aria-current')).toBeNull();
|
|
86
|
+
expect(dots[1].getAttribute('aria-current')).toBe('true');
|
|
87
|
+
expect(dots[2].getAttribute('aria-current')).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
it('hides pager dots for single-tab groups', () => {
|
|
90
|
+
mountCarousel({ node: makeNode(['Only']), wrap: false });
|
|
91
|
+
const dots = host.querySelectorAll('[data-sh3-pager-dot]');
|
|
92
|
+
expect(dots.length).toBe(0);
|
|
93
|
+
});
|
|
94
|
+
it('translates the track based on activeTab', () => {
|
|
95
|
+
const node = makeNode(['A', 'B', 'C'], 1);
|
|
96
|
+
mountCarousel({ node, wrap: false });
|
|
97
|
+
const track = host.querySelector('[data-sh3-carousel-track]');
|
|
98
|
+
expect(track).not.toBeNull();
|
|
99
|
+
expect(track.style.transform).toContain('translateX(-300px)');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
function fakePointer(type, x, y, id = 1) {
|
|
103
|
+
const ev = new Event(type, { bubbles: true, cancelable: true });
|
|
104
|
+
Object.assign(ev, { pointerId: id, clientX: x, clientY: y, pointerType: 'touch' });
|
|
105
|
+
return ev;
|
|
106
|
+
}
|
|
107
|
+
function dispatchSwipe(track, from, to, steps = 4) {
|
|
108
|
+
track.dispatchEvent(fakePointer('pointerdown', from.x, from.y));
|
|
109
|
+
for (let s = 1; s <= steps; s++) {
|
|
110
|
+
const x = from.x + ((to.x - from.x) * s) / steps;
|
|
111
|
+
const y = from.y + ((to.y - from.y) * s) / steps;
|
|
112
|
+
document.dispatchEvent(fakePointer('pointermove', x, y));
|
|
113
|
+
}
|
|
114
|
+
document.dispatchEvent(fakePointer('pointerup', to.x, to.y));
|
|
115
|
+
}
|
|
116
|
+
describe('CarouselTabs (gestures)', () => {
|
|
117
|
+
it('swipe past commit threshold advances activeTab', () => {
|
|
118
|
+
const node = makeNode(['A', 'B', 'C'], 0);
|
|
119
|
+
mountCarousel({ node, wrap: false });
|
|
120
|
+
const track = host.querySelector('[data-sh3-carousel-track]');
|
|
121
|
+
dispatchSwipe(track, { x: 250, y: 100 }, { x: 80, y: 100 });
|
|
122
|
+
flushSync();
|
|
123
|
+
expect(node.activeTab).toBe(1);
|
|
124
|
+
});
|
|
125
|
+
it('short swipe below commit threshold snaps back (no activeTab change)', () => {
|
|
126
|
+
const node = makeNode(['A', 'B', 'C'], 0);
|
|
127
|
+
mountCarousel({ node, wrap: false });
|
|
128
|
+
const track = host.querySelector('[data-sh3-carousel-track]');
|
|
129
|
+
dispatchSwipe(track, { x: 250, y: 100 }, { x: 190, y: 100 });
|
|
130
|
+
flushSync();
|
|
131
|
+
expect(node.activeTab).toBe(0);
|
|
132
|
+
});
|
|
133
|
+
it('vertical-dominant gesture does not change activeTab (axis lock releases)', () => {
|
|
134
|
+
const node = makeNode(['A', 'B', 'C'], 0);
|
|
135
|
+
mountCarousel({ node, wrap: false });
|
|
136
|
+
const track = host.querySelector('[data-sh3-carousel-track]');
|
|
137
|
+
dispatchSwipe(track, { x: 150, y: 50 }, { x: 130, y: 350 });
|
|
138
|
+
flushSync();
|
|
139
|
+
expect(node.activeTab).toBe(0);
|
|
140
|
+
});
|
|
141
|
+
it('at last slide with wrap=false: large drag does NOT advance (edge-resist)', () => {
|
|
142
|
+
const node = makeNode(['A', 'B'], 1, /* wrap */ false);
|
|
143
|
+
mountCarousel({ node, wrap: false });
|
|
144
|
+
const track = host.querySelector('[data-sh3-carousel-track]');
|
|
145
|
+
dispatchSwipe(track, { x: 250, y: 100 }, { x: 50, y: 100 });
|
|
146
|
+
flushSync();
|
|
147
|
+
expect(node.activeTab).toBe(1);
|
|
148
|
+
});
|
|
149
|
+
it('at last slide with wrap=true: large drag wraps to first', () => {
|
|
150
|
+
const node = makeNode(['A', 'B', 'C'], 2, /* wrap */ true);
|
|
151
|
+
mountCarousel({ node, wrap: true });
|
|
152
|
+
const track = host.querySelector('[data-sh3-carousel-track]');
|
|
153
|
+
dispatchSwipe(track, { x: 250, y: 100 }, { x: 50, y: 100 });
|
|
154
|
+
flushSync();
|
|
155
|
+
expect(node.activeTab).toBe(0);
|
|
156
|
+
});
|
|
157
|
+
it('at first slide with wrap=true: large drag right-to-left-back wraps to last', () => {
|
|
158
|
+
const node = makeNode(['A', 'B', 'C'], 0, /* wrap */ true);
|
|
159
|
+
mountCarousel({ node, wrap: true });
|
|
160
|
+
const track = host.querySelector('[data-sh3-carousel-track]');
|
|
161
|
+
dispatchSwipe(track, { x: 50, y: 100 }, { x: 250, y: 100 });
|
|
162
|
+
flushSync();
|
|
163
|
+
expect(node.activeTab).toBe(2);
|
|
164
|
+
});
|
|
165
|
+
it('pointercancel mid-drag does NOT commit, even past the threshold', () => {
|
|
166
|
+
// Repro for the Android bug where descendant elements (buttons,
|
|
167
|
+
// scroll regions) fire pointercancel after we've claimed the
|
|
168
|
+
// gesture — the previous implementation read that as a release at
|
|
169
|
+
// the current dx and instantly auto-completed the swipe with the
|
|
170
|
+
// finger still down.
|
|
171
|
+
const node = makeNode(['A', 'B', 'C'], 0);
|
|
172
|
+
mountCarousel({ node, wrap: false });
|
|
173
|
+
const track = host.querySelector('[data-sh3-carousel-track]');
|
|
174
|
+
track.dispatchEvent(fakePointer('pointerdown', 250, 100));
|
|
175
|
+
// Drag past the commit threshold (containerWidth=300, 40% = 120px).
|
|
176
|
+
document.dispatchEvent(fakePointer('pointermove', 200, 100));
|
|
177
|
+
document.dispatchEvent(fakePointer('pointermove', 100, 100));
|
|
178
|
+
// Cancel instead of release — must NOT commit.
|
|
179
|
+
document.dispatchEvent(fakePointer('pointercancel', 100, 100));
|
|
180
|
+
flushSync();
|
|
181
|
+
expect(node.activeTab).toBe(0);
|
|
182
|
+
});
|
|
183
|
+
it('single-tab carousel: gesture is a no-op', () => {
|
|
184
|
+
const node = makeNode(['Only'], 0);
|
|
185
|
+
mountCarousel({ node, wrap: false });
|
|
186
|
+
const track = host.querySelector('[data-sh3-carousel-track]');
|
|
187
|
+
dispatchSwipe(track, { x: 250, y: 100 }, { x: 50, y: 100 });
|
|
188
|
+
flushSync();
|
|
189
|
+
expect(node.activeTab).toBe(0);
|
|
190
|
+
});
|
|
191
|
+
it('pointerdown on an editable target (input) does not initiate a swipe', () => {
|
|
192
|
+
const node = makeNode(['A', 'B', 'C'], 0);
|
|
193
|
+
mountCarousel({ node, wrap: false });
|
|
194
|
+
const slide = host.querySelector('[data-sh3-slide]');
|
|
195
|
+
const input = document.createElement('input');
|
|
196
|
+
slide.appendChild(input);
|
|
197
|
+
// pointerdown on the input bubbles to the track, but the handler bails
|
|
198
|
+
// because the target is editable.
|
|
199
|
+
input.dispatchEvent(fakePointer('pointerdown', 250, 100));
|
|
200
|
+
document.dispatchEvent(fakePointer('pointermove', 80, 100));
|
|
201
|
+
document.dispatchEvent(fakePointer('pointerup', 80, 100));
|
|
202
|
+
flushSync();
|
|
203
|
+
expect(node.activeTab).toBe(0);
|
|
204
|
+
});
|
|
205
|
+
it('pointerdown inside an overflow-x:scroll element does not initiate a swipe', () => {
|
|
206
|
+
const node = makeNode(['A', 'B', 'C'], 0);
|
|
207
|
+
mountCarousel({ node, wrap: false });
|
|
208
|
+
const slide = host.querySelector('[data-sh3-slide]');
|
|
209
|
+
const scroller = document.createElement('div');
|
|
210
|
+
scroller.style.overflowX = 'scroll';
|
|
211
|
+
slide.appendChild(scroller);
|
|
212
|
+
scroller.dispatchEvent(fakePointer('pointerdown', 250, 100));
|
|
213
|
+
document.dispatchEvent(fakePointer('pointermove', 80, 100));
|
|
214
|
+
document.dispatchEvent(fakePointer('pointerup', 80, 100));
|
|
215
|
+
flushSync();
|
|
216
|
+
expect(node.activeTab).toBe(0);
|
|
217
|
+
});
|
|
218
|
+
it('pointerdown inside an overflow-x:auto element does not initiate a swipe', () => {
|
|
219
|
+
const node = makeNode(['A', 'B', 'C'], 0);
|
|
220
|
+
mountCarousel({ node, wrap: false });
|
|
221
|
+
const slide = host.querySelector('[data-sh3-slide]');
|
|
222
|
+
const scroller = document.createElement('div');
|
|
223
|
+
scroller.style.overflowX = 'auto';
|
|
224
|
+
slide.appendChild(scroller);
|
|
225
|
+
scroller.dispatchEvent(fakePointer('pointerdown', 250, 100));
|
|
226
|
+
document.dispatchEvent(fakePointer('pointermove', 80, 100));
|
|
227
|
+
document.dispatchEvent(fakePointer('pointerup', 80, 100));
|
|
228
|
+
flushSync();
|
|
229
|
+
expect(node.activeTab).toBe(0);
|
|
230
|
+
});
|
|
231
|
+
it('pointerdown inside an overflow-x:hidden element still allows swipe', () => {
|
|
232
|
+
const node = makeNode(['A', 'B', 'C'], 0);
|
|
233
|
+
mountCarousel({ node, wrap: false });
|
|
234
|
+
const slide = host.querySelector('[data-sh3-slide]');
|
|
235
|
+
const wrapper = document.createElement('div');
|
|
236
|
+
wrapper.style.overflowX = 'hidden';
|
|
237
|
+
slide.appendChild(wrapper);
|
|
238
|
+
wrapper.dispatchEvent(fakePointer('pointerdown', 250, 100));
|
|
239
|
+
document.dispatchEvent(fakePointer('pointermove', 80, 100));
|
|
240
|
+
document.dispatchEvent(fakePointer('pointerup', 80, 100));
|
|
241
|
+
flushSync();
|
|
242
|
+
expect(node.activeTab).toBe(1);
|
|
243
|
+
});
|
|
244
|
+
it('pointerdown on contenteditable target does not initiate a swipe', () => {
|
|
245
|
+
const node = makeNode(['A', 'B', 'C'], 0);
|
|
246
|
+
mountCarousel({ node, wrap: false });
|
|
247
|
+
const slide = host.querySelector('[data-sh3-slide]');
|
|
248
|
+
const editable = document.createElement('div');
|
|
249
|
+
editable.setAttribute('contenteditable', 'true');
|
|
250
|
+
slide.appendChild(editable);
|
|
251
|
+
editable.dispatchEvent(fakePointer('pointerdown', 250, 100));
|
|
252
|
+
document.dispatchEvent(fakePointer('pointermove', 80, 100));
|
|
253
|
+
document.dispatchEvent(fakePointer('pointerup', 80, 100));
|
|
254
|
+
flushSync();
|
|
255
|
+
expect(node.activeTab).toBe(0);
|
|
256
|
+
});
|
|
257
|
+
it('marks the track --dragging while a horizontal drag is in flight, and clears it on pointerup', () => {
|
|
258
|
+
const node = makeNode(['A', 'B', 'C'], 0);
|
|
259
|
+
mountCarousel({ node, wrap: false });
|
|
260
|
+
const track = host.querySelector('[data-sh3-carousel-track]');
|
|
261
|
+
track.dispatchEvent(fakePointer('pointerdown', 250, 100));
|
|
262
|
+
document.dispatchEvent(fakePointer('pointermove', 200, 100));
|
|
263
|
+
flushSync();
|
|
264
|
+
expect(track.classList.contains('sh3-carousel-track--dragging')).toBe(true);
|
|
265
|
+
document.dispatchEvent(fakePointer('pointerup', 200, 100));
|
|
266
|
+
flushSync();
|
|
267
|
+
expect(track.classList.contains('sh3-carousel-track--dragging')).toBe(false);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
describe('CarouselTabs — PointerClaim integration', () => {
|
|
271
|
+
it('does not start gesture when pointer is already claimed by an app', () => {
|
|
272
|
+
const node = makeNode(['A', 'B'], 0);
|
|
273
|
+
mountCarousel({ node, wrap: false });
|
|
274
|
+
const track = host.querySelector('[data-sh3-carousel-track]');
|
|
275
|
+
// Pre-claim as if an app pan gesture owns this pointer
|
|
276
|
+
claim(1, { ownerId: 'app:pan', axis: 'xy', priority: 'normal', depth: 99 });
|
|
277
|
+
dispatchSwipe(track, { x: 250, y: 100 }, { x: 50, y: 100 });
|
|
278
|
+
flushSync();
|
|
279
|
+
// Gesture must have been skipped — activeTab unchanged
|
|
280
|
+
expect(node.activeTab).toBe(0);
|
|
281
|
+
});
|
|
282
|
+
it('claims the pointer as sh3:carousel when unclaimed', () => {
|
|
283
|
+
var _a;
|
|
284
|
+
const node = makeNode(['A', 'B'], 0);
|
|
285
|
+
mountCarousel({ node, wrap: false });
|
|
286
|
+
const track = host.querySelector('[data-sh3-carousel-track]');
|
|
287
|
+
track.dispatchEvent(fakePointer('pointerdown', 250, 100, 2));
|
|
288
|
+
expect((_a = getOwner(2)) === null || _a === void 0 ? void 0 : _a.ownerId).toBe('sh3:carousel');
|
|
289
|
+
});
|
|
290
|
+
it('releases claim on pointerup', () => {
|
|
291
|
+
var _a;
|
|
292
|
+
const node = makeNode(['A', 'B'], 0);
|
|
293
|
+
mountCarousel({ node, wrap: false });
|
|
294
|
+
const track = host.querySelector('[data-sh3-carousel-track]');
|
|
295
|
+
track.dispatchEvent(fakePointer('pointerdown', 250, 100, 3));
|
|
296
|
+
expect((_a = getOwner(3)) === null || _a === void 0 ? void 0 : _a.ownerId).toBe('sh3:carousel');
|
|
297
|
+
document.dispatchEvent(fakePointer('pointerup', 250, 100, 3));
|
|
298
|
+
expect(getOwner(3)).toBeNull();
|
|
299
|
+
});
|
|
300
|
+
});
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
</script>
|
|
29
29
|
|
|
30
30
|
<div class="compact-body" data-sh3-region="compact-body">
|
|
31
|
-
<LayoutRenderer rootOverride={rendering.bodyRoot} />
|
|
31
|
+
<LayoutRenderer rootOverride={rendering.bodyRoot} carousels={rendering.carousels} />
|
|
32
32
|
</div>
|
|
33
33
|
|
|
34
34
|
{#each anchors as anchor (anchor)}
|
|
@@ -74,3 +74,52 @@ describe('CompactRenderer (dom)', () => {
|
|
|
74
74
|
expect(drawer.querySelector('header').textContent).toContain('sb');
|
|
75
75
|
});
|
|
76
76
|
});
|
|
77
|
+
describe('CompactRenderer — carousels', () => {
|
|
78
|
+
it('renders a CarouselTabs region for a full-width tabs body', () => {
|
|
79
|
+
const app = {
|
|
80
|
+
manifest: { id: 'carousel-app', label: 'Carousel App', layoutVersion: 6 },
|
|
81
|
+
initialLayout: {
|
|
82
|
+
type: 'tabs',
|
|
83
|
+
activeTab: 0,
|
|
84
|
+
tabs: [
|
|
85
|
+
{ slotId: 's0', viewId: null, label: 'A', role: 'body' },
|
|
86
|
+
{ slotId: 's1', viewId: null, label: 'B', role: 'body' },
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
attachApp(app);
|
|
91
|
+
switchToApp();
|
|
92
|
+
flushSync();
|
|
93
|
+
host = document.createElement('div');
|
|
94
|
+
document.body.appendChild(host);
|
|
95
|
+
mounted = mount(CompactRenderer, { target: host });
|
|
96
|
+
flushSync();
|
|
97
|
+
expect(host.querySelector('[data-sh3-region="carousel"]')).not.toBeNull();
|
|
98
|
+
});
|
|
99
|
+
it('does NOT render a carousel for tabs that are narrower than full body width', () => {
|
|
100
|
+
const app = {
|
|
101
|
+
manifest: { id: 'narrow-app', label: 'Narrow App', layoutVersion: 6 },
|
|
102
|
+
initialLayout: {
|
|
103
|
+
type: 'split',
|
|
104
|
+
direction: 'horizontal',
|
|
105
|
+
sizes: [0.5, 0.5],
|
|
106
|
+
children: [
|
|
107
|
+
{
|
|
108
|
+
type: 'tabs',
|
|
109
|
+
activeTab: 0,
|
|
110
|
+
tabs: [{ slotId: 'l0', viewId: null, label: 'L', role: 'body' }],
|
|
111
|
+
},
|
|
112
|
+
{ type: 'slot', slotId: 'r', viewId: null, role: 'body' },
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
attachApp(app);
|
|
117
|
+
switchToApp();
|
|
118
|
+
flushSync();
|
|
119
|
+
host = document.createElement('div');
|
|
120
|
+
document.body.appendChild(host);
|
|
121
|
+
mounted = mount(CompactRenderer, { target: host });
|
|
122
|
+
flushSync();
|
|
123
|
+
expect(host.querySelector('[data-sh3-region="carousel"]')).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* derive(). See layout/compact/CompactRenderer.svelte.
|
|
18
18
|
*/
|
|
19
19
|
import { EMPTY_BODY } from './types';
|
|
20
|
+
import { enrichCarousels } from './enrichCarousels';
|
|
20
21
|
function effectiveRole(role) {
|
|
21
22
|
return role !== null && role !== void 0 ? role : 'body';
|
|
22
23
|
}
|
|
@@ -151,5 +152,6 @@ export function derive(tree) {
|
|
|
151
152
|
right: toSpec(buckets.right),
|
|
152
153
|
top: toSpec(buckets.top),
|
|
153
154
|
},
|
|
155
|
+
carousels: enrichCarousels(bodyRoot),
|
|
154
156
|
};
|
|
155
157
|
}
|
|
@@ -157,4 +157,41 @@ describe('derive', () => {
|
|
|
157
157
|
expect((_a = result.drawers.left) === null || _a === void 0 ? void 0 : _a.slots.map((s) => s.slotId)).toEqual(['sb']);
|
|
158
158
|
});
|
|
159
159
|
});
|
|
160
|
+
describe('carousels', () => {
|
|
161
|
+
it('includes a carousels Map in the output', () => {
|
|
162
|
+
var _a;
|
|
163
|
+
const tree = {
|
|
164
|
+
type: 'tabs',
|
|
165
|
+
tabs: [{ slotId: 't0', viewId: null, label: 'Only', role: 'body' }],
|
|
166
|
+
activeTab: 0,
|
|
167
|
+
};
|
|
168
|
+
const out = derive(tree);
|
|
169
|
+
expect(out.carousels).toBeInstanceOf(Map);
|
|
170
|
+
expect(out.carousels.size).toBe(1);
|
|
171
|
+
expect((_a = out.carousels.get('')) === null || _a === void 0 ? void 0 : _a.activeLabel).toBe('Only');
|
|
172
|
+
});
|
|
173
|
+
it('carousels Map is empty when bodyRoot has no qualifying tabs', () => {
|
|
174
|
+
const tree = { type: 'slot', slotId: 's', viewId: null, role: 'body' };
|
|
175
|
+
const out = derive(tree);
|
|
176
|
+
expect(out.carousels.size).toBe(0);
|
|
177
|
+
});
|
|
178
|
+
it('detection runs against bodyRoot (post-stripNonBody), not the source tree', () => {
|
|
179
|
+
const tree = {
|
|
180
|
+
type: 'split',
|
|
181
|
+
direction: 'horizontal',
|
|
182
|
+
sizes: [0.3, 0.7],
|
|
183
|
+
children: [
|
|
184
|
+
{ type: 'slot', slotId: 'sb', viewId: null, role: 'sidebar' },
|
|
185
|
+
{
|
|
186
|
+
type: 'tabs',
|
|
187
|
+
tabs: [{ slotId: 't0', viewId: null, label: 'Body Tab', role: 'body' }],
|
|
188
|
+
activeTab: 0,
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
};
|
|
192
|
+
const out = derive(tree);
|
|
193
|
+
expect(out.carousels.size).toBe(1);
|
|
194
|
+
expect(out.carousels.get('')).toBeDefined();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
160
197
|
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { LayoutNode } from '../types';
|
|
2
|
+
export type NodePath = string;
|
|
3
|
+
export interface CarouselInfo {
|
|
4
|
+
wrap: boolean;
|
|
5
|
+
activeLabel: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function pathKey(path: number[]): NodePath;
|
|
8
|
+
export declare function enrichCarousels(root: LayoutNode): Map<NodePath, CarouselInfo>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* enrichCarousels — pure post-derive transform.
|
|
3
|
+
*
|
|
4
|
+
* Walks the compact bodyRoot (the body-only sub-tree returned by
|
|
5
|
+
* stripNonBody) and identifies every TabsNode whose horizontal extent
|
|
6
|
+
* equals the body container's full width. A TabsNode qualifies iff
|
|
7
|
+
* every ancestor split is vertical — any horizontal split anywhere
|
|
8
|
+
* above it disqualifies the node and everything beneath it.
|
|
9
|
+
*
|
|
10
|
+
* The output is a Map keyed by NodePath (dot-joined child indices)
|
|
11
|
+
* carrying { wrap, activeLabel } for the renderer + chrome breadcrumb.
|
|
12
|
+
*
|
|
13
|
+
* The walk does NOT descend into a TabsNode itself once it has been
|
|
14
|
+
* identified — tab children are slot/leaf content, not nested layout
|
|
15
|
+
* nodes that could themselves carouselize.
|
|
16
|
+
*/
|
|
17
|
+
export function pathKey(path) {
|
|
18
|
+
return path.join('.');
|
|
19
|
+
}
|
|
20
|
+
export function enrichCarousels(root) {
|
|
21
|
+
const out = new Map();
|
|
22
|
+
function walk(node, ancestorAllVertical, path) {
|
|
23
|
+
var _a, _b;
|
|
24
|
+
if (node.type === 'tabs') {
|
|
25
|
+
if (ancestorAllVertical) {
|
|
26
|
+
const active = node.tabs[node.activeTab];
|
|
27
|
+
out.set(pathKey(path), {
|
|
28
|
+
wrap: (_a = node.wrap) !== null && _a !== void 0 ? _a : false,
|
|
29
|
+
activeLabel: (_b = active === null || active === void 0 ? void 0 : active.label) !== null && _b !== void 0 ? _b : '',
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (node.type === 'split') {
|
|
35
|
+
const next = ancestorAllVertical && node.direction === 'vertical';
|
|
36
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
37
|
+
walk(node.children[i], next, [...path, i]);
|
|
38
|
+
}
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
walk(root, true, []);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { enrichCarousels, pathKey } from './enrichCarousels';
|
|
3
|
+
function slot(id) {
|
|
4
|
+
return { type: 'slot', slotId: id, viewId: null };
|
|
5
|
+
}
|
|
6
|
+
function tabs(labels, wrap) {
|
|
7
|
+
return Object.assign({ type: 'tabs', tabs: labels.map((l, i) => ({ slotId: `t${i}`, viewId: null, label: l })), activeTab: 0 }, (wrap !== undefined ? { wrap } : {}));
|
|
8
|
+
}
|
|
9
|
+
function vsplit(...children) {
|
|
10
|
+
return {
|
|
11
|
+
type: 'split',
|
|
12
|
+
direction: 'vertical',
|
|
13
|
+
sizes: children.map(() => 1),
|
|
14
|
+
children,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function hsplit(...children) {
|
|
18
|
+
return {
|
|
19
|
+
type: 'split',
|
|
20
|
+
direction: 'horizontal',
|
|
21
|
+
sizes: children.map(() => 1),
|
|
22
|
+
children,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
describe('pathKey', () => {
|
|
26
|
+
it('joins indices with dots; empty path → empty string', () => {
|
|
27
|
+
expect(pathKey([])).toBe('');
|
|
28
|
+
expect(pathKey([0])).toBe('0');
|
|
29
|
+
expect(pathKey([1, 0, 2])).toBe('1.0.2');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe('enrichCarousels', () => {
|
|
33
|
+
it('bodyRoot IS a tabs node → 1 carousel at root', () => {
|
|
34
|
+
const root = tabs(['A', 'B']);
|
|
35
|
+
const out = enrichCarousels(root);
|
|
36
|
+
expect(out.size).toBe(1);
|
|
37
|
+
expect(out.get('')).toEqual({ wrap: false, activeLabel: 'A' });
|
|
38
|
+
});
|
|
39
|
+
it('vertical-split[slot, tabs] → 1 carousel at "1"', () => {
|
|
40
|
+
const root = vsplit(slot('s'), tabs(['X', 'Y']));
|
|
41
|
+
const out = enrichCarousels(root);
|
|
42
|
+
expect(out.size).toBe(1);
|
|
43
|
+
expect(out.get('1')).toEqual({ wrap: false, activeLabel: 'X' });
|
|
44
|
+
});
|
|
45
|
+
it('vertical-split[tabs, vertical-split[tabs, slot]] → 2 carousels (stacked)', () => {
|
|
46
|
+
const inner = vsplit(tabs(['I0', 'I1']), slot('s'));
|
|
47
|
+
const root = vsplit(tabs(['T0']), inner);
|
|
48
|
+
const out = enrichCarousels(root);
|
|
49
|
+
expect(out.size).toBe(2);
|
|
50
|
+
expect(out.get('0')).toEqual({ wrap: false, activeLabel: 'T0' });
|
|
51
|
+
expect(out.get('1.0')).toEqual({ wrap: false, activeLabel: 'I0' });
|
|
52
|
+
});
|
|
53
|
+
it('horizontal-split[tabs, slot] → 0 carousels (tabs is narrowed)', () => {
|
|
54
|
+
const root = hsplit(tabs(['A']), slot('s'));
|
|
55
|
+
const out = enrichCarousels(root);
|
|
56
|
+
expect(out.size).toBe(0);
|
|
57
|
+
});
|
|
58
|
+
it('horizontal-split[tabs] (single child) → 0 carousels (any horizontal ancestor narrows)', () => {
|
|
59
|
+
const root = hsplit(tabs(['A']));
|
|
60
|
+
const out = enrichCarousels(root);
|
|
61
|
+
expect(out.size).toBe(0);
|
|
62
|
+
});
|
|
63
|
+
it('vertical-split[slot, horizontal-split[tabs, slot]] → 0 (inner tabs has horizontal ancestor)', () => {
|
|
64
|
+
const root = vsplit(slot('s'), hsplit(tabs(['A']), slot('s2')));
|
|
65
|
+
const out = enrichCarousels(root);
|
|
66
|
+
expect(out.size).toBe(0);
|
|
67
|
+
});
|
|
68
|
+
it('flows wrap=true through into the map', () => {
|
|
69
|
+
const root = vsplit(slot('s'), tabs(['X'], true));
|
|
70
|
+
const out = enrichCarousels(root);
|
|
71
|
+
expect(out.get('1')).toEqual({ wrap: true, activeLabel: 'X' });
|
|
72
|
+
});
|
|
73
|
+
it('reads activeLabel from the currently-active tab, not always the first', () => {
|
|
74
|
+
const t = tabs(['First', 'Second', 'Third']);
|
|
75
|
+
t.activeTab = 2;
|
|
76
|
+
const out = enrichCarousels(t);
|
|
77
|
+
expect(out.get('')).toEqual({ wrap: false, activeLabel: 'Third' });
|
|
78
|
+
});
|
|
79
|
+
it('does not recurse into the carousel\'s own children (a tabs node\'s tabs are not walked)', () => {
|
|
80
|
+
const root = tabs(['A']);
|
|
81
|
+
const out = enrichCarousels(root);
|
|
82
|
+
expect(out.size).toBe(1);
|
|
83
|
+
});
|
|
84
|
+
it('bare slot → 0 carousels', () => {
|
|
85
|
+
const out = enrichCarousels(slot('s'));
|
|
86
|
+
expect(out.size).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { LayoutNode, SlotNode, SlotRole } from '../types';
|
|
2
|
+
import type { NodePath, CarouselInfo } from './enrichCarousels';
|
|
3
|
+
export type { NodePath, CarouselInfo };
|
|
2
4
|
export type DrawerAnchor = 'left' | 'right' | 'top';
|
|
3
5
|
export interface DrawerSpec {
|
|
4
6
|
slots: Array<{
|
|
@@ -16,6 +18,7 @@ export interface CompactRendering {
|
|
|
16
18
|
right: DrawerSpec | null;
|
|
17
19
|
top: DrawerSpec | null;
|
|
18
20
|
};
|
|
21
|
+
carousels: Map<NodePath, CarouselInfo>;
|
|
19
22
|
}
|
|
20
23
|
export interface DrawerState {
|
|
21
24
|
open: boolean;
|
|
@@ -43,6 +43,8 @@
|
|
|
43
43
|
import { cleanupTree, insertTabIntoTabs, moveTabWithinTabs, removeTabBySlotId, splitNodeAtPath, } from './ops';
|
|
44
44
|
import { layoutStore } from './store.svelte';
|
|
45
45
|
import { isEmptyContent } from './floats';
|
|
46
|
+
import { claim, revoke } from '../gestures/pointerClaim';
|
|
47
|
+
import { ancestorCount } from '../gestures';
|
|
46
48
|
export const dragState = $state({
|
|
47
49
|
phase: 'idle',
|
|
48
50
|
source: null,
|
|
@@ -65,6 +67,7 @@ export function suppressNextClick() {
|
|
|
65
67
|
const DRAG_THRESHOLD_PX = 4;
|
|
66
68
|
let pendingStartX = 0;
|
|
67
69
|
let pendingStartY = 0;
|
|
70
|
+
let activeDragPointerId = null;
|
|
68
71
|
/**
|
|
69
72
|
* Begin a potential tab drag. Call from pointerdown on a tab element.
|
|
70
73
|
* This does not yet enter the dragging phase — movement past the
|
|
@@ -73,6 +76,11 @@ let pendingStartY = 0;
|
|
|
73
76
|
export function beginTabDrag(slotId, entry, sourceRoot, event, tabElement) {
|
|
74
77
|
if (dragState.phase !== 'idle')
|
|
75
78
|
return;
|
|
79
|
+
const depth = ancestorCount(tabElement);
|
|
80
|
+
const claimGranted = claim(event.pointerId, { ownerId: 'sh3:tabdrag', axis: 'xy', priority: 'normal', depth });
|
|
81
|
+
if (!claimGranted)
|
|
82
|
+
return;
|
|
83
|
+
activeDragPointerId = event.pointerId;
|
|
76
84
|
const rect = tabElement.getBoundingClientRect();
|
|
77
85
|
dragState.phase = 'pending';
|
|
78
86
|
dragState.source = {
|
|
@@ -205,6 +213,10 @@ function commit() {
|
|
|
205
213
|
autoCloseEmptyFloat(source.sourceRoot);
|
|
206
214
|
}
|
|
207
215
|
function teardown() {
|
|
216
|
+
if (activeDragPointerId !== null) {
|
|
217
|
+
revoke(activeDragPointerId, 'sh3:tabdrag');
|
|
218
|
+
activeDragPointerId = null;
|
|
219
|
+
}
|
|
208
220
|
dragState.phase = 'idle';
|
|
209
221
|
dragState.source = null;
|
|
210
222
|
dragState.target = null;
|
|
@@ -244,4 +256,5 @@ export function __resetDragStateForTest() {
|
|
|
244
256
|
clickSuppressedUntil = 0;
|
|
245
257
|
pendingStartX = 0;
|
|
246
258
|
pendingStartY = 0;
|
|
259
|
+
activeDragPointerId = null;
|
|
247
260
|
}
|
|
@@ -29,7 +29,7 @@ describe('layout schema v4 → v5 backward compatibility', () => {
|
|
|
29
29
|
expect(slotA.role).toBeUndefined();
|
|
30
30
|
expect(v4Blob.drawers).toBeUndefined();
|
|
31
31
|
});
|
|
32
|
-
it('LAYOUT_SCHEMA_VERSION is
|
|
33
|
-
expect(LAYOUT_SCHEMA_VERSION).toBe(
|
|
32
|
+
it('LAYOUT_SCHEMA_VERSION is 6', () => {
|
|
33
|
+
expect(LAYOUT_SCHEMA_VERSION).toBe(6);
|
|
34
34
|
});
|
|
35
35
|
});
|
package/dist/layout/types.d.ts
CHANGED
|
@@ -89,6 +89,14 @@ export interface TabsNode {
|
|
|
89
89
|
* Runtime-only — not serialized with the layout tree.
|
|
90
90
|
*/
|
|
91
91
|
emptyRenderer?: (container: HTMLElement) => void;
|
|
92
|
+
/**
|
|
93
|
+
* When the framework renders this group as a compact-mode carousel,
|
|
94
|
+
* controls end-of-track behavior:
|
|
95
|
+
* - false (default): edge-resist (rubber-band, snap back).
|
|
96
|
+
* - true: warpback (swipe past last → first; past first → last).
|
|
97
|
+
* Inert when not rendered as a carousel.
|
|
98
|
+
*/
|
|
99
|
+
wrap?: boolean;
|
|
92
100
|
}
|
|
93
101
|
/**
|
|
94
102
|
* A leaf layout node that holds a single mounted view. `slotId` is the stable
|
|
@@ -207,7 +215,7 @@ export type TreeRootRef = {
|
|
|
207
215
|
* the default tree takes over — phase 7 deliberately does not ship a
|
|
208
216
|
* migration framework, only the hook for one.
|
|
209
217
|
*/
|
|
210
|
-
export declare const LAYOUT_SCHEMA_VERSION =
|
|
218
|
+
export declare const LAYOUT_SCHEMA_VERSION = 6;
|
|
211
219
|
/**
|
|
212
220
|
* The wire shape of a persisted layout in the workspace state zone.
|
|
213
221
|
* One blob per sh3 (or per program, once per-program layouts exist);
|
package/dist/layout/types.js
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|