@swiftbrowser/web 0.1.0

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.
Files changed (84) hide show
  1. package/README.md +600 -0
  2. package/app/index.html +125 -0
  3. package/index.html +56 -0
  4. package/node/dist/node/apps.d.ts +58 -0
  5. package/node/dist/node/apps.d.ts.map +1 -0
  6. package/node/dist/node/apps.js +60 -0
  7. package/node/dist/node/apps.js.map +1 -0
  8. package/node/dist/node/config.d.ts +42 -0
  9. package/node/dist/node/config.d.ts.map +1 -0
  10. package/node/dist/node/config.js +99 -0
  11. package/node/dist/node/config.js.map +1 -0
  12. package/node/dist/node/examples.d.ts +39 -0
  13. package/node/dist/node/examples.d.ts.map +1 -0
  14. package/node/dist/node/examples.js +116 -0
  15. package/node/dist/node/examples.js.map +1 -0
  16. package/node/dist/node/index.d.ts +47 -0
  17. package/node/dist/node/index.d.ts.map +1 -0
  18. package/node/dist/node/index.js +52 -0
  19. package/node/dist/node/index.js.map +1 -0
  20. package/node/dist/plugins/app-manifests.d.ts +61 -0
  21. package/node/dist/plugins/app-manifests.d.ts.map +1 -0
  22. package/node/dist/plugins/app-manifests.js +168 -0
  23. package/node/dist/plugins/app-manifests.js.map +1 -0
  24. package/node/dist/plugins/asset-catalogs.d.ts +119 -0
  25. package/node/dist/plugins/asset-catalogs.d.ts.map +1 -0
  26. package/node/dist/plugins/asset-catalogs.js +488 -0
  27. package/node/dist/plugins/asset-catalogs.js.map +1 -0
  28. package/node/dist/plugins/bundle-resources.d.ts +49 -0
  29. package/node/dist/plugins/bundle-resources.d.ts.map +1 -0
  30. package/node/dist/plugins/bundle-resources.js +172 -0
  31. package/node/dist/plugins/bundle-resources.js.map +1 -0
  32. package/node/dist/plugins/swift-wasm.d.ts +33 -0
  33. package/node/dist/plugins/swift-wasm.d.ts.map +1 -0
  34. package/node/dist/plugins/swift-wasm.js +241 -0
  35. package/node/dist/plugins/swift-wasm.js.map +1 -0
  36. package/node/dist/src/assets.d.ts +60 -0
  37. package/node/dist/src/assets.d.ts.map +1 -0
  38. package/node/dist/src/assets.js +54 -0
  39. package/node/dist/src/assets.js.map +1 -0
  40. package/node/dist/src/devEvents.d.ts +31 -0
  41. package/node/dist/src/devEvents.d.ts.map +1 -0
  42. package/node/dist/src/devEvents.js +9 -0
  43. package/node/dist/src/devEvents.js.map +1 -0
  44. package/package.json +56 -0
  45. package/plugins/app-manifests.ts +192 -0
  46. package/plugins/asset-catalogs.ts +558 -0
  47. package/plugins/bundle-resources.ts +195 -0
  48. package/plugins/swift-wasm.ts +259 -0
  49. package/public/_headers +23 -0
  50. package/public/apple-touch-icon.png +0 -0
  51. package/public/icon.svg +14 -0
  52. package/public/manifest.webmanifest +14 -0
  53. package/src/animation.ts +521 -0
  54. package/src/assets.ts +86 -0
  55. package/src/chartDom.ts +481 -0
  56. package/src/devEvents.ts +35 -0
  57. package/src/deviceMode.ts +80 -0
  58. package/src/ghost.ts +25 -0
  59. package/src/interaction.ts +1113 -0
  60. package/src/landing.css +261 -0
  61. package/src/landing.ts +141 -0
  62. package/src/layout/chart.ts +477 -0
  63. package/src/layout/controls.ts +116 -0
  64. package/src/layout/engine.ts +3581 -0
  65. package/src/layout/index.ts +91 -0
  66. package/src/layout/measure.ts +86 -0
  67. package/src/layout/text.ts +124 -0
  68. package/src/layout/types.ts +153 -0
  69. package/src/layout/typography.ts +188 -0
  70. package/src/layoutDom.ts +549 -0
  71. package/src/layoutEvents.ts +110 -0
  72. package/src/locale.ts +64 -0
  73. package/src/main.ts +554 -0
  74. package/src/mock.ts +752 -0
  75. package/src/nodeQueries.ts +60 -0
  76. package/src/protocol.ts +1421 -0
  77. package/src/renderer.ts +2882 -0
  78. package/src/styles.css +2834 -0
  79. package/src/styles.ts +373 -0
  80. package/src/swipe.ts +110 -0
  81. package/src/symbols.ts +595 -0
  82. package/src/typography.ts +76 -0
  83. package/src/vite-env.d.ts +26 -0
  84. package/src/wasm.ts +400 -0
@@ -0,0 +1,1113 @@
1
+ /**
2
+ * Phase 8 pointer interaction (docs/ops-protocol.md, "Phase 8: interaction").
3
+ *
4
+ * One `pointerdown` on the screen starts a *session*; the session lists, in
5
+ * priority order, every behaviour that could own this pointer:
6
+ *
7
+ * 1. swipe back (a pointer down within 24 px of a pushed `navscreen`'s
8
+ * leading edge), like the iOS screen-edge pan gesture it outranks all;
9
+ * 2. the wrappers between the target and the root, innermost first: a
10
+ * `gesture` (drag, long press), a `contextmenu` (long press) and a swipe
11
+ * row (`swipeactions`, or a deletable `listrow` without one);
12
+ * 3. the sheet card (drag down to dismiss);
13
+ * 4. pull to refresh on a `refreshable` scroller at its top.
14
+ *
15
+ * Nothing owns the pointer at first. On each move the candidates are asked in
16
+ * order whether the motion so far is theirs (swipe back: rightwards; a drag
17
+ * gesture: `minimumDistance` in any direction; a swipe row: horizontal; a
18
+ * sheet or refresh: downwards). The first to say yes *claims* the pointer:
19
+ * the rest are dropped, the pointer is captured and every further move goes
20
+ * to the owner until the pointer lifts or the browser cancels it (a native
21
+ * scroll started: `pointercancel`). Long presses run on a timer from the
22
+ * pointer down and win only if nothing moved 10 px; a long press or any
23
+ * claimed drag swallows the click that follows. Taps that claim nothing
24
+ * reach the renderer's click handler untouched, so controls inside gestures
25
+ * and rows keep working.
26
+ *
27
+ * Horizontal wheel scrolling slides swipe rows and vertical wheel at the
28
+ * top of a `refreshable` scroller pulls to refresh; touch moves are read
29
+ * directly for pull to refresh (the browser cancels the pointer once it
30
+ * scrolls the list).
31
+ */
32
+
33
+ import type { LayoutNode } from './layout';
34
+ import { symbolOf, textOf, tintOf, unwrapStyledNode } from './nodeQueries';
35
+ import type { DragEvent, ElementId, RendererEvent } from './protocol';
36
+ import { colorToCSS } from './styles';
37
+ import {
38
+ SWIPE_ACTION_WIDTH,
39
+ SWIPE_FULL_EXTRA,
40
+ SWIPE_START_DISTANCE,
41
+ clampSwipeOffset,
42
+ settleSwipe,
43
+ swipeActionsWidth,
44
+ swipeEdgeOf,
45
+ swipeRestOffset,
46
+ velocityFrom,
47
+ type SwipeEdge,
48
+ type SwipeSpec,
49
+ } from './swipe';
50
+ import { renderSymbol } from './symbols';
51
+
52
+ /** Dragging a sheet down further than this dismisses it (sends a `tap`). */
53
+ export const SHEET_DISMISS_DRAG = 100;
54
+ /** A pointer down this close to a pushed screen's leading edge may swipe back. */
55
+ export const SWIPE_BACK_EDGE = 24;
56
+ /** Releasing a swipe back faster than this (points per second) pops regardless of distance. */
57
+ export const SWIPE_BACK_VELOCITY = 500;
58
+ /** A pull past this many points at the top of a `refreshable` scroller sends `refresh`. */
59
+ export const REFRESH_PULL = 60;
60
+ /** Height of the spinner row shown while `refreshing`. */
61
+ export const REFRESH_ROW = 40;
62
+ /** Default `minimumDuration` of a long press, and the context menu's. */
63
+ export const LONG_PRESS_MS = 500;
64
+ /** Moving this far cancels a long press. */
65
+ const LONG_PRESS_SLOP = 10;
66
+ /** Default `minimumDistance` of a drag gesture. */
67
+ const DRAG_MIN_DISTANCE = 10;
68
+ /** A sheet drag, a swipe back and a pull start after this much motion. */
69
+ const PAN_SLOP = 8;
70
+ /** Duration of the slide a swipe row or a swipe back settles with; keep in sync with styles.css. */
71
+ const SETTLE_MS = 300;
72
+ /** Time Swift gets to remove a swiped-back screen before it snaps back. */
73
+ const SWIPE_BACK_TIMEOUT_MS = 700;
74
+ /** A wheel sequence ends after this much silence. */
75
+ const WHEEL_IDLE_MS = 150;
76
+
77
+ const ID_ATTR = 'sbId';
78
+
79
+ /** What the controller needs from the renderer. */
80
+ export interface InteractionHost {
81
+ readonly root: HTMLElement;
82
+ send(event: RendererEvent): void;
83
+ nodeOf(id: ElementId): LayoutNode | undefined;
84
+ /** Open the popover of a `contextmenu` element anchored to it. */
85
+ openContextMenu(anchor: HTMLElement, id: ElementId): void;
86
+ /** Whether the popover of element `id` is open. */
87
+ isMenuOpen(id: ElementId): boolean;
88
+ closeMenu(): void;
89
+ }
90
+
91
+ interface Sample {
92
+ t: number;
93
+ x: number;
94
+ y: number;
95
+ }
96
+
97
+ /** One swipe action button, from the `swipeactions` children or a deletable row's implied Delete. */
98
+ interface ActionSpec {
99
+ id: ElementId;
100
+ title: string;
101
+ symbol: string | null;
102
+ destructive: boolean;
103
+ tint: string | null;
104
+ event: 'tap' | 'delete';
105
+ }
106
+
107
+ /** A row whose content slides to reveal actions. */
108
+ interface SwipeHost {
109
+ el: HTMLElement;
110
+ id: ElementId;
111
+ spec: SwipeSpec;
112
+ actions: Record<SwipeEdge, ActionSpec[]>;
113
+ }
114
+
115
+ type Candidate =
116
+ | { kind: 'swipeback'; screen: HTMLElement; below: HTMLElement | null; id: ElementId; width: number }
117
+ | {
118
+ kind: 'gesture';
119
+ el: HTMLElement;
120
+ id: ElementId;
121
+ drag: boolean;
122
+ longPress: boolean;
123
+ minDistance: number;
124
+ minDuration: number;
125
+ }
126
+ | { kind: 'contextmenu'; el: HTMLElement; id: ElementId }
127
+ | { kind: 'swipe'; host: SwipeHost }
128
+ | { kind: 'sheet'; sheet: HTMLElement; card: HTMLElement; id: ElementId }
129
+ | { kind: 'refresh'; scroller: HTMLElement; id: ElementId };
130
+
131
+ interface Session {
132
+ pointerId: number;
133
+ startX: number;
134
+ startY: number;
135
+ x: number;
136
+ y: number;
137
+ samples: Sample[];
138
+ candidates: Candidate[];
139
+ claimed: Candidate | null;
140
+ /** A long press fired: the session ignores further motion. */
141
+ done: boolean;
142
+ suppressClick: boolean;
143
+ longPressTimer: number | null;
144
+ /** The pointer went down on a pushed screen's edge strip (touch): a plain tap is forwarded. */
145
+ edgeStrip: HTMLElement | null;
146
+ // gesture drag
147
+ gestureRect: DOMRect | null;
148
+ pendingDrag: DragEvent | null;
149
+ raf: number | null;
150
+ // swipe row
151
+ swipeStart: number;
152
+ swipeOffset: number;
153
+ // sheet / refresh
154
+ dy: number;
155
+ }
156
+
157
+ interface OpenSwipe {
158
+ host: SwipeHost;
159
+ offset: number;
160
+ }
161
+
162
+ interface SwipeBackState {
163
+ screen: HTMLElement;
164
+ below: HTMLElement | null;
165
+ /** Set once released past the threshold: the `tap` went out, the `remove` is awaited. */
166
+ completing: boolean;
167
+ timer: number | null;
168
+ }
169
+
170
+ function round2(v: number): number {
171
+ return Math.round(v * 100) / 100;
172
+ }
173
+
174
+ function clamp01(v: number): number {
175
+ return Math.max(0, Math.min(1, v));
176
+ }
177
+
178
+ export class InteractionController {
179
+ private session: Session | null = null;
180
+ private openSwipe: OpenSwipe | null = null;
181
+ private swipeBack: SwipeBackState | null = null;
182
+ /** Wheel-driven swipe of a row: the host, its live offset and the idle timer that settles it. */
183
+ private wheelSwipe: { host: SwipeHost; offset: number; timer: number } | null = null;
184
+ /** Wheel-driven pull at the top of a refreshable scroller. */
185
+ private wheelPull: { scroller: HTMLElement; pulled: number; sent: boolean; timer: number } | null = null;
186
+ /** Touch-driven pull (the browser cancels the pointer once the list scrolls). */
187
+ private touchPull: { scroller: HTMLElement; id: ElementId; startY: number; sent: boolean } | null = null;
188
+
189
+ constructor(private readonly host: InteractionHost) {
190
+ const root = host.root;
191
+ root.addEventListener('pointerdown', this.onPointerDown);
192
+ root.addEventListener('selectstart', this.onSelectStart);
193
+ root.addEventListener('contextmenu', this.onContextMenu);
194
+ root.addEventListener('wheel', this.onWheel, { passive: false });
195
+ root.addEventListener('scroll', this.onScroll, true);
196
+ root.addEventListener('touchstart', this.onTouchStart, { passive: true });
197
+ root.addEventListener('touchmove', this.onTouchMove, { passive: true });
198
+ root.addEventListener('touchend', this.onTouchEnd, { passive: true });
199
+ root.addEventListener('touchcancel', this.onTouchEnd, { passive: true });
200
+ document.addEventListener('pointerdown', this.onDocumentPointerDown, true);
201
+ }
202
+
203
+ dispose(): void {
204
+ const root = this.host.root;
205
+ root.removeEventListener('pointerdown', this.onPointerDown);
206
+ root.removeEventListener('selectstart', this.onSelectStart);
207
+ root.removeEventListener('contextmenu', this.onContextMenu);
208
+ root.removeEventListener('wheel', this.onWheel);
209
+ root.removeEventListener('scroll', this.onScroll, true);
210
+ root.removeEventListener('touchstart', this.onTouchStart);
211
+ root.removeEventListener('touchmove', this.onTouchMove);
212
+ root.removeEventListener('touchend', this.onTouchEnd);
213
+ root.removeEventListener('touchcancel', this.onTouchEnd);
214
+ document.removeEventListener('pointerdown', this.onDocumentPointerDown, true);
215
+ this.detachWindow();
216
+ this.session = null;
217
+ }
218
+
219
+ // -------------------------------------------------------------------------
220
+ // Clicks the renderer delegates (its `onClick` picks the innermost target)
221
+ // -------------------------------------------------------------------------
222
+
223
+ /** Handle a click on a swipe action or an edit-mode row control; false when `target` is neither. */
224
+ handleClick(target: HTMLElement): boolean {
225
+ if (target.classList.contains('sb-swipe-action')) {
226
+ const id = Number(target.dataset['sbTarget']);
227
+ const action = target.dataset['sbAction'] === 'delete' ? 'delete' : 'tap';
228
+ const rowEl = target.closest<HTMLElement>('.sb-swipeactions, .sb-listrow');
229
+ if (rowEl) this.closeRow(rowEl);
230
+ if (Number.isInteger(id)) this.host.send({ type: action, id });
231
+ return true;
232
+ }
233
+ if (target.classList.contains('sb-listrow-control')) {
234
+ const row = target.closest<HTMLElement>('.sb-listrow');
235
+ const id = Number(row?.dataset[ID_ATTR]);
236
+ if (!row || !Number.isInteger(id)) return true;
237
+ if (row.hasAttribute('data-sb-selectable')) {
238
+ this.host.send({ type: 'tap', id });
239
+ } else if (row.hasAttribute('data-sb-deletable')) {
240
+ // The minus reveals the Delete button; tapping that sends `delete`.
241
+ const host = this.swipeHostFor(row);
242
+ if (host) this.revealEdge(host, 'trailing');
243
+ }
244
+ return true;
245
+ }
246
+ return false;
247
+ }
248
+
249
+ // -------------------------------------------------------------------------
250
+ // Pointer sessions
251
+ // -------------------------------------------------------------------------
252
+
253
+ private readonly onPointerDown = (event: PointerEvent): void => {
254
+ if (this.session || !event.isPrimary || (event.pointerType === 'mouse' && event.button !== 0)) return;
255
+ const target = event.target as HTMLElement | null;
256
+ if (!target) return;
257
+ // Pointer downs in a text field select text, never gesture; a slider
258
+ // (Phase 8 controls) owns its own pointer drag in the renderer.
259
+ const inField = target.closest('input, textarea, [contenteditable], .sb-slider') !== null;
260
+ const candidates = inField ? [] : this.collectCandidates(target, event);
261
+ const edgeStrip = target.classList.contains('sb-navscreen-edge') ? target : null;
262
+ if (candidates.length === 0 && !edgeStrip) return;
263
+ // A mouse drag would otherwise select the row text, and a later press on
264
+ // that selection would start a native drag of it (which cancels the
265
+ // pointer). No selection is ever wanted where a gesture may start.
266
+ clearSelectionIn(this.host.root);
267
+ this.host.root.dataset['sbPointerSession'] = '';
268
+ const session: Session = {
269
+ pointerId: event.pointerId,
270
+ startX: event.clientX,
271
+ startY: event.clientY,
272
+ x: event.clientX,
273
+ y: event.clientY,
274
+ samples: [{ t: event.timeStamp, x: event.clientX, y: event.clientY }],
275
+ candidates,
276
+ claimed: null,
277
+ done: false,
278
+ suppressClick: false,
279
+ longPressTimer: null,
280
+ edgeStrip,
281
+ gestureRect: null,
282
+ pendingDrag: null,
283
+ raf: null,
284
+ swipeStart: 0,
285
+ swipeOffset: 0,
286
+ dy: 0,
287
+ };
288
+ this.session = session;
289
+ window.addEventListener('pointermove', this.onPointerMove);
290
+ window.addEventListener('pointerup', this.onPointerUp);
291
+ window.addEventListener('pointercancel', this.onPointerCancel);
292
+ // The innermost long-press wrapper arms its timer right away.
293
+ const presser = candidates.find((c) => (c.kind === 'gesture' && c.longPress) || c.kind === 'contextmenu');
294
+ if (presser) {
295
+ const duration = presser.kind === 'gesture' ? presser.minDuration : LONG_PRESS_MS;
296
+ session.longPressTimer = window.setTimeout(() => this.fireLongPress(session, presser), duration);
297
+ }
298
+ // An immediate drag gesture (`minimumDistance: 0`) owns the pointer from the start.
299
+ const immediate = candidates.find((c) => c.kind === 'gesture' && c.drag && c.minDistance <= 0);
300
+ if (immediate && candidates[0] === immediate) this.claim(session, immediate, event);
301
+ };
302
+
303
+ private readonly onPointerMove = (event: PointerEvent): void => {
304
+ const session = this.session;
305
+ if (!session || event.pointerId !== session.pointerId) return;
306
+ session.x = event.clientX;
307
+ session.y = event.clientY;
308
+ session.samples.push({ t: event.timeStamp, x: event.clientX, y: event.clientY });
309
+ if (session.samples.length > 12) session.samples.shift();
310
+ if (session.done) return;
311
+ const dx = session.x - session.startX;
312
+ const dy = session.y - session.startY;
313
+ const distance = Math.hypot(dx, dy);
314
+ if (session.longPressTimer !== null && distance > LONG_PRESS_SLOP) {
315
+ window.clearTimeout(session.longPressTimer);
316
+ session.longPressTimer = null;
317
+ }
318
+ if (!session.claimed) {
319
+ const owner = session.candidates.find((c) => this.wants(c, dx, dy, distance));
320
+ if (!owner) return;
321
+ this.claim(session, owner, event);
322
+ }
323
+ if (session.claimed) this.move(session, session.claimed, dx, dy);
324
+ };
325
+
326
+ private readonly onPointerUp = (event: PointerEvent): void => {
327
+ const session = this.session;
328
+ if (!session || event.pointerId !== session.pointerId) return;
329
+ this.finishSession(session, false);
330
+ };
331
+
332
+ private readonly onPointerCancel = (event: PointerEvent): void => {
333
+ const session = this.session;
334
+ if (!session || event.pointerId !== session.pointerId) return;
335
+ this.finishSession(session, true);
336
+ };
337
+
338
+ /** While a session runs nothing in the screen may start a text selection. */
339
+ private readonly onSelectStart = (event: Event): void => {
340
+ if (this.session) event.preventDefault();
341
+ };
342
+
343
+ private finishSession(session: Session, cancelled: boolean): void {
344
+ this.session = null;
345
+ this.detachWindow();
346
+ delete this.host.root.dataset['sbPointerSession'];
347
+ if (session.longPressTimer !== null) window.clearTimeout(session.longPressTimer);
348
+ if (session.raf !== null) cancelAnimationFrame(session.raf);
349
+ const dx = session.x - session.startX;
350
+ const dy = session.y - session.startY;
351
+ const velocity = velocityFrom(session.samples);
352
+ if (session.claimed) {
353
+ if (cancelled) this.cancel(session, session.claimed, dx, dy);
354
+ else this.end(session, session.claimed, dx, dy, velocity);
355
+ session.suppressClick = true;
356
+ } else if (!cancelled && session.edgeStrip && Math.hypot(dx, dy) < LONG_PRESS_SLOP && !session.done) {
357
+ // A plain tap on the (touch-only) edge strip goes to whatever is under it.
358
+ this.forwardTap(session.edgeStrip, session.x, session.y);
359
+ return;
360
+ }
361
+ if (session.suppressClick) suppressNextClick();
362
+ }
363
+
364
+ private detachWindow(): void {
365
+ window.removeEventListener('pointermove', this.onPointerMove);
366
+ window.removeEventListener('pointerup', this.onPointerUp);
367
+ window.removeEventListener('pointercancel', this.onPointerCancel);
368
+ }
369
+
370
+ /** Every behaviour that could own a pointer that went down on `target`, in priority order. */
371
+ private collectCandidates(target: HTMLElement, event: PointerEvent): Candidate[] {
372
+ const out: Candidate[] = [];
373
+ const screen = target.closest<HTMLElement>('.sb-navscreen');
374
+ if (screen && screen.dataset['sbNavPosition'] === 'top' && Number(screen.dataset['sbDepth'] ?? '0') > 0 && !this.swipeBack) {
375
+ const rect = screen.getBoundingClientRect();
376
+ const id = Number(screen.dataset[ID_ATTR]);
377
+ if (event.clientX - rect.left <= SWIPE_BACK_EDGE && Number.isInteger(id)) {
378
+ const below = screen.previousElementSibling as HTMLElement | null;
379
+ out.push({ kind: 'swipeback', screen, id, width: rect.width || screen.offsetWidth, below: below?.classList.contains('sb-navscreen') ? below : null });
380
+ }
381
+ }
382
+ for (let el: HTMLElement | null = target; el && el !== this.host.root; el = el.parentElement) {
383
+ const id = Number(el.dataset[ID_ATTR]);
384
+ if (el.classList.contains('sb-gesture') && Number.isInteger(id)) {
385
+ const node = this.host.nodeOf(id);
386
+ const props = node?.props ?? {};
387
+ const drag = props['drag'] === true;
388
+ const longPress = props['longPress'] === true;
389
+ if (!drag && !longPress) continue;
390
+ const minDistance = typeof props['minimumDistance'] === 'number' ? Math.max(0, props['minimumDistance']) : DRAG_MIN_DISTANCE;
391
+ const minDuration = typeof props['minimumDuration'] === 'number' ? Math.max(0, props['minimumDuration']) * 1000 : LONG_PRESS_MS;
392
+ out.push({ kind: 'gesture', el, id, drag, longPress, minDistance, minDuration });
393
+ } else if (el.classList.contains('sb-contextmenu') && Number.isInteger(id)) {
394
+ out.push({ kind: 'contextmenu', el, id });
395
+ } else if (el.classList.contains('sb-swipeactions') || el.classList.contains('sb-listrow')) {
396
+ const host = this.swipeHostFor(el);
397
+ if (host) out.push({ kind: 'swipe', host });
398
+ }
399
+ }
400
+ const card = target.closest<HTMLElement>('.sb-sheet-card');
401
+ const sheet = card?.parentElement;
402
+ if (card && sheet?.classList.contains('sb-sheet') && sheet.dataset['sbSheetPosition'] !== 'below') {
403
+ const id = Number(sheet.dataset[ID_ATTR]);
404
+ const detents = (sheet.dataset['sbDetents'] ?? '').split(' ');
405
+ // A full-screen cover (`detents: ["full"]`) has no drag to dismiss.
406
+ if (Number.isInteger(id) && !detents.includes('full')) out.push({ kind: 'sheet', sheet, card, id });
407
+ }
408
+ const scroller = this.refreshableScrollerOf(target);
409
+ if (scroller && scroller.el.scrollTop <= 0) out.push({ kind: 'refresh', scroller: scroller.el, id: scroller.id });
410
+ return out;
411
+ }
412
+
413
+ private refreshableScrollerOf(target: Element): { el: HTMLElement; id: ElementId } | null {
414
+ const el = target.closest<HTMLElement>('.sb-list[data-sb-refreshable], .sb-scrollview[data-sb-refreshable]');
415
+ const id = Number(el?.dataset[ID_ATTR]);
416
+ return el && Number.isInteger(id) ? { el, id } : null;
417
+ }
418
+
419
+ /** Whether the motion so far belongs to `c`. */
420
+ private wants(c: Candidate, dx: number, dy: number, distance: number): boolean {
421
+ switch (c.kind) {
422
+ case 'swipeback':
423
+ return dx >= PAN_SLOP && dx > Math.abs(dy);
424
+ case 'gesture':
425
+ return c.drag && distance >= Math.max(c.minDistance, 0.5);
426
+ case 'contextmenu':
427
+ return false;
428
+ case 'swipe': {
429
+ if (Math.abs(dx) < SWIPE_START_DISTANCE || Math.abs(dx) <= Math.abs(dy)) return false;
430
+ // Nothing to reveal that way (and nothing open to close): leave the pointer to the list.
431
+ const open = this.openSwipe?.host.el === c.host.el ? this.openSwipe.offset : 0;
432
+ const edge = swipeEdgeOf(open + dx);
433
+ return edge !== null && (c.host.spec[edge].count > 0 || open !== 0);
434
+ }
435
+ case 'sheet':
436
+ return dy >= PAN_SLOP && dy > Math.abs(dx);
437
+ case 'refresh':
438
+ return dy >= PAN_SLOP && dy > Math.abs(dx) && c.scroller.scrollTop <= 0;
439
+ }
440
+ }
441
+
442
+ private claim(session: Session, c: Candidate, event: PointerEvent): void {
443
+ session.claimed = c;
444
+ session.candidates = [c];
445
+ if (session.longPressTimer !== null) {
446
+ window.clearTimeout(session.longPressTimer);
447
+ session.longPressTimer = null;
448
+ }
449
+ const captureOn = c.kind === 'gesture' ? c.el : c.kind === 'swipe' ? c.host.el : this.host.root;
450
+ try {
451
+ captureOn.setPointerCapture(event.pointerId);
452
+ } catch {
453
+ // Not every environment (or an already-cancelled pointer) allows capture.
454
+ }
455
+ switch (c.kind) {
456
+ case 'gesture': {
457
+ session.gestureRect = c.el.getBoundingClientRect();
458
+ this.host.send(this.dragEvent(session, c, 'began', { x: 0, y: 0 }));
459
+ break;
460
+ }
461
+ case 'swipe': {
462
+ const open = this.openSwipe?.host.el === c.host.el ? this.openSwipe.offset : 0;
463
+ if (this.openSwipe && this.openSwipe.host.el !== c.host.el) this.closeRow(this.openSwipe.host.el);
464
+ session.swipeStart = open;
465
+ session.swipeOffset = open;
466
+ c.host.el.dataset['sbSwiping'] = '';
467
+ const content = contentOf(c.host.el);
468
+ if (content) content.style.transition = 'none';
469
+ this.ensureActions(c.host);
470
+ break;
471
+ }
472
+ case 'swipeback': {
473
+ this.host.closeMenu();
474
+ if (this.openSwipe) this.closeRow(this.openSwipe.host.el);
475
+ c.screen.dataset['sbSwipeDragging'] = '';
476
+ c.screen.style.transition = 'none';
477
+ if (c.below) {
478
+ c.below.style.transition = 'none';
479
+ c.below.style.visibility = 'visible';
480
+ }
481
+ this.swipeBack = { screen: c.screen, below: c.below, completing: false, timer: null };
482
+ break;
483
+ }
484
+ case 'sheet':
485
+ c.sheet.dataset['sbDragging'] = '';
486
+ c.card.style.transition = 'none';
487
+ break;
488
+ case 'refresh': {
489
+ const content = scrollContentOf(c.scroller);
490
+ if (content) content.style.transition = 'none';
491
+ c.scroller.dataset['sbPulling'] = '';
492
+ break;
493
+ }
494
+ case 'contextmenu':
495
+ break;
496
+ }
497
+ }
498
+
499
+ private move(session: Session, c: Candidate, dx: number, dy: number): void {
500
+ switch (c.kind) {
501
+ case 'gesture': {
502
+ // Coalesced: the latest position goes out once per animation frame.
503
+ session.pendingDrag = this.dragEvent(session, c, 'changed', { x: 0, y: 0 });
504
+ if (session.raf === null) {
505
+ session.raf = requestAnimationFrame(() => {
506
+ session.raf = null;
507
+ const pending = session.pendingDrag;
508
+ session.pendingDrag = null;
509
+ if (pending && this.session === session) this.host.send(pending);
510
+ });
511
+ }
512
+ return;
513
+ }
514
+ case 'swipe': {
515
+ const offset = clampSwipeOffset(session.swipeStart + dx, c.host.spec, c.host.el.offsetWidth);
516
+ session.swipeOffset = offset;
517
+ this.applySwipeOffset(c.host, offset);
518
+ return;
519
+ }
520
+ case 'swipeback': {
521
+ const x = Math.max(0, dx);
522
+ const progress = clamp01(x / c.width);
523
+ c.screen.style.transform = `translateX(${x}px)`;
524
+ if (c.below) {
525
+ c.below.style.transform = `translateX(${-30 * (1 - progress)}%)`;
526
+ c.below.style.setProperty('--sb-nav-dim', String(round2(0.12 * (1 - progress))));
527
+ }
528
+ return;
529
+ }
530
+ case 'sheet': {
531
+ const y = Math.max(0, dy);
532
+ session.dy = y;
533
+ c.card.style.transform = `translateY(${y}px)`;
534
+ c.card.style.setProperty('--sb-sheet-drag', `${y}px`);
535
+ return;
536
+ }
537
+ case 'refresh': {
538
+ const pulled = Math.max(0, dy);
539
+ session.dy = pulled;
540
+ const content = scrollContentOf(c.scroller);
541
+ // The content follows with resistance; the spinner fades in over the pull distance.
542
+ if (content) content.style.transform = `translateY(${Math.min(REFRESH_PULL * 1.5, pulled * 0.6)}px)`;
543
+ c.scroller.style.setProperty('--sb-pull', String(round2(clamp01(pulled / REFRESH_PULL))));
544
+ return;
545
+ }
546
+ case 'contextmenu':
547
+ return;
548
+ }
549
+ }
550
+
551
+ private end(session: Session, c: Candidate, dx: number, _dy: number, velocity: { x: number; y: number }): void {
552
+ switch (c.kind) {
553
+ case 'gesture':
554
+ this.host.send(this.dragEvent(session, c, 'ended', velocity));
555
+ return;
556
+ case 'swipe':
557
+ this.settleRow(c.host, session.swipeOffset, velocity.x);
558
+ return;
559
+ case 'swipeback': {
560
+ const x = Math.max(0, dx);
561
+ const pop = x > c.width / 3 || velocity.x > SWIPE_BACK_VELOCITY;
562
+ if (pop) this.completeSwipeBack(c, x);
563
+ else this.snapBackSwipeBack();
564
+ return;
565
+ }
566
+ case 'sheet':
567
+ if (session.dy > SHEET_DISMISS_DRAG) {
568
+ // Keep the dragged offset: the exit clone continues from here.
569
+ delete c.sheet.dataset['sbDragging'];
570
+ this.host.send({ type: 'tap', id: c.id });
571
+ return;
572
+ }
573
+ this.resetSheet(c);
574
+ return;
575
+ case 'refresh': {
576
+ this.resetPull(c.scroller);
577
+ if (session.dy >= REFRESH_PULL) this.host.send({ type: 'refresh', id: c.id });
578
+ return;
579
+ }
580
+ case 'contextmenu':
581
+ return;
582
+ }
583
+ }
584
+
585
+ private cancel(session: Session, c: Candidate, _dx: number, _dy: number): void {
586
+ switch (c.kind) {
587
+ case 'gesture':
588
+ this.host.send(this.dragEvent(session, c, 'cancelled', { x: 0, y: 0 }));
589
+ return;
590
+ case 'swipe':
591
+ this.settleRow(c.host, session.swipeOffset, 0);
592
+ return;
593
+ case 'swipeback':
594
+ this.snapBackSwipeBack();
595
+ return;
596
+ case 'sheet':
597
+ this.resetSheet(c);
598
+ return;
599
+ case 'refresh':
600
+ this.resetPull(c.scroller);
601
+ return;
602
+ case 'contextmenu':
603
+ return;
604
+ }
605
+ }
606
+
607
+ private fireLongPress(session: Session, c: Candidate): void {
608
+ session.longPressTimer = null;
609
+ if (this.session !== session || session.claimed) return;
610
+ session.done = true;
611
+ session.suppressClick = true;
612
+ if (c.kind === 'gesture') this.host.send({ type: 'longpress', id: c.id });
613
+ else if (c.kind === 'contextmenu') this.host.openContextMenu(c.el, c.id);
614
+ }
615
+
616
+ private dragEvent(session: Session, c: Extract<Candidate, { kind: 'gesture' }>, phase: DragEvent['phase'], velocity: { x: number; y: number }): DragEvent {
617
+ const rect = session.gestureRect ?? c.el.getBoundingClientRect();
618
+ return {
619
+ type: 'drag',
620
+ id: c.id,
621
+ phase,
622
+ translation: { width: round2(session.x - session.startX), height: round2(session.y - session.startY) },
623
+ location: { x: round2(session.x - rect.left), y: round2(session.y - rect.top) },
624
+ startLocation: { x: round2(session.startX - rect.left), y: round2(session.startY - rect.top) },
625
+ velocity: { width: round2(velocity.x), height: round2(velocity.y) },
626
+ };
627
+ }
628
+
629
+ /** A tap on the touch-only edge strip: click whatever is under it. */
630
+ private forwardTap(strip: HTMLElement, x: number, y: number): void {
631
+ strip.style.pointerEvents = 'none';
632
+ const under = document.elementFromPoint(x, y);
633
+ strip.style.pointerEvents = '';
634
+ under?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, clientX: x, clientY: y }));
635
+ }
636
+
637
+ // -------------------------------------------------------------------------
638
+ // Swipe rows
639
+ // -------------------------------------------------------------------------
640
+
641
+ /**
642
+ * The swipe host an element belongs to: a `swipeactions` (inside a list),
643
+ * or a deletable `listrow` without one, which implies a single Delete.
644
+ */
645
+ private swipeHostFor(el: HTMLElement): SwipeHost | null {
646
+ if (!el.closest('.sb-list')) return null;
647
+ const id = Number(el.dataset[ID_ATTR]);
648
+ if (!Number.isInteger(id)) return null;
649
+ if (el.classList.contains('sb-listrow')) {
650
+ // A `listrow` whose content is a `swipeactions` (a `List(selection:)`
651
+ // or editable row with swipe actions): the row is the host, so the
652
+ // actions reach the row's edge and the whole row slides.
653
+ const inner = el.querySelector<HTMLElement>('.sb-swipeactions');
654
+ if (inner && inner.closest('.sb-listrow') === el) return this.swipeHostFor(inner);
655
+ }
656
+ if (el.classList.contains('sb-swipeactions')) {
657
+ const node = this.host.nodeOf(id);
658
+ if (!node) return null;
659
+ const row = el.closest<HTMLElement>('.sb-listrow');
660
+ const hostEl = row && row.querySelector('.sb-swipeactions') === el ? row : el;
661
+ const trailingCount = Math.max(0, Math.floor(Number(node.props['trailing']) || 0));
662
+ const leadingCount = Math.max(0, Math.floor(Number(node.props['leading']) || 0));
663
+ const items = node.children.slice(1);
664
+ const toSpec = (item: LayoutNode): ActionSpec | null => {
665
+ const button = unwrapStyledNode(item);
666
+ if (button.kind !== 'button') return null;
667
+ const tint = tintOf(item);
668
+ return {
669
+ id: button.id,
670
+ title: textOf(button),
671
+ symbol: symbolOf(button),
672
+ destructive: button.props['role'] === 'destructive',
673
+ tint: tint ? colorToCSS(tint) : null,
674
+ event: 'tap',
675
+ };
676
+ };
677
+ const trailing = items.slice(0, trailingCount).map(toSpec).filter((a): a is ActionSpec => a !== null);
678
+ const leading = items.slice(trailingCount, trailingCount + leadingCount).map(toSpec).filter((a): a is ActionSpec => a !== null);
679
+ return {
680
+ el: hostEl,
681
+ id,
682
+ spec: {
683
+ trailing: { count: trailing.length, fullSwipe: node.props['fullSwipeTrailing'] !== false },
684
+ leading: { count: leading.length, fullSwipe: node.props['fullSwipeLeading'] !== false },
685
+ },
686
+ actions: { trailing, leading },
687
+ };
688
+ }
689
+ if (el.classList.contains('sb-listrow') && el.hasAttribute('data-sb-deletable') && !el.querySelector('.sb-swipeactions')) {
690
+ return {
691
+ el,
692
+ id,
693
+ spec: { trailing: { count: 1, fullSwipe: true }, leading: { count: 0, fullSwipe: false } },
694
+ actions: {
695
+ trailing: [{ id, title: 'Delete', symbol: null, destructive: true, tint: null, event: 'delete' }],
696
+ leading: [],
697
+ },
698
+ };
699
+ }
700
+ return null;
701
+ }
702
+
703
+ /** Build (once) the action buttons of both edges behind the row content. */
704
+ private ensureActions(host: SwipeHost): void {
705
+ for (const edge of ['trailing', 'leading'] as SwipeEdge[]) {
706
+ const actions = host.actions[edge];
707
+ let container = host.el.querySelector<HTMLElement>(`:scope > .sb-swipe-actions[data-sb-edge="${edge}"]`);
708
+ if (actions.length === 0) {
709
+ container?.remove();
710
+ continue;
711
+ }
712
+ if (container) continue;
713
+ container = document.createElement('div');
714
+ container.className = 'sb-swipe-actions';
715
+ container.dataset['sbEdge'] = edge;
716
+ container.style.width = '0px';
717
+ for (const action of actions) {
718
+ const button = document.createElement('button');
719
+ button.type = 'button';
720
+ button.className = 'sb-swipe-action';
721
+ button.dataset['sbTarget'] = String(action.id);
722
+ button.dataset['sbAction'] = action.event;
723
+ if (action.destructive) button.dataset['sbRole'] = 'destructive';
724
+ if (action.tint) button.style.setProperty('--sb-swipe-tint', action.tint);
725
+ if (action.symbol) {
726
+ const glyph = renderSymbol(action.symbol);
727
+ if (glyph) {
728
+ const symbol = document.createElement('span');
729
+ symbol.className = 'sb-swipe-action-symbol';
730
+ symbol.dataset['sbSymbol'] = action.symbol;
731
+ symbol.append(glyph);
732
+ button.append(symbol);
733
+ }
734
+ }
735
+ const title = document.createElement('span');
736
+ title.className = 'sb-swipe-action-title';
737
+ title.textContent = action.title;
738
+ button.append(title);
739
+ container.append(button);
740
+ }
741
+ // Behind the content slot (which paints the row background).
742
+ host.el.prepend(container);
743
+ }
744
+ }
745
+
746
+ private applySwipeOffset(host: SwipeHost, offset: number): void {
747
+ const content = contentOf(host.el);
748
+ if (content) content.style.transform = offset === 0 ? '' : `translateX(${round2(offset)}px)`;
749
+ const edge = swipeEdgeOf(offset);
750
+ for (const e of ['trailing', 'leading'] as SwipeEdge[]) {
751
+ const container = host.el.querySelector<HTMLElement>(`:scope > .sb-swipe-actions[data-sb-edge="${e}"]`);
752
+ if (!container) continue;
753
+ if (e !== edge) {
754
+ container.style.width = '0px';
755
+ delete container.dataset['sbStretch'];
756
+ delete container.dataset['sbFull'];
757
+ continue;
758
+ }
759
+ const total = swipeActionsWidth(host.spec[e].count);
760
+ const shown = Math.abs(offset);
761
+ container.style.width = `${round2(shown)}px`;
762
+ container.toggleAttribute('data-sb-stretch', shown > total);
763
+ container.toggleAttribute('data-sb-full', host.spec[e].fullSwipe && shown > total + SWIPE_FULL_EXTRA);
764
+ }
765
+ if (offset !== 0) host.el.dataset['sbSwipeOpen'] = edge ?? '';
766
+ else delete host.el.dataset['sbSwipeOpen'];
767
+ }
768
+
769
+ /** The pointer lifted (or a wheel sequence ended) at `offset`: open, close or perform the first action. */
770
+ private settleRow(host: SwipeHost, offset: number, velocityX: number): void {
771
+ delete host.el.dataset['sbSwiping'];
772
+ const content = contentOf(host.el);
773
+ if (content) content.style.transition = '';
774
+ const edge = swipeEdgeOf(offset);
775
+ const settle = settleSwipe(offset, velocityX, host.spec);
776
+ if (!edge || settle === 'closed') {
777
+ this.closeRow(host.el);
778
+ return;
779
+ }
780
+ if (settle === 'full') {
781
+ const first = host.actions[edge][0];
782
+ this.closeRow(host.el);
783
+ if (first) this.host.send({ type: first.event, id: first.id });
784
+ return;
785
+ }
786
+ const rest = swipeRestOffset('open', edge, host.spec);
787
+ this.applySwipeOffset(host, rest);
788
+ this.openSwipe = { host, offset: rest };
789
+ }
790
+
791
+ /** Slide the row open to show one edge's actions (the edit-mode minus). */
792
+ private revealEdge(host: SwipeHost, edge: SwipeEdge): void {
793
+ if (this.openSwipe && this.openSwipe.host.el !== host.el) this.closeRow(this.openSwipe.host.el);
794
+ this.ensureActions(host);
795
+ const content = contentOf(host.el);
796
+ if (content) content.style.transition = '';
797
+ const rest = swipeRestOffset('open', edge, host.spec);
798
+ // Start the containers at 0 so the first frame transitions.
799
+ this.applySwipeOffset(host, 0);
800
+ void host.el.offsetWidth;
801
+ this.applySwipeOffset(host, rest);
802
+ this.openSwipe = { host, offset: rest };
803
+ }
804
+
805
+ /** Close an open (or dragged) row with the slide animation; drops its action buttons afterwards. */
806
+ closeRow(rowEl: HTMLElement): void {
807
+ if (this.openSwipe?.host.el === rowEl) this.openSwipe = null;
808
+ if (this.wheelSwipe?.host.el === rowEl) {
809
+ window.clearTimeout(this.wheelSwipe.timer);
810
+ this.wheelSwipe = null;
811
+ }
812
+ delete rowEl.dataset['sbSwiping'];
813
+ delete rowEl.dataset['sbSwipeOpen'];
814
+ const content = contentOf(rowEl);
815
+ if (content) {
816
+ content.style.transition = '';
817
+ content.style.transform = '';
818
+ }
819
+ const containers = rowEl.querySelectorAll<HTMLElement>(':scope > .sb-swipe-actions');
820
+ containers.forEach((c) => {
821
+ c.style.width = '0px';
822
+ delete c.dataset['sbStretch'];
823
+ delete c.dataset['sbFull'];
824
+ });
825
+ window.setTimeout(() => {
826
+ if (rowEl.dataset['sbSwipeOpen'] === undefined && rowEl.dataset['sbSwiping'] === undefined) {
827
+ rowEl.querySelectorAll(':scope > .sb-swipe-actions').forEach((c) => c.remove());
828
+ }
829
+ }, SETTLE_MS);
830
+ }
831
+
832
+ /** The row currently showing its actions (for tests), or null. */
833
+ get openSwipeRow(): HTMLElement | null {
834
+ return this.openSwipe?.host.el ?? null;
835
+ }
836
+
837
+ /** A pointer went down anywhere: an open row not under it closes (and the tap is swallowed). */
838
+ private readonly onDocumentPointerDown = (event: PointerEvent): void => {
839
+ const open = this.openSwipe;
840
+ if (!open) return;
841
+ const target = event.target as Node | null;
842
+ if (target && open.host.el.contains(target)) return;
843
+ this.closeRow(open.host.el);
844
+ if (target && this.host.root.contains(target)) suppressNextClick();
845
+ };
846
+
847
+ /** Scrolling a list closes its open row and ends a wheel pull. */
848
+ private readonly onScroll = (event: Event): void => {
849
+ const target = event.target as HTMLElement | null;
850
+ if (this.openSwipe && target && target.contains(this.openSwipe.host.el)) this.closeRow(this.openSwipe.host.el);
851
+ if (this.wheelPull && target === this.wheelPull.scroller && target.scrollTop > 0) this.endWheelPull();
852
+ };
853
+
854
+ // -------------------------------------------------------------------------
855
+ // Wheel: horizontal slides rows, vertical at the top pulls to refresh
856
+ // -------------------------------------------------------------------------
857
+
858
+ private readonly onWheel = (event: WheelEvent): void => {
859
+ const target = event.target as HTMLElement | null;
860
+ if (!target) return;
861
+ const horizontal = Math.abs(event.deltaX) > Math.abs(event.deltaY);
862
+ if (horizontal) {
863
+ const rowEl = target.closest<HTMLElement>('.sb-swipeactions, .sb-listrow');
864
+ const host = rowEl ? this.swipeHostFor(rowEl) : null;
865
+ if (!host) return;
866
+ let state = this.wheelSwipe;
867
+ if (!state || state.host.el !== host.el) {
868
+ if (state) this.finishWheelSwipe();
869
+ const open = this.openSwipe?.host.el === host.el ? this.openSwipe.offset : 0;
870
+ if (this.openSwipe && this.openSwipe.host.el !== host.el) this.closeRow(this.openSwipe.host.el);
871
+ host.el.dataset['sbSwiping'] = '';
872
+ const content = contentOf(host.el);
873
+ if (content) content.style.transition = 'none';
874
+ this.ensureActions(host);
875
+ state = { host, offset: open, timer: 0 };
876
+ this.wheelSwipe = state;
877
+ } else {
878
+ window.clearTimeout(state.timer);
879
+ }
880
+ // Scrolling right moves the content left (reveals the trailing edge).
881
+ const next = clampSwipeOffset(state.offset - event.deltaX, host.spec, host.el.offsetWidth);
882
+ if (next === 0 && state.offset === 0) {
883
+ // Nothing to reveal that way: let the page have the wheel.
884
+ this.finishWheelSwipe();
885
+ return;
886
+ }
887
+ event.preventDefault();
888
+ state.offset = next;
889
+ this.applySwipeOffset(host, next);
890
+ state.timer = window.setTimeout(() => this.finishWheelSwipe(), WHEEL_IDLE_MS);
891
+ return;
892
+ }
893
+ // Vertical: a pull (wheel up) at the top of a refreshable scroller.
894
+ const scroller = this.refreshableScrollerOf(target);
895
+ if (!scroller || scroller.el.scrollTop > 0 || event.deltaY >= 0) {
896
+ if (this.wheelPull && event.deltaY > 0) this.endWheelPull();
897
+ return;
898
+ }
899
+ let pull = this.wheelPull;
900
+ if (!pull || pull.scroller !== scroller.el) {
901
+ if (pull) this.endWheelPull();
902
+ pull = { scroller: scroller.el, pulled: 0, sent: false, timer: 0 };
903
+ this.wheelPull = pull;
904
+ } else {
905
+ window.clearTimeout(pull.timer);
906
+ }
907
+ pull.pulled += -event.deltaY;
908
+ scroller.el.style.setProperty('--sb-pull', String(round2(clamp01(pull.pulled / REFRESH_PULL))));
909
+ if (pull.pulled >= REFRESH_PULL && !pull.sent) {
910
+ pull.sent = true;
911
+ this.host.send({ type: 'refresh', id: scroller.id });
912
+ }
913
+ pull.timer = window.setTimeout(() => this.endWheelPull(), WHEEL_IDLE_MS * 2);
914
+ };
915
+
916
+ private finishWheelSwipe(): void {
917
+ const state = this.wheelSwipe;
918
+ if (!state) return;
919
+ window.clearTimeout(state.timer);
920
+ this.wheelSwipe = null;
921
+ this.settleRow(state.host, state.offset, 0);
922
+ }
923
+
924
+ private endWheelPull(): void {
925
+ const pull = this.wheelPull;
926
+ if (!pull) return;
927
+ window.clearTimeout(pull.timer);
928
+ this.wheelPull = null;
929
+ pull.scroller.style.removeProperty('--sb-pull');
930
+ }
931
+
932
+ // -------------------------------------------------------------------------
933
+ // Touch pull to refresh (independent of the pointer, which the native
934
+ // scroll cancels) and the native context menu
935
+ // -------------------------------------------------------------------------
936
+
937
+ private readonly onTouchStart = (event: TouchEvent): void => {
938
+ const touch = event.touches[0];
939
+ if (!touch || event.touches.length !== 1) return;
940
+ const scroller = this.refreshableScrollerOf(event.target as Element);
941
+ if (!scroller || scroller.el.scrollTop > 0) return;
942
+ this.touchPull = { scroller: scroller.el, id: scroller.id, startY: touch.clientY, sent: false };
943
+ };
944
+
945
+ private readonly onTouchMove = (event: TouchEvent): void => {
946
+ const pull = this.touchPull;
947
+ const touch = event.touches[0];
948
+ if (!pull || !touch) return;
949
+ if (pull.scroller.scrollTop > 0) {
950
+ this.touchPull = null;
951
+ return;
952
+ }
953
+ const dy = touch.clientY - pull.startY;
954
+ if (dy >= REFRESH_PULL && !pull.sent) {
955
+ pull.sent = true;
956
+ this.host.send({ type: 'refresh', id: pull.id });
957
+ }
958
+ };
959
+
960
+ private readonly onTouchEnd = (): void => {
961
+ this.touchPull = null;
962
+ };
963
+
964
+ private readonly onContextMenu = (event: MouseEvent): void => {
965
+ const target = event.target as HTMLElement | null;
966
+ const el = target?.closest<HTMLElement>('.sb-contextmenu, .sb-gesture[data-sb-long-press]');
967
+ if (!el) return;
968
+ // The browser's menu never shows on a gesture or context-menu subtree.
969
+ event.preventDefault();
970
+ if (!el.classList.contains('sb-contextmenu')) return;
971
+ const id = Number(el.dataset[ID_ATTR]);
972
+ if (!Number.isInteger(id) || this.host.isMenuOpen(id)) return;
973
+ this.host.openContextMenu(el, id);
974
+ };
975
+
976
+ // -------------------------------------------------------------------------
977
+ // Sheet drag and pull to refresh resets
978
+ // -------------------------------------------------------------------------
979
+
980
+ private resetSheet(c: Extract<Candidate, { kind: 'sheet' }>): void {
981
+ c.card.style.transition = '';
982
+ c.card.style.transform = '';
983
+ c.card.style.removeProperty('--sb-sheet-drag');
984
+ delete c.sheet.dataset['sbDragging'];
985
+ }
986
+
987
+ private resetPull(scroller: HTMLElement): void {
988
+ const content = scrollContentOf(scroller);
989
+ if (content) {
990
+ content.style.transition = '';
991
+ content.style.transform = '';
992
+ }
993
+ delete scroller.dataset['sbPulling'];
994
+ scroller.style.removeProperty('--sb-pull');
995
+ }
996
+
997
+ // -------------------------------------------------------------------------
998
+ // Swipe back
999
+ // -------------------------------------------------------------------------
1000
+
1001
+ /**
1002
+ * Released past the threshold: the `tap` goes out and the screens stay
1003
+ * where the drag left them (`--sb-nav-from`), so the pop clone the
1004
+ * renderer makes when the `remove` arrives continues from there. Should
1005
+ * Swift not pop, the screen snaps back after a moment.
1006
+ */
1007
+ private completeSwipeBack(c: Extract<Candidate, { kind: 'swipeback' }>, x: number): void {
1008
+ const state = this.swipeBack;
1009
+ if (!state) return;
1010
+ state.completing = true;
1011
+ c.screen.style.setProperty('--sb-nav-from', `${round2(x)}px`);
1012
+ c.screen.dataset['sbSwipePop'] = '';
1013
+ this.host.send({ type: 'tap', id: c.id });
1014
+ state.timer = window.setTimeout(() => {
1015
+ if (this.swipeBack === state && c.screen.isConnected) this.snapBackSwipeBack();
1016
+ }, SWIPE_BACK_TIMEOUT_MS);
1017
+ }
1018
+
1019
+ /** Released short of the threshold (or cancelled): both screens slide back to their resting places. */
1020
+ private snapBackSwipeBack(): void {
1021
+ const state = this.swipeBack;
1022
+ if (!state) return;
1023
+ this.swipeBack = null;
1024
+ if (state.timer !== null) window.clearTimeout(state.timer);
1025
+ const { screen, below } = state;
1026
+ delete screen.dataset['sbSwipeDragging'];
1027
+ delete screen.dataset['sbSwipePop'];
1028
+ screen.style.removeProperty('--sb-nav-from');
1029
+ // Dropping the inline transforms lets the stylesheet's 0.4 s transition
1030
+ // carry each screen from where it is to its resting transform.
1031
+ void screen.offsetWidth;
1032
+ screen.style.transition = '';
1033
+ screen.style.transform = '';
1034
+ if (below) {
1035
+ below.style.transition = '';
1036
+ below.style.transform = '';
1037
+ below.style.visibility = '';
1038
+ below.style.removeProperty('--sb-nav-dim');
1039
+ }
1040
+ }
1041
+
1042
+ /**
1043
+ * The renderer is about to remove `screen` from its stack. When a swipe
1044
+ * back put it there, the screen below is handed back to the stylesheet so
1045
+ * it slides the rest of the way to 0 (it becomes the top screen), and the
1046
+ * pop clone starts from `--sb-nav-from`. Returns true in that case.
1047
+ */
1048
+ screenRemoved(screen: HTMLElement): boolean {
1049
+ const state = this.swipeBack;
1050
+ if (!state || state.screen !== screen) return false;
1051
+ this.swipeBack = null;
1052
+ if (state.timer !== null) window.clearTimeout(state.timer);
1053
+ const below = state.below;
1054
+ if (below) {
1055
+ // Before-change style is the dragged position; the transition runs
1056
+ // once `refreshNavStack` makes it the top screen.
1057
+ void below.offsetWidth;
1058
+ below.style.transition = '';
1059
+ below.style.transform = '';
1060
+ below.style.visibility = '';
1061
+ below.style.removeProperty('--sb-nav-dim');
1062
+ }
1063
+ return state.completing;
1064
+ }
1065
+
1066
+ /** Whether a swipe back is in progress (dragging or awaiting the pop). */
1067
+ get swipingBack(): boolean {
1068
+ return this.swipeBack !== null;
1069
+ }
1070
+ }
1071
+
1072
+ /** Drop a text selection that lies inside `root` (a press on selected text would drag the text instead). */
1073
+ function clearSelectionIn(root: HTMLElement): void {
1074
+ const selection = root.ownerDocument.getSelection?.();
1075
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0) return;
1076
+ const anchor = selection.anchorNode;
1077
+ if (anchor && root.contains(anchor)) selection.removeAllRanges();
1078
+ }
1079
+
1080
+ /** The content slot of a swipe row (`swipeactions` / `listrow`). */
1081
+ function contentOf(rowEl: HTMLElement): HTMLElement | null {
1082
+ return rowEl.querySelector<HTMLElement>(':scope > .sb-swipe-content');
1083
+ }
1084
+
1085
+ /** The scrolled content box of a `list` / `scrollview`. */
1086
+ function scrollContentOf(scroller: HTMLElement): HTMLElement | null {
1087
+ return scroller.querySelector<HTMLElement>(':scope > .sb-list-content, :scope > .sb-scroll-content');
1088
+ }
1089
+
1090
+ /**
1091
+ * Swallow the click that follows a drag or a long press so nothing under the
1092
+ * pointer fires. Registered on the window's capture phase, ahead of the
1093
+ * renderer's document-level menu-closing listener.
1094
+ */
1095
+ export function suppressNextClick(): void {
1096
+ const swallow = (click: Event): void => {
1097
+ click.stopImmediatePropagation();
1098
+ click.preventDefault();
1099
+ cleanup();
1100
+ };
1101
+ const cleanup = (): void => {
1102
+ window.removeEventListener('click', swallow, true);
1103
+ window.removeEventListener('pointerdown', cleanup, true);
1104
+ window.clearTimeout(timer);
1105
+ };
1106
+ window.addEventListener('click', swallow, true);
1107
+ // The click arrives right after the pointer lifts; drop the guard if none
1108
+ // does, and as soon as a new pointer goes down (its click is a new tap).
1109
+ window.addEventListener('pointerdown', cleanup, true);
1110
+ const timer = window.setTimeout(cleanup, 150);
1111
+ }
1112
+
1113
+ export { SWIPE_ACTION_WIDTH };