@swiftbrowser/web 0.17.8 → 0.18.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/src/renderer.ts CHANGED
@@ -49,6 +49,10 @@ import {
49
49
  import {
50
50
  CanvasTextMeasurer,
51
51
  LayoutPass,
52
+ defaultFont,
53
+ resolveFont,
54
+ textHeight,
55
+ wrapText,
52
56
  type LayoutEnvironment,
53
57
  type LayoutNode,
54
58
  type LayoutResult,
@@ -58,11 +62,15 @@ import { LayoutReporter } from './layoutEvents';
58
62
  import { createGaugeRing, isCircularGauge, updateGaugeRing } from './gaugeRing';
59
63
  import { bitmapStore, showBitmap } from './bitmaps';
60
64
  import { clearRichText, runTemplates } from './richText';
65
+ import { markDeleteBackward, wantsDeleteBackward } from './editorSelection';
66
+ import { applyPlainEditorSelection, focusPlainEditor, isPlainEditor, listenToPlainEditors } from './plainEditor';
67
+ import { applyRichEditor, createRichEditor, focusRichEditor, isRichEditor, listenToRichEditors, richEditorText } from './richEditor';
61
68
  import { applyLayout, setTabHidden, type BoxChange } from './layoutDom';
62
69
  import { BarMorph } from './barMorph';
63
70
  import { ghostClone } from './ghost';
64
71
  import { ExternalDrops } from './dragDrop';
65
72
  import { InteractionController, type InteractionHost } from './interaction';
73
+ import { TapCounter } from './gestureComposition';
66
74
  import { PressEffects } from './press';
67
75
  import { symbolOf, textOf, unwrapStyledNode } from './nodeQueries';
68
76
  import {
@@ -86,6 +94,7 @@ import {
86
94
  type FontWeight,
87
95
  type GaugeProps,
88
96
  type GeometryEvent,
97
+ type ScrollGeometryEvent,
89
98
  type GestureProps,
90
99
  type GridProps,
91
100
  type GridRowProps,
@@ -151,6 +160,7 @@ import {
151
160
  import { filledSymbolName, renderSymbol } from './symbols';
152
161
  import { applyPathShape, hasPathFrames, playPathFrames } from './pathShape';
153
162
  import { VideoEvents, applyVideo, stopVideos } from './video';
163
+ import { loadableURL } from './hostRequests';
154
164
  import { PinchController } from './pinch';
155
165
  import { applyCanvas, createCanvasElement } from './canvasDrawing';
156
166
  import { scrollToTarget } from './scrollTo';
@@ -166,6 +176,9 @@ import { colorMultiplyFilter } from './colorMultiply';
166
176
  import { largeTitleOf, largeTitleTextOf } from './largeTitle';
167
177
  import { applyEdgeEffect } from './scrollEdge';
168
178
  import { discardScrollOffsets, readScrollOffsets, trackScroller } from './scrollSync';
179
+ import { drawScrollTransitions, setScrollTransition } from './scrollTransition';
180
+ import { ScrollGeometryReporter, readScrollGeometry } from './scrollGeometry';
181
+ import { drawPinnedViews, setPinnedViews } from './pinnedViews';
169
182
  import { DEFAULT_TYPE_SIZE, TEXT_STYLE_WEIGHT, resolveTextStyle, textStyleVar } from './typography';
170
183
 
171
184
  export type EventCallback = (event: RendererEvent) => void;
@@ -391,12 +404,21 @@ export class Renderer {
391
404
  private readonly hover: HoverTracker;
392
405
  /** A frame check of the `reportsFrame` readers is queued for the next animation frame (after a scroll). */
393
406
  private frameCheckQueued = false;
407
+ /** `reportsGeometry` scroll views (src/scrollGeometry.ts): their ids, the last report of each, and whether a check waits for the next frame. */
408
+ private readonly scrollReporters = new Set<ElementId>();
409
+ private readonly scrollGeometry = new ScrollGeometryReporter();
410
+ private scrollGeometryQueued = false;
411
+ /** `scrollview`s whose `scrollOffset` command is new this pass: scrolled after its layout (`performOffsetScrolls`), and the serial each last carried out. */
412
+ private pendingOffsets: ElementId[] = [];
413
+ private readonly offsetSerials = new Map<ElementId, number>();
394
414
  private readonly hostContents = new Map<ElementId, HTMLElement>();
395
415
  /** The `host` elements, adopted or still waiting for their content. */
396
416
  private readonly hosts = new Set<ElementId>();
397
417
  private readonly hostSizes = new WeakMap<HTMLElement, { width: number; height: number }>();
398
418
  private readonly hostMeasures = new Map<string, { width: number; height: number }>();
399
419
  private readonly stopHostChanges: () => void;
420
+ private readonly stopRichEditors: () => void;
421
+ private readonly stopPlainEditors: () => void;
400
422
  /**
401
423
  * The root takes its content's height (`LayoutEnvironment.contentHeight`):
402
424
  * a SwiftUI island in a page's own layout. Set before the first commit.
@@ -445,7 +467,7 @@ export class Renderer {
445
467
  /** The elements whose size is reported (`geometry`, or `reportsSize` props): what `collectGeometry` visits. */
446
468
  private readonly geometryReporters = new Set<ElementId>();
447
469
  /** `geometry` and `layout` events collected by layout passes, sent once the ops are applied. */
448
- private pendingGeometry: (GeometryEvent | LayoutEvent)[] = [];
470
+ private pendingGeometry: (GeometryEvent | LayoutEvent | ScrollGeometryEvent)[] = [];
449
471
  /** Phase 6: dedupes `layout` events per element (src/layoutEvents.ts). */
450
472
  private readonly layoutReporter = new LayoutReporter();
451
473
  /** Nesting depth of `applyOps` (events are flushed when it returns to 0). */
@@ -493,6 +515,7 @@ export class Renderer {
493
515
  const hit = event.target as HTMLElement | null;
494
516
  const target = hit?.closest<HTMLElement>(
495
517
  '.sb-button, .sb-navlink, .sb-navbar-back, .sb-navbar-more, .sb-navsplit-toggle, .sb-switch, .sb-sheet-scrim, ' +
518
+ '.sb-toggle[data-sb-toggle-style="button"], .sb-toolbar .sb-toggle:not([data-sb-toggle-style]), ' +
496
519
  '.sb-tabbar-item, .sb-segment, .sb-picker-option, .sb-picker-menu-item, .sb-picker[data-sb-style="menu"], ' +
497
520
  '.sb-menu, .sb-tapgesture, .sb-stepper-button, .sb-alert-scrim, .sb-alert-ok, ' +
498
521
  // Phase 8: swipe action buttons, the edit-mode row control and selectable rows (the row sends `tap` itself)
@@ -618,12 +641,13 @@ export class Renderer {
618
641
  this.onEvent({ type: 'tap', id });
619
642
  return;
620
643
  }
621
- if (target.classList.contains('sb-switch')) {
644
+ if (target.classList.contains('sb-switch') || target.classList.contains('sb-toggle')) {
645
+ // The switch, or a toggle drawn as a button (the whole of it).
622
646
  const toggle = target.closest<HTMLElement>('.sb-toggle');
623
647
  const id = Number(toggle?.dataset[ID_ATTR]);
624
648
  if (!toggle || !Number.isInteger(id)) return;
625
649
  event.preventDefault();
626
- const value = target.getAttribute('aria-checked') !== 'true';
650
+ const value = toggle.dataset['sbOn'] !== 'true';
627
651
  // Flip optimistically so the switch animates right away; Swift's
628
652
  // `update` confirms (or reverts) the state.
629
653
  setSwitchState(toggle, value);
@@ -633,23 +657,38 @@ export class Renderer {
633
657
  const id = Number(target.dataset[ID_ATTR]);
634
658
  if (!Number.isInteger(id)) return;
635
659
  event.preventDefault();
636
- if (target.hasAttribute('data-sb-tap-location')) {
637
- // `onTapGesture { point in }`: the point in the element's own
638
- // coordinates (points; the device frame may be scaled).
639
- const rect = target.getBoundingClientRect();
640
- const scale = target.offsetWidth > 0 ? rect.width / target.offsetWidth : 1;
641
- const x = Math.round(((event.clientX - rect.left) / (scale || 1)) * 100) / 100;
642
- const y = Math.round(((event.clientY - rect.top) / (scale || 1)) * 100) / 100;
643
- this.onEvent({ type: 'tap', id, x, y });
660
+ if (target.classList.contains('sb-tapgesture')) {
661
+ // `onTapGesture(count: 2)`: the innermost double tap on the way up
662
+ // counts the clicks; the innermost single tap waits for it.
663
+ const multi = target.closest<HTMLElement>('.sb-tapgesture[data-sb-tap-count]');
664
+ const single = target.closest<HTMLElement>('.sb-tapgesture:not([data-sb-tap-count])');
665
+ this.taps.click(multi, Number(multi?.dataset['sbTapCount'] ?? 1), single, { x: event.clientX, y: event.clientY });
644
666
  return;
645
667
  }
646
668
  this.onEvent({ type: 'tap', id });
647
669
  };
648
670
 
671
+ /** A `tapgesture` fires: with `location`, the click's point in the element's own coordinates (points; the device frame may be scaled). */
672
+ private readonly taps = new TapCounter<HTMLElement, { x: number; y: number }>((el, point) => {
673
+ const id = Number(el.dataset[ID_ATTR]);
674
+ if (!Number.isInteger(id)) return;
675
+ if (el.hasAttribute('data-sb-tap-location')) {
676
+ const rect = el.getBoundingClientRect();
677
+ const scale = el.offsetWidth > 0 ? rect.width / el.offsetWidth : 1;
678
+ const x = Math.round(((point.x - rect.left) / (scale || 1)) * 100) / 100;
679
+ const y = Math.round(((point.y - rect.top) / (scale || 1)) * 100) / 100;
680
+ this.onEvent({ type: 'tap', id, x, y });
681
+ return;
682
+ }
683
+ this.onEvent({ type: 'tap', id });
684
+ });
685
+
649
686
  /**
650
687
  * Phase 12: after a commit, the bound text fields (`data-sb-focus`) take or
651
688
  * give up focus to match what Swift asked for. The `focus` event the move
652
- * raises is echoed back so the state and the DOM agree.
689
+ * raises is echoed back so the state and the DOM agree. A field the app
690
+ * focuses is scrolled into view (`revealFocused`), as SwiftUI shows a
691
+ * field its `@FocusState` focuses.
653
692
  */
654
693
  private syncFocus(): void {
655
694
  const active = document.activeElement;
@@ -658,11 +697,30 @@ export class Renderer {
658
697
  // `preventScroll`: a field in a sheet still sliding in sits below the
659
698
  // screen, and the browser would scroll the whole screen to reveal it,
660
699
  // blanking the page until something scrolls it back.
661
- if (wanted && active !== input) input.focus({ preventScroll: true });
662
- else if (!wanted && active === input) input.blur();
700
+ if (wanted && active !== input) {
701
+ if (isRichEditor(input)) focusRichEditor(input);
702
+ else if (isPlainEditor(input)) focusPlainEditor(input);
703
+ else input.focus({ preventScroll: true });
704
+ this.revealFocused(input);
705
+ } else if (!wanted && active === input) input.blur();
663
706
  }
664
707
  }
665
708
 
709
+ /**
710
+ * Scrolls the scroll views and lists around a text input as little as
711
+ * shows it, from the layout's frames (src/scrollTo.ts), never the page:
712
+ * the frames are where the views rest, so a sheet still sliding in is no
713
+ * reason to scroll, and in device mode they already stop above the
714
+ * keyboard, which the focus raised before this ran (`watchKeyboard`
715
+ * reports its known height on `focusin`, and `setKeyboard` lays out).
716
+ */
717
+ private revealFocused(input: HTMLElement): void {
718
+ const result = this.layoutResult;
719
+ const id = Number(input.dataset[ID_ATTR]);
720
+ if (!result || !Number.isInteger(id)) return;
721
+ scrollToTarget(this, result, id, undefined, false);
722
+ }
723
+
666
724
  /**
667
725
  * `.scrollDismissesKeyboard`: scrolling a list or scroll view marked
668
726
  * `data-sb-dismisses-keyboard` takes the focus from the text input being
@@ -672,16 +730,34 @@ export class Renderer {
672
730
  const scroller = event.target;
673
731
  if (!(scroller instanceof HTMLElement) || scroller.dataset['sbDismissesKeyboard'] !== 'true') return;
674
732
  const active = document.activeElement;
675
- if ((active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) && scroller.contains(active)) active.blur();
733
+ if ((active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement || isRichEditor(active)) && scroller.contains(active)) (active as HTMLElement).blur();
734
+ };
735
+
736
+ /**
737
+ * A press anywhere but another text input leaves the text being edited
738
+ * focused, as on iOS, where only a text input takes the first responder
739
+ * (a Bold button acts on the selection still there; a stepper, a row or
740
+ * the space beside the editor leaves the keyboard up): the mouse down
741
+ * that would move the focus does nothing else.
742
+ */
743
+ private readonly onControlMouseDown = (event: MouseEvent): void => {
744
+ const active = document.activeElement;
745
+ const editing = active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement || isRichEditor(active);
746
+ if (!editing || !this.root.contains(active)) return;
747
+ const target = event.target as HTMLElement | null;
748
+ if (target && !target.closest('input, textarea, [contenteditable]')) event.preventDefault();
676
749
  };
677
750
 
678
751
  /** Phase 12: a bound field (`data-sb-focus`) reports focus changes. */
679
752
  private readonly onFocusChange = (event: FocusEvent): void => {
753
+ // A screen's `keyboard` toolbars show while one of its text inputs is
754
+ // focused: the focus moving is a layout change there.
755
+ if (this.commits > 0 && this.hasKeyboardToolbars()) this.layout();
680
756
  // A bound `texteditor` (UIKit's text views) reports focus like a field.
681
757
  const input = event.target as HTMLElement | null;
682
758
  const isField =
683
759
  (input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) && input.classList.contains('sb-textfield');
684
- const isEditor = input instanceof HTMLTextAreaElement && input.classList.contains('sb-texteditor');
760
+ const isEditor = (input instanceof HTMLTextAreaElement || isRichEditor(input)) && input!.classList.contains('sb-texteditor');
685
761
  if (!input || (!isField && !isEditor)) return;
686
762
  if (input.dataset['sbFocus'] === undefined || !this.root.contains(input)) return;
687
763
  const id = Number(input.dataset[ID_ATTR]);
@@ -718,7 +794,8 @@ export class Renderer {
718
794
  if (
719
795
  !target.classList.contains('sb-navlink') &&
720
796
  !target.classList.contains('sb-menu') &&
721
- !(target.classList.contains('sb-picker') && target.dataset['sbStyle'] === 'menu')
797
+ !(target.classList.contains('sb-picker') && target.dataset['sbStyle'] === 'menu') &&
798
+ !(target.classList.contains('sb-toggle') && target.dataset['sbToggleStyle'] === 'button')
722
799
  ) {
723
800
  return;
724
801
  }
@@ -743,6 +820,23 @@ export class Renderer {
743
820
  }
744
821
  };
745
822
 
823
+ /**
824
+ * Backspace in a `textfield` or plain `texteditor` with `deleteBackward`,
825
+ * nothing selected and the caret at the start: the key deletes nothing
826
+ * and the app hears `deletebackward` (the rich editor's own `beforeinput`
827
+ * does the same, src/richEditor.ts).
828
+ */
829
+ private readonly onBeforeInput = (event: InputEvent): void => {
830
+ if (event.inputType !== 'deleteContentBackward') return;
831
+ const input = event.target;
832
+ if (!(input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) || !this.root.contains(input) || !wantsDeleteBackward(input)) return;
833
+ if (input.selectionStart !== 0 || input.selectionEnd !== 0) return;
834
+ const id = Number(input.dataset[ID_ATTR]);
835
+ if (!Number.isInteger(id)) return;
836
+ event.preventDefault();
837
+ this.onEvent({ type: 'deletebackward', id });
838
+ };
839
+
746
840
  private readonly onInput = (event: Event): void => {
747
841
  const input = event.target as HTMLInputElement | null;
748
842
  if (!input || !this.root.contains(input)) return;
@@ -754,7 +848,8 @@ export class Renderer {
754
848
  const owner = isField ? input : input.closest<HTMLElement>('.sb-searchfield');
755
849
  const id = Number(owner?.dataset[ID_ATTR]);
756
850
  if (!Number.isInteger(id)) return;
757
- this.onEvent({ type: 'text', id, value: input.value });
851
+ const value = isRichEditor(input) ? richEditorText(input) : input.value;
852
+ this.onEvent({ type: 'text', id, value });
758
853
  };
759
854
 
760
855
  /**
@@ -879,7 +974,9 @@ export class Renderer {
879
974
  // created detached and moved around freely.
880
975
  root.addEventListener('click', this.onClick);
881
976
  root.addEventListener('pointerdown', this.onRowPress);
977
+ root.addEventListener('mousedown', this.onControlMouseDown);
882
978
  root.addEventListener('keydown', this.onKeyDown);
979
+ root.addEventListener('beforeinput', this.onBeforeInput);
883
980
  root.addEventListener('input', this.onInput);
884
981
  root.addEventListener('focusin', this.onFocusChange);
885
982
  root.addEventListener('focusout', this.onFocusChange);
@@ -949,8 +1046,48 @@ export class Renderer {
949
1046
  });
950
1047
  }
951
1048
  this.stopHostChanges = onHostContentChange(() => this.hostContentChanged());
1049
+ const editorId = (el: HTMLElement): number | null => {
1050
+ const id = Number(el.dataset[ID_ATTR]);
1051
+ return Number.isInteger(id) ? id : null;
1052
+ };
1053
+ this.stopRichEditors = listenToRichEditors(root, editorId, (event) => this.onEvent(event));
1054
+ // After `onInput` above, so a keystroke's `text` goes before its `selection`.
1055
+ this.stopPlainEditors = listenToPlainEditors(root, editorId, (event) => this.onEvent(event));
952
1056
  this.hover = new HoverTracker(root, (id, value) => this.onEvent({ type: 'hover', id, value }));
953
1057
  root.addEventListener('scroll', this.queueFrameCheck, { capture: true, passive: true });
1058
+ // Scroll transitions and pinned views follow the scroll in the frame it is drawn in.
1059
+ root.addEventListener('scroll', drawScrollTransitions, { capture: true, passive: true });
1060
+ root.addEventListener('scroll', drawPinnedViews, { capture: true, passive: true });
1061
+ root.addEventListener('scroll', this.queueScrollGeometryCheck, { capture: true, passive: true });
1062
+ }
1063
+
1064
+ /**
1065
+ * `reportsGeometry` scroll views report where they are once per animation
1066
+ * frame while anything scrolls (the offsets change without a layout pass).
1067
+ */
1068
+ private readonly queueScrollGeometryCheck = (): void => {
1069
+ if (this.scrollGeometryQueued || this.commits === 0 || this.scrollReporters.size === 0) return;
1070
+ this.scrollGeometryQueued = true;
1071
+ requestAnimationFrame(() => {
1072
+ this.scrollGeometryQueued = false;
1073
+ if (!this.layoutResult) return;
1074
+ this.collectScrollGeometry(this.layoutResult);
1075
+ if (this.applying === 0) this.flushGeometry();
1076
+ });
1077
+ };
1078
+
1079
+ /** Queue a `scrollgeometry` event for every reporting scroll view whose geometry changed. */
1080
+ private collectScrollGeometry(result: LayoutResult): void {
1081
+ for (const id of this.scrollReporters) {
1082
+ const el = this.elements.get(id);
1083
+ if (!el || !el.isConnected) continue;
1084
+ const frame = result.frames.get(id);
1085
+ if (!frame) continue; // inside a hidden tab
1086
+ const covered = result.chrome.get(id)?.['bottomInset'];
1087
+ const bottomInset = covered ? Math.max(0, frame.y + frame.height - covered.y) : 0;
1088
+ const event = this.scrollGeometry.report(id, readScrollGeometry(el, frame, result.contentSizes.get(id), bottomInset));
1089
+ if (event) this.pendingGeometry.push(event);
1090
+ }
954
1091
  }
955
1092
 
956
1093
  /**
@@ -1080,11 +1217,16 @@ export class Renderer {
1080
1217
  // from the page's last layout, without them.
1081
1218
  this.root.dispatchEvent(new CustomEvent('sb:precommit', { detail: { commits: this.commits + 1 } }));
1082
1219
  const changes = this.layout();
1083
- this.animator.commitNextFrame(pass, op.animation, changes);
1220
+ const completion = op.completion;
1221
+ this.animator.commitNextFrame(pass, op.animation, changes, typeof completion === 'number' ? (started) => this.completeAnimation(completion, started) : undefined);
1084
1222
  this.beginBarMorphsNextFrame();
1085
1223
  this.commits += 1;
1224
+ const focusedBefore = this.focusedFieldId();
1086
1225
  this.syncFocus();
1226
+ // The focus the app moved shows or hides a screen's keyboard bar.
1227
+ if (this.focusedFieldId() !== focusedBefore && this.hasKeyboardToolbars()) this.layout();
1087
1228
  this.performScrolls(op.animation != null);
1229
+ this.performOffsetScrolls(op.animation != null);
1088
1230
  this.checkLazySpacers();
1089
1231
  this.openPendingMenu();
1090
1232
  this.root.dispatchEvent(
@@ -1100,6 +1242,26 @@ export class Renderer {
1100
1242
  }
1101
1243
  }
1102
1244
 
1245
+ /**
1246
+ * `withAnimation(_:completionCriteria:_:completion:)`: the pass's `commit`
1247
+ * carried a completion serial; once the animations its commit started have
1248
+ * ended (at once when it started none) the app hears `animationcomplete`.
1249
+ * An animation that never ends (`repeatForever`) counts as ended after one
1250
+ * run of its duration, as `.logicallyComplete` does.
1251
+ */
1252
+ private completeAnimation(serial: number, started: readonly globalThis.Animation[]): void {
1253
+ const ended = started.map((animation) => {
1254
+ const timing = animation.effect?.getComputedTiming();
1255
+ const end = timing?.endTime;
1256
+ if (typeof end === 'number' && Number.isFinite(end)) return animation.finished.then(() => undefined, () => undefined);
1257
+ const run = (Number(timing?.delay) || 0) + (Number(timing?.duration) || 0);
1258
+ return new Promise<void>((resolve) => window.setTimeout(resolve, run));
1259
+ });
1260
+ const send = (): void => this.onEvent({ type: 'animationcomplete', serial });
1261
+ if (ended.length === 0) send();
1262
+ else void Promise.all(ended).then(send, send);
1263
+ }
1264
+
1103
1265
  /**
1104
1266
  * Whether the pass starting at `ops[start]` can animate: its `commit` has
1105
1267
  * an animation, or one of its updates changes an `animationToken`
@@ -1276,8 +1438,28 @@ export class Renderer {
1276
1438
  setKeyboard(height: number): void {
1277
1439
  const next = Number.isFinite(height) && height > 0 ? Math.round(height) : 0;
1278
1440
  if (next === this.keyboard) return;
1441
+ const rose = next > this.keyboard;
1279
1442
  this.keyboard = next;
1280
- if (this.commits > 0) this.layout();
1443
+ if (this.commits === 0) return;
1444
+ this.layout();
1445
+ // The keyboard covers more: the text input being edited comes out from under it, as iOS keeps it above the keyboard.
1446
+ const active = document.activeElement;
1447
+ if (rose && active instanceof HTMLElement && this.root.contains(active) && (active.classList.contains('sb-textfield') || active.classList.contains('sb-texteditor'))) {
1448
+ this.revealFocused(active);
1449
+ }
1450
+ }
1451
+
1452
+ /**
1453
+ * The size `text` in `fontProps` (a Font JSON object over the body font)
1454
+ * takes wrapped to `width` (one line when null), with the typography the
1455
+ * layout engine lays `text` elements out with: for an app that paginates
1456
+ * or sizes from its text (@swiftbrowser/react's `measureText`).
1457
+ */
1458
+ measureText(text: string, fontProps: unknown, width: number | null): { width: number; height: number; lines: number } {
1459
+ const env = this.layoutEnvironment();
1460
+ const font = resolveFont(fontProps, defaultFont(env), env);
1461
+ const wrapped = wrapText(text, font, width, null, this.measurer);
1462
+ return { width: wrapped.width, height: textHeight(wrapped.lines.length, font), lines: wrapped.lines.length };
1281
1463
  }
1282
1464
 
1283
1465
  /**
@@ -1293,6 +1475,7 @@ export class Renderer {
1293
1475
  layoutEnvironment(): LayoutEnvironment {
1294
1476
  const screen = this.resizeObserver ? (this.rootSize ??= this.readRootSize()) : this.readRootSize();
1295
1477
  if (!this.safeArea) this.safeArea = readSafeArea(this.root);
1478
+ const focused = this.focusedFieldId();
1296
1479
  return {
1297
1480
  screen: { ...screen },
1298
1481
  safeArea: this.safeArea,
@@ -1302,9 +1485,25 @@ export class Renderer {
1302
1485
  assets: this.assets,
1303
1486
  ...(this.contentHeight ? { contentHeight: true } : {}),
1304
1487
  ...(this.keyboard > 0 ? { keyboard: this.keyboard } : {}),
1488
+ ...(focused !== null ? { focusedField: focused } : {}),
1305
1489
  };
1306
1490
  }
1307
1491
 
1492
+ /** The element id of the text input being edited in the root (a field, an editor or a search field's input), or null. */
1493
+ private focusedFieldId(): number | null {
1494
+ const active = typeof document === 'undefined' ? null : document.activeElement;
1495
+ if (!(active instanceof HTMLElement) || !this.root.contains(active)) return null;
1496
+ const owner = active.classList.contains('sb-searchfield-input') ? active.closest<HTMLElement>('.sb-searchfield') : active;
1497
+ if (!owner || !(owner.classList.contains('sb-textfield') || owner.classList.contains('sb-texteditor') || owner.classList.contains('sb-searchfield'))) return null;
1498
+ const id = Number(owner.dataset[ID_ATTR]);
1499
+ return Number.isInteger(id) ? id : null;
1500
+ }
1501
+
1502
+ /** Whether a screen has `keyboard` toolbars: their bar follows the focus, so a focus change lays out again. */
1503
+ private hasKeyboardToolbars(): boolean {
1504
+ return this.root.querySelector('.sb-toolbar[data-sb-placement="keyboard"]') !== null;
1505
+ }
1506
+
1308
1507
  private readRootSize(): { width: number; height: number } {
1309
1508
  return {
1310
1509
  width: this.root.clientWidth || DEFAULT_SCREEN.width,
@@ -1481,7 +1680,10 @@ export class Renderer {
1481
1680
  }
1482
1681
  this.root.style.height = `${bottom + env.safeArea.bottom}px`;
1483
1682
  }
1683
+ drawScrollTransitions();
1684
+ drawPinnedViews();
1484
1685
  this.collectGeometry(result);
1686
+ this.collectScrollGeometry(result);
1485
1687
  this.collectLayouts(result);
1486
1688
  if (this.applying === 0) this.flushGeometry();
1487
1689
  this.queueVisibilityCheck();
@@ -1731,12 +1933,18 @@ export class Renderer {
1731
1933
  this.closeMenu();
1732
1934
  this.root.removeEventListener('click', this.onClick);
1733
1935
  this.root.removeEventListener('pointerdown', this.onRowPress);
1936
+ this.root.removeEventListener('mousedown', this.onControlMouseDown);
1734
1937
  this.root.removeEventListener('keydown', this.onKeyDown);
1938
+ this.root.removeEventListener('beforeinput', this.onBeforeInput);
1735
1939
  this.root.removeEventListener('input', this.onInput);
1736
1940
  this.root.removeEventListener('focusin', this.onFocusChange);
1737
1941
  this.root.removeEventListener('focusout', this.onFocusChange);
1738
1942
  this.root.removeEventListener('scroll', this.onScrollDismissKeyboard, true);
1739
1943
  this.root.removeEventListener('scroll', this.queueVisibilityCheck, true);
1944
+ this.root.removeEventListener('scroll', this.queueFrameCheck, true);
1945
+ this.root.removeEventListener('scroll', drawScrollTransitions, true);
1946
+ this.root.removeEventListener('scroll', drawPinnedViews, true);
1947
+ this.root.removeEventListener('scroll', this.queueScrollGeometryCheck, true);
1740
1948
  this.root.removeEventListener('scroll', this.onPositionScroll, true);
1741
1949
  this.root.removeEventListener('scrollend', this.onPositionScrollEnd, true);
1742
1950
  this.root.removeEventListener('touchstart', this.onPositionTouchStart, true);
@@ -1753,8 +1961,11 @@ export class Renderer {
1753
1961
  this.resizeObserver?.disconnect();
1754
1962
  this.hostObserver?.disconnect();
1755
1963
  this.stopHostChanges();
1964
+ this.stopRichEditors();
1965
+ this.stopPlainEditors();
1756
1966
  this.hover.dispose();
1757
1967
  this.root.removeEventListener('scroll', this.queueFrameCheck, true);
1968
+ this.root.removeEventListener('scroll', drawScrollTransitions, true);
1758
1969
  this.root.removeEventListener('change', this.onChange);
1759
1970
  this.root.removeEventListener('pointerdown', this.onSliderPointerDown);
1760
1971
  this.reset();
@@ -1768,8 +1979,14 @@ export class Renderer {
1768
1979
  if (id === ROOT_ID) throw new RendererError('Element 0 is the root and cannot be created', op);
1769
1980
  if (this.elements.has(id)) throw new RendererError(`Element ${id} already exists`, op);
1770
1981
  if (!ALL_KINDS.has(kind)) throw new RendererError(`Unknown element kind ${String(kind)}`, op);
1771
- // A vertical `textfield` is a `<textarea>` (its axis is fixed at creation).
1772
- const el = kind === 'textfield' && (props as TextFieldProps).axis === 'vertical' ? createMultilineField() : createShell(kind);
1982
+ // A vertical `textfield` is a `<textarea>` (its axis is fixed at creation),
1983
+ // a `texteditor` with runs the rich editor (src/richEditor.ts).
1984
+ const el =
1985
+ kind === 'textfield' && (props as TextFieldProps).axis === 'vertical'
1986
+ ? createMultilineField()
1987
+ : kind === 'texteditor' && Array.isArray((props as TextEditorProps).runs)
1988
+ ? createRichEditor()
1989
+ : createShell(kind);
1773
1990
  el.className = `sb-${kind}`;
1774
1991
  el.dataset[ID_ATTR] = String(id);
1775
1992
  el.dataset[KIND_ATTR] = kind;
@@ -2011,8 +2228,20 @@ export class Renderer {
2011
2228
  this.onEvent({ type: 'scrollposition', id: target });
2012
2229
  }
2013
2230
 
2014
- /** A `styled` whose style carries a `scrollTo` not seen before is scrolled to after this pass's layout. */
2231
+ /**
2232
+ * A `styled` whose style carries a `scrollTo` not seen before is scrolled
2233
+ * to after this pass's layout; so is a `scrollview` whose `scrollOffset`
2234
+ * command is new. A `scrollview` with `reportsGeometry` is noted to report.
2235
+ */
2015
2236
  private noteScroll(id: ElementId, kind: ElementKind, props: ElementProps): void {
2237
+ if (kind === 'scrollview') {
2238
+ const scroll = props as ScrollViewProps;
2239
+ if (scroll.reportsGeometry === true) this.scrollReporters.add(id);
2240
+ else this.scrollReporters.delete(id);
2241
+ const serial = scroll.scrollOffset?.serial;
2242
+ if (typeof serial === 'number' && serial > (this.offsetSerials.get(id) ?? 0)) this.pendingOffsets.push(id);
2243
+ return;
2244
+ }
2016
2245
  if (kind !== 'styled') return;
2017
2246
  const serial = (props as StyledProps)?.style?.scrollTo?.serial;
2018
2247
  if (typeof serial !== 'number' || serial <= this.scrollSerial) return;
@@ -2070,6 +2299,33 @@ export class Renderer {
2070
2299
  for (const { command } of commands) this.scrollSerial = Math.max(this.scrollSerial, command.serial);
2071
2300
  }
2072
2301
 
2302
+ /**
2303
+ * `scrollOffset` (a `.scrollPosition` bound to a point, `scrollTo(x:y:)`):
2304
+ * each scroll view with a command not carried out yet scrolls to it,
2305
+ * smoothly when the pass's commit is animated; an axis the command leaves
2306
+ * out keeps its offset. The view reports its position afresh when it next
2307
+ * settles, as after a `scrollTo` to a target.
2308
+ */
2309
+ private performOffsetScrolls(animated: boolean): void {
2310
+ if (this.pendingOffsets.length === 0) return;
2311
+ const ids = this.pendingOffsets;
2312
+ this.pendingOffsets = [];
2313
+ for (const id of ids) {
2314
+ const el = this.elements.get(id);
2315
+ const command = (this.nodes.get(id)?.props as Partial<ScrollViewProps> | undefined)?.scrollOffset;
2316
+ if (!el || !command || !(command.serial > (this.offsetSerials.get(id) ?? 0))) continue;
2317
+ this.offsetSerials.set(id, command.serial);
2318
+ const left = typeof command.x === 'number' && Number.isFinite(command.x) ? Math.max(0, command.x) : el.scrollLeft;
2319
+ const top = typeof command.y === 'number' && Number.isFinite(command.y) ? Math.max(0, command.y) : el.scrollTop;
2320
+ if (animated && typeof el.scrollTo === 'function') el.scrollTo({ left, top, behavior: 'smooth' });
2321
+ else {
2322
+ el.scrollLeft = left;
2323
+ el.scrollTop = top;
2324
+ }
2325
+ this.reportedPositions.delete(el);
2326
+ }
2327
+ }
2328
+
2073
2329
  private update(id: ElementId, props: ElementProps, op: Op): void {
2074
2330
  if (id === ROOT_ID) {
2075
2331
  // Phase 12: the root carries no element props; an update of it is the
@@ -2397,6 +2653,9 @@ export class Renderer {
2397
2653
  this.boxes.delete(id);
2398
2654
  this.dirty.delete(id);
2399
2655
  this.reportedGeometry.delete(id);
2656
+ this.scrollReporters.delete(id);
2657
+ this.scrollGeometry.forget(id);
2658
+ this.offsetSerials.delete(id);
2400
2659
  this.geometryReporters.delete(id);
2401
2660
  this.layoutReporter.forget(id);
2402
2661
  const node = this.nodes.get(id);
@@ -2689,7 +2948,10 @@ function createShell(kind: ElementKind): HTMLElement {
2689
2948
  bar.append(back, inlineTitle, div('sb-navbar-trailing'), more);
2690
2949
  const body = div('sb-navscreen-body');
2691
2950
  const slot = div('sb-slot sb-navscreen-content');
2692
- body.append(div('sb-navbar-large-title'), slot);
2951
+ // The `keyboard` toolbars' bar (chrome `keyboardBar`), under their items.
2952
+ const keyboardBar = div('sb-keyboard-bar');
2953
+ keyboardBar.hidden = true;
2954
+ body.append(div('sb-navbar-large-title'), slot, keyboardBar);
2693
2955
  // Phase 8: a 24px strip along the leading edge of a pushed screen
2694
2956
  // that, on touch devices, keeps the browser from scrolling so a swipe
2695
2957
  // back can start there (styles.css; the controller forwards plain taps).
@@ -3015,15 +3277,14 @@ function listRowOf(el: HTMLElement, root: HTMLElement): HTMLElement | null {
3015
3277
 
3016
3278
  /** Controls a row may hold besides its button for the row to stay the button's: none. */
3017
3279
  const ROW_CONTROLS =
3018
- 'input, textarea, .sb-navlink, .sb-switch, .sb-picker, .sb-stepper, .sb-slider, .sb-menu, .sb-tapgesture, .sb-link, .sb-datepicker';
3280
+ 'input, textarea, [contenteditable], .sb-navlink, .sb-switch, .sb-picker, .sb-stepper, .sb-slider, .sb-menu, .sb-tapgesture, .sb-link, .sb-datepicker';
3019
3281
 
3020
3282
  /**
3021
3283
  * A list row holding one button (with other views beside it, or under a
3022
3284
  * modifier) taps the button wherever it is tapped, as a UITableView cell
3023
3285
  * does on iOS; a row that is the button itself is that already. Not a
3024
- * `.plain` or `.borderless` button (both arrive as `plain`), which SwiftUI
3025
- * keeps to its own label in a row, a disabled one, or a row with other
3026
- * controls.
3286
+ * `.plain` or `.borderless` button, which SwiftUI keeps to its own label in
3287
+ * a row, a disabled one, or a row with other controls.
3027
3288
  */
3028
3289
  function rowAction(el: HTMLElement, root: HTMLElement): HTMLElement | null {
3029
3290
  const row = listRowOf(el, root);
@@ -3031,7 +3292,7 @@ function rowAction(el: HTMLElement, root: HTMLElement): HTMLElement | null {
3031
3292
  const buttons = row.querySelectorAll<HTMLElement>('.sb-button');
3032
3293
  if (buttons.length !== 1) return null;
3033
3294
  const button = buttons[0]!;
3034
- if (button.dataset['sbStyle'] === 'plain' || button.closest('[data-sb-disabled]')) return null;
3295
+ if (button.dataset['sbStyle'] === 'plain' || button.dataset['sbStyle'] === 'borderless' || button.closest('[data-sb-disabled]')) return null;
3035
3296
  if (row.querySelector(ROW_CONTROLS)) return null;
3036
3297
  return button;
3037
3298
  }
@@ -3449,6 +3710,9 @@ function applyTextEditor(area: HTMLTextAreaElement, props: TextEditorProps): voi
3449
3710
  // Bound focus, as for a `textfield` (moved in `syncFocus` after the commit).
3450
3711
  if (typeof props.focused === 'boolean') area.dataset['sbFocus'] = String(props.focused);
3451
3712
  else delete area.dataset['sbFocus'];
3713
+ markDeleteBackward(area, props.deleteBackward === true);
3714
+ // A bound selection, under the rich editor's rules (src/plainEditor.ts).
3715
+ applyPlainEditorSelection(area, props);
3452
3716
  }
3453
3717
 
3454
3718
  // ---------------------------------------------------------------------------
@@ -3482,10 +3746,12 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
3482
3746
  case 'vstack':
3483
3747
  applyVStack(el, props as VStackProps);
3484
3748
  applyScrollTarget(el, (props as VStackProps).scrollTarget === true);
3749
+ setPinnedViews(el, (props as VStackProps).pinned);
3485
3750
  break;
3486
3751
  case 'hstack':
3487
3752
  applyHStack(el, props as HStackProps);
3488
3753
  applyScrollTarget(el, (props as HStackProps).scrollTarget === true);
3754
+ setPinnedViews(el, (props as HStackProps).pinned);
3489
3755
  break;
3490
3756
  case 'spacer':
3491
3757
  applySpacer(el, props as SpacerProps);
@@ -3502,10 +3768,15 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
3502
3768
  case 'image':
3503
3769
  applyImage(el, props as ImageProps, assets);
3504
3770
  break;
3505
- case 'tapgesture':
3506
- // `location`: the tap is sent with its point (`onTapGesture { point in }`).
3507
- el.toggleAttribute('data-sb-tap-location', (props as TapGestureProps | undefined)?.location === true);
3771
+ case 'tapgesture': {
3772
+ // `location`: the tap is sent with its point (`onTapGesture { point in }`); `count`: the taps it takes.
3773
+ const tap = props as TapGestureProps | undefined;
3774
+ el.toggleAttribute('data-sb-tap-location', tap?.location === true);
3775
+ const count = Math.floor(tap?.count ?? 1);
3776
+ if (count > 1) el.dataset['sbTapCount'] = String(count);
3777
+ else delete el.dataset['sbTapCount'];
3508
3778
  break;
3779
+ }
3509
3780
  case 'divider':
3510
3781
  case 'navstack':
3511
3782
  case 'geometry':
@@ -3535,7 +3806,7 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
3535
3806
  const placement = (props as ToolbarProps).placement;
3536
3807
  // `secondary` items shown in the bar look like the trailing ones (glass capsules).
3537
3808
  el.dataset['sbPlacement'] =
3538
- placement === 'leading' || placement === 'principal' || placement === 'bottom' || placement === 'largeTitle' ? placement : 'trailing';
3809
+ placement === 'leading' || placement === 'principal' || placement === 'bottom' || placement === 'largeTitle' || placement === 'keyboard' ? placement : 'trailing';
3539
3810
  // A `ToolbarSpacer`: draws nothing.
3540
3811
  el.toggleAttribute('data-sb-spacer', typeof (props as ToolbarProps).spacer === 'string');
3541
3812
  break;
@@ -3595,9 +3866,22 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
3595
3866
  case 'textfield':
3596
3867
  applyTextField(el as HTMLInputElement | HTMLTextAreaElement, props as TextFieldProps);
3597
3868
  break;
3598
- case 'toggle':
3599
- setSwitchState(el, Boolean((props as ToggleProps).isOn));
3869
+ case 'toggle': {
3870
+ const toggle = props as ToggleProps;
3871
+ // `switch` is marked too, so a bar draws it as a switch, not its own toggle.
3872
+ if (toggle.style === 'button' || toggle.style === 'switch') el.dataset['sbToggleStyle'] = toggle.style;
3873
+ else delete el.dataset['sbToggleStyle'];
3874
+ if (toggle.style === 'button') {
3875
+ el.setAttribute('role', 'button');
3876
+ el.tabIndex = 0;
3877
+ } else if (el.getAttribute('role') === 'button') {
3878
+ el.removeAttribute('role');
3879
+ el.removeAttribute('aria-pressed');
3880
+ el.removeAttribute('tabindex');
3881
+ }
3882
+ setSwitchState(el, Boolean(toggle.isOn));
3600
3883
  break;
3884
+ }
3601
3885
  case 'navscreen':
3602
3886
  applyNavScreen(el, props as NavScreenProps);
3603
3887
  break;
@@ -3627,12 +3911,14 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
3627
3911
  const grid = props as LazyVGridProps;
3628
3912
  el.dataset['sbAlignment'] = grid.alignment ?? 'center';
3629
3913
  el.dataset['sbColumns'] = String(Array.isArray(grid.columns) ? grid.columns.length : 0);
3914
+ setPinnedViews(el, grid.pinned);
3630
3915
  break;
3631
3916
  }
3632
3917
  case 'lazyhgrid': {
3633
3918
  const grid = props as LazyHGridProps;
3634
3919
  el.dataset['sbAlignment'] = grid.alignment ?? 'center';
3635
3920
  el.dataset['sbRows'] = String(Array.isArray(grid.rows) ? grid.rows.length : 0);
3921
+ setPinnedViews(el, grid.pinned);
3636
3922
  break;
3637
3923
  }
3638
3924
  case 'gauge':
@@ -3709,7 +3995,8 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
3709
3995
  applyDatePicker(el, (props as DatePickerProps) ?? {});
3710
3996
  break;
3711
3997
  case 'texteditor':
3712
- applyTextEditor(el as HTMLTextAreaElement, (props as TextEditorProps) ?? {});
3998
+ if (isRichEditor(el)) applyRichEditor(el, (props as TextEditorProps) ?? {});
3999
+ else applyTextEditor(el as HTMLTextAreaElement, (props as TextEditorProps) ?? {});
3713
4000
  break;
3714
4001
  case 'sectionheader':
3715
4002
  case 'sectionfooter':
@@ -3759,6 +4046,7 @@ function runTemplate(run: TextRun, assets: AssetCatalog): HTMLElement {
3759
4046
  }
3760
4047
  const s = el.style;
3761
4048
  if (run.foreground !== undefined) s.color = styleToCSS(run.foreground as ShapeStyle, 'text').solid;
4049
+ if (run.background !== undefined) s.backgroundColor = styleToCSS(run.background as ShapeStyle, 'paint').solid ?? '';
3762
4050
  const lines: string[] = [];
3763
4051
  let lineColor: unknown;
3764
4052
  if (run.underline) {
@@ -3999,28 +4287,36 @@ function applyImage(el: HTMLElement, props: ImageProps, assets: AssetCatalog): v
3999
4287
  if (isUrlImage(props)) {
4000
4288
  // Phase 13: the page decoded this URL for `AsyncImage` already, so the
4001
4289
  // <img> shows at once; the layout engine sized the box from the props.
4290
+ // A `file:` URL of the app's files loads at the object URL the files
4291
+ // host makes of it (hostRequests.ts `loadableURL`), once it has.
4002
4292
  delete el.dataset['sbSymbol'];
4003
4293
  delete el.dataset['sbAsset'];
4294
+ const changed = el.dataset['sbUrl'] !== props.url;
4004
4295
  el.dataset['sbUrl'] = props.url;
4005
4296
  if (props.resizable) el.dataset['sbResizable'] = '';
4006
4297
  else delete el.dataset['sbResizable'];
4007
4298
  // React's opt-in to the browser's own image interactions (styles.css).
4008
4299
  const web = props.webInteractions === true;
4009
4300
  el.toggleAttribute('data-sb-web', web);
4301
+ const url = props.url;
4010
4302
  const existing = el.firstElementChild;
4011
4303
  if (existing instanceof HTMLImageElement) {
4012
4304
  existing.draggable = web;
4013
- if (existing.getAttribute('src') !== props.url && existing.dataset['sbPending'] !== props.url) {
4014
- swapImageSource(existing, props.url);
4305
+ if (changed) {
4306
+ const loadable = loadableURL(url);
4307
+ if (typeof loadable === 'string') swapImageSource(existing, loadable);
4308
+ else void loadable.then((src) => el.dataset['sbUrl'] === url && swapImageSource(existing, src));
4015
4309
  }
4016
4310
  return;
4017
4311
  }
4018
4312
  const img = document.createElement('img');
4019
- img.src = props.url;
4313
+ const loadable = loadableURL(url);
4314
+ if (typeof loadable === 'string') img.src = loadable;
4315
+ else void loadable.then((src) => el.dataset['sbUrl'] === url && img.setAttribute('src', src));
4020
4316
  img.alt = '';
4021
4317
  img.draggable = web;
4022
4318
  img.decoding = 'async';
4023
- found(props.url, img);
4319
+ found(url, img);
4024
4320
  return;
4025
4321
  }
4026
4322
  delete el.dataset['sbUrl'];
@@ -4131,6 +4427,7 @@ function applyTextField(input: HTMLInputElement | HTMLTextAreaElement, props: Te
4131
4427
  }
4132
4428
  if (props.submit === true) input.dataset['sbSubmit'] = 'true';
4133
4429
  else delete input.dataset['sbSubmit'];
4430
+ markDeleteBackward(input, props.deleteBackward === true);
4134
4431
  if (typeof props.submitLabel === 'string' && props.submitLabel.length > 0) input.enterKeyHint = props.submitLabel;
4135
4432
  else input.removeAttribute('enterkeyhint');
4136
4433
  applyTextInputHints(input, props);
@@ -4202,6 +4499,7 @@ function applySearchField(el: HTMLElement, props: SearchFieldProps): void {
4202
4499
 
4203
4500
  function setSwitchState(toggle: HTMLElement, isOn: boolean): void {
4204
4501
  toggle.dataset['sbOn'] = String(isOn);
4502
+ if (toggle.dataset['sbToggleStyle'] === 'button') toggle.setAttribute('aria-pressed', String(isOn));
4205
4503
  const sw = toggle.querySelector<HTMLElement>(':scope > .sb-switch');
4206
4504
  sw?.setAttribute('aria-checked', String(isOn));
4207
4505
  }
@@ -4498,6 +4796,9 @@ export function applyStyle(el: HTMLElement, style: Style): void {
4498
4796
  s.pointerEvents = 'none';
4499
4797
  el.dataset['sbHitTesting'] = 'false';
4500
4798
  }
4799
+ // `.contentShape(Rectangle())`: the box takes input across its area, which a box already does; marked for the stylesheet and tests.
4800
+ if (style.contentShape) el.dataset['sbContentShape'] = style.contentShape;
4801
+ else delete el.dataset['sbContentShape'];
4501
4802
  // Hover, tooltips, cursors, symbol effects and matched geometry ids (pointerEffects.ts).
4502
4803
  applyPointerAndEffects(el, style);
4503
4804
 
@@ -4522,9 +4823,17 @@ export function applyStyle(el: HTMLElement, style: Style): void {
4522
4823
  if (style.blendMode) s.mixBlendMode = style.blendMode;
4523
4824
  else if (s.mixBlendMode) s.mixBlendMode = '';
4524
4825
  // `.zIndex`: in front of siblings with a lower index (boxes are positioned).
4525
- // CSS takes integers; ×1000 keeps fractional indices (0.5) apart.
4526
- if (style.zIndex != null && Number.isFinite(style.zIndex)) s.zIndex = String(Math.round(style.zIndex * 1000));
4527
- else if (s.zIndex) s.zIndex = '';
4826
+ // CSS takes integers; ×1000 keeps fractional indices (0.5) apart. The
4827
+ // stack it is in stacks its children on its own (styles.css,
4828
+ // `[data-sb-z]`), so a negative index orders the box among its siblings
4829
+ // only, as SwiftUI's does, and never sends it behind the views around.
4830
+ if (style.zIndex != null && Number.isFinite(style.zIndex)) {
4831
+ s.zIndex = String(Math.round(style.zIndex * 1000));
4832
+ el.dataset['sbZ'] = '';
4833
+ } else if (s.zIndex) {
4834
+ s.zIndex = '';
4835
+ delete el.dataset['sbZ'];
4836
+ }
4528
4837
 
4529
4838
  // Phase 3 text and layout hints (the engine reads them from the node tree).
4530
4839
  if (style.layoutPriority != null && Number.isFinite(style.layoutPriority)) {
@@ -4570,6 +4879,8 @@ export function applyStyle(el: HTMLElement, style: Style): void {
4570
4879
  const { x, y } = style.gridCellAnchor;
4571
4880
  el.dataset['sbGridCellAnchor'] = `${x} ${y}`;
4572
4881
  }
4882
+ // `.scrollTransition`: drawn over the effects above as the box scrolls (scrollTransition.ts).
4883
+ setScrollTransition(el, style);
4573
4884
  }
4574
4885
 
4575
4886
  // Colors and ShapeStyles resolve in src/styles.ts; re-exported for callers of the renderer.