@swiftbrowser/web 0.3.1 → 0.5.0

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
@@ -56,6 +56,7 @@ import { bitmapStore, showBitmap } from './bitmaps';
56
56
  import { clearRichText, runTemplates } from './richText';
57
57
  import { applyLayout, setTabHidden, type BoxChange } from './layoutDom';
58
58
  import { ghostClone } from './ghost';
59
+ import { ExternalDrops } from './dragDrop';
59
60
  import { InteractionController, type InteractionHost } from './interaction';
60
61
  import { symbolOf, textOf, unwrapStyledNode } from './nodeQueries';
61
62
  import {
@@ -85,9 +86,12 @@ import {
85
86
  type HStackProps,
86
87
  type LayoutEvent,
87
88
  type LazyVGridProps,
89
+ type LazyHGridProps,
88
90
  type ImageProps,
91
+ type InspectorProps,
89
92
  type ListProps,
90
93
  type ListRowProps,
94
+ type DropDestinationProps,
91
95
  type NavLinkProps,
92
96
  type TapGestureProps,
93
97
  type NavScreenProps,
@@ -105,6 +109,7 @@ import {
105
109
  type SwipeActionsProps,
106
110
  type TabProps,
107
111
  type TabViewProps,
112
+ type HostProps,
108
113
  type TextFieldProps,
109
114
  type TextProps,
110
115
  type TextRun,
@@ -144,7 +149,11 @@ import { applyCanvas, createCanvasElement } from './canvasDrawing';
144
149
  import { scrollToTarget } from './scrollTo';
145
150
  import { EMPTY_CATALOG, assetColorVar, type AssetCatalog } from './assets';
146
151
  import { colorToCSS, effectsCSS, styleToCSS, type PaintCSS } from './styles';
152
+ import { hostContent, onHostContentChange } from './hostContent';
153
+ import { HoverTracker, applyPointerAndEffects, safeAreaOverlap } from './pointerEffects';
147
154
  import { colorMultiplyFilter } from './colorMultiply';
155
+ import { largeTitleOf } from './largeTitle';
156
+ import { applyEdgeEffect } from './scrollEdge';
148
157
  import { DEFAULT_TYPE_SIZE, TEXT_STYLE_WEIGHT, resolveTextStyle, textStyleVar } from './typography';
149
158
 
150
159
  export type EventCallback = (event: RendererEvent) => void;
@@ -161,6 +170,23 @@ const ID_ATTR = 'sbId';
161
170
  /** `animationToken` of a `styled` box, kept across `update` to detect changes. */
162
171
  const ANIMATION_TOKEN = 'sbAnimationToken';
163
172
 
173
+ /**
174
+ * Kinds whose own changes animate even outside `withAnimation`: their token
175
+ * changes with what they present (an inspector's `presented`).
176
+ */
177
+ const IMPLICITLY_ANIMATED: ReadonlySet<ElementKind> = new Set(['inspector']);
178
+
179
+ /**
180
+ * The animation token an element of `kind` has with `props`: a `styled`
181
+ * box's `animationToken` (`.animation(_:value:)`), an inspector's
182
+ * `presented`. An `update` that changes it animates its subtree.
183
+ */
184
+ function animationTokenOf(kind: ElementKind | undefined, props: unknown): string | undefined {
185
+ if (kind === 'inspector') return (props as InspectorProps | null)?.presented === true ? 'shown' : 'hidden';
186
+ const token = (props as { style?: { animationToken?: number | null } } | null)?.style?.animationToken;
187
+ return token == null ? undefined : String(token);
188
+ }
189
+
164
190
  /** Duration of the navigation push/pop slide; keep in sync with styles.css (`--sb-nav-easing`). */
165
191
  export const NAV_TRANSITION_MS = 400;
166
192
  /** Duration of the sheet present/dismiss slide; keep in sync with styles.css. */
@@ -169,6 +195,7 @@ export const SHEET_TRANSITION_MS = 400;
169
195
  export { SHEET_DISMISS_DRAG } from './interaction';
170
196
 
171
197
  const ALL_KINDS: ReadonlySet<string> = new Set<ElementKind>([
198
+ 'host',
172
199
  'text',
173
200
  'vstack',
174
201
  'hstack',
@@ -197,6 +224,7 @@ const ALL_KINDS: ReadonlySet<string> = new Set<ElementKind>([
197
224
  'grid',
198
225
  'gridrow',
199
226
  'lazyvgrid',
227
+ 'lazyhgrid',
200
228
  'gauge',
201
229
  'decorated',
202
230
  'menu',
@@ -205,6 +233,8 @@ const ALL_KINDS: ReadonlySet<string> = new Set<ElementKind>([
205
233
  'tapgesture',
206
234
  // Phase 7
207
235
  'navsplit',
236
+ 'inspector',
237
+ 'inspectorpanel',
208
238
  'viewthatfits',
209
239
  'chart',
210
240
  'chartitem',
@@ -297,11 +327,33 @@ export class Renderer {
297
327
  */
298
328
  private rootSize: { width: number; height: number } | null = null;
299
329
  private readonly resizeObserver: ResizeObserver | null = null;
330
+ /**
331
+ * `host` elements (src/hostContent.ts): their content elements, observed
332
+ * for size changes, and the measurements of the current layout pass.
333
+ */
334
+ private readonly hostObserver: ResizeObserver | null = null;
335
+ /** `hover` events for boxes whose style asks for them (pointerEffects.ts). */
336
+ private readonly hover: HoverTracker;
337
+ /** A frame check of the `reportsFrame` readers is queued for the next animation frame (after a scroll). */
338
+ private frameCheckQueued = false;
339
+ private readonly hostContents = new Map<ElementId, HTMLElement>();
340
+ /** The `host` elements, adopted or still waiting for their content. */
341
+ private readonly hosts = new Set<ElementId>();
342
+ private readonly hostSizes = new WeakMap<HTMLElement, { width: number; height: number }>();
343
+ private readonly hostMeasures = new Map<string, { width: number; height: number }>();
344
+ private readonly stopHostChanges: () => void;
345
+ /**
346
+ * The root takes its content's height (`LayoutEnvironment.contentHeight`):
347
+ * a SwiftUI island in a page's own layout. Set before the first commit.
348
+ */
349
+ contentHeight = false;
300
350
  /**
301
351
  * Phase 8: owns every pointer gesture (drags, long presses, swipe rows,
302
352
  * swipe back, the sheet drag, pull to refresh) and arbitrates between them.
303
353
  */
304
354
  readonly interaction: InteractionController;
355
+ /** Text, links and files dragged in from outside the page (src/dragDrop.ts). */
356
+ private readonly externalDrops: ExternalDrops;
305
357
  /** `video` elements' media events, as `video` events (src/video.ts). */
306
358
  private readonly videoEvents: VideoEvents;
307
359
  private readonly measurer = new CanvasTextMeasurer();
@@ -329,7 +381,7 @@ export class Renderer {
329
381
  /** Lazy menus: a menu the viewer opened whose items Swift is building. */
330
382
  private pendingMenu: { anchor: HTMLElement; id: ElementId; kind: 'menu' | 'contextmenu' } | null = null;
331
383
  /** Size last reported for every `geometry` element (whole px). */
332
- private readonly reportedGeometry = new Map<ElementId, { width: number; height: number }>();
384
+ private readonly reportedGeometry = new Map<ElementId, { width: number; height: number; key: string }>();
333
385
  /** `geometry` and `layout` events collected by layout passes, sent once the ops are applied. */
334
386
  private pendingGeometry: (GeometryEvent | LayoutEvent)[] = [];
335
387
  /** Phase 6: dedupes `layout` events per element (src/layoutEvents.ts). */
@@ -349,7 +401,7 @@ export class Renderer {
349
401
  // `closest` picks the innermost control, so a button inside a
350
402
  // `tapgesture` (or a menu label) handles the click alone.
351
403
  const target = (event.target as HTMLElement | null)?.closest<HTMLElement>(
352
- '.sb-button, .sb-navlink, .sb-navbar-back, .sb-switch, .sb-sheet-scrim, ' +
404
+ '.sb-button, .sb-navlink, .sb-navbar-back, .sb-navsplit-toggle, .sb-switch, .sb-sheet-scrim, ' +
353
405
  '.sb-tabbar-item, .sb-segment, .sb-picker-option, .sb-picker-menu-item, .sb-picker[data-sb-style="menu"], ' +
354
406
  '.sb-menu, .sb-tapgesture, .sb-stepper-button, .sb-alert-scrim, .sb-alert-ok, ' +
355
407
  // Phase 8: swipe action buttons, the edit-mode row control and selectable rows (the row sends `tap` itself)
@@ -450,6 +502,14 @@ export class Renderer {
450
502
  this.onEvent({ type: 'tap', id });
451
503
  return;
452
504
  }
505
+ if (target.classList.contains('sb-navsplit-toggle')) {
506
+ const split = target.closest<HTMLElement>('.sb-navsplit');
507
+ const id = Number(split?.dataset[ID_ATTR]);
508
+ if (!split || !Number.isInteger(id)) return;
509
+ event.preventDefault();
510
+ this.onEvent({ type: 'tap', id });
511
+ return;
512
+ }
453
513
  if (target.classList.contains('sb-navbar-back')) {
454
514
  const id = Number(target.dataset['sbTarget']);
455
515
  if (!Number.isInteger(id)) return;
@@ -728,6 +788,7 @@ export class Renderer {
728
788
  closeMenu: () => this.closeMenu(),
729
789
  };
730
790
  this.interaction = new InteractionController(host);
791
+ this.externalDrops = new ExternalDrops(host);
731
792
  root.addEventListener('change', this.onChange);
732
793
  root.addEventListener('pointerdown', this.onSliderPointerDown);
733
794
  if (typeof ResizeObserver !== 'undefined') {
@@ -740,6 +801,92 @@ export class Renderer {
740
801
  });
741
802
  this.resizeObserver.observe(root);
742
803
  }
804
+ this.measurer.measureHost = (content, width) => this.measureHostContent(content, width);
805
+ if (typeof ResizeObserver !== 'undefined') {
806
+ this.hostObserver = new ResizeObserver((entries) => {
807
+ let changed = false;
808
+ for (const entry of entries) {
809
+ const el = entry.target as HTMLElement;
810
+ const size = { width: el.offsetWidth, height: el.offsetHeight };
811
+ const last = this.hostSizes.get(el);
812
+ if (last && last.width === size.width && last.height === size.height) continue;
813
+ this.hostSizes.set(el, size);
814
+ changed = true;
815
+ }
816
+ if (changed) this.hostContentChanged();
817
+ });
818
+ }
819
+ this.stopHostChanges = onHostContentChange(() => this.hostContentChanged());
820
+ this.hover = new HoverTracker(root, (id, value) => this.onEvent({ type: 'hover', id, value }));
821
+ root.addEventListener('scroll', this.queueFrameCheck, { capture: true, passive: true });
822
+ }
823
+
824
+ /**
825
+ * Geometry readers with `reportsFrame` report where they are on screen,
826
+ * which scrolling changes without a layout pass: after a scroll their
827
+ * frames are checked once per animation frame.
828
+ */
829
+ private readonly queueFrameCheck = (): void => {
830
+ if (this.frameCheckQueued || this.commits === 0) return;
831
+ this.frameCheckQueued = true;
832
+ requestAnimationFrame(() => {
833
+ this.frameCheckQueued = false;
834
+ if (!this.layoutResult) return;
835
+ this.collectGeometry(this.layoutResult, true);
836
+ if (this.applying === 0) this.flushGeometry();
837
+ });
838
+ };
839
+
840
+ /**
841
+ * A `host` element's content changed (the page said so, or it resized):
842
+ * the hosts are measured again and the tree laid out. A host whose content
843
+ * was not registered when its element was created (a page that registers
844
+ * from an effect, after its commit) adopts it now.
845
+ */
846
+ private hostContentChanged(): void {
847
+ if (this.commits === 0) return;
848
+ for (const id of this.hosts) {
849
+ if (this.hostContents.has(id)) continue;
850
+ const el = this.elements.get(id);
851
+ const node = this.nodes.get(id);
852
+ if (el && node) this.adoptHostContent(id, el, node.props as unknown as HostProps);
853
+ }
854
+ if (this.hostContents.size === 0) return;
855
+ for (const id of this.hostContents.keys()) {
856
+ const node = this.nodes.get(id);
857
+ if (node) this.engine?.invalidate(node);
858
+ }
859
+ this.layout();
860
+ }
861
+
862
+ /**
863
+ * The host measurer (src/layout, `measureHost`): the content element laid
864
+ * out at `width`, or at its own ideal width when null. Measured in place,
865
+ * so the content sees the device's tokens and fonts; the styles it needs
866
+ * for the measurement are restored right after.
867
+ */
868
+ private measureHostContent(content: string, width: number | null): { width: number; height: number } {
869
+ const key = `${content}\u0000${width ?? ''}`;
870
+ const cached = this.hostMeasures.get(key);
871
+ if (cached) return cached;
872
+ const el = hostContent(content);
873
+ if (!el) return { width: 0, height: 0 };
874
+ const { style } = el;
875
+ const saved = { width: style.width, height: style.height, maxWidth: style.maxWidth };
876
+ style.height = 'auto';
877
+ if (width === null) {
878
+ style.width = 'max-content';
879
+ style.maxWidth = 'none';
880
+ } else {
881
+ style.width = `${width}px`;
882
+ }
883
+ const rect = el.getBoundingClientRect();
884
+ const size = { width: Math.ceil(rect.width), height: Math.ceil(rect.height) };
885
+ style.width = saved.width;
886
+ style.height = saved.height;
887
+ style.maxWidth = saved.maxWidth;
888
+ this.hostMeasures.set(key, size);
889
+ return size;
743
890
  }
744
891
 
745
892
  /** Apply a batch of ops in order. Throws `RendererError` on an invalid op. */
@@ -818,10 +965,10 @@ export class Renderer {
818
965
  const op = ops[index]!;
819
966
  if (op.op === 'commit') return op.animation != null;
820
967
  if (op.op !== 'update') continue;
821
- const token = (op.props as { style?: { animationToken?: number | null } } | null)?.style?.animationToken;
822
- if (token == null) continue;
823
968
  const el = this.elements.get(op.id);
824
- if (!el || el.dataset[ANIMATION_TOKEN] !== String(token)) return true;
969
+ const token = animationTokenOf(el?.dataset[KIND_ATTR] as ElementKind | undefined, op.props);
970
+ if (token == null) continue;
971
+ if (!el || el.dataset[ANIMATION_TOKEN] !== token) return true;
825
972
  }
826
973
  return true;
827
974
  }
@@ -946,6 +1093,7 @@ export class Renderer {
946
1093
  devicePixelRatio: typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1,
947
1094
  colorScheme: this.environment.colorScheme,
948
1095
  assets: this.assets,
1096
+ ...(this.contentHeight ? { contentHeight: true } : {}),
949
1097
  };
950
1098
  }
951
1099
 
@@ -968,6 +1116,7 @@ export class Renderer {
968
1116
  layout(): BoxChange[] {
969
1117
  const root = this.nodes.get(ROOT_ID);
970
1118
  if (!root) return [];
1119
+ this.hostMeasures.clear();
971
1120
  const env = this.layoutEnvironment();
972
1121
  const envKey = JSON.stringify({ ...env, assets: undefined });
973
1122
  if (!this.engine || envKey !== this.engineEnvKey) {
@@ -991,6 +1140,15 @@ export class Renderer {
991
1140
  };
992
1141
  const changes = applyLayout(ctx, result);
993
1142
  this.boxes = ctx.boxes;
1143
+ if (this.contentHeight) {
1144
+ // The island's root is as tall as its content.
1145
+ let bottom = 0;
1146
+ for (const child of root.children) {
1147
+ const frame = result.frames.get(child.id);
1148
+ if (frame) bottom = Math.max(bottom, frame.y + frame.height);
1149
+ }
1150
+ this.root.style.height = `${bottom + env.safeArea.bottom}px`;
1151
+ }
994
1152
  this.collectGeometry(result);
995
1153
  this.collectLayouts(result);
996
1154
  if (this.applying === 0) this.flushGeometry();
@@ -1004,17 +1162,49 @@ export class Renderer {
1004
1162
  * (whole px) differs from the one last reported. Nothing is queued for an
1005
1163
  * unchanged size, which is what lets the Swift re-render loop converge.
1006
1164
  */
1007
- private collectGeometry(result: LayoutResult): void {
1165
+ private collectGeometry(result: LayoutResult, framesOnly = false): void {
1166
+ let rootRect: DOMRect | null = null;
1008
1167
  for (const [id, node] of this.nodes) {
1009
1168
  if (node.kind !== 'geometry' && node.props['reportsSize'] !== true) continue;
1169
+ const reportsFrame = node.props['reportsFrame'] === true;
1170
+ if (framesOnly && !reportsFrame) continue;
1010
1171
  const frame = result.frames.get(id);
1011
1172
  if (!frame) continue; // e.g. inside a hidden tab
1012
1173
  const width = Math.round(frame.width);
1013
1174
  const height = Math.round(frame.height);
1175
+ const event: GeometryEvent = { type: 'geometry', id, width, height };
1176
+ let key = `${width}x${height}`;
1177
+ if (reportsFrame) {
1178
+ // Where it is drawn now (scrolled), in the root's coordinates.
1179
+ const el = this.elements.get(id);
1180
+ rootRect ??= this.root.getBoundingClientRect();
1181
+ const rect = el?.getBoundingClientRect();
1182
+ const x = rect ? Math.round(rect.left - rootRect.left) : Math.round(frame.x);
1183
+ const y = rect ? Math.round(rect.top - rootRect.top) : Math.round(frame.y);
1184
+ const env = this.layoutEnvironment();
1185
+ event.x = x;
1186
+ event.y = y;
1187
+ event.safeArea = safeAreaOverlap({ x, y, width, height }, env.screen, env.safeArea);
1188
+ key += `@${x},${y}/${event.safeArea.top},${event.safeArea.leading},${event.safeArea.bottom},${event.safeArea.trailing}`;
1189
+ // The visible rectangle of the scroll view (or list) the reader is in.
1190
+ const scroller = el?.parentElement?.closest<HTMLElement>('.sb-scrollview, .sb-list');
1191
+ if (scroller) {
1192
+ const view = scroller.getBoundingClientRect();
1193
+ event.scrollView = {
1194
+ x: Math.round(view.left - rootRect.left),
1195
+ y: Math.round(view.top - rootRect.top),
1196
+ width: Math.round(view.width),
1197
+ height: Math.round(view.height),
1198
+ axis: scroller.dataset['sbAxes'] === 'horizontal' ? 'horizontal' : 'vertical',
1199
+ };
1200
+ const v = event.scrollView;
1201
+ key += `/${v.x},${v.y},${v.width},${v.height},${v.axis}`;
1202
+ }
1203
+ }
1014
1204
  const last = this.reportedGeometry.get(id);
1015
- if (last && last.width === width && last.height === height) continue;
1016
- this.reportedGeometry.set(id, { width, height });
1017
- this.pendingGeometry.push({ type: 'geometry', id, width, height });
1205
+ if (last && last.key === key) continue;
1206
+ this.reportedGeometry.set(id, { width, height, key });
1207
+ this.pendingGeometry.push(event);
1018
1208
  }
1019
1209
  }
1020
1210
 
@@ -1193,8 +1383,13 @@ export class Renderer {
1193
1383
  this.root.removeEventListener('focusout', this.onFocusChange);
1194
1384
  this.root.removeEventListener('scroll', this.onScrollDismissKeyboard, true);
1195
1385
  this.interaction.dispose();
1386
+ this.externalDrops.dispose();
1196
1387
  this.videoEvents.dispose();
1197
1388
  this.resizeObserver?.disconnect();
1389
+ this.hostObserver?.disconnect();
1390
+ this.stopHostChanges();
1391
+ this.hover.dispose();
1392
+ this.root.removeEventListener('scroll', this.queueFrameCheck, true);
1198
1393
  this.root.removeEventListener('change', this.onChange);
1199
1394
  this.root.removeEventListener('pointerdown', this.onSliderPointerDown);
1200
1395
  this.reset();
@@ -1218,6 +1413,27 @@ export class Renderer {
1218
1413
  applyProps(el, kind, props, this.assets);
1219
1414
  this.noteScroll(id, kind, props);
1220
1415
  if (kind === 'lazyspacer') this.lazySpacers.add(id);
1416
+ if (kind === 'host') {
1417
+ this.hosts.add(id);
1418
+ this.adoptHostContent(id, el, props as HostProps);
1419
+ }
1420
+ }
1421
+
1422
+ /** A `host` element takes the page's content element (src/hostContent.ts) and watches its size. */
1423
+ private adoptHostContent(id: ElementId, el: HTMLElement, props: HostProps): void {
1424
+ const content = hostContent(props.content);
1425
+ const previous = this.hostContents.get(id);
1426
+ if (previous && previous !== content) {
1427
+ this.hostObserver?.unobserve(previous);
1428
+ previous.remove();
1429
+ this.hostContents.delete(id);
1430
+ }
1431
+ if (!content) return;
1432
+ if (content.parentElement !== el) el.appendChild(content);
1433
+ if (previous !== content) {
1434
+ this.hostContents.set(id, content);
1435
+ this.hostObserver?.observe(content);
1436
+ }
1221
1437
  }
1222
1438
 
1223
1439
  /** After a scroll, the lazy spacers are checked on the next frame (once per frame). */
@@ -1291,6 +1507,24 @@ export class Renderer {
1291
1507
  if (!el) throw new RendererError(`Cannot update unknown element ${id}`, op);
1292
1508
  const kind = el.dataset[KIND_ATTR] as ElementKind;
1293
1509
  const node = this.nodes.get(id);
1510
+ // `.contentTransition`: a text changing under one keeps a copy of how it
1511
+ // looked, for the Animator to play the change with (when the pass animates).
1512
+ if (kind === 'text' && node && this.passAnimates !== false && (props as TextProps).text !== node.props['text']) {
1513
+ const host = el.closest<HTMLElement>('[data-sb-content-transition]');
1514
+ if (host && this.root.contains(host)) {
1515
+ this.passRootRect ??= this.animator.rootRect();
1516
+ const rect = this.animator.rectOf(el, this.passRootRect);
1517
+ if (rect) {
1518
+ this.pass.contentChanges.push({
1519
+ el,
1520
+ clone: ghostClone(el),
1521
+ rect,
1522
+ kind: host.dataset['sbContentTransition'] as 'numericText' | 'opacity' | 'interpolate',
1523
+ countsDown: countsDown(host.dataset['sbCountsDown'], String(node.props['text'] ?? ''), String((props as TextProps).text ?? '')),
1524
+ });
1525
+ }
1526
+ }
1527
+ }
1294
1528
  if (node) {
1295
1529
  node.props = asRecord(props);
1296
1530
  // Phase 9: the next layout re-measures this subtree and its ancestors;
@@ -1324,9 +1558,11 @@ export class Renderer {
1324
1558
  if (kind === 'text') el.textContent = '';
1325
1559
  applyProps(el, kind, props, this.assets);
1326
1560
  this.noteScroll(id, kind, props);
1561
+ if (kind === 'host') this.adoptHostContent(id, el, props as HostProps);
1327
1562
  const newToken = el.dataset[ANIMATION_TOKEN];
1328
1563
  const tokenChanged = (previous?.tokenChanged ?? false) || (newToken !== undefined && newToken !== oldToken);
1329
1564
  const record: UpdatedRecord = { el, before, tokenChanged };
1565
+ if (IMPLICITLY_ANIMATED.has(kind)) record.implicit = true;
1330
1566
  const animation = ANIMATIONS.get(el);
1331
1567
  if (animation) record.animation = animation;
1332
1568
  this.pass.updated.set(el, record);
@@ -1450,11 +1686,19 @@ export class Renderer {
1450
1686
  const ancestors: HTMLElement[] = [];
1451
1687
  for (let a = parent; a && a !== this.root; a = a.parentElement!) ancestors.push(a);
1452
1688
  let rect: DOMRect | null = null;
1689
+ const matched: { id: string; el: HTMLElement; rect: DOMRect }[] = [];
1453
1690
  if (this.passAnimates !== false) {
1454
1691
  this.passRootRect ??= this.animator.rootRect();
1455
1692
  rect = this.animator.rectOf(el, this.passRootRect);
1693
+ // `.matchedGeometryEffect`: where the matched boxes in the subtree were.
1694
+ const candidates = el.dataset['sbMatched'] !== undefined ? [el] : [];
1695
+ candidates.push(...el.querySelectorAll<HTMLElement>('[data-sb-matched]'));
1696
+ for (const box of candidates) {
1697
+ const boxRect = this.animator.rectOf(box, this.passRootRect);
1698
+ if (boxRect) matched.push({ id: box.dataset['sbMatched']!, el: box, rect: boxRect });
1699
+ }
1456
1700
  }
1457
- this.pass.removed.push({ el, ancestors, rect, transition: transitionOf(el) });
1701
+ this.pass.removed.push({ el, ancestors, rect, transition: transitionOf(el), ...(matched.length ? { matched } : {}) });
1458
1702
  }
1459
1703
  this.pass.inserted.delete(el);
1460
1704
  this.pass.updated.delete(el);
@@ -1480,6 +1724,12 @@ export class Renderer {
1480
1724
 
1481
1725
  /** Forget an element in every map; detaches its node from the tree. */
1482
1726
  private forget(id: ElementId): void {
1727
+ const content = this.hostContents.get(id);
1728
+ if (content) {
1729
+ this.hostObserver?.unobserve(content);
1730
+ this.hostContents.delete(id);
1731
+ }
1732
+ this.hosts.delete(id);
1483
1733
  this.elements.delete(id);
1484
1734
  this.boxes.delete(id);
1485
1735
  this.dirty.delete(id);
@@ -1705,6 +1955,9 @@ function div(className: string): HTMLDivElement {
1705
1955
  /** Create the element and any chrome it owns; props are applied separately. */
1706
1956
  function createShell(kind: ElementKind): HTMLElement {
1707
1957
  switch (kind) {
1958
+ case 'host':
1959
+ // The page's content element is adopted by the renderer (`adoptHostContent`).
1960
+ return div('sb-host');
1708
1961
  case 'button': {
1709
1962
  const el = document.createElement('button');
1710
1963
  el.type = 'button';
@@ -1912,6 +2165,15 @@ function createShell(kind: ElementKind): HTMLElement {
1912
2165
  }
1913
2166
  case 'tapgesture':
1914
2167
  return document.createElement('div');
2168
+ case 'inspector': {
2169
+ // The content and the panel go into the slot; the panel draws the
2170
+ // hairline before it (styles.css), so it slides with the panel.
2171
+ const el = document.createElement('div');
2172
+ const slot = div('sb-slot sb-inspector-columns');
2173
+ el.append(slot);
2174
+ SLOTS.set(el, slot);
2175
+ return el;
2176
+ }
1915
2177
  case 'navsplit': {
1916
2178
  // Phase 7: the two columns go into the slot; the separator is chrome
1917
2179
  // positioned from the engine's `separator` rect.
@@ -1919,15 +2181,25 @@ function createShell(kind: ElementKind): HTMLElement {
1919
2181
  const slot = div('sb-slot sb-navsplit-columns');
1920
2182
  const separator = div('sb-navsplit-separator');
1921
2183
  separator.setAttribute('aria-hidden', 'true');
1922
- el.append(slot, separator);
2184
+ // iPadOS: the sidebar toggle, at the engine's `toggle` rect; a tap
2185
+ // asks Swift to show or hide the sidebar.
2186
+ const toggle = document.createElement('button');
2187
+ toggle.type = 'button';
2188
+ toggle.className = 'sb-navsplit-toggle';
2189
+ toggle.setAttribute('aria-label', 'Toggle Sidebar');
2190
+ const symbol = renderSymbol('sidebar.left');
2191
+ if (symbol) toggle.append(symbol);
2192
+ el.append(slot, separator, toggle);
1923
2193
  SLOTS.set(el, slot);
1924
2194
  return el;
1925
2195
  }
1926
2196
  case 'viewthatfits':
1927
2197
  return document.createElement('div');
1928
- // Phase 8: gestures, rows, navigation
2198
+ // Phase 8: gestures, rows, navigation; drag and drop
1929
2199
  case 'gesture':
1930
2200
  case 'contextmenu':
2201
+ case 'draggable':
2202
+ case 'dropdestination':
1931
2203
  return document.createElement('div');
1932
2204
  case 'swipeactions':
1933
2205
  case 'listrow': {
@@ -1958,6 +2230,15 @@ function createShell(kind: ElementKind): HTMLElement {
1958
2230
  }
1959
2231
  control.append(circle);
1960
2232
  el.append(control);
2233
+ // The reorder grip of a movable row (`chrome.reorderControl`), at the
2234
+ // trailing edge: three lines, as iOS draws it. It lives in the content
2235
+ // slot, so it slides with the content when the minus (or a swipe)
2236
+ // reveals Delete; the control above stays, as on iOS.
2237
+ const grip = div('sb-listrow-grip');
2238
+ grip.hidden = true;
2239
+ grip.setAttribute('aria-label', 'Reorder');
2240
+ grip.append(gripGlyph());
2241
+ slot.append(grip);
1961
2242
  }
1962
2243
  return el;
1963
2244
  }
@@ -2064,6 +2345,22 @@ function isSectionSupplement(el: HTMLElement): boolean {
2064
2345
  return el.classList.contains('sb-sectionheader') || el.classList.contains('sb-sectionfooter');
2065
2346
  }
2066
2347
 
2348
+ /** The reorder grip as iOS draws it: three 21 pt lines, 1.33 thick, 4.67 apart. */
2349
+ function gripGlyph(): SVGSVGElement {
2350
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
2351
+ svg.setAttribute('viewBox', '0 0 21 11');
2352
+ svg.setAttribute('aria-hidden', 'true');
2353
+ for (const y of [0.67, 5.33, 10]) {
2354
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
2355
+ line.setAttribute('x1', '0');
2356
+ line.setAttribute('x2', '21');
2357
+ line.setAttribute('y1', String(y));
2358
+ line.setAttribute('y2', String(y));
2359
+ svg.append(line);
2360
+ }
2361
+ return svg;
2362
+ }
2363
+
2067
2364
  function span(className: string): HTMLSpanElement {
2068
2365
  const el = document.createElement('span');
2069
2366
  el.className = className;
@@ -2413,6 +2710,9 @@ function applyTextEditor(area: HTMLTextAreaElement, props: TextEditorProps): voi
2413
2710
 
2414
2711
  function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, assets: AssetCatalog): void {
2415
2712
  switch (kind) {
2713
+ case 'host':
2714
+ el.dataset['sbSizing'] = (props as HostProps).sizing;
2715
+ break;
2416
2716
  case 'text':
2417
2717
  applyText(el, props as TextProps, assets);
2418
2718
  break;
@@ -2447,8 +2747,15 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
2447
2747
  case 'navstack':
2448
2748
  case 'geometry':
2449
2749
  case 'menu':
2750
+ case 'inspector':
2751
+ // The engine reads `presented`. Showing or hiding the panel animates
2752
+ // by default, as in SwiftUI (`animationTokenOf`).
2753
+ el.dataset[ANIMATION_TOKEN] = animationTokenOf(kind, props)!;
2754
+ break;
2755
+ case 'inspectorpanel':
2756
+ break;
2450
2757
  case 'navsplit':
2451
- // No props.
2758
+ // No props (a navsplit's `sidebarVisible` is read by the engine and layoutDom).
2452
2759
  break;
2453
2760
  case 'navlink':
2454
2761
  // Phase 7: a selected row (inside a `List(selection:)`) gets a rounded fill.
@@ -2463,6 +2770,8 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
2463
2770
  const placement = (props as ToolbarProps).placement;
2464
2771
  el.dataset['sbPlacement'] =
2465
2772
  placement === 'leading' || placement === 'principal' || placement === 'bottom' ? placement : 'trailing';
2773
+ // A `ToolbarSpacer`: draws nothing.
2774
+ el.toggleAttribute('data-sb-spacer', typeof (props as ToolbarProps).spacer === 'string');
2466
2775
  break;
2467
2776
  }
2468
2777
  case 'searchfield':
@@ -2477,6 +2786,7 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
2477
2786
  // `.scrollContentBackground(.hidden)`: no grouped background (styles.css).
2478
2787
  if (list.background === 'hidden') el.dataset['sbBackground'] = 'hidden';
2479
2788
  if (list.dismissesKeyboard === true) el.dataset['sbDismissesKeyboard'] = 'true';
2789
+ applyEdgeEffect(el, list.edgeEffect);
2480
2790
  break;
2481
2791
  }
2482
2792
  case 'tabview': {
@@ -2505,6 +2815,7 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
2505
2815
  if (scroll.snap === 'viewAligned' || scroll.snap === 'paging') el.dataset['sbSnap'] = scroll.snap;
2506
2816
  else delete el.dataset['sbSnap'];
2507
2817
  if (scroll.dismissesKeyboard === true) el.dataset['sbDismissesKeyboard'] = 'true';
2818
+ applyEdgeEffect(el, scroll.edgeEffect);
2508
2819
  break;
2509
2820
  }
2510
2821
  case 'section':
@@ -2547,6 +2858,12 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
2547
2858
  el.dataset['sbColumns'] = String(Array.isArray(grid.columns) ? grid.columns.length : 0);
2548
2859
  break;
2549
2860
  }
2861
+ case 'lazyhgrid': {
2862
+ const grid = props as LazyHGridProps;
2863
+ el.dataset['sbAlignment'] = grid.alignment ?? 'center';
2864
+ el.dataset['sbRows'] = String(Array.isArray(grid.rows) ? grid.rows.length : 0);
2865
+ break;
2866
+ }
2550
2867
  case 'gauge':
2551
2868
  applyGauge(el, props as GaugeProps);
2552
2869
  break;
@@ -2587,11 +2904,19 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
2587
2904
  }
2588
2905
  case 'contextmenu':
2589
2906
  break;
2907
+ case 'draggable':
2908
+ break;
2909
+ case 'dropdestination': {
2910
+ const accepts = (props as DropDestinationProps | undefined)?.accepts;
2911
+ el.dataset['sbAccepts'] = Array.isArray(accepts) ? accepts.filter((a) => typeof a === 'string').join(' ') : '';
2912
+ break;
2913
+ }
2590
2914
  case 'listrow': {
2591
2915
  const row = (props as ListRowProps | undefined) ?? {};
2592
2916
  el.toggleAttribute('data-sb-selectable', row.selectable === true);
2593
2917
  el.toggleAttribute('data-sb-selected', row.selected === true);
2594
2918
  el.toggleAttribute('data-sb-deletable', row.deletable === true);
2919
+ el.toggleAttribute('data-sb-movable', row.movable === true);
2595
2920
  if (row.selectable) el.setAttribute('aria-selected', String(row.selected === true));
2596
2921
  else el.removeAttribute('aria-selected');
2597
2922
  const control = el.querySelector<HTMLElement>(':scope > .sb-listrow-control');
@@ -3051,7 +3376,7 @@ function applyNavScreen(el: HTMLElement, props: NavScreenProps): void {
3051
3376
  el.dataset['sbDepth'] = String(depth);
3052
3377
  el.dataset['sbDisplay'] = large ? 'large' : 'inline';
3053
3378
  const inlineTitle = el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-title');
3054
- const largeTitle = el.querySelector<HTMLElement>(':scope > .sb-navscreen-body > .sb-navbar-large-title');
3379
+ const largeTitle = largeTitleOf(el);
3055
3380
  // A large title that scrolls away collapses into the inline one (shown
3056
3381
  // with `data-sb-collapsed`, src/largeTitle.ts).
3057
3382
  if (inlineTitle) inlineTitle.textContent = title;
@@ -3201,7 +3526,7 @@ export function applyStyle(el: HTMLElement, style: Style): void {
3201
3526
  // typography.ts); the fallbacks are the Large sizes.
3202
3527
  const large = resolveTextStyle(textStyle, DEFAULT_TYPE_SIZE);
3203
3528
  s.fontSize = `var(${textStyleVar(textStyle, 'size')}, ${large.fontSize}px)`;
3204
- s.lineHeight = `var(${textStyleVar(textStyle, 'lh')}, ${large.lineHeight}px)`;
3529
+ s.lineHeight = `var(${textStyleVar(textStyle, 'lh')}, ${Math.round(large.lineHeight * 100) / 100}px)`;
3205
3530
  el.dataset['sbTextStyle'] = textStyle;
3206
3531
  const implied = TEXT_STYLE_WEIGHT[textStyle];
3207
3532
  if (!weight && implied) s.fontWeight = FONT_WEIGHT[implied];
@@ -3309,6 +3634,8 @@ export function applyStyle(el: HTMLElement, style: Style): void {
3309
3634
  s.pointerEvents = 'none';
3310
3635
  el.dataset['sbHitTesting'] = 'false';
3311
3636
  }
3637
+ // Hover, tooltips, cursors, symbol effects and matched geometry ids (pointerEffects.ts).
3638
+ applyPointerAndEffects(el, style);
3312
3639
 
3313
3640
  let opacity = style.opacity;
3314
3641
  if (style.disabled) {
@@ -3439,3 +3766,16 @@ function lazySpacerIsNear(el: HTMLElement): boolean {
3439
3766
  }
3440
3767
  return rect.top < view.bottom + view.height && rect.bottom > view.top - view.height;
3441
3768
  }
3769
+
3770
+ /**
3771
+ * Which way a `.numericText` change rolls: the direction it names, or else
3772
+ * down when the new text reads as a smaller number than the old one (as
3773
+ * SwiftUI's `numericText(value:)` decides from the value).
3774
+ */
3775
+ export function countsDown(named: string | undefined, before: string, after: string): boolean {
3776
+ if (named !== undefined) return named === 'true';
3777
+ const number = (text: string): number => Number.parseFloat(text.replace(/[^\d.+-]/g, ''));
3778
+ const from = number(before);
3779
+ const to = number(after);
3780
+ return Number.isFinite(from) && Number.isFinite(to) && to < from;
3781
+ }