@swiftbrowser/web 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,10 @@
9
9
  * 2. the wrappers between the target and the root, innermost first: a
10
10
  * `gesture` (drag, long press), a `contextmenu` (long press) and a swipe
11
11
  * row (`swipeactions`, or a deletable `listrow` without one);
12
- * 3. the sheet card (drag down to dismiss);
12
+ * 3. the sheet card (drag down to dismiss): inside a vertical scroll view
13
+ * only while that view is at its top, as UIKit hands a sheet's scroll
14
+ * view's downward drag to the sheet there (a touch's first downward move
15
+ * is kept from scrolling natively so the pointer reaches the sheet);
13
16
  * 4. pull to refresh on a `refreshable` scroller at its top.
14
17
  *
15
18
  * Nothing owns the pointer at first. On each move the candidates are asked in
@@ -188,6 +191,8 @@ export class InteractionController {
188
191
  private wheelPull: { scroller: HTMLElement; pulled: number; sent: boolean; timer: number } | null = null;
189
192
  /** Touch-driven pull (the browser cancels the pointer once the list scrolls). */
190
193
  private touchPull: { scroller: HTMLElement; id: ElementId; startY: number; sent: boolean } | null = null;
194
+ /** A touch in a sheet's scroll view at its top: its first move decides who scrolls. */
195
+ private touchSheet: { scroller: HTMLElement; startY: number } | null = null;
191
196
 
192
197
  constructor(private readonly host: InteractionHost) {
193
198
  const root = host.root;
@@ -198,6 +203,7 @@ export class InteractionController {
198
203
  root.addEventListener('scroll', this.onScroll, true);
199
204
  root.addEventListener('touchstart', this.onTouchStart, { passive: true });
200
205
  root.addEventListener('touchmove', this.onTouchMove, { passive: true });
206
+ root.addEventListener('touchmove', this.onSheetTouchMove, { passive: false });
201
207
  root.addEventListener('touchend', this.onTouchEnd, { passive: true });
202
208
  root.addEventListener('touchcancel', this.onTouchEnd, { passive: true });
203
209
  document.addEventListener('pointerdown', this.onDocumentPointerDown, true);
@@ -212,6 +218,7 @@ export class InteractionController {
212
218
  root.removeEventListener('scroll', this.onScroll, true);
213
219
  root.removeEventListener('touchstart', this.onTouchStart);
214
220
  root.removeEventListener('touchmove', this.onTouchMove);
221
+ root.removeEventListener('touchmove', this.onSheetTouchMove);
215
222
  root.removeEventListener('touchend', this.onTouchEnd);
216
223
  root.removeEventListener('touchcancel', this.onTouchEnd);
217
224
  document.removeEventListener('pointerdown', this.onDocumentPointerDown, true);
@@ -405,7 +412,9 @@ export class InteractionController {
405
412
  }
406
413
  const card = target.closest<HTMLElement>('.sb-sheet-card');
407
414
  const sheet = card?.parentElement;
408
- if (card && sheet?.classList.contains('sb-sheet') && sheet.dataset['sbSheetPosition'] !== 'below') {
415
+ // A scroll view in the sheet that is scrolled down keeps the drag.
416
+ const sheetScroller = card ? verticalScrollerIn(target, card) : null;
417
+ if (card && sheet?.classList.contains('sb-sheet') && sheet.dataset['sbSheetPosition'] !== 'below' && !(sheetScroller && sheetScroller.scrollTop > 0)) {
409
418
  const id = Number(sheet.dataset[ID_ATTR]);
410
419
  const detents = (sheet.dataset['sbDetents'] ?? '').split(' ');
411
420
  // A full-screen cover (`detents: ["full"]`) has no drag to dismiss.
@@ -946,7 +955,15 @@ export class InteractionController {
946
955
 
947
956
  private readonly onTouchStart = (event: TouchEvent): void => {
948
957
  const touch = event.touches[0];
958
+ this.touchSheet = null;
949
959
  if (!touch || event.touches.length !== 1) return;
960
+ const target = event.target as HTMLElement;
961
+ const card = target.closest<HTMLElement>('.sb-sheet-card');
962
+ const sheetScroller = card ? verticalScrollerIn(target, card) : null;
963
+ // A refreshable scroller keeps its pull to refresh.
964
+ if (sheetScroller && sheetScroller.scrollTop <= 0 && sheetScroller.dataset['sbRefreshable'] === undefined) {
965
+ this.touchSheet = { scroller: sheetScroller, startY: touch.clientY };
966
+ }
950
967
  const scroller = this.refreshableScrollerOf(event.target as Element);
951
968
  if (!scroller || scroller.el.scrollTop > 0) return;
952
969
  this.touchPull = { scroller: scroller.el, id: scroller.id, startY: touch.clientY, sent: false };
@@ -969,6 +986,25 @@ export class InteractionController {
969
986
 
970
987
  private readonly onTouchEnd = (): void => {
971
988
  this.touchPull = null;
989
+ this.touchSheet = null;
990
+ };
991
+
992
+ /**
993
+ * The first move of a touch in a sheet's scroll view at its top: downwards,
994
+ * it is kept from scrolling (or overscrolling) the view, so the pointer
995
+ * stays with the sheet's drag; upwards, the view scrolls as usual.
996
+ */
997
+ private readonly onSheetTouchMove = (event: TouchEvent): void => {
998
+ const pending = this.touchSheet;
999
+ const touch = event.touches[0];
1000
+ if (!pending || !touch) return;
1001
+ const dy = touch.clientY - pending.startY;
1002
+ if (dy === 0) return;
1003
+ if (dy > 0 && pending.scroller.scrollTop <= 0 && event.cancelable) {
1004
+ event.preventDefault();
1005
+ return; // keep deciding: every move of this drag stays with the sheet
1006
+ }
1007
+ this.touchSheet = null;
972
1008
  };
973
1009
 
974
1010
  private readonly onContextMenu = (event: MouseEvent): void => {
@@ -1088,6 +1124,18 @@ function clearSelectionIn(root: HTMLElement): void {
1088
1124
  }
1089
1125
 
1090
1126
  /** A scroll view that scrolls horizontally (`ScrollView(.horizontal)` or both axes). */
1127
+ /** The innermost vertically scrolling `list` / `scrollview` from `target` up to `limit` (excluded). */
1128
+ function verticalScrollerIn(target: HTMLElement, limit: HTMLElement): HTMLElement | null {
1129
+ for (let el: HTMLElement | null = target; el && el !== limit; el = el.parentElement) {
1130
+ if (el.classList.contains('sb-list')) return el;
1131
+ if (el.classList.contains('sb-scrollview')) {
1132
+ const axes = el.dataset['sbAxes'];
1133
+ if (axes === 'vertical' || axes === 'both' || axes === undefined) return el;
1134
+ }
1135
+ }
1136
+ return null;
1137
+ }
1138
+
1091
1139
  function isHorizontalScroller(el: HTMLElement): boolean {
1092
1140
  if (!el.classList.contains('sb-scrollview')) return false;
1093
1141
  const axes = el.dataset['sbAxes'];
@@ -0,0 +1,79 @@
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).
11
+ */
12
+
13
+ /** The scroll offset past which the title has gone under the bar. */
14
+ const COLLAPSE_OFFSET = 44;
15
+ /** How far below the bar's edge content fades in (styles.css matches). */
16
+ const EDGE_FADE = 16;
17
+
18
+ const bound = new WeakSet<HTMLElement>();
19
+
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;
24
+ }
25
+
26
+ /**
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.
29
+ */
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);
37
+ }
38
+ return;
39
+ }
40
+ scroller.dataset['sbScrollsTitle'] = '';
41
+ if (!bound.has(scroller)) {
42
+ bound.add(scroller);
43
+ scroller.addEventListener('scroll', () => syncLargeTitle(scroller), { passive: true });
44
+ }
45
+ syncLargeTitle(scroller);
46
+ }
47
+
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'];
68
+ }
69
+
70
+ function setMask(el: HTMLElement, mask: string): void {
71
+ el.style.setProperty('mask-image', mask);
72
+ el.style.setProperty('-webkit-mask-image', mask);
73
+ }
74
+
75
+ function reset(screen: HTMLElement, title: HTMLElement): void {
76
+ title.style.transform = '';
77
+ setMask(title, '');
78
+ delete screen.dataset['sbCollapsed'];
79
+ }