sh3-core 0.19.1 → 0.19.3
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 +3 -1
- package/dist/actions/menuBarModel.js +8 -0
- package/dist/actions/menuBarModel.test.js +61 -0
- package/dist/app/admin/ApiKeysView.svelte +6 -5
- package/dist/app/store/PermissionConfirmModal.svelte +23 -0
- package/dist/app/store/PermissionConfirmModal.svelte.d.ts +1 -0
- package/dist/app/store/StoreView.svelte +6 -1
- package/dist/chrome/CompactChrome.svelte.test.js +7 -4
- package/dist/env/client.d.ts +5 -4
- package/dist/env/client.js +11 -17
- package/dist/env/serverUrl.d.ts +2 -0
- package/dist/env/serverUrl.js +8 -0
- package/dist/gestures/index.d.ts +17 -0
- package/dist/gestures/index.js +27 -0
- package/dist/keys/client.js +6 -7
- package/dist/keys/revocation-bus.svelte.js +11 -1
- package/dist/layout/compact/CarouselTabs.svelte +150 -14
- package/dist/layout/compact/CarouselTabs.svelte.test.js +222 -2
- package/dist/layout/compact/CompactRenderer.svelte +1 -1
- package/dist/layout/compact/CompactRenderer.svelte.test.js +5 -3
- package/dist/layout/compact/derive.js +7 -16
- package/dist/layout/compact/derive.test.js +30 -9
- package/dist/layout/drag.svelte.js +16 -3
- package/dist/layout/inspection.d.ts +20 -9
- package/dist/layout/inspection.js +66 -11
- package/dist/layout/inspection.svelte.test.d.ts +1 -0
- package/dist/layout/inspection.svelte.test.js +114 -0
- package/dist/layout/store.schemaVersion.test.js +2 -2
- package/dist/layout/types.d.ts +11 -8
- package/dist/layout/types.js +1 -1
- package/dist/layout/types.test.js +2 -2
- package/dist/overlays/FloatFrame.svelte +93 -22
- package/dist/primitives/ResizableSplitter.svelte +42 -8
- package/dist/registry/checkFetch.d.ts +6 -0
- package/dist/registry/checkFetch.js +23 -0
- package/dist/sh3/views/KeysAndPeers.svelte +4 -3
- package/dist/shards/activate-runtime.test.js +99 -1
- package/dist/shards/activate.svelte.js +12 -3
- package/dist/shards/registry.d.ts +8 -1
- package/dist/shards/registry.js +13 -2
- package/dist/shards/registry.test.js +25 -4
- package/dist/shards/types.d.ts +14 -1
- package/dist/shell-shard/ScrollbackView.svelte +145 -67
- package/dist/shell-shard/ScrollbackView.svelte.test.d.ts +1 -0
- package/dist/shell-shard/ScrollbackView.svelte.test.js +182 -0
- package/dist/shell-shard/dispatch-gating.test.js +38 -2
- package/dist/shell-shard/dispatch.js +9 -1
- package/dist/shell-shard/registry-resolve.test.js +50 -0
- package/dist/shell-shard/registry.d.ts +2 -1
- package/dist/shell-shard/registry.js +12 -2
- package/dist/shell-shard/verbs/help.js +5 -4
- package/dist/shell-shard/verbs/help.svelte.test.js +5 -2
- package/dist/verbs/types.d.ts +10 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import SlotContainer from '../SlotContainer.svelte';
|
|
20
20
|
import SlotDropZone from '../SlotDropZone.svelte';
|
|
21
21
|
import { claim, revoke, isOwner } from '../../gestures/pointerClaim';
|
|
22
|
-
import { ancestorCount } from '../../gestures';
|
|
22
|
+
import { ancestorCount, EDGE_PX, logGesture } from '../../gestures';
|
|
23
23
|
|
|
24
24
|
let {
|
|
25
25
|
node,
|
|
@@ -62,6 +62,27 @@
|
|
|
62
62
|
let dragging = $state(false);
|
|
63
63
|
let claimed = $state(false);
|
|
64
64
|
let activePointerId: number | null = null;
|
|
65
|
+
/**
|
|
66
|
+
* Set to true when the pointer-down landed inside the left/right edge
|
|
67
|
+
* gutter. Gutter-initiated drags get the "invincible" treatment: once
|
|
68
|
+
* the axis-lock threshold is crossed we explicitly call
|
|
69
|
+
* `setPointerCapture` on the carousel container so the descendant the
|
|
70
|
+
* touch originally landed on (textarea, scroll region, preview canvas
|
|
71
|
+
* — see the earlier `cancel-our-id` log targets) no longer receives the
|
|
72
|
+
* pointer stream and therefore cannot fire `pointercancel` to abort us.
|
|
73
|
+
*
|
|
74
|
+
* Mid-track drags don't get capture — the documented contract is that
|
|
75
|
+
* a descendant claiming the gesture there wins.
|
|
76
|
+
*/
|
|
77
|
+
let startedInGutter = false;
|
|
78
|
+
/**
|
|
79
|
+
* Soft window (`performance.now()` deadline) during which a single
|
|
80
|
+
* `pointercancel` is treated as the synthetic Android transfer-cancel
|
|
81
|
+
* that fires when `setPointerCapture` moves implicit capture from a
|
|
82
|
+
* descendant up to the container. Without this filter, the carousel's
|
|
83
|
+
* own capture call would immediately abort itself.
|
|
84
|
+
*/
|
|
85
|
+
let ignoreCancelUntil = 0;
|
|
65
86
|
|
|
66
87
|
type PointerSnapshot = { id: number; x: number; y: number; t: number };
|
|
67
88
|
let downSnap: PointerSnapshot | null = null;
|
|
@@ -72,6 +93,16 @@
|
|
|
72
93
|
const COMMIT_FRACTION = 0.4;
|
|
73
94
|
const COMMIT_VELOCITY = 500;
|
|
74
95
|
const RUBBER_BAND_MAX_FRACTION = 0.3;
|
|
96
|
+
/** Window (ms) during which the post-transfer pointercancel is swallowed. */
|
|
97
|
+
const TRANSFER_CANCEL_WINDOW_MS = 100;
|
|
98
|
+
|
|
99
|
+
function dbg(label: string, ev: PointerEvent | null): void {
|
|
100
|
+
logGesture(`carousel:${label}`, ev, {
|
|
101
|
+
claimed,
|
|
102
|
+
dragging,
|
|
103
|
+
downId: downSnap?.id,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
75
106
|
/**
|
|
76
107
|
* If the pointer takes longer than this to cross the axis-lock threshold,
|
|
77
108
|
* treat the gesture as text-selection intent (slow drag over text) and
|
|
@@ -80,16 +111,45 @@
|
|
|
80
111
|
*/
|
|
81
112
|
const SLOW_DRAG_TIMEOUT_MS = 250;
|
|
82
113
|
|
|
114
|
+
/**
|
|
115
|
+
* True only when an ancestor has scrollable horizontal overflow that
|
|
116
|
+
* *actually* overflows. `overflow-x: auto` on a wrapper that doesn't
|
|
117
|
+
* exceed its clientWidth used to disqualify every swipe inside it — a
|
|
118
|
+
* common false positive for body views that set `overflow: auto` to get
|
|
119
|
+
* vertical scrolling. We now also require `scrollWidth > clientWidth`
|
|
120
|
+
* so only genuinely scrollable regions block the carousel.
|
|
121
|
+
*/
|
|
83
122
|
function hasNativeHorizontalScroll(target: EventTarget | null): boolean {
|
|
84
123
|
let el = target as HTMLElement | null;
|
|
85
124
|
while (el && el !== containerEl) {
|
|
86
125
|
const ox = getComputedStyle(el).overflowX;
|
|
87
|
-
if (ox === 'auto' || ox === 'scroll')
|
|
126
|
+
if ((ox === 'auto' || ox === 'scroll') && el.scrollWidth > el.clientWidth) {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
88
129
|
el = el.parentElement;
|
|
89
130
|
}
|
|
90
131
|
return false;
|
|
91
132
|
}
|
|
92
133
|
|
|
134
|
+
/**
|
|
135
|
+
* True when the pointer-down x sits in the left or right EDGE_PX strip of
|
|
136
|
+
* the carousel container. Edge-gutter pointer-downs claim the swipe
|
|
137
|
+
* regardless of editable-target or native-horizontal-scroll content
|
|
138
|
+
* underneath — the gutter is documented as an unconditional swipe
|
|
139
|
+
* initiation zone. Without this, any input or `overflow: auto` region
|
|
140
|
+
* touching the edge silently kills edge swipes.
|
|
141
|
+
*/
|
|
142
|
+
function isInEdgeGutter(clientX: number): boolean {
|
|
143
|
+
if (!containerEl || containerWidth === 0) return false;
|
|
144
|
+
// rect.left for the offset, containerWidth (reactive, ResizeObserver-fed)
|
|
145
|
+
// for the width — getBoundingClientRect().width is 0 under happy-dom and
|
|
146
|
+
// similar non-layout DOMs, while containerWidth is seeded from the
|
|
147
|
+
// ResizeObserver path used everywhere else in this component.
|
|
148
|
+
const rect = containerEl.getBoundingClientRect();
|
|
149
|
+
const local = clientX - rect.left;
|
|
150
|
+
return local < EDGE_PX || local > containerWidth - EDGE_PX;
|
|
151
|
+
}
|
|
152
|
+
|
|
93
153
|
function isEditableTarget(target: EventTarget | null): boolean {
|
|
94
154
|
const el = target as HTMLElement | null;
|
|
95
155
|
if (!el || typeof el.tagName !== 'string') return false;
|
|
@@ -118,16 +178,28 @@
|
|
|
118
178
|
}
|
|
119
179
|
|
|
120
180
|
function onPointerDown(ev: PointerEvent) {
|
|
121
|
-
|
|
122
|
-
|
|
181
|
+
// Structural bails — apply regardless of where the touch lands. Without
|
|
182
|
+
// these the carousel can't even set up a meaningful gesture.
|
|
123
183
|
if (tabCount < 2) return;
|
|
124
184
|
if (containerWidth === 0) return;
|
|
125
185
|
// Multi-touch (pinch-zoom etc.) is not a swipe — bail.
|
|
126
186
|
if (ev.isPrimary === false) return;
|
|
187
|
+
// Edge gutter override: pointer-downs in the left/right EDGE_PX strip
|
|
188
|
+
// claim the swipe unconditionally. This is the published invariant for
|
|
189
|
+
// edge initiation; bailing here would re-introduce the bug where any
|
|
190
|
+
// editable target or overflow-auto wrapper sitting near the screen edge
|
|
191
|
+
// silently kills the gesture.
|
|
192
|
+
const inGutter = isInEdgeGutter(ev.clientX);
|
|
193
|
+
if (!inGutter) {
|
|
194
|
+
if (isEditableTarget(ev.target)) return;
|
|
195
|
+
if (hasNativeHorizontalScroll(ev.target)) return;
|
|
196
|
+
}
|
|
127
197
|
const depth = containerEl ? ancestorCount(containerEl) : 0;
|
|
128
198
|
const claimGranted = claim(ev.pointerId, { ownerId: 'sh3:carousel', axis: 'x', priority: 'normal', depth });
|
|
129
199
|
if (!claimGranted) return;
|
|
130
200
|
activePointerId = ev.pointerId;
|
|
201
|
+
startedInGutter = inGutter;
|
|
202
|
+
ignoreCancelUntil = 0;
|
|
131
203
|
downSnap = { id: ev.pointerId, x: ev.clientX, y: ev.clientY, t: performance.now() };
|
|
132
204
|
lastSnap = { ...downSnap };
|
|
133
205
|
dragging = false;
|
|
@@ -140,7 +212,11 @@
|
|
|
140
212
|
|
|
141
213
|
function onPointerMove(ev: PointerEvent) {
|
|
142
214
|
if (!downSnap || ev.pointerId !== downSnap.id) return;
|
|
143
|
-
if (!isOwner(ev.pointerId, 'sh3:carousel')) {
|
|
215
|
+
if (!isOwner(ev.pointerId, 'sh3:carousel')) {
|
|
216
|
+
dbg('claim-stolen', ev);
|
|
217
|
+
endGesture();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
144
220
|
const dx = ev.clientX - downSnap.x;
|
|
145
221
|
const dy = ev.clientY - downSnap.y;
|
|
146
222
|
if (!claimed) {
|
|
@@ -159,12 +235,29 @@
|
|
|
159
235
|
if (!horizDominates) return;
|
|
160
236
|
claimed = true;
|
|
161
237
|
dragging = true;
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
238
|
+
// Gutter-initiated drags get the "invincible" guarantee: transfer
|
|
239
|
+
// implicit pointer capture from the descendant the touch landed on
|
|
240
|
+
// (a TEXTAREA, scroll region, etc.) up to the carousel container so
|
|
241
|
+
// those descendants can no longer fire pointercancel mid-pan. The
|
|
242
|
+
// transfer itself fires one synthetic pointercancel on Android (see
|
|
243
|
+
// CarouselTabs commit 638d75a for the original incident); the
|
|
244
|
+
// ignoreCancelUntil window below swallows just that one cancel —
|
|
245
|
+
// every other cancel still aborts as before.
|
|
246
|
+
//
|
|
247
|
+
// Mid-track drags intentionally skip this: the contract there is
|
|
248
|
+
// that a descendant claiming the gesture wins, matching the
|
|
249
|
+
// "carousel aborts if it's caught by an inner element" behavior
|
|
250
|
+
// the rest of the bail logic already encodes.
|
|
251
|
+
if (startedInGutter && containerEl) {
|
|
252
|
+
try {
|
|
253
|
+
containerEl.setPointerCapture(ev.pointerId);
|
|
254
|
+
ignoreCancelUntil = performance.now() + TRANSFER_CANCEL_WINDOW_MS;
|
|
255
|
+
} catch {
|
|
256
|
+
// setPointerCapture can throw if the pointer is no longer
|
|
257
|
+
// active (race with platform-level cancel). Safe to swallow —
|
|
258
|
+
// the gesture will run on implicit capture as before.
|
|
259
|
+
}
|
|
260
|
+
}
|
|
168
261
|
// Clear any selection that began during the pre-claim window so
|
|
169
262
|
// it doesn't visually streak across the slide while dragging.
|
|
170
263
|
if (typeof window !== 'undefined') {
|
|
@@ -177,8 +270,14 @@
|
|
|
177
270
|
}
|
|
178
271
|
|
|
179
272
|
function onPointerUp(ev: PointerEvent) {
|
|
180
|
-
if (!downSnap
|
|
181
|
-
|
|
273
|
+
if (!downSnap) return;
|
|
274
|
+
// Filter by pointer id. A pointerup for a different pointer (e.g. a
|
|
275
|
+
// second finger lifting, a stylus releasing while a finger drag is
|
|
276
|
+
// active, or a phantom multi-pointer) must NOT tear down our gesture —
|
|
277
|
+
// earlier behavior here was to call endGesture() on any unrelated
|
|
278
|
+
// pointerup, which dropped legitimate drags on multi-touch devices.
|
|
279
|
+
if (ev.pointerId !== downSnap.id) {
|
|
280
|
+
dbg('pointerup-other-id', ev);
|
|
182
281
|
return;
|
|
183
282
|
}
|
|
184
283
|
const dx = ev.clientX - downSnap.x;
|
|
@@ -198,12 +297,47 @@
|
|
|
198
297
|
* mid-drag would be read as a release at the current dx and trip
|
|
199
298
|
* the commit threshold, "auto-completing" the swipe with the
|
|
200
299
|
* finger still down.
|
|
300
|
+
*
|
|
301
|
+
* MUST filter by pointer id. The previous unconditional implementation
|
|
302
|
+
* meant any pointercancel — palm contact, ghost touches, a stylus
|
|
303
|
+
* cancellation while a finger drag was active — terminated the carousel
|
|
304
|
+
* gesture. That's the most likely cause of "drag releases mid-pan on
|
|
305
|
+
* some screens" reports.
|
|
201
306
|
*/
|
|
202
|
-
function onPointerCancel(
|
|
307
|
+
function onPointerCancel(ev: PointerEvent) {
|
|
308
|
+
if (!downSnap) return;
|
|
309
|
+
if (ev.pointerId !== downSnap.id) {
|
|
310
|
+
dbg('cancel-other-id', ev);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
// Single-shot swallow for the synthetic Android transfer-cancel that
|
|
314
|
+
// fires right after we call `setPointerCapture` on a gutter swipe.
|
|
315
|
+
// Clear the deadline once consumed so a real cancel later in the same
|
|
316
|
+
// gesture still aborts normally.
|
|
317
|
+
if (performance.now() < ignoreCancelUntil) {
|
|
318
|
+
dbg('cancel-ignored-transfer', ev);
|
|
319
|
+
ignoreCancelUntil = 0;
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
dbg('cancel-our-id', ev);
|
|
203
323
|
endGesture();
|
|
204
324
|
}
|
|
205
325
|
|
|
206
326
|
function endGesture() {
|
|
327
|
+
// Release explicit pointer capture if we acquired it for a gutter
|
|
328
|
+
// swipe. Browsers auto-release on pointerup/cancel, but doing it
|
|
329
|
+
// defensively here keeps the container's `hasPointerCapture` state
|
|
330
|
+
// clean if endGesture is called from a path that didn't naturally
|
|
331
|
+
// release (claim-stolen, unmount, etc.).
|
|
332
|
+
if (startedInGutter && containerEl && activePointerId !== null) {
|
|
333
|
+
try {
|
|
334
|
+
if (containerEl.hasPointerCapture?.(activePointerId)) {
|
|
335
|
+
containerEl.releasePointerCapture(activePointerId);
|
|
336
|
+
}
|
|
337
|
+
} catch {
|
|
338
|
+
// Defensive — happy-dom / older browsers may not implement it.
|
|
339
|
+
}
|
|
340
|
+
}
|
|
207
341
|
if (activePointerId !== null) {
|
|
208
342
|
revoke(activePointerId, 'sh3:carousel');
|
|
209
343
|
activePointerId = null;
|
|
@@ -216,6 +350,8 @@
|
|
|
216
350
|
dragging = false;
|
|
217
351
|
claimed = false;
|
|
218
352
|
dragDelta = 0;
|
|
353
|
+
startedInGutter = false;
|
|
354
|
+
ignoreCancelUntil = 0;
|
|
219
355
|
}
|
|
220
356
|
|
|
221
357
|
function commitOrSnap(dx: number, velocity: number) {
|
|
@@ -202,25 +202,32 @@ describe('CarouselTabs (gestures)', () => {
|
|
|
202
202
|
flushSync();
|
|
203
203
|
expect(node.activeTab).toBe(0);
|
|
204
204
|
});
|
|
205
|
-
it('pointerdown inside an overflow-x:scroll element does not initiate a swipe', () => {
|
|
205
|
+
it('pointerdown inside an overflow-x:scroll element with actual horizontal overflow does not initiate a swipe', () => {
|
|
206
206
|
const node = makeNode(['A', 'B', 'C'], 0);
|
|
207
207
|
mountCarousel({ node, wrap: false });
|
|
208
208
|
const slide = host.querySelector('[data-sh3-slide]');
|
|
209
209
|
const scroller = document.createElement('div');
|
|
210
210
|
scroller.style.overflowX = 'scroll';
|
|
211
|
+
Object.defineProperty(scroller, 'scrollWidth', { value: 1000, configurable: true });
|
|
212
|
+
Object.defineProperty(scroller, 'clientWidth', { value: 200, configurable: true });
|
|
211
213
|
slide.appendChild(scroller);
|
|
214
|
+
// Pointer-down in the center (x=250 < 276 right-gutter boundary).
|
|
215
|
+
// dx=-170 would commit if the bail were absent, so a passing test
|
|
216
|
+
// genuinely proves the gesture never started.
|
|
212
217
|
scroller.dispatchEvent(fakePointer('pointerdown', 250, 100));
|
|
213
218
|
document.dispatchEvent(fakePointer('pointermove', 80, 100));
|
|
214
219
|
document.dispatchEvent(fakePointer('pointerup', 80, 100));
|
|
215
220
|
flushSync();
|
|
216
221
|
expect(node.activeTab).toBe(0);
|
|
217
222
|
});
|
|
218
|
-
it('pointerdown inside an overflow-x:auto element does not initiate a swipe', () => {
|
|
223
|
+
it('pointerdown inside an overflow-x:auto element with actual horizontal overflow does not initiate a swipe', () => {
|
|
219
224
|
const node = makeNode(['A', 'B', 'C'], 0);
|
|
220
225
|
mountCarousel({ node, wrap: false });
|
|
221
226
|
const slide = host.querySelector('[data-sh3-slide]');
|
|
222
227
|
const scroller = document.createElement('div');
|
|
223
228
|
scroller.style.overflowX = 'auto';
|
|
229
|
+
Object.defineProperty(scroller, 'scrollWidth', { value: 1000, configurable: true });
|
|
230
|
+
Object.defineProperty(scroller, 'clientWidth', { value: 200, configurable: true });
|
|
224
231
|
slide.appendChild(scroller);
|
|
225
232
|
scroller.dispatchEvent(fakePointer('pointerdown', 250, 100));
|
|
226
233
|
document.dispatchEvent(fakePointer('pointermove', 80, 100));
|
|
@@ -228,6 +235,25 @@ describe('CarouselTabs (gestures)', () => {
|
|
|
228
235
|
flushSync();
|
|
229
236
|
expect(node.activeTab).toBe(0);
|
|
230
237
|
});
|
|
238
|
+
it('overflow-x:auto with no actual horizontal overflow does NOT block the swipe', () => {
|
|
239
|
+
// The common false-positive: a body view sets `overflow: auto` to get
|
|
240
|
+
// vertical scrolling; the resolved overflow-x is `auto` even though the
|
|
241
|
+
// content fits horizontally. Previously this killed every carousel swipe
|
|
242
|
+
// initiated inside the body. With the scrollWidth>clientWidth tightening,
|
|
243
|
+
// only genuinely scrollable regions block.
|
|
244
|
+
const node = makeNode(['A', 'B', 'C'], 0);
|
|
245
|
+
mountCarousel({ node, wrap: false });
|
|
246
|
+
const slide = host.querySelector('[data-sh3-slide]');
|
|
247
|
+
const wrapper = document.createElement('div');
|
|
248
|
+
wrapper.style.overflowX = 'auto';
|
|
249
|
+
// scrollWidth / clientWidth default to 0 in happy-dom — no actual overflow.
|
|
250
|
+
slide.appendChild(wrapper);
|
|
251
|
+
wrapper.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(1);
|
|
256
|
+
});
|
|
231
257
|
it('pointerdown inside an overflow-x:hidden element still allows swipe', () => {
|
|
232
258
|
const node = makeNode(['A', 'B', 'C'], 0);
|
|
233
259
|
mountCarousel({ node, wrap: false });
|
|
@@ -267,6 +293,200 @@ describe('CarouselTabs (gestures)', () => {
|
|
|
267
293
|
expect(track.classList.contains('sh3-carousel-track--dragging')).toBe(false);
|
|
268
294
|
});
|
|
269
295
|
});
|
|
296
|
+
describe('CarouselTabs — multi-pointer filter', () => {
|
|
297
|
+
// The carousel attaches its pointermove / pointerup / pointercancel
|
|
298
|
+
// listeners on `document`, so any pointer's events for those types
|
|
299
|
+
// reach our handler. Without filtering by pointer id, an unrelated
|
|
300
|
+
// pointer's release or cancel (palm contact, second finger, stylus
|
|
301
|
+
// ghost) would tear down a legitimate drag.
|
|
302
|
+
it('pointercancel for a DIFFERENT pointer id does not abort the active drag', () => {
|
|
303
|
+
const node = makeNode(['A', 'B', 'C'], 0);
|
|
304
|
+
mountCarousel({ node, wrap: false });
|
|
305
|
+
const track = host.querySelector('[data-sh3-carousel-track]');
|
|
306
|
+
// Drag with pointer id 1.
|
|
307
|
+
track.dispatchEvent(fakePointer('pointerdown', 250, 100, 1));
|
|
308
|
+
document.dispatchEvent(fakePointer('pointermove', 200, 100, 1));
|
|
309
|
+
document.dispatchEvent(fakePointer('pointermove', 100, 100, 1));
|
|
310
|
+
// A different pointer (id 2) is cancelled — must NOT end our gesture.
|
|
311
|
+
document.dispatchEvent(fakePointer('pointercancel', 0, 0, 2));
|
|
312
|
+
// Our pointer keeps going past the commit threshold and releases.
|
|
313
|
+
document.dispatchEvent(fakePointer('pointermove', 80, 100, 1));
|
|
314
|
+
document.dispatchEvent(fakePointer('pointerup', 80, 100, 1));
|
|
315
|
+
flushSync();
|
|
316
|
+
expect(node.activeTab).toBe(1);
|
|
317
|
+
});
|
|
318
|
+
it('pointerup for a DIFFERENT pointer id does not end the active drag', () => {
|
|
319
|
+
const node = makeNode(['A', 'B', 'C'], 0);
|
|
320
|
+
mountCarousel({ node, wrap: false });
|
|
321
|
+
const track = host.querySelector('[data-sh3-carousel-track]');
|
|
322
|
+
track.dispatchEvent(fakePointer('pointerdown', 250, 100, 1));
|
|
323
|
+
document.dispatchEvent(fakePointer('pointermove', 200, 100, 1));
|
|
324
|
+
document.dispatchEvent(fakePointer('pointermove', 100, 100, 1));
|
|
325
|
+
// A different pointer (id 2) is released — must NOT end our gesture.
|
|
326
|
+
document.dispatchEvent(fakePointer('pointerup', 0, 0, 2));
|
|
327
|
+
document.dispatchEvent(fakePointer('pointermove', 80, 100, 1));
|
|
328
|
+
document.dispatchEvent(fakePointer('pointerup', 80, 100, 1));
|
|
329
|
+
flushSync();
|
|
330
|
+
expect(node.activeTab).toBe(1);
|
|
331
|
+
});
|
|
332
|
+
it('pointercancel for OUR pointer id still aborts', () => {
|
|
333
|
+
// Sanity check — the existing abort-on-cancel behavior is preserved
|
|
334
|
+
// when the cancellation is for our active pointer.
|
|
335
|
+
const node = makeNode(['A', 'B', 'C'], 0);
|
|
336
|
+
mountCarousel({ node, wrap: false });
|
|
337
|
+
const track = host.querySelector('[data-sh3-carousel-track]');
|
|
338
|
+
track.dispatchEvent(fakePointer('pointerdown', 250, 100, 1));
|
|
339
|
+
document.dispatchEvent(fakePointer('pointermove', 200, 100, 1));
|
|
340
|
+
document.dispatchEvent(fakePointer('pointermove', 100, 100, 1));
|
|
341
|
+
document.dispatchEvent(fakePointer('pointercancel', 100, 100, 1));
|
|
342
|
+
flushSync();
|
|
343
|
+
expect(node.activeTab).toBe(0);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
describe('CarouselTabs — edge gutter invariant', () => {
|
|
347
|
+
// EDGE_PX = 24. Container is 300px wide. Left gutter: x < 24.
|
|
348
|
+
// Right gutter: x > 276.
|
|
349
|
+
it('left-edge pointer-down initiates a swipe even on an editable target', () => {
|
|
350
|
+
// Without the gutter override, an <input> sitting under the finger at
|
|
351
|
+
// the screen edge would silently swallow the drag (isEditableTarget bail).
|
|
352
|
+
const node = makeNode(['A', 'B', 'C'], 1);
|
|
353
|
+
mountCarousel({ node, wrap: false });
|
|
354
|
+
const slide = host.querySelectorAll('[data-sh3-slide]')[1];
|
|
355
|
+
const input = document.createElement('input');
|
|
356
|
+
slide.appendChild(input);
|
|
357
|
+
// x=10 — inside left edge gutter. Swipe right → previous tab.
|
|
358
|
+
input.dispatchEvent(fakePointer('pointerdown', 10, 100));
|
|
359
|
+
document.dispatchEvent(fakePointer('pointermove', 80, 100));
|
|
360
|
+
document.dispatchEvent(fakePointer('pointermove', 200, 100));
|
|
361
|
+
document.dispatchEvent(fakePointer('pointerup', 200, 100));
|
|
362
|
+
flushSync();
|
|
363
|
+
expect(node.activeTab).toBe(0);
|
|
364
|
+
});
|
|
365
|
+
it('right-edge pointer-down initiates a swipe even inside an overflow-x:auto region with real overflow', () => {
|
|
366
|
+
const node = makeNode(['A', 'B', 'C'], 0);
|
|
367
|
+
mountCarousel({ node, wrap: false });
|
|
368
|
+
const slide = host.querySelectorAll('[data-sh3-slide]')[0];
|
|
369
|
+
const scroller = document.createElement('div');
|
|
370
|
+
scroller.style.overflowX = 'auto';
|
|
371
|
+
Object.defineProperty(scroller, 'scrollWidth', { value: 1000, configurable: true });
|
|
372
|
+
Object.defineProperty(scroller, 'clientWidth', { value: 200, configurable: true });
|
|
373
|
+
slide.appendChild(scroller);
|
|
374
|
+
// x=290 — inside right edge gutter (300 - 24 = 276). Swipe left → next tab.
|
|
375
|
+
scroller.dispatchEvent(fakePointer('pointerdown', 290, 100));
|
|
376
|
+
document.dispatchEvent(fakePointer('pointermove', 200, 100));
|
|
377
|
+
document.dispatchEvent(fakePointer('pointermove', 80, 100));
|
|
378
|
+
document.dispatchEvent(fakePointer('pointerup', 80, 100));
|
|
379
|
+
flushSync();
|
|
380
|
+
expect(node.activeTab).toBe(1);
|
|
381
|
+
});
|
|
382
|
+
it('left-edge pointer-down on a contenteditable still initiates a swipe', () => {
|
|
383
|
+
const node = makeNode(['A', 'B', 'C'], 1);
|
|
384
|
+
mountCarousel({ node, wrap: false });
|
|
385
|
+
const slide = host.querySelectorAll('[data-sh3-slide]')[1];
|
|
386
|
+
const editable = document.createElement('div');
|
|
387
|
+
editable.setAttribute('contenteditable', 'true');
|
|
388
|
+
slide.appendChild(editable);
|
|
389
|
+
editable.dispatchEvent(fakePointer('pointerdown', 5, 100));
|
|
390
|
+
document.dispatchEvent(fakePointer('pointermove', 80, 100));
|
|
391
|
+
document.dispatchEvent(fakePointer('pointermove', 200, 100));
|
|
392
|
+
document.dispatchEvent(fakePointer('pointerup', 200, 100));
|
|
393
|
+
flushSync();
|
|
394
|
+
expect(node.activeTab).toBe(0);
|
|
395
|
+
});
|
|
396
|
+
it('gutter pointer-down: transfers pointer capture to the carousel container on threshold-cross', () => {
|
|
397
|
+
// Concrete proof that the "invincible gutter" mechanism actually fires:
|
|
398
|
+
// once horizontal dominance is established on a gutter-initiated drag,
|
|
399
|
+
// setPointerCapture must be called on the carousel container so the
|
|
400
|
+
// descendant the touch landed on no longer receives the pointer stream.
|
|
401
|
+
const node = makeNode(['A', 'B', 'C'], 1);
|
|
402
|
+
mountCarousel({ node, wrap: false });
|
|
403
|
+
const carousel = host.querySelector('.sh3-carousel');
|
|
404
|
+
const track = host.querySelector('[data-sh3-carousel-track]');
|
|
405
|
+
let captured = null;
|
|
406
|
+
carousel.setPointerCapture = (id) => { captured = id; };
|
|
407
|
+
carousel.hasPointerCapture = (id) => captured === id;
|
|
408
|
+
carousel.releasePointerCapture = (id) => { if (captured === id)
|
|
409
|
+
captured = null; };
|
|
410
|
+
track.dispatchEvent(fakePointer('pointerdown', 10, 100, 7)); // left gutter
|
|
411
|
+
document.dispatchEvent(fakePointer('pointermove', 80, 100, 7)); // crosses threshold
|
|
412
|
+
expect(captured).toBe(7);
|
|
413
|
+
});
|
|
414
|
+
it('mid-track pointer-down: does NOT transfer pointer capture (contract preserved)', () => {
|
|
415
|
+
// The "invincible" treatment is scoped to the gutter on purpose. Inside
|
|
416
|
+
// the slide, the existing rule still holds: descendants can claim and
|
|
417
|
+
// the carousel aborts.
|
|
418
|
+
const node = makeNode(['A', 'B', 'C'], 1);
|
|
419
|
+
mountCarousel({ node, wrap: false });
|
|
420
|
+
const carousel = host.querySelector('.sh3-carousel');
|
|
421
|
+
const track = host.querySelector('[data-sh3-carousel-track]');
|
|
422
|
+
let captured = null;
|
|
423
|
+
carousel.setPointerCapture = (id) => { captured = id; };
|
|
424
|
+
carousel.hasPointerCapture = (id) => captured === id;
|
|
425
|
+
carousel.releasePointerCapture = (id) => { if (captured === id)
|
|
426
|
+
captured = null; };
|
|
427
|
+
track.dispatchEvent(fakePointer('pointerdown', 150, 100, 7)); // center, not gutter
|
|
428
|
+
document.dispatchEvent(fakePointer('pointermove', 80, 100, 7));
|
|
429
|
+
expect(captured).toBeNull();
|
|
430
|
+
});
|
|
431
|
+
it('gutter swipe: a pointercancel within the transfer window is swallowed, drag continues', () => {
|
|
432
|
+
// Synthetic Android-style "transfer cancel": setPointerCapture on the
|
|
433
|
+
// container fires a pointercancel on the original target. Without the
|
|
434
|
+
// ignoreCancelUntil window, that cancel would abort the very drag the
|
|
435
|
+
// gutter just guaranteed.
|
|
436
|
+
const node = makeNode(['A', 'B', 'C'], 1);
|
|
437
|
+
mountCarousel({ node, wrap: false });
|
|
438
|
+
const carousel = host.querySelector('.sh3-carousel');
|
|
439
|
+
const track = host.querySelector('[data-sh3-carousel-track]');
|
|
440
|
+
carousel.setPointerCapture = () => { };
|
|
441
|
+
carousel.hasPointerCapture = () => false;
|
|
442
|
+
carousel.releasePointerCapture = () => { };
|
|
443
|
+
track.dispatchEvent(fakePointer('pointerdown', 10, 100, 9)); // left gutter
|
|
444
|
+
document.dispatchEvent(fakePointer('pointermove', 80, 100, 9)); // crosses threshold → capture armed
|
|
445
|
+
// Transfer cancel — should be swallowed:
|
|
446
|
+
document.dispatchEvent(fakePointer('pointercancel', 80, 100, 9));
|
|
447
|
+
// Drag continues rightward; with starting activeTab=1, rightward swipe
|
|
448
|
+
// (positive dx) goes to previous tab (activeTab=0).
|
|
449
|
+
document.dispatchEvent(fakePointer('pointermove', 200, 100, 9));
|
|
450
|
+
document.dispatchEvent(fakePointer('pointerup', 200, 100, 9));
|
|
451
|
+
flushSync();
|
|
452
|
+
expect(node.activeTab).toBe(0);
|
|
453
|
+
});
|
|
454
|
+
it('gutter swipe: only the FIRST cancel within the window is swallowed; a second cancel aborts', () => {
|
|
455
|
+
// Defensive: the swallow is single-shot. After one cancel the window
|
|
456
|
+
// closes so a subsequent real cancel still aborts the drag.
|
|
457
|
+
const node = makeNode(['A', 'B', 'C'], 1);
|
|
458
|
+
mountCarousel({ node, wrap: false });
|
|
459
|
+
const carousel = host.querySelector('.sh3-carousel');
|
|
460
|
+
const track = host.querySelector('[data-sh3-carousel-track]');
|
|
461
|
+
carousel.setPointerCapture = () => { };
|
|
462
|
+
carousel.hasPointerCapture = () => false;
|
|
463
|
+
carousel.releasePointerCapture = () => { };
|
|
464
|
+
track.dispatchEvent(fakePointer('pointerdown', 10, 100, 11));
|
|
465
|
+
document.dispatchEvent(fakePointer('pointermove', 80, 100, 11)); // claim, arm window
|
|
466
|
+
document.dispatchEvent(fakePointer('pointercancel', 80, 100, 11)); // swallow
|
|
467
|
+
document.dispatchEvent(fakePointer('pointercancel', 80, 100, 11)); // abort
|
|
468
|
+
// Subsequent move/up should now be ignored:
|
|
469
|
+
document.dispatchEvent(fakePointer('pointermove', 200, 100, 11));
|
|
470
|
+
document.dispatchEvent(fakePointer('pointerup', 200, 100, 11));
|
|
471
|
+
flushSync();
|
|
472
|
+
expect(node.activeTab).toBe(1);
|
|
473
|
+
});
|
|
474
|
+
it('center pointer-down on the same editable target still bails (gutter is not the whole carousel)', () => {
|
|
475
|
+
// Sanity check: the override is scoped to the edge zones only —
|
|
476
|
+
// mid-track pointer-downs on inputs continue to focus the input.
|
|
477
|
+
const node = makeNode(['A', 'B', 'C'], 0);
|
|
478
|
+
mountCarousel({ node, wrap: false });
|
|
479
|
+
const slide = host.querySelector('[data-sh3-slide]');
|
|
480
|
+
const input = document.createElement('input');
|
|
481
|
+
slide.appendChild(input);
|
|
482
|
+
// x=150 — mid-track, not in either gutter.
|
|
483
|
+
input.dispatchEvent(fakePointer('pointerdown', 150, 100));
|
|
484
|
+
document.dispatchEvent(fakePointer('pointermove', 30, 100));
|
|
485
|
+
document.dispatchEvent(fakePointer('pointerup', 30, 100));
|
|
486
|
+
flushSync();
|
|
487
|
+
expect(node.activeTab).toBe(0);
|
|
488
|
+
});
|
|
489
|
+
});
|
|
270
490
|
describe('CarouselTabs — PointerClaim integration', () => {
|
|
271
491
|
it('does not start gesture when pointer is already claimed by an app', () => {
|
|
272
492
|
const node = makeNode(['A', 'B'], 0);
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* the docked content so the surfaces stack correctly.
|
|
12
12
|
*
|
|
13
13
|
* View-default role lookup is intentionally omitted in v1 — derive()
|
|
14
|
-
* reads slot.role /
|
|
14
|
+
* reads slot.role / tabs.role directly. Apps that want non-body slots
|
|
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
|
*/
|
|
@@ -81,9 +81,10 @@ describe('CompactRenderer — carousels', () => {
|
|
|
81
81
|
initialLayout: {
|
|
82
82
|
type: 'tabs',
|
|
83
83
|
activeTab: 0,
|
|
84
|
+
role: 'body',
|
|
84
85
|
tabs: [
|
|
85
|
-
{ slotId: 's0', viewId: null, label: 'A'
|
|
86
|
-
{ slotId: 's1', viewId: null, label: 'B'
|
|
86
|
+
{ slotId: 's0', viewId: null, label: 'A' },
|
|
87
|
+
{ slotId: 's1', viewId: null, label: 'B' },
|
|
87
88
|
],
|
|
88
89
|
},
|
|
89
90
|
};
|
|
@@ -107,7 +108,8 @@ describe('CompactRenderer — carousels', () => {
|
|
|
107
108
|
{
|
|
108
109
|
type: 'tabs',
|
|
109
110
|
activeTab: 0,
|
|
110
|
-
|
|
111
|
+
role: 'body',
|
|
112
|
+
tabs: [{ slotId: 'l0', viewId: null, label: 'L' }],
|
|
111
113
|
},
|
|
112
114
|
{ type: 'slot', slotId: 'r', viewId: null, role: 'body' },
|
|
113
115
|
],
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* locks that anchor — inner splits don't retag (a vertical split
|
|
12
12
|
* inside a left-anchored subtree keeps both children on the left).
|
|
13
13
|
*
|
|
14
|
-
* Note: this transform reads slot.role /
|
|
14
|
+
* Note: this transform reads slot.role / tabs.role only. View-level
|
|
15
15
|
* defaultRole resolution happens at the call site via resolveRole(),
|
|
16
16
|
* which materializes a tree with effective roles before passing to
|
|
17
17
|
* derive(). See layout/compact/CompactRenderer.svelte.
|
|
@@ -36,7 +36,7 @@ function collectSlots(node) {
|
|
|
36
36
|
viewId: t.viewId,
|
|
37
37
|
label: t.label,
|
|
38
38
|
icon: t.icon,
|
|
39
|
-
role: effectiveRole(
|
|
39
|
+
role: effectiveRole(node.role),
|
|
40
40
|
}));
|
|
41
41
|
}
|
|
42
42
|
return node.children.flatMap(collectSlots);
|
|
@@ -49,16 +49,7 @@ function stripNonBody(node) {
|
|
|
49
49
|
return effectiveRole(node.role) === 'body' ? node : null;
|
|
50
50
|
}
|
|
51
51
|
if (node.type === 'tabs') {
|
|
52
|
-
|
|
53
|
-
if (bodyTabs.length === 0)
|
|
54
|
-
return null;
|
|
55
|
-
if (bodyTabs.length === node.tabs.length)
|
|
56
|
-
return node;
|
|
57
|
-
return {
|
|
58
|
-
type: 'tabs',
|
|
59
|
-
activeTab: Math.min(node.activeTab, bodyTabs.length - 1),
|
|
60
|
-
tabs: bodyTabs,
|
|
61
|
-
};
|
|
52
|
+
return effectiveRole(node.role) === 'body' ? node : null;
|
|
62
53
|
}
|
|
63
54
|
// split
|
|
64
55
|
const survivors = [];
|
|
@@ -95,11 +86,11 @@ function partitionDrawers(node) {
|
|
|
95
86
|
return;
|
|
96
87
|
}
|
|
97
88
|
if (n.type === 'tabs') {
|
|
89
|
+
const role = effectiveRole(n.role);
|
|
90
|
+
if (role === 'body')
|
|
91
|
+
return;
|
|
92
|
+
const anchor = hint !== null && hint !== void 0 ? hint : defaultAnchor(role);
|
|
98
93
|
for (const t of n.tabs) {
|
|
99
|
-
const role = effectiveRole(t.role);
|
|
100
|
-
if (role === 'body')
|
|
101
|
-
continue;
|
|
102
|
-
const anchor = hint !== null && hint !== void 0 ? hint : defaultAnchor(role);
|
|
103
94
|
buckets[anchor].push({
|
|
104
95
|
slotId: t.slotId, viewId: t.viewId, label: t.label, icon: t.icon, role,
|
|
105
96
|
});
|