@swiftbrowser/web 0.9.13 → 0.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.9.13",
3
+ "version": "0.10.1",
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": {
@@ -28,6 +28,12 @@
28
28
  * pen: the browser's edge swipe (Safari's, Android's back gesture) is the
29
29
  * swipe back, and it lands here as a `popstate`. A mouse drag from the edge
30
30
  * still pops through the renderer, since no browser turns one into history.
31
+ *
32
+ * Every `popstate` the browser makes also starts the renderer's repaint
33
+ * keep-alive (`Renderer.keepRepainting`): Safari's swipe shows a snapshot of
34
+ * the entry and, once its animation ends, hides the live page until a new
35
+ * frame is committed, so a pop that finished animating by then would stay
36
+ * under the snapshot for Safari's 3 s watchdog, taking the touches unseen.
31
37
  */
32
38
  import type { Renderer } from './renderer';
33
39
 
@@ -48,10 +54,19 @@ function shown(win: Window, el: HTMLElement): boolean {
48
54
  return win.getComputedStyle(el).visibility !== 'hidden';
49
55
  }
50
56
 
57
+ /** How long a back the browser made waits for the app to pop a level before what it shows is recorded as it is. */
58
+ const DISMISS_WAIT_MS = 1000;
59
+
51
60
  export class BrowserHistory {
52
61
  private readonly win: Window;
53
- /** Levels the latest `popstate` still asks to dismiss, one commit at a time. */
54
- private pendingPops = 0;
62
+ /**
63
+ * A back the browser made, while the app pops to it: the depth the entry
64
+ * names, the depth the latest dismissal started from, and until when the
65
+ * app's pop is waited for. A pop can take more than one commit (a compact
66
+ * split view clears its sidebar selection first, and the detail goes on
67
+ * the next), so a commit that still shows the level is not a new one.
68
+ */
69
+ private dismissing: { target: number; from: number; until: number } | null = null;
55
70
  /** `popstate` events to ignore: the ones our own `history.go` causes. */
56
71
  private ignorePops = 0;
57
72
  /**
@@ -199,6 +214,15 @@ export class BrowserHistory {
199
214
  return top ? Number(top.dataset['sbDepth'] ?? '0') || 0 : 0;
200
215
  }
201
216
 
217
+ /** Dismisses the level above `depth` for the back in progress, and waits for the app's pop. */
218
+ private dismissFrom(depth: number): void {
219
+ const dismissing = this.dismissing;
220
+ if (!dismissing) return;
221
+ dismissing.from = depth;
222
+ dismissing.until = performance.now() + DISMISS_WAIT_MS;
223
+ if (!this.dismissOne()) this.dismissing = null;
224
+ }
225
+
202
226
  /** Asks the app to dismiss one level: the top sheet when nothing is pushed inside it, else the deepest visible stack's top screen. */
203
227
  private dismissOne(): boolean {
204
228
  const sheets = this.sheets();
@@ -276,13 +300,16 @@ export class BrowserHistory {
276
300
  const depth = this.depth();
277
301
  const recorded = this.recorded();
278
302
  const selection = this.selection();
279
- if (this.pendingPops > 0) {
303
+ const dismissing = this.dismissing;
304
+ if (dismissing) {
280
305
  // Dismissing for a `popstate`: the entry already names the target depth.
281
- if (depth > recorded) {
282
- if (!this.dismissOne()) this.pendingPops = 0;
283
- else this.pendingPops -= 1;
284
- } else this.pendingPops = 0;
285
- return;
306
+ if (depth > dismissing.target && performance.now() < dismissing.until) {
307
+ // The latest dismissal landed: the next level. Otherwise the app has not popped yet.
308
+ if (depth < dismissing.from) this.dismissFrom(depth);
309
+ return;
310
+ }
311
+ // Arrived, or the app kept the level (it is then recorded as any other).
312
+ this.dismissing = null;
286
313
  }
287
314
  if (depth > recorded) {
288
315
  // Under our own pending `go`, a push would land relative to wherever the browser is when it runs (and
@@ -317,12 +344,15 @@ export class BrowserHistory {
317
344
  }
318
345
  const target = stateOf(event.state);
319
346
  if (!target) return; // an entry of the page's own (before the base, or the app's router): not ours
347
+ // The browser moved on its own (a swipe, a button): keep painting past its transition.
348
+ this.renderer.keepRepainting();
320
349
  const depth = this.depth();
350
+ // A move the browser makes replaces any back still in progress.
351
+ this.dismissing = null;
321
352
  if (target.sbDepth < depth) {
322
- // Back: dismiss one level now, the rest as each commit lands.
323
- this.pendingPops = depth - target.sbDepth;
324
- if (!this.dismissOne()) this.pendingPops = 0;
325
- else this.pendingPops -= 1;
353
+ // Back: dismiss one level now, the rest as each pop lands.
354
+ this.dismissing = { target: target.sbDepth, from: depth, until: 0 };
355
+ this.dismissFrom(depth);
326
356
  } else if (target.sbDepth > depth) {
327
357
  // Forward: a screen cannot be re-pushed; return to the entry that matches.
328
358
  this.go(depth - target.sbDepth, depth);
@@ -85,6 +85,8 @@ export interface Context {
85
85
  imageScale: number;
86
86
  /** Phase 7: true inside the sidebar column of a `navsplit` (a selected `navlink` row drops its chevron there). */
87
87
  splitSidebar: boolean;
88
+ /** Inside a leading or trailing item of a navigation bar (a segmented picker there is the bar's own capsule). */
89
+ navBarItem?: boolean;
88
90
  /** Rows of a `sidebar`-style list (iPadOS): its links have no chevron. */
89
91
  sidebarList?: boolean;
90
92
  /**
@@ -219,6 +221,17 @@ const TAB_BAR_ITEM_INSET = 4;
219
221
  const PICKER_SEGMENTED_HEIGHT = 32;
220
222
  const PICKER_SEGMENT_PAD = 20;
221
223
  const PICKER_SEGMENT_FONT_SIZE = 13;
224
+ // iOS 26, a segmented picker in a navigation bar item (measured in the
225
+ // simulator, Examples/Fidelity `topbarsegmented`, with the item's shared
226
+ // glass hidden): the control is a 44-tall glass capsule itself, no track;
227
+ // the selected segment a 36-tall gray capsule inset 4 (no shadow); labels
228
+ // 15 regular; segments of equal width, the widest label and 21 either side.
229
+ // The capsule keeps 4 clear of the bar's other capsules beyond their usual
230
+ // 12 (it sits 16 from the next one).
231
+ const BAR_SEGMENTED_FONT_SIZE = 15;
232
+ const BAR_SEGMENT_PAD = 42;
233
+ const BAR_SEGMENTED_INSET = 4;
234
+ const BAR_SEGMENTED_MARGIN = 4;
222
235
  const PICKER_MENU_ROW_HEIGHT = 44;
223
236
  const PICKER_MENU_HEIGHT = 34;
224
237
  /** `chevron.up.chevron.down` glyph width plus the gap to the value text. */
@@ -290,6 +303,7 @@ function isSymbolItem(item: LayoutNode): boolean {
290
303
 
291
304
  /** A bar item's slot in its glass capsule. */
292
305
  function navSlot(item: LayoutNode, width: number): number {
306
+ if (isSegmentedItem(item)) return width + 2 * BAR_SEGMENTED_MARGIN;
293
307
  if (isSymbolItem(item) && width <= NAV_GLASS_SIZE) return NAV_GLASS_SIZE;
294
308
  return Math.max(NAV_GLASS_SIZE, width + 2 * NAV_GLASS_PAD_H);
295
309
  }
@@ -521,6 +535,12 @@ function ignoresKeyboard(node: LayoutNode): boolean {
521
535
  }
522
536
 
523
537
  /** The element under a chain of `styled` wrappers. */
538
+ /** A segmented picker as a navigation bar item (under its styled wrappers): it is a capsule of its own. */
539
+ function isSegmentedItem(item: LayoutNode): boolean {
540
+ const inner = unwrapStyled(item);
541
+ return inner.kind === 'picker' && str(inner.props['style']) === 'segmented';
542
+ }
543
+
524
544
  function unwrapStyled(node: LayoutNode): LayoutNode {
525
545
  let current = node;
526
546
  while (current.kind === 'styled' && current.children.length === 1) {
@@ -1131,6 +1151,8 @@ interface PickerPlan {
1131
1151
  valueWidth: number;
1132
1152
  /** Inline style: whether a label row precedes the options. */
1133
1153
  labelRow: boolean;
1154
+ /** Segmented style in a navigation bar item: the bar's glass capsule, its segments inset. */
1155
+ bar?: boolean;
1134
1156
  }
1135
1157
 
1136
1158
  // Phase 6 plans.
@@ -1550,6 +1572,12 @@ export class LayoutPass {
1550
1572
  if (ctx.barAbove === undefined && ctx.stackAxis === null && !ctx.listRow) return ctx;
1551
1573
  return { ...ctx, barAbove: undefined, stackAxis: null, listRow: false };
1552
1574
  }
1575
+ case 'toolbar': {
1576
+ const placement = str(node.props['placement']);
1577
+ const navBarItem = placement === 'leading' || placement === 'trailing';
1578
+ if (ctx.stackAxis === null && !ctx.listRow && ctx.navBarItem === navBarItem) return ctx;
1579
+ return { ...ctx, stackAxis: null, listRow: false, navBarItem };
1580
+ }
1553
1581
  case 'gesture':
1554
1582
  case 'swipeactions':
1555
1583
  case 'contextmenu':
@@ -2014,6 +2042,23 @@ export class LayoutPass {
2014
2042
  labelRow,
2015
2043
  };
2016
2044
  }
2045
+ if (style === 'segmented' && ctx.navBarItem) {
2046
+ const font = makeFont('system', BAR_SEGMENTED_FONT_SIZE, 400);
2047
+ let widest = 0;
2048
+ for (const option of options) widest = Math.max(widest, this.measurer.measureWidth(option, font));
2049
+ const natural = options.length * (widest + BAR_SEGMENT_PAD) + 2 * BAR_SEGMENTED_INSET;
2050
+ return {
2051
+ size: { width: finite(p.width) ? p.width : natural, height: NAV_GLASS_SIZE },
2052
+ style: 'segmented',
2053
+ font,
2054
+ options,
2055
+ selected,
2056
+ labelWidth: 0,
2057
+ valueWidth: 0,
2058
+ labelRow: false,
2059
+ bar: true,
2060
+ };
2061
+ }
2017
2062
  if (style === 'segmented') {
2018
2063
  const font = makeFont('system', PICKER_SEGMENT_FONT_SIZE, 600);
2019
2064
  let natural = 0;
@@ -2057,9 +2102,14 @@ export class LayoutPass {
2057
2102
  const chrome: Record<string, Rect> = { ...existing };
2058
2103
  if (plan.style === 'segmented') {
2059
2104
  const n = Math.max(1, plan.options.length);
2060
- const segmentWidth = size.width / n;
2105
+ // In a bar the segments sit inside the capsule, inset all round; `bar` (the
2106
+ // control's frame) tells the page to draw it as the bar draws it.
2107
+ const inset = plan.bar ? BAR_SEGMENTED_INSET : 0;
2108
+ if (plan.bar) chrome['bar'] = { x, y, width: size.width, height: size.height };
2109
+ else delete chrome['bar'];
2110
+ const segmentWidth = Math.max(0, size.width - 2 * inset) / n;
2061
2111
  for (let i = 0; i < plan.options.length; i++) {
2062
- chrome[`segment${i}`] = { x: x + i * segmentWidth, y, width: segmentWidth, height: size.height };
2112
+ chrome[`segment${i}`] = { x: x + inset + i * segmentWidth, y: y + inset, width: segmentWidth, height: Math.max(0, size.height - 2 * inset) };
2063
2113
  }
2064
2114
  } else if (plan.style === 'inline') {
2065
2115
  // Rows span the list row when there is one (the hit area and the
@@ -3448,6 +3498,10 @@ export class LayoutPass {
3448
3498
  interface Run {
3449
3499
  placement: 'leading' | 'trailing';
3450
3500
  groups: Group[];
3501
+ /** A segmented picker's own run: the glass is the control, inside its margin. */
3502
+ segmented?: boolean;
3503
+ /** `.sharedBackgroundVisibility(.hidden)`: a run of its own with no glass. */
3504
+ bare?: boolean;
3451
3505
  /** The capsule's width: the slots 12 apart, a point past the end slots (none for a lone item). */
3452
3506
  width: number;
3453
3507
  }
@@ -3469,6 +3523,20 @@ export class LayoutPass {
3469
3523
  }
3470
3524
  const group = groupOf.get(toolbar);
3471
3525
  if (!group) continue;
3526
+ // A segmented picker is a capsule of its own: it neither joins nor is joined (iOS 26
3527
+ // draws its glass, and an item's shared glass around it would show as a second capsule).
3528
+ if (toolbar.children.some(isSegmentedItem)) {
3529
+ delete open[placement];
3530
+ runs.push({ placement, groups: [group], width: 0, segmented: true });
3531
+ continue;
3532
+ }
3533
+ // An item that hides the bar's shared glass stands alone, at its own width.
3534
+ if (str(toolbar.props['sharedBackground']) === 'hidden') {
3535
+ delete open[placement];
3536
+ group.slots = group.sizes.map((s) => s.width);
3537
+ runs.push({ placement, groups: [group], width: 0, bare: true });
3538
+ continue;
3539
+ }
3472
3540
  let run = open[placement];
3473
3541
  if (!run) {
3474
3542
  run = { placement, groups: [], width: 0 };
@@ -3564,8 +3632,13 @@ export class LayoutPass {
3564
3632
  // items, in slots 12 apart from `startX`; each toolbar's frame spans its
3565
3633
  // own items' slots. Runs of a placement follow their order in the
3566
3634
  // content, leading-to-trailing.
3567
- const placeRun = (run: Run, startX: number, index: number): void => {
3568
- chrome[`${run.placement}Glass${index}`] = { x: startX, y: rowY, width: run.width, height: NAV_BAR_HEIGHT };
3635
+ // The capsules are numbered as they are drawn (the page reads them until one is missing).
3636
+ const glassCount = { leading: 0, trailing: 0 };
3637
+ const placeRun = (run: Run, startX: number): void => {
3638
+ const margin = run.segmented ? BAR_SEGMENTED_MARGIN : 0;
3639
+ if (!run.bare) {
3640
+ chrome[`${run.placement}Glass${glassCount[run.placement]++}`] = { x: startX + margin, y: rowY, width: Math.max(0, run.width - 2 * margin), height: NAV_BAR_HEIGHT };
3641
+ }
3569
3642
  const count = run.groups.reduce((sum, g) => sum + (g.slots?.length ?? 0), 0);
3570
3643
  let cursor = startX + (count > 1 ? NAV_RUN_PAD : 0);
3571
3644
  for (const group of run.groups) {
@@ -3581,19 +3654,18 @@ export class LayoutPass {
3581
3654
  this.setFrame(group.toolbar, first, rowY, { width: last - first, height: NAV_BAR_HEIGHT });
3582
3655
  }
3583
3656
  };
3584
- let leadingIndex = 0;
3585
3657
  for (const run of runs.filter((r) => r.placement === 'leading')) {
3586
3658
  if (run.width === 0) continue;
3587
- placeRun(run, leadingCursor, leadingIndex++);
3659
+ placeRun(run, leadingCursor);
3588
3660
  leadingCursor += run.width + NAV_GROUP_SPACING;
3589
3661
  }
3590
3662
  const trailingRuns = runs.filter((r) => r.placement === 'trailing' && r.width > 0);
3591
3663
  let trailingCursor = trailingEdge - (trailingWidth > 0 ? trailingWidth - NAV_GROUP_SPACING : 0);
3592
3664
  const trailingStart = trailingCursor;
3593
- trailingRuns.forEach((run, index) => {
3594
- placeRun(run, trailingCursor, index);
3665
+ for (const run of trailingRuns) {
3666
+ placeRun(run, trailingCursor);
3595
3667
  trailingCursor += run.width + NAV_GROUP_SPACING;
3596
- });
3668
+ }
3597
3669
  if (trailingRuns.length > 0) trailingEdge = trailingStart - NAV_GROUP_SPACING;
3598
3670
  for (const group of groups) {
3599
3671
  if (group.placement !== 'principal') continue;
package/src/layoutDom.ts CHANGED
@@ -953,6 +953,8 @@ function applyChrome(
953
953
  case 'picker': {
954
954
  setFont(el, result.fonts.get(node.id));
955
955
  if (node.props['style'] === 'segmented') {
956
+ // In a navigation bar item the control is the bar's glass capsule (engine `bar`).
957
+ el.toggleAttribute('data-sb-bar', chrome?.['bar'] !== undefined);
956
958
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-segment-track'), frame, box);
957
959
  const segments = el.querySelectorAll<HTMLElement>(':scope > .sb-segment');
958
960
  segments.forEach((segment, i) => placeChrome(segment, chrome?.[`segment${i}`], box));
package/src/protocol.ts CHANGED
@@ -489,6 +489,8 @@ export type ButtonRole = 'destructive' | 'cancel' | null;
489
489
  export interface ButtonProps {
490
490
  style?: ButtonStyle;
491
491
  role?: ButtonRole;
492
+ /** An `EditButton`: the edit mode it shows ("Done" / "Edit") and flips. A native renderer draws the platform's edit button from it. */
493
+ editMode?: 'active' | 'inactive';
492
494
  }
493
495
  export interface StyledProps {
494
496
  style: Style;
@@ -539,6 +541,13 @@ export interface UrlImageProps {
539
541
  resizable: boolean;
540
542
  width: number;
541
543
  height: number;
544
+ /**
545
+ * React's opt-in (`AsyncImage webInteractions`): the `<img>` keeps the
546
+ * browser's own image interactions (a long press's Save / Share / Copy on
547
+ * iOS, dragging it out, the right-click menu) instead of being inert as a
548
+ * SwiftUI image is. Swift apps never send it.
549
+ */
550
+ webInteractions?: boolean;
542
551
  }
543
552
  /**
544
553
  * A `UIImage` / `CGImage` bitmap (`Image(uiImage:)`): a recipe the page draws
@@ -620,6 +629,14 @@ export interface TextFieldProps {
620
629
  submit?: boolean;
621
630
  /** Phase 12 `.submitLabel`: the return key's label (`enterkeyhint`). */
622
631
  submitLabel?: string;
632
+ /** `.keyboardType(_:)`: a `UIKeyboardType` case name (`emailAddress`, `URL`, `numberPad`…), the on-screen keyboard (`inputmode`). */
633
+ keyboard?: string;
634
+ /** `.textContentType(_:)`: a `UITextContentType` name (`emailAddress`, `oneTimeCode`…), what autofill offers (`autocomplete`). */
635
+ contentType?: string;
636
+ /** `.textInputAutocapitalization(_:)` (`autocapitalize`). */
637
+ autocapitalization?: 'never' | 'words' | 'sentences' | 'characters';
638
+ /** `.autocorrectionDisabled()` (`autocorrect="off"`). */
639
+ autocorrectionDisabled?: boolean;
623
640
  }
624
641
  export interface ToggleProps {
625
642
  isOn: boolean;
@@ -918,6 +935,12 @@ export interface ToolbarProps {
918
935
  * apart; fixed ones are 8 wide.
919
936
  */
920
937
  spacer?: 'flexible' | 'fixed';
938
+ /**
939
+ * `.sharedBackgroundVisibility(.hidden)`: a leading or trailing group of
940
+ * the navigation bar draws no glass of the bar's and is a run of its own
941
+ * (a segmented picker is drawn as its own glass capsule either way).
942
+ */
943
+ sharedBackground?: 'hidden';
921
944
  }
922
945
  /**
923
946
  * `.searchable(text:)`, an extra child of a `navscreen`: a 36pt rounded
package/src/renderer.ts CHANGED
@@ -217,6 +217,16 @@ function dismissMenu(menu: HTMLElement): void {
217
217
 
218
218
  /** Duration of the navigation push/pop slide; keep in sync with styles.css (`--sb-nav-easing`). */
219
219
  export const NAV_TRANSITION_MS = 400;
220
+ /**
221
+ * How long `keepRepainting` paints after a browser history navigation:
222
+ * through Safari's 3 s snapshot-removal watchdog, with room for a slow
223
+ * device. Measured on an iPhone: painting for 1.5 s (past the swipe
224
+ * animation alone) removed the stall but left a single freeze of the touch
225
+ * in progress at the 3 s mark; 5 s left none.
226
+ */
227
+ export const REPAINT_KEEPALIVE_MS = 5000;
228
+ /** Between two repaints of the keep-alive: a few frames, so the cost is nothing and the first frame after Safari hides the page is near. */
229
+ const REPAINT_KEEPALIVE_INTERVAL_MS = 50;
220
230
  /** Duration of the sheet present/dismiss slide; keep in sync with styles.css. */
221
231
  export const SHEET_TRANSITION_MS = 400;
222
232
  // Phase 8: the sheet drag (and every other pointer gesture) lives in src/interaction.ts.
@@ -1294,6 +1304,52 @@ export class Renderer {
1294
1304
  this.onEvent({ type: 'tap', id });
1295
1305
  }
1296
1306
 
1307
+ /** The repaint keep-alive (`keepRepainting`): its box, timer and deadline while it runs. */
1308
+ private repaintKeepAlive: { el: HTMLElement; timer: number; until: number; on: boolean } | null = null;
1309
+
1310
+ /**
1311
+ * Keeps the page painting for `duration` ms: a 1 px box in the root
1312
+ * alternates between two near-identical colors every few frames, so each
1313
+ * frame carries a real repaint. Safari's back and forward swipe shows a
1314
+ * snapshot of the entry it goes to and, once its own animation ends, hides
1315
+ * the live page until the page commits a new frame; a pop that has already
1316
+ * finished animating by then leaves nothing to commit, and Safari keeps the
1317
+ * snapshot (a still image, under which the live page takes the touches)
1318
+ * until a 3 s watchdog; and a page that stops painting before that
1319
+ * watchdog has a touch in progress interrupted when it fires, so the
1320
+ * painting runs through it. Called from browserHistory.ts for every
1321
+ * history navigation the browser makes; a call while one runs extends it.
1322
+ */
1323
+ keepRepainting(duration = REPAINT_KEEPALIVE_MS): void {
1324
+ const until = performance.now() + duration;
1325
+ if (this.repaintKeepAlive) {
1326
+ this.repaintKeepAlive.until = Math.max(this.repaintKeepAlive.until, until);
1327
+ return;
1328
+ }
1329
+ const el = document.createElement('div');
1330
+ el.setAttribute('aria-hidden', 'true');
1331
+ el.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;pointer-events:none;z-index:2147483647;background:rgba(127,127,127,0.02)';
1332
+ this.root.append(el);
1333
+ const state = { el, timer: 0, until, on: false };
1334
+ this.repaintKeepAlive = state;
1335
+ state.timer = window.setInterval(() => {
1336
+ if (performance.now() >= state.until) {
1337
+ this.stopRepainting();
1338
+ return;
1339
+ }
1340
+ state.on = !state.on;
1341
+ el.style.background = state.on ? 'rgba(127,127,127,0.03)' : 'rgba(127,127,127,0.02)';
1342
+ }, REPAINT_KEEPALIVE_INTERVAL_MS);
1343
+ }
1344
+
1345
+ private stopRepainting(): void {
1346
+ const state = this.repaintKeepAlive;
1347
+ if (!state) return;
1348
+ this.repaintKeepAlive = null;
1349
+ window.clearInterval(state.timer);
1350
+ state.el.remove();
1351
+ }
1352
+
1297
1353
  /**
1298
1354
  * Run the layout engine over the current tree and position every element.
1299
1355
  * Returns the elements whose box changed. Runs at every `commit`; call it
@@ -1584,6 +1640,7 @@ export class Renderer {
1584
1640
  }
1585
1641
 
1586
1642
  dispose(): void {
1643
+ this.stopRepainting();
1587
1644
  this.closeMenu();
1588
1645
  this.root.removeEventListener('click', this.onClick);
1589
1646
  this.root.removeEventListener('pointerdown', this.onRowPress);
@@ -1875,6 +1932,29 @@ export class Renderer {
1875
1932
  this.pendingScrolls.push(id);
1876
1933
  }
1877
1934
 
1935
+ /**
1936
+ * `ScrollContext.animateScroll` (src/scrollTo.ts): an animated `scrollTo`
1937
+ * of a snapping scroll view on one axis runs as the touch pager's run (the
1938
+ * snap off until it rests, the last frame writing the offset), held from
1939
+ * reporting its position until then as a finger's drive is, so an app
1940
+ * re-centring its pages on the page it reports does so once, at rest.
1941
+ */
1942
+ animateScroll(el: HTMLElement, left: number, top: number): boolean {
1943
+ if (el.dataset['sbSnap'] === undefined) return false;
1944
+ const axes = el.dataset['sbAxes'];
1945
+ if (axes !== 'horizontal' && axes !== 'vertical') return false;
1946
+ if (el.dataset['sbReportsPosition'] !== undefined) {
1947
+ this.heldScrolls.add(el);
1948
+ const pending = this.positionTimers.get(el);
1949
+ if (pending !== undefined) {
1950
+ window.clearTimeout(pending);
1951
+ this.positionTimers.delete(el);
1952
+ }
1953
+ }
1954
+ this.touchPaging.animateTo(el, axes === 'horizontal', axes === 'horizontal' ? left : top);
1955
+ return true;
1956
+ }
1957
+
1878
1958
  /** `ScrollViewProxy.scrollTo`: scroll to the targets this pass marked (src/scrollTo.ts). */
1879
1959
  private performScrolls(animated: boolean): void {
1880
1960
  if (this.pendingScrolls.length === 0) return;
@@ -3798,8 +3878,12 @@ function applyImage(el: HTMLElement, props: ImageProps, assets: AssetCatalog): v
3798
3878
  el.dataset['sbUrl'] = props.url;
3799
3879
  if (props.resizable) el.dataset['sbResizable'] = '';
3800
3880
  else delete el.dataset['sbResizable'];
3881
+ // React's opt-in to the browser's own image interactions (styles.css).
3882
+ const web = props.webInteractions === true;
3883
+ el.toggleAttribute('data-sb-web', web);
3801
3884
  const existing = el.firstElementChild;
3802
3885
  if (existing instanceof HTMLImageElement) {
3886
+ existing.draggable = web;
3803
3887
  if (existing.getAttribute('src') !== props.url && existing.dataset['sbPending'] !== props.url) {
3804
3888
  swapImageSource(existing, props.url);
3805
3889
  }
@@ -3808,12 +3892,13 @@ function applyImage(el: HTMLElement, props: ImageProps, assets: AssetCatalog): v
3808
3892
  const img = document.createElement('img');
3809
3893
  img.src = props.url;
3810
3894
  img.alt = '';
3811
- img.draggable = false;
3895
+ img.draggable = web;
3812
3896
  img.decoding = 'async';
3813
3897
  found(props.url, img);
3814
3898
  return;
3815
3899
  }
3816
3900
  delete el.dataset['sbUrl'];
3901
+ el.removeAttribute('data-sb-web');
3817
3902
  if (isAssetImage(props)) {
3818
3903
  const name = props.name ?? '';
3819
3904
  delete el.dataset['sbSymbol'];
@@ -3922,6 +4007,54 @@ function applyTextField(input: HTMLInputElement | HTMLTextAreaElement, props: Te
3922
4007
  else delete input.dataset['sbSubmit'];
3923
4008
  if (typeof props.submitLabel === 'string' && props.submitLabel.length > 0) input.enterKeyHint = props.submitLabel;
3924
4009
  else input.removeAttribute('enterkeyhint');
4010
+ applyTextInputHints(input, props);
4011
+ }
4012
+
4013
+ /** `UIKeyboardType` → `inputmode`: the browser's nearest on-screen keyboard. */
4014
+ const INPUT_MODES: Record<string, string> = {
4015
+ emailAddress: 'email',
4016
+ URL: 'url',
4017
+ numberPad: 'numeric',
4018
+ asciiCapableNumberPad: 'numeric',
4019
+ decimalPad: 'decimal',
4020
+ phonePad: 'tel',
4021
+ namePhonePad: 'tel',
4022
+ webSearch: 'search',
4023
+ };
4024
+
4025
+ /** `UITextContentType` → `autocomplete`: what autofill offers. */
4026
+ const AUTOCOMPLETE: Record<string, string> = {
4027
+ name: 'name',
4028
+ givenName: 'given-name',
4029
+ familyName: 'family-name',
4030
+ emailAddress: 'email',
4031
+ telephoneNumber: 'tel',
4032
+ username: 'username',
4033
+ password: 'current-password',
4034
+ newPassword: 'new-password',
4035
+ oneTimeCode: 'one-time-code',
4036
+ URL: 'url',
4037
+ fullStreetAddress: 'street-address',
4038
+ postalCode: 'postal-code',
4039
+ creditCardNumber: 'cc-number',
4040
+ };
4041
+
4042
+ /**
4043
+ * `.keyboardType`, `.textContentType`, `.textInputAutocapitalization` and
4044
+ * `.autocorrectionDisabled` as the input's attributes (docs/ops-protocol.md,
4045
+ * "Text input hints"). Without a content type autofill stays off, as a field
4046
+ * in an app does not offer the page's saved values.
4047
+ */
4048
+ function applyTextInputHints(input: HTMLInputElement | HTMLTextAreaElement, props: TextFieldProps): void {
4049
+ const mode = props.keyboard ? INPUT_MODES[props.keyboard] : undefined;
4050
+ if (mode) input.inputMode = mode;
4051
+ else input.removeAttribute('inputmode');
4052
+ input.autocomplete = ((props.contentType ? AUTOCOMPLETE[props.contentType] : undefined) ?? 'off') as AutoFill;
4053
+ const capitalize = props.autocapitalization === 'never' ? 'none' : props.autocapitalization;
4054
+ if (capitalize) input.setAttribute('autocapitalize', capitalize);
4055
+ else input.removeAttribute('autocapitalize');
4056
+ if (props.autocorrectionDisabled === true) input.setAttribute('autocorrect', 'off');
4057
+ else input.removeAttribute('autocorrect');
3925
4058
  }
3926
4059
 
3927
4060
  /** Phase 12 `.scrollTargetLayout()`: the stack's children snap (styles.css). */
package/src/scrollTo.ts CHANGED
@@ -49,6 +49,14 @@ export interface ScrollContext {
49
49
  nodes: Map<number, LayoutNode>;
50
50
  parents: Map<number, number>;
51
51
  elements: Map<number, HTMLElement>;
52
+ /**
53
+ * Runs an animated scroll of `el` to the offsets itself, returning false
54
+ * to leave it to the browser's smooth scrolling. The renderer takes the
55
+ * snapping scroll views (`scrollTargetBehavior`): a browser animating a
56
+ * scroll-snapped container may snap it again once the animation ends or
57
+ * the content moves (Safari does), so they run as a finger's page does.
58
+ */
59
+ animateScroll?: (el: HTMLElement, left: number, top: number) => boolean;
52
60
  }
53
61
 
54
62
  function anchorOf(command: ScrollToCommand | undefined): UnitPoint | null {
@@ -116,7 +124,9 @@ export function scrollToTarget(
116
124
  changed = true;
117
125
  }
118
126
  if (changed) {
119
- if (smooth && typeof el.scrollTo === 'function') {
127
+ if (smooth && ctx.animateScroll?.(el, left, top)) {
128
+ // Run by the context (a snapping scroll view).
129
+ } else if (smooth && typeof el.scrollTo === 'function') {
120
130
  el.scrollTo({ left, top, behavior: 'smooth' });
121
131
  } else {
122
132
  el.scrollLeft = left;
package/src/styles.css CHANGED
@@ -666,6 +666,17 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
666
666
  -webkit-user-select: none;
667
667
  }
668
668
 
669
+ /* React's `AsyncImage webInteractions`: the picture is a web page's image
670
+ again, with the browser's long press (Save / Share / Copy on iOS), drag
671
+ out and right-click menu. */
672
+ .sb-image[data-sb-web] > img {
673
+ pointer-events: auto;
674
+ user-select: auto;
675
+ -webkit-user-select: auto;
676
+ -webkit-touch-callout: default;
677
+ -webkit-user-drag: auto;
678
+ }
679
+
669
680
  /* `.interpolation(.none)`: a QR code's modules stay sharp when scaled up. */
670
681
  .sb-image[data-sb-interpolation='none'] > img,
671
682
  .sb-image[data-sb-interpolation='none'] > canvas {
@@ -1914,6 +1925,28 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
1914
1925
  max-width: calc(100% - 8px);
1915
1926
  }
1916
1927
 
1928
+ /* iOS 26: a segmented picker in a navigation bar item is the bar's glass
1929
+ capsule itself (the run's glass behind it, engine `bar`): no track, the
1930
+ labels in the engine's font (15 regular), the selected segment a gray
1931
+ capsule the glass tints, with no shadow, 2 in from its neighbours. */
1932
+ .sb-picker[data-sb-style="segmented"][data-sb-bar] > .sb-segment-track {
1933
+ display: none;
1934
+ }
1935
+
1936
+ .sb-picker[data-sb-style="segmented"][data-sb-bar] > .sb-segment {
1937
+ font: inherit;
1938
+ letter-spacing: normal;
1939
+ }
1940
+
1941
+ .sb-picker[data-sb-style="segmented"][data-sb-bar] > .sb-segment::before {
1942
+ inset: 0 2px;
1943
+ }
1944
+
1945
+ .sb-picker[data-sb-style="segmented"][data-sb-bar] > .sb-segment[aria-checked="true"]::before {
1946
+ background: var(--sb-glass-selected);
1947
+ box-shadow: none;
1948
+ }
1949
+
1917
1950
  .sb-segment:focus-visible {
1918
1951
  outline: 2px solid var(--sb-color-accent);
1919
1952
  outline-offset: -2px;
@@ -2998,7 +3031,8 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2998
3031
  }
2999
3032
 
3000
3033
  :is([data-sb-hidden], [data-sb-disabled], [data-sb-hit-testing="false"], .sb-navscreen--pop, .sb-exit-clone, .sb-sheet--out, .sb-alert--out, .sb-tab[data-sb-tab-hidden]) [data-sb-kind],
3001
- :is([data-sb-hidden], [data-sb-disabled], [data-sb-hit-testing="false"], .sb-navscreen--pop, .sb-exit-clone, .sb-sheet--out, .sb-alert--out, .sb-tab[data-sb-tab-hidden]) .sb-navbar > * {
3034
+ :is([data-sb-hidden], [data-sb-disabled], [data-sb-hit-testing="false"], .sb-navscreen--pop, .sb-exit-clone, .sb-sheet--out, .sb-alert--out, .sb-tab[data-sb-tab-hidden]) .sb-navbar > *,
3035
+ :is([data-sb-hidden], [data-sb-disabled], [data-sb-hit-testing="false"], .sb-navscreen--pop, .sb-exit-clone, .sb-sheet--out, .sb-alert--out, .sb-tab[data-sb-tab-hidden]) .sb-image[data-sb-web] > img {
3002
3036
  pointer-events: none !important;
3003
3037
  }
3004
3038
 
@@ -334,6 +334,22 @@ export class TouchPaging {
334
334
  this.runTo(el, g.horizontal, offset, destination);
335
335
  };
336
336
 
337
+ /**
338
+ * The app scrolls `el` to `to` with an animation (`withAnimation` around
339
+ * `ScrollViewProxy.scrollTo`): the same eased run a finger's page ends
340
+ * with, the snap off until it rests, the last frame writing `to` itself
341
+ * and `onRest` reporting the page. A browser's own smooth scroll of a
342
+ * snapping container is left to snap it again when it likes; Safari does,
343
+ * after the animation or when the app re-centres its pages.
344
+ */
345
+ animateTo(el: HTMLElement, horizontal: boolean, to: number): void {
346
+ this.stopRun();
347
+ const max = horizontal ? el.scrollWidth - el.clientWidth : el.scrollHeight - el.clientHeight;
348
+ const target = Math.min(Math.max(0, max), Math.max(0, to));
349
+ el.style.scrollSnapType = 'none';
350
+ this.runTo(el, horizontal, horizontal ? el.scrollLeft : el.scrollTop, target);
351
+ }
352
+
337
353
  /** Runs the offset to `to` with an ease-out, then lets the container snap again. */
338
354
  private runTo(el: HTMLElement, horizontal: boolean, from: number, to: number): void {
339
355
  this.stopRun();