@swiftbrowser/web 0.9.11 → 0.9.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.9.11",
3
+ "version": "0.9.13",
4
4
  "type": "module",
5
5
  "description": "SwiftBrowser web renderer and dev server: applies render ops from a Swift/Wasm app to the DOM inside an iPhone frame.",
6
6
  "repository": {
@@ -11,7 +11,11 @@
11
11
  * the back button and the scrim send, so the app's own state stays the
12
12
  * source of truth; the page's URL is never touched, and an app that maps
13
13
  * screens to paths should set them with `replaceState` on the entry the
14
- * renderer made.
14
+ * renderer made. When the app pops on its own, the renderer's `history.go`
15
+ * back is asynchronous and the app's `replaceState` for the screen it shows
16
+ * now runs before the move lands, on the entry being left; the renderer
17
+ * applies that URL again to the entry it arrives at, so the address bar
18
+ * follows the bar's back button as it follows the browser's.
15
19
  *
16
20
  * A two-column split view pushes nothing when its sidebar's selection
17
21
  * changes, so that is an entry of its own: choosing another sidebar row
@@ -60,6 +64,13 @@ export class BrowserHistory {
60
64
  /** A `popstate` asked for the entry's sidebar selection: the change it brings is recorded, not pushed. */
61
65
  private restoringSelection = false;
62
66
  private enabled = false;
67
+ /** The page's own `replaceState`, while ours stands in for it. */
68
+ private pageReplaceState: History['replaceState'] | null = null;
69
+ private pageReplaceStateWasOwn = false;
70
+ /** A URL the page set while our own `history.go` was on its way: meant for the entry it lands on. */
71
+ private landingUrl: string | URL | null = null;
72
+ /** A level appeared while our own `history.go` was on its way: its entry is pushed once the move lands. */
73
+ private syncOnArrival = false;
63
74
 
64
75
  constructor(
65
76
  private readonly renderer: Renderer,
@@ -75,10 +86,15 @@ export class BrowserHistory {
75
86
  this.enabled = true;
76
87
  const current = stateOf(this.win.history.state);
77
88
  if (!current) this.win.history.replaceState({ ...(this.win.history.state ?? {}), ...this.entry(0, this.selection()) }, '');
89
+ this.watchReplaceState();
78
90
  this.renderer.interaction.nativeBackGesture = true;
79
91
  this.root.addEventListener('sb:commit', this.onCommit);
80
92
  this.win.addEventListener('popstate', this.onPopState);
81
- this.sync();
93
+ // Enabled before the first commit (a mount, or a remount such as React's
94
+ // StrictMode makes in development) the screen is not drawn yet: fewer
95
+ // levels than the entry records is not the app popping, so only the
96
+ // levels already shown are pushed here and the first commit settles the rest.
97
+ this.sync(true);
82
98
  }
83
99
 
84
100
  dispose(): void {
@@ -87,10 +103,44 @@ export class BrowserHistory {
87
103
  this.renderer.interaction.nativeBackGesture = false;
88
104
  if (this.movingTimer) clearTimeout(this.movingTimer);
89
105
  this.movingTo = null;
106
+ this.landingUrl = null;
107
+ this.syncOnArrival = false;
108
+ this.unwatchReplaceState();
90
109
  this.root.removeEventListener('sb:commit', this.onCommit);
91
110
  this.win.removeEventListener('popstate', this.onPopState);
92
111
  }
93
112
 
113
+ /**
114
+ * Stands in for the page's `replaceState`: it still runs at once, and a
115
+ * URL given while our own `go` is on its way is remembered for the entry
116
+ * the move lands on.
117
+ */
118
+ private watchReplaceState(): void {
119
+ const history = this.win.history;
120
+ const original = history.replaceState;
121
+ this.pageReplaceState = original;
122
+ this.pageReplaceStateWasOwn = Object.prototype.hasOwnProperty.call(history, 'replaceState');
123
+ history.replaceState = (data: unknown, unused: string, url?: string | URL | null): void => {
124
+ if (this.movingTo !== null && url !== undefined && url !== null) this.landingUrl = url;
125
+ original.call(history, data, unused, url);
126
+ };
127
+ }
128
+
129
+ private unwatchReplaceState(): void {
130
+ const original = this.pageReplaceState;
131
+ if (!original) return;
132
+ const history = this.win.history;
133
+ // A browser's is the prototype's: removing ours uncovers it. A stand-in's own property is put back.
134
+ if (this.pageReplaceStateWasOwn) history.replaceState = original;
135
+ else delete (history as Partial<History>).replaceState;
136
+ this.pageReplaceState = null;
137
+ }
138
+
139
+ /** `replaceState` as the page had it, for our own entries. */
140
+ private replaceEntry(data: unknown, url?: string | URL | null): void {
141
+ (this.pageReplaceState ?? this.win.history.replaceState).call(this.win.history, data, '', url);
142
+ }
143
+
94
144
  /** The levels above the base the app shows now: sheets, plus pushed screens in each visible stack of the top layer. */
95
145
  depth(): number {
96
146
  const sheets = this.sheets();
@@ -192,7 +242,13 @@ export class BrowserHistory {
192
242
  this.movingTimer = setTimeout(() => {
193
243
  if (this.movingTo === null) return;
194
244
  this.movingTo = null;
245
+ // No move (a go past the ends fires nothing): the URL already sits on the entry at hand.
246
+ this.landingUrl = null;
195
247
  this.ignorePops = Math.max(0, this.ignorePops - 1);
248
+ if (this.syncOnArrival) {
249
+ this.syncOnArrival = false;
250
+ this.sync();
251
+ }
196
252
  }, 1000);
197
253
  this.win.history.go(delta);
198
254
  }
@@ -201,12 +257,21 @@ export class BrowserHistory {
201
257
  this.movingTo = null;
202
258
  if (this.movingTimer) clearTimeout(this.movingTimer);
203
259
  this.movingTimer = null;
260
+ // The page's URL for what it shows now, set on the entry we left: it belongs here.
261
+ const url = this.landingUrl;
262
+ this.landingUrl = null;
263
+ if (url !== null) this.replaceEntry(this.win.history.state, url);
264
+ // Levels that appeared during the move get their entries now.
265
+ if (this.syncOnArrival) {
266
+ this.syncOnArrival = false;
267
+ this.sync();
268
+ }
204
269
  }
205
270
 
206
271
  private readonly onCommit = (): void => this.sync();
207
272
 
208
- /** After a commit: new levels get entries; levels the app dismissed itself take the history back with them; a moved sidebar selection is an entry too. */
209
- sync(): void {
273
+ /** After a commit: new levels get entries; levels the app dismissed itself take the history back with them; a moved sidebar selection is an entry too. `enabling`: nothing is drawn yet, so no level is taken as dismissed. */
274
+ sync(enabling = false): void {
210
275
  if (!this.enabled) return;
211
276
  const depth = this.depth();
212
277
  const recorded = this.recorded();
@@ -220,8 +285,15 @@ export class BrowserHistory {
220
285
  return;
221
286
  }
222
287
  if (depth > recorded) {
288
+ // Under our own pending `go`, a push would land relative to wherever the browser is when it runs (and
289
+ // Chrome moves back past it), leaving the app on an entry below what it shows: push once the move lands.
290
+ if (this.movingTo !== null) {
291
+ this.syncOnArrival = true;
292
+ return;
293
+ }
223
294
  for (let level = recorded + 1; level <= depth; level++) this.win.history.pushState(this.entry(level, selection), '');
224
295
  } else if (depth < recorded) {
296
+ if (enabling) return;
225
297
  // The app popped or dismissed on its own (back button, swipe, code): leave those entries.
226
298
  this.go(depth - recorded, depth);
227
299
  } else if (this.movingTo === null && selection !== this.recordedSelection()) {
@@ -231,7 +303,7 @@ export class BrowserHistory {
231
303
  const history = this.win.history;
232
304
  if (this.restoringSelection || !this.recordedSelection()) {
233
305
  const { sbSelection: _, ...rest } = (history.state ?? {}) as Partial<HistoryState>;
234
- history.replaceState({ ...rest, ...this.entry(depth, selection) }, '');
306
+ this.replaceEntry({ ...rest, ...this.entry(depth, selection) });
235
307
  } else history.pushState(this.entry(depth, selection), '');
236
308
  }
237
309
  this.restoringSelection = false;
package/src/largeTitle.ts CHANGED
@@ -42,6 +42,20 @@ export function largeTitleOf(screen: HTMLElement): HTMLElement | null {
42
42
  return title;
43
43
  }
44
44
 
45
+ /**
46
+ * The large title's text: a span of its own, as the title element also
47
+ * holds a `.largeTitle` toolbar (which then hides the text, styles.css).
48
+ */
49
+ export function largeTitleTextOf(title: HTMLElement): HTMLElement {
50
+ let text = title.querySelector<HTMLElement>(':scope > .sb-navbar-large-title-text');
51
+ if (!text) {
52
+ text = document.createElement('span');
53
+ text.className = 'sb-navbar-large-title-text';
54
+ title.prepend(text);
55
+ }
56
+ return text;
57
+ }
58
+
45
59
  /** Whether a screen's large title currently scrolls inside one of its scroll views. */
46
60
  export function largeTitleScrolls(screen: HTMLElement, title: HTMLElement): boolean {
47
61
  return title.parentElement !== null && !title.parentElement.classList.contains('sb-navscreen-body') && screen.contains(title);
@@ -3429,7 +3429,7 @@ export class LayoutPass {
3429
3429
  for (const spacer of parts.toolbars.filter(isSpacer)) this.hide(spacer, node);
3430
3430
  const barToolbars = parts.toolbars.filter((t) => {
3431
3431
  const placement = str(t.props['placement']);
3432
- return placement !== 'bottom' && placement !== 'secondary' && !isSpacer(t);
3432
+ return placement !== 'bottom' && placement !== 'secondary' && placement !== 'largeTitle' && !isSpacer(t);
3433
3433
  });
3434
3434
  const secondaryToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'secondary' && !isSpacer(t));
3435
3435
  const groups: Group[] = barToolbars.map((toolbar) => {
@@ -3624,6 +3624,28 @@ export class LayoutPass {
3624
3624
  if (!inlineSecondary.some((entry) => entry.toolbar === toolbar)) this.hide(toolbar, node);
3625
3625
  else for (const item of items) if (collapsedSecondary.some((entry) => entry.item === item)) this.hide(item, node);
3626
3626
  }
3627
+ // `.largeTitle` items (iOS 26): in the large title row, in place of the
3628
+ // title's text, from its leading margin and centered in the row; the
3629
+ // renderer moves the toolbar into the title element, so they scroll with
3630
+ // the content as the title does, and the bar shows the title once the row
3631
+ // has gone under it. While the title is inline there is no row: hidden.
3632
+ for (const toolbar of parts.toolbars) {
3633
+ if (str(toolbar.props['placement']) !== 'largeTitle' || isSpacer(toolbar)) continue;
3634
+ const row = chrome['largeTitle'];
3635
+ if (!row) {
3636
+ this.hide(toolbar, node);
3637
+ continue;
3638
+ }
3639
+ const itemCtx = this.childContext(toolbar, cctx);
3640
+ const room: Proposal = { width: Math.max(0, row.width - 2 * NAV_ITEM_MARGIN), height: null };
3641
+ let cursor = row.x + NAV_ITEM_MARGIN;
3642
+ for (const item of toolbar.children) {
3643
+ const itemSize = this.measure(item, itemCtx, room);
3644
+ this.place(item, itemCtx, room, cursor, row.y + (row.height - itemSize.height) / 2);
3645
+ cursor += itemSize.width + NAV_ITEM_SPACING;
3646
+ }
3647
+ this.setFrame(toolbar, row.x, row.y, { width: row.width, height: row.height });
3648
+ }
3627
3649
  if (bottomBarHeight > 0) {
3628
3650
  this.placeBottomToolbars(bottomToolbars, cctx, chrome, x, y + size.height - bottomBarHeight, size.width);
3629
3651
  }
package/src/layoutDom.ts CHANGED
@@ -41,7 +41,7 @@ import type { ShapeStyle } from './protocol';
41
41
  import { applyChartChrome } from './chartDom';
42
42
  import { bitmapLaidOut } from './bitmaps';
43
43
  import { drawRichText } from './richText';
44
- import { bindScrollUnderBar, largeTitleOf, largeTitleScrolls } from './largeTitle';
44
+ import { bindScrollUnderBar, largeTitleOf, largeTitleScrolls, largeTitleTextOf } from './largeTitle';
45
45
  import { bindScrollEdgeBottom } from './scrollEdge';
46
46
  import { beginScrollSyncBatch, flushScrollSyncBatch, type ScrollExtent } from './scrollSync';
47
47
  import { BAR_GLASS_KIND, TOOLBAR_ANIMATION, TOOLBAR_TRANSITION, baseTiming, timingFor, transitionStart } from './animation';
@@ -122,7 +122,14 @@ function applyFrames(ctx: LayoutDomContext, result: LayoutResult, changes: BoxCh
122
122
  /** The element's box relative to the origin its `left`/`top` use; applied when it differs from the last pass's. */
123
123
  const applyBox = (id: number, node: LayoutNode, el: HTMLElement, frame: Rect): { box: Rect; moved: boolean; previous: Rect | null } => {
124
124
  const box = drawnBox(node, boxOf(ctx, result, node, frame));
125
- const origin = node.kind === 'sheet' ? { x: 0, y: 0 } : isSectionSupplement(node) ? supplementOrigin(ctx, result, id) : originOf(ctx, result, ctx.parents.get(id) ?? ROOT_ID);
125
+ const origin =
126
+ node.kind === 'sheet'
127
+ ? { x: 0, y: 0 }
128
+ : isSectionSupplement(node)
129
+ ? supplementOrigin(ctx, result, id)
130
+ : isLargeTitleToolbar(node)
131
+ ? largeTitleToolbarOrigin(ctx, result, id)
132
+ : originOf(ctx, result, ctx.parents.get(id) ?? ROOT_ID);
126
133
  const relative = { x: box.x - origin.x, y: box.y - origin.y, width: box.width, height: box.height };
127
134
  const previous = ctx.boxes.get(id) ?? null;
128
135
  boxes.set(id, relative);
@@ -233,6 +240,18 @@ function supplementOrigin(ctx: LayoutDomContext, result: LayoutResult, id: numbe
233
240
  return boxOf(ctx, result, parent, frame);
234
241
  }
235
242
 
243
+ /** A `.largeTitle` toolbar: the renderer keeps it in its screen's large title element. */
244
+ export function isLargeTitleToolbar(node: LayoutNode): boolean {
245
+ return node.kind === 'toolbar' && node.props['placement'] === 'largeTitle';
246
+ }
247
+
248
+ /** A `.largeTitle` toolbar sits in the large title element, which is placed at the screen's `largeTitle` row. */
249
+ function largeTitleToolbarOrigin(ctx: LayoutDomContext, result: LayoutResult, id: number): { x: number; y: number } {
250
+ const parentId = ctx.parents.get(id) ?? ROOT_ID;
251
+ const row = result.chrome.get(parentId)?.['largeTitle'];
252
+ return row ?? originOf(ctx, result, parentId);
253
+ }
254
+
236
255
  /** Screen-coordinate origin that the children of `parentId` are positioned against. */
237
256
  export function originOf(ctx: LayoutDomContext, result: LayoutResult, parentId: number): { x: number; y: number } {
238
257
  if (parentId === ROOT_ID) return { x: 0, y: 0 };
@@ -837,7 +856,8 @@ function applyChrome(
837
856
  if (texts?.['largeTitle']) {
838
857
  setFont(largeTitle, texts['largeTitle'].font);
839
858
  const line = texts['largeTitle'].lines.join('\n');
840
- if (largeTitle.textContent !== line) largeTitle.textContent = line;
859
+ const text = largeTitleTextOf(largeTitle);
860
+ if (text.textContent !== line) text.textContent = line;
841
861
  }
842
862
  } else {
843
863
  largeTitle.hidden = true;
package/src/protocol.ts CHANGED
@@ -897,7 +897,13 @@ export interface DecoratedProps {
897
897
  export interface MenuProps {
898
898
  deferred?: boolean;
899
899
  }
900
- export type ToolbarPlacement = 'trailing' | 'leading' | 'principal' | 'bottom' | 'secondary';
900
+ /**
901
+ * `largeTitle` (iOS 26 `.largeTitle`): the items take the large title row in
902
+ * place of the title's text, scrolling with the content as the title does;
903
+ * the bar shows the title once the row has gone under it. Hidden while the
904
+ * screen's title is inline.
905
+ */
906
+ export type ToolbarPlacement = 'trailing' | 'leading' | 'principal' | 'bottom' | 'secondary' | 'largeTitle';
901
907
  /**
902
908
  * `.toolbar { ToolbarItem(placement:) { ... } }`, an extra child of a
903
909
  * `navscreen` after its content. The children are the items, laid out at
@@ -1884,6 +1890,16 @@ export interface Style {
1884
1890
  hitTesting?: boolean;
1885
1891
  }
1886
1892
 
1893
+ export interface Style {
1894
+ /**
1895
+ * `.onScrollVisibilityChange(threshold:_:)`: the page sends
1896
+ * `scrollvisibility` to the box when the share of it inside its scroll
1897
+ * view's visible area (below a bar the view scrolls under, above a bar
1898
+ * covering its bottom) crosses `threshold` (src/scrollVisibility.ts).
1899
+ */
1900
+ scrollVisibility?: { threshold: number };
1901
+ }
1902
+
1887
1903
  export interface Style {
1888
1904
  /** `.blendMode`: the CSS `mix-blend-mode` the box is composited with. */
1889
1905
  blendMode?: string;
@@ -1957,6 +1973,16 @@ export interface ScrollPositionEvent {
1957
1973
  type: 'scrollposition';
1958
1974
  id: ElementId;
1959
1975
  }
1976
+ /**
1977
+ * Sent to a `styled` box with `scrollVisibility`: the share of it inside its
1978
+ * scroll view's visible area came to the threshold or above (`visible`), or
1979
+ * fell below it. The first check after the box appears says where it is.
1980
+ */
1981
+ export interface ScrollVisibilityEvent {
1982
+ type: 'scrollvisibility';
1983
+ id: ElementId;
1984
+ visible: boolean;
1985
+ }
1960
1986
  /**
1961
1987
  * A movable `listrow` was dragged to a new place: `to` is the index among
1962
1988
  * its container's children (the `list`'s, or its `section`'s) before which
@@ -2002,7 +2028,7 @@ export interface DropEvent {
2002
2028
  }
2003
2029
 
2004
2030
  /** The Phase 8 events (merged into `RendererEvent` above). */
2005
- export type InteractionEvent = DragEvent | PinchEvent | LongPressEvent | DeleteEvent | RefreshEvent | ScrollPositionEvent | MoveEvent | DragStartEvent | DragEndEvent | DropEvent;
2031
+ export type InteractionEvent = DragEvent | PinchEvent | LongPressEvent | DeleteEvent | RefreshEvent | ScrollPositionEvent | ScrollVisibilityEvent | MoveEvent | DragStartEvent | DragEndEvent | DropEvent;
2006
2032
  // Phase 8: alerts, controls, effects
2007
2033
  // (docs/ops-protocol.md, "Phase 8: interaction"; gestures, swipe actions,
2008
2034
  // context menus, list rows and refresh live in their own block)
package/src/renderer.ts CHANGED
@@ -153,13 +153,14 @@ import { applyCanvas, createCanvasElement } from './canvasDrawing';
153
153
  import { scrollToTarget } from './scrollTo';
154
154
  import { POSITION_SETTLE_MS, POSITION_SNAP_WAITS, anchorPoint, positionTarget, restsOnSnapPoint, type PositionCandidate } from './scrollPosition';
155
155
  import { TouchPaging } from './touchPaging';
156
+ import { isScrollVisible } from './scrollVisibility';
156
157
  import { applyDefaultScrollAnchor, keepScrollAnchors } from './scrollAnchor';
157
158
  import { EMPTY_CATALOG, assetColorVar, type AssetCatalog } from './assets';
158
159
  import { HIERARCHY_OPACITY, colorToCSS, effectsCSS, styleToCSS, type PaintCSS } from './styles';
159
160
  import { hostContent, onHostContentChange } from './hostContent';
160
161
  import { HoverTracker, applyPointerAndEffects, safeAreaOverlap } from './pointerEffects';
161
162
  import { colorMultiplyFilter } from './colorMultiply';
162
- import { largeTitleOf } from './largeTitle';
163
+ import { largeTitleOf, largeTitleTextOf } from './largeTitle';
163
164
  import { applyEdgeEffect } from './scrollEdge';
164
165
  import { discardScrollOffsets, readScrollOffsets, trackScroller } from './scrollSync';
165
166
  import { DEFAULT_TYPE_SIZE, TEXT_STYLE_WEIGHT, resolveTextStyle, textStyleVar } from './typography';
@@ -440,6 +441,10 @@ export class Renderer {
440
441
  private pendingScrolls: ElementId[] = [];
441
442
  /** Lazy rows: the `lazyspacer`s on the page, and those already reported near the visible area. */
442
443
  private lazySpacers = new Set<ElementId>();
444
+ /** `styled` boxes with `scrollVisibility`, and what each was last told (src/scrollVisibility.ts). */
445
+ private readonly visibilityTargets = new Map<ElementId, number>();
446
+ private readonly visibilityReported = new Map<ElementId, boolean>();
447
+ private visibilityCheckQueued = false;
443
448
  private lazyReported = new Set<ElementId>();
444
449
  private lazyCheckQueued = false;
445
450
  /** The highest `scrollTo` serial seen: a command keeps its serial until the reader re-runs, and runs once. */
@@ -864,6 +869,7 @@ export class Renderer {
864
869
  root.addEventListener('focusout', this.onFocusChange);
865
870
  root.addEventListener('scroll', this.onScrollDismissKeyboard, true);
866
871
  root.addEventListener('scroll', this.queueLazyCheck, { capture: true, passive: true });
872
+ root.addEventListener('scroll', this.queueVisibilityCheck, { capture: true, passive: true });
867
873
  root.addEventListener('scroll', this.onPositionScroll, { capture: true, passive: true });
868
874
  root.addEventListener('scrollend', this.onPositionScrollEnd, { capture: true, passive: true });
869
875
  root.addEventListener('touchstart', this.onPositionTouchStart, { capture: true, passive: true });
@@ -1335,6 +1341,7 @@ export class Renderer {
1335
1341
  this.collectGeometry(result);
1336
1342
  this.collectLayouts(result);
1337
1343
  if (this.applying === 0) this.flushGeometry();
1344
+ this.queueVisibilityCheck();
1338
1345
  this.afterLayout?.();
1339
1346
  return changes;
1340
1347
  }
@@ -1585,6 +1592,7 @@ export class Renderer {
1585
1592
  this.root.removeEventListener('focusin', this.onFocusChange);
1586
1593
  this.root.removeEventListener('focusout', this.onFocusChange);
1587
1594
  this.root.removeEventListener('scroll', this.onScrollDismissKeyboard, true);
1595
+ this.root.removeEventListener('scroll', this.queueVisibilityCheck, true);
1588
1596
  this.root.removeEventListener('scroll', this.onPositionScroll, true);
1589
1597
  this.root.removeEventListener('scrollend', this.onPositionScrollEnd, true);
1590
1598
  this.root.removeEventListener('touchstart', this.onPositionTouchStart, true);
@@ -1626,6 +1634,7 @@ export class Renderer {
1626
1634
  if (kind === 'geometry' || (props as { reportsSize?: unknown }).reportsSize === true) this.geometryReporters.add(id);
1627
1635
  applyProps(el, kind, props, this.assets);
1628
1636
  this.noteScroll(id, kind, props);
1637
+ this.noteVisibility(id, kind, props);
1629
1638
  if (kind === 'list' || kind === 'scrollview') trackScroller(el);
1630
1639
  if (kind === 'lazyspacer') this.lazySpacers.add(id);
1631
1640
  if (kind === 'host') {
@@ -1634,6 +1643,18 @@ export class Renderer {
1634
1643
  }
1635
1644
  }
1636
1645
 
1646
+ /** A toolbar whose placement changed to or from `largeTitle` moves between its screen's content slot and large title. */
1647
+ private rehomeToolbar(id: ElementId, el: HTMLElement, op: Op): void {
1648
+ const parentId = this.parents.get(id);
1649
+ const parent = parentId === undefined ? undefined : this.elements.get(parentId);
1650
+ if (parentId === undefined || !parent || !parent.classList.contains('sb-navscreen') || !el.isConnected) return;
1651
+ const expected = el.dataset['sbPlacement'] === 'largeTitle' ? (largeTitleOf(parent) ?? slotOf(parent)) : slotOf(parent);
1652
+ if (el.parentElement === expected) return;
1653
+ const node = this.nodes.get(id);
1654
+ const index = node ? (this.nodes.get(parentId)?.children.indexOf(node) ?? -1) : -1;
1655
+ if (index >= 0) this.insert(parentId, id, index, op);
1656
+ }
1657
+
1637
1658
  /** A `host` element takes the page's content element (src/hostContent.ts) and watches its size. */
1638
1659
  private adoptHostContent(id: ElementId, el: HTMLElement, props: HostProps): void {
1639
1660
  const content = hostContent(props.content);
@@ -1680,6 +1701,64 @@ export class Renderer {
1680
1701
  }
1681
1702
  }
1682
1703
 
1704
+ // -------------------------------------------------------------------------
1705
+ // `.onScrollVisibilityChange` (src/scrollVisibility.ts)
1706
+ // -------------------------------------------------------------------------
1707
+
1708
+ /** A `styled` box with `scrollVisibility` is watched; any other forgets it was. */
1709
+ private noteVisibility(id: ElementId, kind: ElementKind, props: ElementProps): void {
1710
+ const spec = kind === 'styled' ? (props as Partial<StyledProps>)?.style?.scrollVisibility : undefined;
1711
+ if (spec && typeof spec.threshold === 'number') {
1712
+ this.visibilityTargets.set(id, spec.threshold);
1713
+ return;
1714
+ }
1715
+ this.visibilityTargets.delete(id);
1716
+ this.visibilityReported.delete(id);
1717
+ }
1718
+
1719
+ /** After a scroll or a layout, the watched boxes are checked on the next frame (once per frame). */
1720
+ private readonly queueVisibilityCheck = (): void => {
1721
+ if (this.visibilityCheckQueued || this.visibilityTargets.size === 0) return;
1722
+ this.visibilityCheckQueued = true;
1723
+ requestAnimationFrame(() => {
1724
+ this.visibilityCheckQueued = false;
1725
+ this.checkVisibility();
1726
+ });
1727
+ };
1728
+
1729
+ /**
1730
+ * Each watched box against its scroll view's visible area (the view's box
1731
+ * less the bars it runs under), or the root's outside one: told when it
1732
+ * comes into view or leaves it, and once when first checked.
1733
+ */
1734
+ private checkVisibility(): void {
1735
+ for (const [id, threshold] of this.visibilityTargets) {
1736
+ const el = this.elements.get(id);
1737
+ if (!el || !el.isConnected) {
1738
+ this.visibilityTargets.delete(id);
1739
+ this.visibilityReported.delete(id);
1740
+ continue;
1741
+ }
1742
+ const box = el.getBoundingClientRect();
1743
+ if (box.width === 0 && box.height === 0) continue;
1744
+ const scroller = el.parentElement?.closest<HTMLElement>('.sb-scrollview, .sb-list') ?? null;
1745
+ const area = (scroller ?? this.root).getBoundingClientRect();
1746
+ const viewport = { x: area.left, y: area.top, width: area.width, height: area.height };
1747
+ if (scroller) {
1748
+ const chrome = this.layoutResult?.chrome.get(Number(scroller.dataset[ID_ATTR]));
1749
+ const top = chrome?.['topInset']?.height ?? 0;
1750
+ const covered = chrome?.['bottomInset'];
1751
+ viewport.y += top;
1752
+ viewport.height -= top;
1753
+ if (covered) viewport.height = Math.max(0, viewport.height - covered.height);
1754
+ }
1755
+ const visible = isScrollVisible({ x: box.left, y: box.top, width: box.width, height: box.height }, viewport, threshold);
1756
+ if (this.visibilityReported.get(id) === visible) continue;
1757
+ this.visibilityReported.set(id, visible);
1758
+ this.onEvent({ type: 'scrollvisibility', id, visible });
1759
+ }
1760
+ }
1761
+
1683
1762
  // -------------------------------------------------------------------------
1684
1763
  // `.scrollPosition(id:)` reading back (src/scrollPosition.ts)
1685
1764
  // -------------------------------------------------------------------------
@@ -1808,10 +1887,19 @@ export class Renderer {
1808
1887
  .map((id) => ({ id, command: (this.nodes.get(id)?.props as Partial<StyledProps> | undefined)?.style?.scrollTo }))
1809
1888
  .filter((entry): entry is { id: ElementId; command: ScrollToCommand } => entry.command !== undefined)
1810
1889
  .sort((a, b) => a.command.serial - b.command.serial);
1890
+ // A page a finger set running gives way to the app's commands; a drag in progress carries on across them.
1891
+ const fresh = commands.some(({ command }) => command.serial > this.scrollSerial);
1892
+ if (fresh) this.touchPaging.beforeScrollCommands();
1811
1893
  for (const { id, command } of commands) {
1812
1894
  if (command.serial <= this.scrollSerial) continue;
1813
1895
  scrollToTarget(this, result, id, command, animated);
1896
+ // The scroll views it moved report their position afresh when they next settle, even one they
1897
+ // reported last: the app set its binding itself, and where the view rests may not be it.
1898
+ for (let scroller = this.elements.get(id)?.parentElement?.closest<HTMLElement>('.sb-scrollview, .sb-list'); scroller; scroller = scroller.parentElement?.closest<HTMLElement>('.sb-scrollview, .sb-list') ?? null) {
1899
+ this.reportedPositions.delete(scroller);
1900
+ }
1814
1901
  }
1902
+ if (fresh) this.touchPaging.afterScrollCommands();
1815
1903
  for (const { command } of commands) this.scrollSerial = Math.max(this.scrollSerial, command.serial);
1816
1904
  }
1817
1905
 
@@ -1901,7 +1989,9 @@ export class Renderer {
1901
1989
  if (kind === 'text') el.textContent = '';
1902
1990
  applyProps(el, kind, props, this.assets);
1903
1991
  this.noteScroll(id, kind, props);
1992
+ this.noteVisibility(id, kind, props);
1904
1993
  if (kind === 'host') this.adoptHostContent(id, el, props as HostProps);
1994
+ if (kind === 'toolbar') this.rehomeToolbar(id, el, op);
1905
1995
  const newToken = el.dataset[ANIMATION_TOKEN];
1906
1996
  const tokenChanged = (previous?.tokenChanged ?? false) || (newToken !== undefined && newToken !== oldToken);
1907
1997
  const record: UpdatedRecord = { el, before, tokenChanged };
@@ -1942,7 +2032,9 @@ export class Renderer {
1942
2032
  // A section's view header / footer lives beside its rows' card, in the
1943
2033
  // section element itself; the rows after a header are one DOM slot lower.
1944
2034
  const supplement = isSectionSupplement(el);
1945
- const container = supplement && parent.classList.contains('sb-section') ? parent : slotOf(parent);
2035
+ // A `.largeTitle` toolbar lives in its screen's large title element, which scrolls with the content.
2036
+ const inLargeTitle = el.dataset['sbPlacement'] === 'largeTitle' && parent.classList.contains('sb-navscreen');
2037
+ const container = supplement && parent.classList.contains('sb-section') ? parent : inLargeTitle ? (largeTitleOf(parent) ?? slotOf(parent)) : slotOf(parent);
1946
2038
  // Moves: `index` is the element's position in the parent's child list
1947
2039
  // *after* the op is applied. Detaching first makes the index arithmetic
1948
2040
  // identical for fresh inserts and for moves within the same parent.
@@ -1954,9 +2046,21 @@ export class Renderer {
1954
2046
  const first = this.nodes.get(parentId)?.children.find((child) => child.id !== id);
1955
2047
  if (first?.kind === 'sectionheader') slotIndex -= 1;
1956
2048
  }
2049
+ // A screen's children may live in two containers (its content slot and
2050
+ // its large title): the next sibling in the same container is the reference.
2051
+ const screenReference = (): Element | null => {
2052
+ const siblings = (this.nodes.get(parentId)?.children ?? []).filter((child) => child.id !== id);
2053
+ for (const sibling of siblings.slice(index)) {
2054
+ const sib = this.elements.get(sibling.id);
2055
+ if (sib && sib.parentElement === container) return sib;
2056
+ }
2057
+ return null;
2058
+ };
1957
2059
  const reference = supplement
1958
2060
  ? el.classList.contains('sb-sectionheader') ? container.firstChild : null
1959
- : (container.children[slotIndex] ?? null);
2061
+ : parent.classList.contains('sb-navscreen')
2062
+ ? screenReference()
2063
+ : (container.children[slotIndex] ?? null);
1960
2064
  container.insertBefore(el, reference);
1961
2065
  if (!wasAttached) this.pass.inserted.add(el);
1962
2066
  // Mirror the move in the layout tree.
@@ -3225,7 +3329,7 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
3225
3329
  const placement = (props as ToolbarProps).placement;
3226
3330
  // `secondary` items shown in the bar look like the trailing ones (glass capsules).
3227
3331
  el.dataset['sbPlacement'] =
3228
- placement === 'leading' || placement === 'principal' || placement === 'bottom' ? placement : 'trailing';
3332
+ placement === 'leading' || placement === 'principal' || placement === 'bottom' || placement === 'largeTitle' ? placement : 'trailing';
3229
3333
  // A `ToolbarSpacer`: draws nothing.
3230
3334
  el.toggleAttribute('data-sb-spacer', typeof (props as ToolbarProps).spacer === 'string');
3231
3335
  break;
@@ -3855,9 +3959,17 @@ function applyNavScreen(el: HTMLElement, props: NavScreenProps): void {
3855
3959
  const largeTitle = largeTitleOf(el);
3856
3960
  // A large title that scrolls away collapses into the inline one (shown
3857
3961
  // with `data-sb-collapsed`, src/largeTitle.ts).
3858
- if (inlineTitle) inlineTitle.textContent = title;
3962
+ if (inlineTitle) {
3963
+ const appearing = inlineTitle.textContent === '' && title !== '' && inlineTitle.isConnected;
3964
+ inlineTitle.textContent = title;
3965
+ // An inline title given to an empty bar (a header row that has scrolled
3966
+ // under it handing the bar its name) fades in as a collapsing large title does.
3967
+ if (appearing && !large && typeof inlineTitle.animate === 'function') {
3968
+ inlineTitle.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 150, easing: 'ease' });
3969
+ }
3970
+ }
3859
3971
  if (largeTitle) {
3860
- largeTitle.textContent = large ? title : '';
3972
+ largeTitleTextOf(largeTitle).textContent = large ? title : '';
3861
3973
  largeTitle.hidden = !large;
3862
3974
  }
3863
3975
  el.setAttribute('aria-label', title);
@@ -0,0 +1,38 @@
1
+ /**
2
+ * `.onScrollVisibilityChange(threshold:_:)` (iOS 18; docs/ops-protocol.md,
3
+ * "Scroll visibility"): a `styled` box with `scrollVisibility` is told when
4
+ * the share of it inside its scroll view's visible area crosses its
5
+ * threshold. The visible area is the scroll view's box less a bar it scrolls
6
+ * under (its `topInset`) and a bar covering its bottom (`bottomInset`), as
7
+ * content under a bar is not in view; outside a scroll view it is the root.
8
+ */
9
+
10
+ export interface Span {
11
+ start: number;
12
+ length: number;
13
+ }
14
+
15
+ /** The share (0...1) of `target` along an axis inside `viewport`; 0 for a target of no extent. */
16
+ export function visibleShare(target: Span, viewport: Span): number {
17
+ if (target.length <= 0) return 0;
18
+ const start = Math.max(target.start, viewport.start);
19
+ const end = Math.min(target.start + target.length, viewport.start + viewport.length);
20
+ return Math.max(0, end - start) / target.length;
21
+ }
22
+
23
+ /**
24
+ * Whether a box is visible: some of it is in view, and at least `threshold`
25
+ * of it along the scroll axes (both shares multiplied for a view scrolling
26
+ * both ways, the other axis counted too, as a box off to the side is not in
27
+ * view).
28
+ */
29
+ export function isScrollVisible(
30
+ target: { x: number; y: number; width: number; height: number },
31
+ viewport: { x: number; y: number; width: number; height: number },
32
+ threshold: number,
33
+ ): boolean {
34
+ const horizontal = visibleShare({ start: target.x, length: target.width }, { start: viewport.x, length: viewport.width });
35
+ const vertical = visibleShare({ start: target.y, length: target.height }, { start: viewport.y, length: viewport.height });
36
+ const share = horizontal * vertical;
37
+ return share > 0 && share >= Math.min(1, Math.max(0, threshold));
38
+ }
package/src/styles.css CHANGED
@@ -751,6 +751,17 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
751
751
  touch-action: pan-x pinch-zoom;
752
752
  }
753
753
 
754
+ /* Nothing inside widens the pans the browser may make along the paging axis:
755
+ a button's `manipulation` would let Safari pan the view itself under a
756
+ quick swipe that starts on it. */
757
+ .sb-scrollview[data-sb-snap="paging"][data-sb-axes="horizontal"] * {
758
+ touch-action: pan-y pinch-zoom;
759
+ }
760
+
761
+ .sb-scrollview[data-sb-snap="paging"][data-sb-axes="vertical"] * {
762
+ touch-action: pan-x pinch-zoom;
763
+ }
764
+
754
765
  .sb-scroll-content,
755
766
  .sb-list-content {
756
767
  position: relative;
@@ -1242,6 +1253,22 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
1242
1253
  display: none;
1243
1254
  }
1244
1255
 
1256
+ /* A `.largeTitle` toolbar (iOS 26) takes the row in place of the title's
1257
+ text, which the bar still shows once the row has scrolled under it. Its
1258
+ items are content, drawn as anywhere else and answering touches. */
1259
+ .sb-navbar-large-title:has(> .sb-toolbar[data-sb-placement="largeTitle"]:not([hidden])) > .sb-navbar-large-title-text {
1260
+ visibility: hidden;
1261
+ }
1262
+
1263
+ .sb-navbar-large-title > .sb-toolbar {
1264
+ font-size: var(--sb-font-size-body);
1265
+ font-weight: 400;
1266
+ line-height: normal;
1267
+ letter-spacing: normal;
1268
+ white-space: normal;
1269
+ pointer-events: auto;
1270
+ }
1271
+
1245
1272
  /* The large title scrolls with the screen's root list or scroll view
1246
1273
  (src/largeTitle.ts moves it into it); drags on it scroll that view. There
1247
1274
  it is content, under the bar. */
@@ -18,8 +18,14 @@ export const FLICK_VELOCITY = 0.3;
18
18
  export const FLICK_WINDOW_MS = 100;
19
19
  /** How far a flick carries on inside a page longer than the viewport (its velocity times this). */
20
20
  export const FLICK_RUN_MS = 250;
21
- /** A touch is a drag once it moved this far; the larger axis then decides whose gesture it is. */
22
- export const DIRECTION_SLOP = 6;
21
+ /**
22
+ * A touch is a drag once it moved this far; the larger axis of that first
23
+ * move decides whose gesture it is. Small, and under Safari's own threshold
24
+ * for starting a pan: a move left to the browser lets it begin a pan, after
25
+ * which it cancels the touch for the page, and a drag of ours would end in
26
+ * a run from wherever the finger was.
27
+ */
28
+ export const DIRECTION_SLOP = 3;
23
29
 
24
30
  /** Where a scroll view may rest for one page: at its start, or anywhere a page longer than the viewport fills it (`start === end` for a page the viewport's size). */
25
31
  export interface RestInterval {
@@ -120,19 +126,37 @@ interface Gesture {
120
126
  /** The finger position (along the axis) where our drag took over. */
121
127
  fromPos: number;
122
128
  samples: TouchSample[];
129
+ /** The page under the leading edge as of the last move, to find again after the app's scroll commands. */
130
+ page: Page | null;
123
131
  }
124
132
 
125
133
  interface Run {
126
134
  el: HTMLElement;
127
135
  frame: number;
128
- /** The offset the last frame wrote: another writer moving the view ends the run. */
129
- written: number;
136
+ }
137
+
138
+ /** Where a drag was when the app's scroll commands began: the offset, and the page under it to find again after. */
139
+ interface Held {
140
+ el: HTMLElement;
141
+ offset: number;
142
+ page: Page | null;
130
143
  }
131
144
 
132
145
  const PAGING_SELECTOR = '.sb-scrollview[data-sb-snap="paging"]';
133
146
 
147
+ export interface Page {
148
+ element: Element;
149
+ start: number;
150
+ length: number;
151
+ }
152
+
153
+ /** The page whose extent holds `offset` (the leading edge), if any. */
154
+ function pageAt(el: HTMLElement, horizontal: boolean, offset: number): Page | null {
155
+ return pagesOf(el, horizontal).find((p) => p.start <= offset + 1 && offset < p.start + p.length) ?? null;
156
+ }
157
+
134
158
  /** Pages of a paging scroll view in scroll coordinates along its axis: the children of its `scrollTargetLayout` stack, else of its content. */
135
- export function pagesOf(el: HTMLElement, horizontal: boolean): { start: number; length: number }[] {
159
+ export function pagesOf(el: HTMLElement, horizontal: boolean): Page[] {
136
160
  const targets = [...el.querySelectorAll<HTMLElement>('[data-sb-scroll-target] > *')].filter((t) => t.parentElement?.closest('.sb-scrollview, .sb-list') === el);
137
161
  const children = targets.length > 0 ? targets : [...((el.firstElementChild?.children ?? []) as Iterable<Element>)];
138
162
  const box = el.getBoundingClientRect();
@@ -141,13 +165,14 @@ export function pagesOf(el: HTMLElement, horizontal: boolean): { start: number;
141
165
  const offset = horizontal ? el.scrollLeft : el.scrollTop;
142
166
  return children.map((child) => {
143
167
  const rect = child.getBoundingClientRect();
144
- return horizontal ? { start: rect.left - box.left - padding + offset, length: rect.width } : { start: rect.top - box.top - padding + offset, length: rect.height };
168
+ return horizontal ? { element: child, start: rect.left - box.left - padding + offset, length: rect.width } : { element: child, start: rect.top - box.top - padding + offset, length: rect.height };
145
169
  });
146
170
  }
147
171
 
148
172
  export class TouchPaging {
149
173
  private gesture: Gesture | null = null;
150
174
  private run: Run | null = null;
175
+ private held: Held | null = null;
151
176
 
152
177
  /**
153
178
  * `onRest(el)`: the view came to rest on a page after a gesture of ours
@@ -159,7 +184,9 @@ export class TouchPaging {
159
184
  private readonly onRest: (el: HTMLElement) => void = () => {},
160
185
  ) {
161
186
  root.addEventListener('touchstart', this.onTouchStart, { passive: true });
162
- root.addEventListener('touchmove', this.onTouchMove, { passive: true });
187
+ // Not passive: a drag of ours cancels the browser's own handling of the moves (Safari pans a
188
+ // scroll view under a finger whatever `touch-action` says, and the two would fight over it).
189
+ root.addEventListener('touchmove', this.onTouchMove, { passive: false });
163
190
  root.addEventListener('touchend', this.onTouchEnd, { passive: true });
164
191
  root.addEventListener('touchcancel', this.onTouchEnd, { passive: true });
165
192
  }
@@ -169,6 +196,67 @@ export class TouchPaging {
169
196
  return (this.gesture?.el === el && this.gesture.ours === true) || this.run?.el === el;
170
197
  }
171
198
 
199
+ /**
200
+ * The app is about to scroll (`scrollTo` commands): a run still going
201
+ * gives way to them, and a drag in progress notes where the view is and
202
+ * which page is under the finger, to carry on from there once the
203
+ * commands are done (`afterScrollCommands`).
204
+ */
205
+ beforeScrollCommands(): void {
206
+ const run = this.run;
207
+ if (run) {
208
+ this.stopRun();
209
+ this.onRest(run.el);
210
+ }
211
+ const g = this.gesture;
212
+ this.held = null;
213
+ if (!g?.ours) return;
214
+ // The page as of the drag's last move: the commands' pass has already changed the tree by now.
215
+ this.held = { el: g.el, offset: g.horizontal ? g.el.scrollLeft : g.el.scrollTop, page: g.page };
216
+ }
217
+
218
+ /**
219
+ * The app's scroll commands are done. A drag in progress keeps the
220
+ * content under the finger where it was: an app re-centring its pages
221
+ * on the one arrived at moves the content a page and scrolls the same
222
+ * page, which the command's own offset would show aligned, dropping the
223
+ * drag so far. The page's new places reach the DOM on the next frame
224
+ * (the renderer commits its layout then), so the drag carries on from
225
+ * them at the next frame or the next move, whichever comes first.
226
+ */
227
+ afterScrollCommands(): void {
228
+ const held = this.held;
229
+ if (!held) return;
230
+ const g = this.gesture;
231
+ if (!g?.ours || g.el !== held.el) {
232
+ this.held = null;
233
+ return;
234
+ }
235
+ requestAnimationFrame(() => this.carryOn());
236
+ }
237
+
238
+ /**
239
+ * The page that was under the finger is found again and the offset
240
+ * moved by what it moved; without it (a page that is gone), the drag
241
+ * carries on from the offset the commands set.
242
+ */
243
+ private carryOn(): void {
244
+ const held = this.held;
245
+ this.held = null;
246
+ const g = this.gesture;
247
+ if (!held || !g?.ours || g.el !== held.el) return;
248
+ const now = g.horizontal ? g.el.scrollLeft : g.el.scrollTop;
249
+ const page = held.page ? pagesOf(g.el, g.horizontal).find((p) => p.element === held.page!.element) : undefined;
250
+ const shift = page ? page.start - held.page!.start : now - held.offset;
251
+ g.from += shift;
252
+ if (page) {
253
+ const max = g.horizontal ? g.el.scrollWidth - g.el.clientWidth : g.el.scrollHeight - g.el.clientHeight;
254
+ const offset = Math.min(max, Math.max(0, held.offset + shift));
255
+ if (g.horizontal) g.el.scrollLeft = offset;
256
+ else g.el.scrollTop = offset;
257
+ }
258
+ }
259
+
172
260
  dispose(): void {
173
261
  this.root.removeEventListener('touchstart', this.onTouchStart);
174
262
  this.root.removeEventListener('touchmove', this.onTouchMove);
@@ -186,7 +274,7 @@ export class TouchPaging {
186
274
  const axes = el.dataset['sbAxes'];
187
275
  if (axes !== 'horizontal' && axes !== 'vertical') return;
188
276
  const horizontal = axes === 'horizontal';
189
- this.gesture = { el, horizontal, startX: touch.clientX, startY: touch.clientY, ours: null, from: 0, fromPos: 0, samples: [] };
277
+ this.gesture = { el, horizontal, startX: touch.clientX, startY: touch.clientY, ours: null, from: 0, fromPos: 0, samples: [], page: null };
190
278
  // A finger on a page still running to its place catches it: the drag is
191
279
  // ours from here, and the lift runs it on to a page.
192
280
  if (this.run?.el === el) {
@@ -202,6 +290,7 @@ export class TouchPaging {
202
290
  const g = this.gesture;
203
291
  const touch = event.touches[0];
204
292
  if (!g || !touch) return;
293
+ if (this.held) this.carryOn();
205
294
  const pos = g.horizontal ? touch.clientX : touch.clientY;
206
295
  if (g.ours === null) {
207
296
  const dx = touch.clientX - g.startX;
@@ -218,10 +307,12 @@ export class TouchPaging {
218
307
  g.from = g.horizontal ? g.el.scrollLeft : g.el.scrollTop;
219
308
  g.fromPos = pos;
220
309
  }
310
+ if (event.cancelable) event.preventDefault();
221
311
  const max = g.horizontal ? g.el.scrollWidth - g.el.clientWidth : g.el.scrollHeight - g.el.clientHeight;
222
312
  const offset = Math.min(max, Math.max(0, g.from - (pos - g.fromPos)));
223
313
  if (g.horizontal) g.el.scrollLeft = offset;
224
314
  else g.el.scrollTop = offset;
315
+ g.page = pageAt(g.el, g.horizontal, offset);
225
316
  g.samples.push({ t: event.timeStamp, pos });
226
317
  if (g.samples.length > 20) g.samples.shift();
227
318
  };
@@ -230,6 +321,7 @@ export class TouchPaging {
230
321
  const g = this.gesture;
231
322
  if (!g) return;
232
323
  if (event.touches.length > 0) return;
324
+ if (this.held) this.carryOn();
233
325
  this.gesture = null;
234
326
  if (!g.ours) return;
235
327
  const el = g.el;
@@ -253,15 +345,10 @@ export class TouchPaging {
253
345
  }
254
346
  const duration = runDuration(distance);
255
347
  const started = performance.now();
348
+ // The run writes the offset every frame and the last write is the page itself, whatever
349
+ // else moved the view meanwhile (the offset Safari reads back can trail a write); only an
350
+ // app's own scroll command ends it early (`interrupt`).
256
351
  const step = (now: number): void => {
257
- const run = this.run;
258
- const current = horizontal ? el.scrollLeft : el.scrollTop;
259
- // Another writer moved the view (an app re-centring on the page): the run is over.
260
- if (run && Math.abs(current - run.written) > 1) {
261
- this.stopRun();
262
- this.onRest(el);
263
- return;
264
- }
265
352
  const t = (now - started) / duration;
266
353
  const offset = t >= 1 ? to : from + distance * easeOut(t);
267
354
  if (horizontal) el.scrollLeft = offset;
@@ -272,9 +359,9 @@ export class TouchPaging {
272
359
  this.onRest(el);
273
360
  return;
274
361
  }
275
- this.run = { el, frame: requestAnimationFrame(step), written: offset };
362
+ this.run = { el, frame: requestAnimationFrame(step) };
276
363
  };
277
- this.run = { el, frame: requestAnimationFrame(step), written: from };
364
+ this.run = { el, frame: requestAnimationFrame(step) };
278
365
  }
279
366
 
280
367
  private stopRun(): void {