@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.
- package/README.md +3 -1
- package/package.json +4 -2
- package/src/animation.ts +154 -3
- package/src/dragDrop.ts +549 -0
- package/src/hostContent.ts +37 -0
- package/src/interaction.ts +128 -22
- package/src/largeTitle.ts +95 -54
- package/src/layout/engine.ts +415 -88
- package/src/layout/index.ts +5 -2
- package/src/layout/measure.ts +2 -0
- package/src/layout/text.ts +6 -3
- package/src/layout/types.ts +16 -1
- package/src/layout/typography.ts +30 -10
- package/src/layoutDom.ts +66 -16
- package/src/pointerEffects.ts +182 -0
- package/src/protocol.ts +188 -9
- package/src/renderer.ts +316 -15
- package/src/scrollEdge.ts +87 -0
- package/src/scrollTo.ts +6 -0
- package/src/styles.css +413 -16
- package/src/typography.ts +5 -2
package/src/interaction.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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`)
|
|
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,
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
1035
|
-
|
|
1036
|
-
|
|
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
|
-
|
|
1152
|
-
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* (
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* title
|
|
10
|
-
*
|
|
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
|
|
21
|
-
|
|
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
|
|
28
|
-
*
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
delete scroller.dataset['
|
|
35
|
-
|
|
36
|
-
if (
|
|
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['
|
|
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', () =>
|
|
91
|
+
scroller.addEventListener('scroll', () => syncScrollUnderBar(scroller), { passive: true });
|
|
44
92
|
}
|
|
45
|
-
|
|
93
|
+
syncScrollUnderBar(scroller);
|
|
46
94
|
}
|
|
47
95
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
if (!
|
|
52
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
}
|