@swiftbrowser/web 0.3.1 → 0.4.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.
@@ -34,8 +34,16 @@
34
34
  * top of a `refreshable` scroller pulls to refresh; touch moves are read
35
35
  * directly for pull to refresh (the browser cancels the pointer once it
36
36
  * scrolls the list).
37
+ *
38
+ * Two more candidates lift something (src/dragDrop.ts does the DOM work):
39
+ * a movable `listrow` (by its edit-mode grip from the pointer down, or by a
40
+ * long press when the list is not editing) is reordered among its
41
+ * neighbours, and a `draggable` (a 10 px mouse drag, or a long press on a
42
+ * touch screen) is carried to a `dropdestination`. While either owns the
43
+ * pointer its touch moves are kept from scrolling.
37
44
  */
38
45
 
46
+ import { RowReorder, ViewDrag } from './dragDrop';
39
47
  import type { LayoutNode } from './layout';
40
48
  import { symbolOf, textOf, tintOf, unwrapStyledNode } from './nodeQueries';
41
49
  import type { DragEvent, ElementId, RendererEvent } from './protocol';
@@ -132,7 +140,11 @@ type Candidate =
132
140
  | { kind: 'contextmenu'; el: HTMLElement; id: ElementId }
133
141
  | { kind: 'swipe'; host: SwipeHost }
134
142
  | { kind: 'sheet'; sheet: HTMLElement; card: HTMLElement; id: ElementId }
135
- | { kind: 'refresh'; scroller: HTMLElement; id: ElementId };
143
+ | { kind: 'refresh'; scroller: HTMLElement; id: ElementId }
144
+ /** A movable `listrow`: `immediate` from its grip (owned from the pointer down), else lifted by a long press. */
145
+ | { kind: 'reorder'; row: HTMLElement; id: ElementId; immediate: boolean }
146
+ /** A `draggable`: lifted by a long press (`byPress`, touch) or by moving 10 px (mouse). */
147
+ | { kind: 'dragsource'; el: HTMLElement; id: ElementId; byPress: boolean };
136
148
 
137
149
  interface Session {
138
150
  pointerId: number;
@@ -158,6 +170,9 @@ interface Session {
158
170
  swipeOffset: number;
159
171
  // sheet / refresh
160
172
  dy: number;
173
+ // reorder / drag source (src/dragDrop.ts)
174
+ reorder: RowReorder | null;
175
+ viewDrag: ViewDrag | null;
161
176
  }
162
177
 
163
178
  interface OpenSwipe {
@@ -193,6 +208,8 @@ export class InteractionController {
193
208
  private touchPull: { scroller: HTMLElement; id: ElementId; startY: number; sent: boolean } | null = null;
194
209
  /** A touch in a sheet's scroll view at its top: its first move decides who scrolls. */
195
210
  private touchSheet: { scroller: HTMLElement; startY: number } | null = null;
211
+ /** The last row reorder, which may still be settling after its pointer lifted. */
212
+ private reorder: RowReorder | null = null;
196
213
 
197
214
  constructor(private readonly host: InteractionHost) {
198
215
  const root = host.root;
@@ -204,6 +221,7 @@ export class InteractionController {
204
221
  root.addEventListener('touchstart', this.onTouchStart, { passive: true });
205
222
  root.addEventListener('touchmove', this.onTouchMove, { passive: true });
206
223
  root.addEventListener('touchmove', this.onSheetTouchMove, { passive: false });
224
+ root.addEventListener('touchmove', this.onLiftedTouchMove, { passive: false });
207
225
  root.addEventListener('touchend', this.onTouchEnd, { passive: true });
208
226
  root.addEventListener('touchcancel', this.onTouchEnd, { passive: true });
209
227
  document.addEventListener('pointerdown', this.onDocumentPointerDown, true);
@@ -219,6 +237,7 @@ export class InteractionController {
219
237
  root.removeEventListener('touchstart', this.onTouchStart);
220
238
  root.removeEventListener('touchmove', this.onTouchMove);
221
239
  root.removeEventListener('touchmove', this.onSheetTouchMove);
240
+ root.removeEventListener('touchmove', this.onLiftedTouchMove);
222
241
  root.removeEventListener('touchend', this.onTouchEnd);
223
242
  root.removeEventListener('touchcancel', this.onTouchEnd);
224
243
  document.removeEventListener('pointerdown', this.onDocumentPointerDown, true);
@@ -265,8 +284,9 @@ export class InteractionController {
265
284
  const target = event.target as HTMLElement | null;
266
285
  if (!target) return;
267
286
  // Pointer downs in a text field select text, never gesture; a slider
268
- // (Phase 8 controls) owns its own pointer drag in the renderer.
269
- const inField = target.closest('input, textarea, [contenteditable], .sb-slider') !== null;
287
+ // (Phase 8 controls) owns its own pointer drag in the renderer, and a
288
+ // `host` element's content is the page's own DOM.
289
+ const inField = target.closest('input, textarea, [contenteditable], .sb-slider, .sb-host') !== null;
270
290
  const candidates = inField ? [] : this.collectCandidates(target, event);
271
291
  const edgeStrip = target.classList.contains('sb-navscreen-edge') ? target : null;
272
292
  if (candidates.length === 0 && !edgeStrip) return;
@@ -294,20 +314,35 @@ export class InteractionController {
294
314
  swipeStart: 0,
295
315
  swipeOffset: 0,
296
316
  dy: 0,
317
+ reorder: null,
318
+ viewDrag: null,
297
319
  };
298
320
  this.session = session;
299
321
  window.addEventListener('pointermove', this.onPointerMove);
300
322
  window.addEventListener('pointerup', this.onPointerUp);
301
323
  window.addEventListener('pointercancel', this.onPointerCancel);
302
- // The innermost long-press wrapper arms its timer right away.
303
- const presser = candidates.find((c) => (c.kind === 'gesture' && c.longPress) || c.kind === 'contextmenu');
324
+ // The innermost long-press wrapper arms its timer right away (a row or
325
+ // view lifted by a long press counts; a context menu inside it wins).
326
+ const presser = candidates.find(
327
+ (c) =>
328
+ (c.kind === 'gesture' && c.longPress) ||
329
+ c.kind === 'contextmenu' ||
330
+ (c.kind === 'reorder' && !c.immediate) ||
331
+ (c.kind === 'dragsource' && c.byPress),
332
+ );
304
333
  if (presser) {
305
334
  const duration = presser.kind === 'gesture' ? presser.minDuration : LONG_PRESS_MS;
306
335
  session.longPressTimer = window.setTimeout(() => this.fireLongPress(session, presser), duration);
307
336
  }
308
- // An immediate drag gesture (`minimumDistance: 0`) owns the pointer from the start.
309
- const immediate = candidates.find((c) => c.kind === 'gesture' && c.drag && c.minDistance <= 0);
310
- if (immediate && candidates[0] === immediate) this.claim(session, immediate, event);
337
+ // An immediate drag gesture (`minimumDistance: 0`) and a row's grip own the pointer from the start.
338
+ const immediate = candidates.find((c) => (c.kind === 'gesture' && c.drag && c.minDistance <= 0) || (c.kind === 'reorder' && c.immediate));
339
+ if (immediate && candidates[0] === immediate) this.claim(session, immediate, event.pointerId);
340
+ };
341
+
342
+ /** A lifted row or view moves with the finger: the list under it must not scroll. */
343
+ private readonly onLiftedTouchMove = (event: TouchEvent): void => {
344
+ const claimed = this.session?.claimed;
345
+ if (claimed && (claimed.kind === 'reorder' || claimed.kind === 'dragsource') && event.cancelable) event.preventDefault();
311
346
  };
312
347
 
313
348
  private readonly onPointerMove = (event: PointerEvent): void => {
@@ -328,7 +363,7 @@ export class InteractionController {
328
363
  if (!session.claimed) {
329
364
  const owner = session.candidates.find((c) => this.wants(c, dx, dy, distance));
330
365
  if (!owner) return;
331
- this.claim(session, owner, event);
366
+ this.claim(session, owner, event.pointerId);
332
367
  }
333
368
  if (session.claimed) this.move(session, session.claimed, dx, dy);
334
369
  };
@@ -380,6 +415,14 @@ export class InteractionController {
380
415
  /** Every behaviour that could own a pointer that went down on `target`, in priority order. */
381
416
  private collectCandidates(target: HTMLElement, event: PointerEvent): Candidate[] {
382
417
  const out: Candidate[] = [];
418
+ // A press on a movable row's grip lifts the row right away, above all else.
419
+ const grip = target.closest<HTMLElement>('.sb-listrow-grip');
420
+ const gripRow = grip?.closest<HTMLElement>('.sb-listrow') ?? null;
421
+ const gripRowId = Number(gripRow?.dataset[ID_ATTR]);
422
+ if (gripRow && Number.isInteger(gripRowId) && !this.reorderSettling(gripRow)) {
423
+ out.push({ kind: 'reorder', row: gripRow, id: gripRowId, immediate: true });
424
+ }
425
+ const byPress = event.pointerType !== 'mouse';
383
426
  const screen = target.closest<HTMLElement>('.sb-navscreen');
384
427
  if (screen && screen.dataset['sbNavPosition'] === 'top' && Number(screen.dataset['sbDepth'] ?? '0') > 0 && !this.swipeBack) {
385
428
  const rect = screen.getBoundingClientRect();
@@ -405,9 +448,21 @@ export class InteractionController {
405
448
  out.push({ kind: 'gesture', el, id, drag, longPress, minDistance, minDuration });
406
449
  } else if (el.classList.contains('sb-contextmenu') && Number.isInteger(id)) {
407
450
  out.push({ kind: 'contextmenu', el, id });
451
+ } else if (el.classList.contains('sb-draggable') && Number.isInteger(id)) {
452
+ out.push({ kind: 'dragsource', el, id, byPress });
408
453
  } else if ((el.classList.contains('sb-swipeactions') || el.classList.contains('sb-listrow')) && !inHorizontalScroller) {
409
454
  const host = this.swipeHostFor(el);
410
455
  if (host) out.push({ kind: 'swipe', host });
456
+ // A movable row in a list that is not editing lifts on a long press.
457
+ if (
458
+ el.classList.contains('sb-listrow') &&
459
+ el.hasAttribute('data-sb-movable') &&
460
+ Number.isInteger(id) &&
461
+ !el.closest('.sb-list')?.hasAttribute('data-sb-editing') &&
462
+ !this.reorderSettling(el)
463
+ ) {
464
+ out.push({ kind: 'reorder', row: el, id, immediate: false });
465
+ }
411
466
  }
412
467
  }
413
468
  const card = target.closest<HTMLElement>('.sb-sheet-card');
@@ -451,10 +506,15 @@ export class InteractionController {
451
506
  return dy >= PAN_SLOP && dy > Math.abs(dx);
452
507
  case 'refresh':
453
508
  return dy >= PAN_SLOP && dy > Math.abs(dx) && c.scroller.scrollTop <= 0;
509
+ case 'reorder':
510
+ // Owned from the pointer down (the grip) or by a long press, never by motion.
511
+ return false;
512
+ case 'dragsource':
513
+ return !c.byPress && distance >= DRAG_MIN_DISTANCE;
454
514
  }
455
515
  }
456
516
 
457
- private claim(session: Session, c: Candidate, event: PointerEvent): void {
517
+ private claim(session: Session, c: Candidate, pointerId: number): void {
458
518
  session.claimed = c;
459
519
  session.candidates = [c];
460
520
  if (session.longPressTimer !== null) {
@@ -463,7 +523,7 @@ export class InteractionController {
463
523
  }
464
524
  const captureOn = c.kind === 'gesture' ? c.el : c.kind === 'swipe' ? c.host.el : this.host.root;
465
525
  try {
466
- captureOn.setPointerCapture(event.pointerId);
526
+ captureOn.setPointerCapture(pointerId);
467
527
  } catch {
468
528
  // Not every environment (or an already-cancelled pointer) allows capture.
469
529
  }
@@ -501,13 +561,28 @@ export class InteractionController {
501
561
  c.card.style.transition = 'none';
502
562
  break;
503
563
  case 'refresh': {
504
- const content = scrollContentOf(c.scroller);
505
- if (content) content.style.transition = 'none';
564
+ for (const el of pulledOf(c.scroller)) el.style.transition = 'none';
506
565
  c.scroller.dataset['sbPulling'] = '';
507
566
  break;
508
567
  }
509
568
  case 'contextmenu':
510
569
  break;
570
+ case 'reorder': {
571
+ this.host.closeMenu();
572
+ if (this.openSwipe) this.closeRow(this.openSwipe.host.el);
573
+ const reorder = new RowReorder(c.row, c.id, this.host);
574
+ this.reorder = reorder;
575
+ session.reorder = reorder;
576
+ reorder.start(session.y);
577
+ break;
578
+ }
579
+ case 'dragsource': {
580
+ this.host.closeMenu();
581
+ const drag = new ViewDrag(c.el, c.id, this.host);
582
+ session.viewDrag = drag;
583
+ drag.start();
584
+ break;
585
+ }
511
586
  }
512
587
  }
513
588
 
@@ -552,14 +627,20 @@ export class InteractionController {
552
627
  case 'refresh': {
553
628
  const pulled = Math.max(0, dy);
554
629
  session.dy = pulled;
555
- const content = scrollContentOf(c.scroller);
556
630
  // The content follows with resistance; the spinner fades in over the pull distance.
557
- if (content) content.style.transform = `translateY(${Math.min(REFRESH_PULL * 1.5, pulled * 0.6)}px)`;
631
+ const transform = `translateY(${Math.min(REFRESH_PULL * 1.5, pulled * 0.6)}px)`;
632
+ for (const el of pulledOf(c.scroller)) el.style.transform = transform;
558
633
  c.scroller.style.setProperty('--sb-pull', String(round2(clamp01(pulled / REFRESH_PULL))));
559
634
  return;
560
635
  }
561
636
  case 'contextmenu':
562
637
  return;
638
+ case 'reorder':
639
+ session.reorder?.move(dy, session.y);
640
+ return;
641
+ case 'dragsource':
642
+ session.viewDrag?.move(dx, dy, session.x, session.y);
643
+ return;
563
644
  }
564
645
  }
565
646
 
@@ -594,6 +675,12 @@ export class InteractionController {
594
675
  }
595
676
  case 'contextmenu':
596
677
  return;
678
+ case 'reorder':
679
+ session.reorder?.end();
680
+ return;
681
+ case 'dragsource':
682
+ session.viewDrag?.end(session.x, session.y);
683
+ return;
597
684
  }
598
685
  }
599
686
 
@@ -616,18 +703,35 @@ export class InteractionController {
616
703
  return;
617
704
  case 'contextmenu':
618
705
  return;
706
+ case 'reorder':
707
+ session.reorder?.cancel();
708
+ return;
709
+ case 'dragsource':
710
+ session.viewDrag?.cancel();
711
+ return;
619
712
  }
620
713
  }
621
714
 
622
715
  private fireLongPress(session: Session, c: Candidate): void {
623
716
  session.longPressTimer = null;
624
717
  if (this.session !== session || session.claimed) return;
625
- session.done = true;
626
718
  session.suppressClick = true;
719
+ if (c.kind === 'reorder' || c.kind === 'dragsource') {
720
+ // Lifted: the pointer is owned from here, and every move drags.
721
+ this.claim(session, c, session.pointerId);
722
+ return;
723
+ }
724
+ session.done = true;
627
725
  if (c.kind === 'gesture') this.host.send({ type: 'longpress', id: c.id });
628
726
  else if (c.kind === 'contextmenu') this.host.openContextMenu(c.el, c.id);
629
727
  }
630
728
 
729
+ /** Whether a row of this card is still sliding into place after a reorder (its `move` is pending). */
730
+ private reorderSettling(row: HTMLElement): boolean {
731
+ const reorder = this.reorder;
732
+ return reorder !== null && reorder.settling && reorder.row.parentElement === row.parentElement;
733
+ }
734
+
631
735
  private dragEvent(session: Session, c: Extract<Candidate, { kind: 'gesture' }>, phase: DragEvent['phase'], velocity: { x: number; y: number }): DragEvent {
632
736
  const rect = session.gestureRect ?? c.el.getBoundingClientRect();
633
737
  return {
@@ -1031,10 +1135,9 @@ export class InteractionController {
1031
1135
  }
1032
1136
 
1033
1137
  private resetPull(scroller: HTMLElement): void {
1034
- const content = scrollContentOf(scroller);
1035
- if (content) {
1036
- content.style.transition = '';
1037
- content.style.transform = '';
1138
+ for (const el of pulledOf(scroller)) {
1139
+ el.style.transition = '';
1140
+ el.style.transform = '';
1038
1141
  }
1039
1142
  delete scroller.dataset['sbPulling'];
1040
1143
  scroller.style.removeProperty('--sb-pull');
@@ -1148,8 +1251,11 @@ function contentOf(rowEl: HTMLElement): HTMLElement | null {
1148
1251
  }
1149
1252
 
1150
1253
  /** The scrolled content box of a `list` / `scrollview`. */
1151
- function scrollContentOf(scroller: HTMLElement): HTMLElement | null {
1152
- return scroller.querySelector<HTMLElement>(':scope > .sb-list-content, :scope > .sb-scroll-content');
1254
+ /** What a pull to refresh moves: the content, and a large title that scrolls with it (src/largeTitle.ts). */
1255
+ function pulledOf(scroller: HTMLElement): HTMLElement[] {
1256
+ return Array.from(
1257
+ scroller.querySelectorAll<HTMLElement>(':scope > .sb-list-content, :scope > .sb-scroll-content, :scope > .sb-navbar-large-title'),
1258
+ );
1153
1259
  }
1154
1260
 
1155
1261
  /**
package/src/largeTitle.ts CHANGED
@@ -1,79 +1,120 @@
1
1
  /**
2
- * A navigation screen's large title scrolls with the screen's root list or
3
- * scroll view (iOS): the engine starts that scroll view under the bar, insets
4
- * its content by the title row and marks it with a `largeTitle` chrome rect
5
- * (layout/engine.ts, `placeNavScreen`). Here the title follows the scroll
6
- * offset and fades out at the bar's edge, as the scrolled content does
7
- * (`data-sb-scrolled`, iOS 26's scroll edge effect), and once it has gone
8
- * under the bar the screen gets `data-sb-collapsed`, which shows the inline
9
- * title (styles.css). Pulled past the top (a touch overscroll), the title
10
- * moves down with the content (iOS 26 no longer grows it).
2
+ * iOS 26: a navigation screen's root list or vertical scroll view starts at
3
+ * the top of the screen and its content scrolls under the bar; a large title
4
+ * scrolls with it. The engine insets the scroll view's content by the bar
5
+ * (and the title row) and marks it with a `topInset` chrome rect, plus a
6
+ * `largeTitle` rect when the title scrolls with it (layout/engine.ts,
7
+ * `placeNavScreen`).
8
+ *
9
+ * The title element moves into the scroll view (an absolutely positioned
10
+ * child, which the browser scrolls with the content), so it moves in step
11
+ * with the content however fast it scrolls, overscroll included. Scrolling
12
+ * only toggles flags: `data-sb-scrolled` on the screen once content is under
13
+ * the bar (its scroll edge effect, styles.css) and `data-sb-collapsed` once
14
+ * the title has gone under it, which shows the inline title. A list or
15
+ * scroll view deeper in the screen's content whose top touches the bar runs
16
+ * under it too (without the title). Its `data-sb-edge-top`
17
+ * (`.scrollEdgeEffectStyle` / `.scrollEdgeEffectHidden`, scrollEdge.ts) is
18
+ * copied to the screen, whose bar draws the effect.
11
19
  */
12
20
 
21
+ import type { Rect } from './layout';
22
+ import { setValue } from './scrollEdge';
23
+
13
24
  /** The scroll offset past which the title has gone under the bar. */
14
25
  const COLLAPSE_OFFSET = 44;
15
- /** How far below the bar's edge content fades in (styles.css matches). */
16
- const EDGE_FADE = 16;
17
26
 
18
27
  const bound = new WeakSet<HTMLElement>();
28
+ /** Each screen's large title element, wherever it currently is. */
29
+ const titles = new WeakMap<HTMLElement, HTMLElement>();
30
+
31
+ /**
32
+ * A screen's large title element: in the screen's body, or in the scroll
33
+ * view that carries it (put back in the body if that scroll view is gone).
34
+ */
35
+ export function largeTitleOf(screen: HTMLElement): HTMLElement | null {
36
+ let title = titles.get(screen) ?? screen.querySelector<HTMLElement>(':scope > .sb-navscreen-body > .sb-navbar-large-title');
37
+ if (!title) return null;
38
+ titles.set(screen, title);
39
+ const body = screen.querySelector<HTMLElement>(':scope > .sb-navscreen-body');
40
+ if (body && !screen.contains(title.parentElement)) body.prepend(title);
41
+ return title;
42
+ }
43
+
44
+ /** Whether a screen's large title currently scrolls inside one of its scroll views. */
45
+ export function largeTitleScrolls(screen: HTMLElement, title: HTMLElement): boolean {
46
+ return title.parentElement !== null && !title.parentElement.classList.contains('sb-navscreen-body') && screen.contains(title);
47
+ }
19
48
 
20
- function largeTitleOf(scroller: HTMLElement): { screen: HTMLElement; title: HTMLElement } | null {
21
- const screen = scroller.closest<HTMLElement>('.sb-navscreen');
22
- const title = screen?.querySelector<HTMLElement>(':scope > .sb-navscreen-body > .sb-navbar-large-title');
23
- return screen && title ? { screen, title } : null;
49
+ function screenOf(scroller: HTMLElement): HTMLElement | null {
50
+ return scroller.closest<HTMLElement>('.sb-navscreen');
24
51
  }
25
52
 
26
53
  /**
27
- * Called for every list and scroll view the layout reaches: `scrolls` is
28
- * whether the engine marked it as carrying its screen's large title.
54
+ * Called for every list and scroll view the layout reaches with its frame and
55
+ * the engine's `topInset` / `largeTitle` rects (absent when it is not its
56
+ * screen's root scroller).
29
57
  */
30
- export function bindLargeTitle(scroller: HTMLElement, scrolls: boolean): void {
31
- if (!scrolls) {
32
- if (scroller.dataset['sbScrollsTitle'] !== undefined) {
33
- delete scroller.dataset['sbScrollsTitle'];
34
- delete scroller.dataset['sbScrolled'];
35
- const parts = largeTitleOf(scroller);
36
- if (parts) reset(parts.screen, parts.title);
58
+ export function bindScrollUnderBar(scroller: HTMLElement, frame: Rect, topInset: Rect | undefined, titleRect: Rect | undefined): void {
59
+ const screen = screenOf(scroller);
60
+ if (topInset === undefined) {
61
+ if (scroller.dataset['sbUnderBar'] !== undefined) {
62
+ delete scroller.dataset['sbUnderBar'];
63
+ scroller.style.removeProperty('--sb-top-inset');
64
+ if (screen) {
65
+ delete screen.dataset['sbScrolled'];
66
+ delete screen.dataset['sbCollapsed'];
67
+ delete screen.dataset['sbEdgeTop'];
68
+ }
37
69
  }
70
+ if (screen) releaseTitle(screen, scroller);
38
71
  return;
39
72
  }
40
- scroller.dataset['sbScrollsTitle'] = '';
73
+ scroller.dataset['sbUnderBar'] = '';
74
+ scroller.style.setProperty('--sb-top-inset', `${topInset.height}px`);
75
+ if (screen) {
76
+ setValue(screen, 'sbEdgeTop', scroller.dataset['sbEdgeTop'] ?? null);
77
+ const title = largeTitleOf(screen);
78
+ if (title && titleRect) {
79
+ if (title.parentElement !== scroller) scroller.append(title);
80
+ const s = title.style;
81
+ s.left = `${titleRect.x - frame.x}px`;
82
+ s.top = `${titleRect.y - frame.y}px`;
83
+ s.width = `${titleRect.width}px`;
84
+ s.height = `${titleRect.height}px`;
85
+ } else {
86
+ releaseTitle(screen, scroller);
87
+ }
88
+ }
41
89
  if (!bound.has(scroller)) {
42
90
  bound.add(scroller);
43
- scroller.addEventListener('scroll', () => syncLargeTitle(scroller), { passive: true });
91
+ scroller.addEventListener('scroll', () => syncScrollUnderBar(scroller), { passive: true });
44
92
  }
45
- syncLargeTitle(scroller);
93
+ syncScrollUnderBar(scroller);
46
94
  }
47
95
 
48
- export function syncLargeTitle(scroller: HTMLElement): void {
49
- if (scroller.dataset['sbScrollsTitle'] === undefined) return;
50
- const parts = largeTitleOf(scroller);
51
- if (!parts) return;
52
- const { screen, title } = parts;
53
- const offset = scroller.scrollTop;
54
- const s = title.style;
55
- if (offset > 0) {
56
- s.transform = `translateY(${-offset}px)`;
57
- // Hide what has gone above the title's row (under the bar), fading in
58
- // below the edge.
59
- setMask(title, `linear-gradient(to bottom, transparent ${offset}px, #000 ${offset + EDGE_FADE}px)`);
60
- scroller.dataset['sbScrolled'] = '';
61
- } else {
62
- s.transform = offset < 0 ? `translateY(${-offset}px)` : '';
63
- setMask(title, '');
64
- delete scroller.dataset['sbScrolled'];
65
- }
66
- if (offset > COLLAPSE_OFFSET) screen.dataset['sbCollapsed'] = '';
67
- else delete screen.dataset['sbCollapsed'];
96
+ /** Put a title this scroll view carries back in its screen's body. */
97
+ function releaseTitle(screen: HTMLElement, scroller: HTMLElement): void {
98
+ const title = titles.get(screen);
99
+ if (!title || title.parentElement !== scroller) return;
100
+ screen.querySelector<HTMLElement>(':scope > .sb-navscreen-body')?.prepend(title);
68
101
  }
69
102
 
70
- function setMask(el: HTMLElement, mask: string): void {
71
- el.style.setProperty('mask-image', mask);
72
- el.style.setProperty('-webkit-mask-image', mask);
103
+ export function syncScrollUnderBar(scroller: HTMLElement): void {
104
+ if (scroller.dataset['sbUnderBar'] === undefined) return;
105
+ const screen = screenOf(scroller);
106
+ if (!screen) return;
107
+ const offset = scroller.scrollTop;
108
+ setFlag(screen, 'sbScrolled', offset > 0);
109
+ const title = titles.get(screen);
110
+ const carries = title !== undefined && title.parentElement === scroller;
111
+ setFlag(screen, 'sbCollapsed', carries && offset > COLLAPSE_OFFSET);
73
112
  }
74
113
 
75
- function reset(screen: HTMLElement, title: HTMLElement): void {
76
- title.style.transform = '';
77
- setMask(title, '');
78
- delete screen.dataset['sbCollapsed'];
114
+ function setFlag(el: HTMLElement, key: string, on: boolean): void {
115
+ if (on) {
116
+ if (el.dataset[key] === undefined) el.dataset[key] = '';
117
+ } else if (el.dataset[key] !== undefined) {
118
+ delete el.dataset[key];
119
+ }
79
120
  }