@swiftbrowser/web 0.9.12 → 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.12",
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
  // -------------------------------------------------------------------------
@@ -1910,7 +1989,9 @@ export class Renderer {
1910
1989
  if (kind === 'text') el.textContent = '';
1911
1990
  applyProps(el, kind, props, this.assets);
1912
1991
  this.noteScroll(id, kind, props);
1992
+ this.noteVisibility(id, kind, props);
1913
1993
  if (kind === 'host') this.adoptHostContent(id, el, props as HostProps);
1994
+ if (kind === 'toolbar') this.rehomeToolbar(id, el, op);
1914
1995
  const newToken = el.dataset[ANIMATION_TOKEN];
1915
1996
  const tokenChanged = (previous?.tokenChanged ?? false) || (newToken !== undefined && newToken !== oldToken);
1916
1997
  const record: UpdatedRecord = { el, before, tokenChanged };
@@ -1951,7 +2032,9 @@ export class Renderer {
1951
2032
  // A section's view header / footer lives beside its rows' card, in the
1952
2033
  // section element itself; the rows after a header are one DOM slot lower.
1953
2034
  const supplement = isSectionSupplement(el);
1954
- 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);
1955
2038
  // Moves: `index` is the element's position in the parent's child list
1956
2039
  // *after* the op is applied. Detaching first makes the index arithmetic
1957
2040
  // identical for fresh inserts and for moves within the same parent.
@@ -1963,9 +2046,21 @@ export class Renderer {
1963
2046
  const first = this.nodes.get(parentId)?.children.find((child) => child.id !== id);
1964
2047
  if (first?.kind === 'sectionheader') slotIndex -= 1;
1965
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
+ };
1966
2059
  const reference = supplement
1967
2060
  ? el.classList.contains('sb-sectionheader') ? container.firstChild : null
1968
- : (container.children[slotIndex] ?? null);
2061
+ : parent.classList.contains('sb-navscreen')
2062
+ ? screenReference()
2063
+ : (container.children[slotIndex] ?? null);
1969
2064
  container.insertBefore(el, reference);
1970
2065
  if (!wasAttached) this.pass.inserted.add(el);
1971
2066
  // Mirror the move in the layout tree.
@@ -3234,7 +3329,7 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
3234
3329
  const placement = (props as ToolbarProps).placement;
3235
3330
  // `secondary` items shown in the bar look like the trailing ones (glass capsules).
3236
3331
  el.dataset['sbPlacement'] =
3237
- placement === 'leading' || placement === 'principal' || placement === 'bottom' ? placement : 'trailing';
3332
+ placement === 'leading' || placement === 'principal' || placement === 'bottom' || placement === 'largeTitle' ? placement : 'trailing';
3238
3333
  // A `ToolbarSpacer`: draws nothing.
3239
3334
  el.toggleAttribute('data-sb-spacer', typeof (props as ToolbarProps).spacer === 'string');
3240
3335
  break;
@@ -3864,9 +3959,17 @@ function applyNavScreen(el: HTMLElement, props: NavScreenProps): void {
3864
3959
  const largeTitle = largeTitleOf(el);
3865
3960
  // A large title that scrolls away collapses into the inline one (shown
3866
3961
  // with `data-sb-collapsed`, src/largeTitle.ts).
3867
- 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
+ }
3868
3971
  if (largeTitle) {
3869
- largeTitle.textContent = large ? title : '';
3972
+ largeTitleTextOf(largeTitle).textContent = large ? title : '';
3870
3973
  largeTitle.hidden = !large;
3871
3974
  }
3872
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
@@ -1253,6 +1253,22 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
1253
1253
  display: none;
1254
1254
  }
1255
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
+
1256
1272
  /* The large title scrolls with the screen's root list or scroll view
1257
1273
  (src/largeTitle.ts moves it into it); drags on it scroll that view. There
1258
1274
  it is content, under the bar. */