@swiftbrowser/web 0.3.0 → 0.4.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 {
@@ -86,8 +87,10 @@ import {
86
87
  type LayoutEvent,
87
88
  type LazyVGridProps,
88
89
  type ImageProps,
90
+ type InspectorProps,
89
91
  type ListProps,
90
92
  type ListRowProps,
93
+ type DropDestinationProps,
91
94
  type NavLinkProps,
92
95
  type TapGestureProps,
93
96
  type NavScreenProps,
@@ -105,6 +108,7 @@ import {
105
108
  type SwipeActionsProps,
106
109
  type TabProps,
107
110
  type TabViewProps,
111
+ type HostProps,
108
112
  type TextFieldProps,
109
113
  type TextProps,
110
114
  type TextRun,
@@ -144,7 +148,11 @@ import { applyCanvas, createCanvasElement } from './canvasDrawing';
144
148
  import { scrollToTarget } from './scrollTo';
145
149
  import { EMPTY_CATALOG, assetColorVar, type AssetCatalog } from './assets';
146
150
  import { colorToCSS, effectsCSS, styleToCSS, type PaintCSS } from './styles';
151
+ import { hostContent, onHostContentChange } from './hostContent';
152
+ import { HoverTracker, applyPointerAndEffects, safeAreaOverlap } from './pointerEffects';
147
153
  import { colorMultiplyFilter } from './colorMultiply';
154
+ import { largeTitleOf } from './largeTitle';
155
+ import { applyEdgeEffect } from './scrollEdge';
148
156
  import { DEFAULT_TYPE_SIZE, TEXT_STYLE_WEIGHT, resolveTextStyle, textStyleVar } from './typography';
149
157
 
150
158
  export type EventCallback = (event: RendererEvent) => void;
@@ -161,6 +169,23 @@ const ID_ATTR = 'sbId';
161
169
  /** `animationToken` of a `styled` box, kept across `update` to detect changes. */
162
170
  const ANIMATION_TOKEN = 'sbAnimationToken';
163
171
 
172
+ /**
173
+ * Kinds whose own changes animate even outside `withAnimation`: their token
174
+ * changes with what they present (an inspector's `presented`).
175
+ */
176
+ const IMPLICITLY_ANIMATED: ReadonlySet<ElementKind> = new Set(['inspector']);
177
+
178
+ /**
179
+ * The animation token an element of `kind` has with `props`: a `styled`
180
+ * box's `animationToken` (`.animation(_:value:)`), an inspector's
181
+ * `presented`. An `update` that changes it animates its subtree.
182
+ */
183
+ function animationTokenOf(kind: ElementKind | undefined, props: unknown): string | undefined {
184
+ if (kind === 'inspector') return (props as InspectorProps | null)?.presented === true ? 'shown' : 'hidden';
185
+ const token = (props as { style?: { animationToken?: number | null } } | null)?.style?.animationToken;
186
+ return token == null ? undefined : String(token);
187
+ }
188
+
164
189
  /** Duration of the navigation push/pop slide; keep in sync with styles.css (`--sb-nav-easing`). */
165
190
  export const NAV_TRANSITION_MS = 400;
166
191
  /** Duration of the sheet present/dismiss slide; keep in sync with styles.css. */
@@ -169,6 +194,7 @@ export const SHEET_TRANSITION_MS = 400;
169
194
  export { SHEET_DISMISS_DRAG } from './interaction';
170
195
 
171
196
  const ALL_KINDS: ReadonlySet<string> = new Set<ElementKind>([
197
+ 'host',
172
198
  'text',
173
199
  'vstack',
174
200
  'hstack',
@@ -205,6 +231,8 @@ const ALL_KINDS: ReadonlySet<string> = new Set<ElementKind>([
205
231
  'tapgesture',
206
232
  // Phase 7
207
233
  'navsplit',
234
+ 'inspector',
235
+ 'inspectorpanel',
208
236
  'viewthatfits',
209
237
  'chart',
210
238
  'chartitem',
@@ -297,11 +325,31 @@ export class Renderer {
297
325
  */
298
326
  private rootSize: { width: number; height: number } | null = null;
299
327
  private readonly resizeObserver: ResizeObserver | null = null;
328
+ /**
329
+ * `host` elements (src/hostContent.ts): their content elements, observed
330
+ * for size changes, and the measurements of the current layout pass.
331
+ */
332
+ private readonly hostObserver: ResizeObserver | null = null;
333
+ /** `hover` events for boxes whose style asks for them (pointerEffects.ts). */
334
+ private readonly hover: HoverTracker;
335
+ /** A frame check of the `reportsFrame` readers is queued for the next animation frame (after a scroll). */
336
+ private frameCheckQueued = false;
337
+ private readonly hostContents = new Map<ElementId, HTMLElement>();
338
+ private readonly hostSizes = new WeakMap<HTMLElement, { width: number; height: number }>();
339
+ private readonly hostMeasures = new Map<string, { width: number; height: number }>();
340
+ private readonly stopHostChanges: () => void;
341
+ /**
342
+ * The root takes its content's height (`LayoutEnvironment.contentHeight`):
343
+ * a SwiftUI island in a page's own layout. Set before the first commit.
344
+ */
345
+ contentHeight = false;
300
346
  /**
301
347
  * Phase 8: owns every pointer gesture (drags, long presses, swipe rows,
302
348
  * swipe back, the sheet drag, pull to refresh) and arbitrates between them.
303
349
  */
304
350
  readonly interaction: InteractionController;
351
+ /** Text, links and files dragged in from outside the page (src/dragDrop.ts). */
352
+ private readonly externalDrops: ExternalDrops;
305
353
  /** `video` elements' media events, as `video` events (src/video.ts). */
306
354
  private readonly videoEvents: VideoEvents;
307
355
  private readonly measurer = new CanvasTextMeasurer();
@@ -329,7 +377,7 @@ export class Renderer {
329
377
  /** Lazy menus: a menu the viewer opened whose items Swift is building. */
330
378
  private pendingMenu: { anchor: HTMLElement; id: ElementId; kind: 'menu' | 'contextmenu' } | null = null;
331
379
  /** Size last reported for every `geometry` element (whole px). */
332
- private readonly reportedGeometry = new Map<ElementId, { width: number; height: number }>();
380
+ private readonly reportedGeometry = new Map<ElementId, { width: number; height: number; key: string }>();
333
381
  /** `geometry` and `layout` events collected by layout passes, sent once the ops are applied. */
334
382
  private pendingGeometry: (GeometryEvent | LayoutEvent)[] = [];
335
383
  /** Phase 6: dedupes `layout` events per element (src/layoutEvents.ts). */
@@ -349,7 +397,7 @@ export class Renderer {
349
397
  // `closest` picks the innermost control, so a button inside a
350
398
  // `tapgesture` (or a menu label) handles the click alone.
351
399
  const target = (event.target as HTMLElement | null)?.closest<HTMLElement>(
352
- '.sb-button, .sb-navlink, .sb-navbar-back, .sb-switch, .sb-sheet-scrim, ' +
400
+ '.sb-button, .sb-navlink, .sb-navbar-back, .sb-navsplit-toggle, .sb-switch, .sb-sheet-scrim, ' +
353
401
  '.sb-tabbar-item, .sb-segment, .sb-picker-option, .sb-picker-menu-item, .sb-picker[data-sb-style="menu"], ' +
354
402
  '.sb-menu, .sb-tapgesture, .sb-stepper-button, .sb-alert-scrim, .sb-alert-ok, ' +
355
403
  // Phase 8: swipe action buttons, the edit-mode row control and selectable rows (the row sends `tap` itself)
@@ -450,6 +498,14 @@ export class Renderer {
450
498
  this.onEvent({ type: 'tap', id });
451
499
  return;
452
500
  }
501
+ if (target.classList.contains('sb-navsplit-toggle')) {
502
+ const split = target.closest<HTMLElement>('.sb-navsplit');
503
+ const id = Number(split?.dataset[ID_ATTR]);
504
+ if (!split || !Number.isInteger(id)) return;
505
+ event.preventDefault();
506
+ this.onEvent({ type: 'tap', id });
507
+ return;
508
+ }
453
509
  if (target.classList.contains('sb-navbar-back')) {
454
510
  const id = Number(target.dataset['sbTarget']);
455
511
  if (!Number.isInteger(id)) return;
@@ -728,6 +784,7 @@ export class Renderer {
728
784
  closeMenu: () => this.closeMenu(),
729
785
  };
730
786
  this.interaction = new InteractionController(host);
787
+ this.externalDrops = new ExternalDrops(host);
731
788
  root.addEventListener('change', this.onChange);
732
789
  root.addEventListener('pointerdown', this.onSliderPointerDown);
733
790
  if (typeof ResizeObserver !== 'undefined') {
@@ -740,6 +797,83 @@ export class Renderer {
740
797
  });
741
798
  this.resizeObserver.observe(root);
742
799
  }
800
+ this.measurer.measureHost = (content, width) => this.measureHostContent(content, width);
801
+ if (typeof ResizeObserver !== 'undefined') {
802
+ this.hostObserver = new ResizeObserver((entries) => {
803
+ let changed = false;
804
+ for (const entry of entries) {
805
+ const el = entry.target as HTMLElement;
806
+ const size = { width: el.offsetWidth, height: el.offsetHeight };
807
+ const last = this.hostSizes.get(el);
808
+ if (last && last.width === size.width && last.height === size.height) continue;
809
+ this.hostSizes.set(el, size);
810
+ changed = true;
811
+ }
812
+ if (changed) this.hostContentChanged();
813
+ });
814
+ }
815
+ this.stopHostChanges = onHostContentChange(() => this.hostContentChanged());
816
+ this.hover = new HoverTracker(root, (id, value) => this.onEvent({ type: 'hover', id, value }));
817
+ root.addEventListener('scroll', this.queueFrameCheck, { capture: true, passive: true });
818
+ }
819
+
820
+ /**
821
+ * Geometry readers with `reportsFrame` report where they are on screen,
822
+ * which scrolling changes without a layout pass: after a scroll their
823
+ * frames are checked once per animation frame.
824
+ */
825
+ private readonly queueFrameCheck = (): void => {
826
+ if (this.frameCheckQueued || this.commits === 0) return;
827
+ this.frameCheckQueued = true;
828
+ requestAnimationFrame(() => {
829
+ this.frameCheckQueued = false;
830
+ if (!this.layoutResult) return;
831
+ this.collectGeometry(this.layoutResult, true);
832
+ if (this.applying === 0) this.flushGeometry();
833
+ });
834
+ };
835
+
836
+ /**
837
+ * A `host` element's content changed (the page said so, or it resized):
838
+ * the hosts are measured again and the tree laid out.
839
+ */
840
+ private hostContentChanged(): void {
841
+ if (this.commits === 0 || this.hostContents.size === 0) return;
842
+ for (const id of this.hostContents.keys()) {
843
+ const node = this.nodes.get(id);
844
+ if (node) this.engine?.invalidate(node);
845
+ }
846
+ this.layout();
847
+ }
848
+
849
+ /**
850
+ * The host measurer (src/layout, `measureHost`): the content element laid
851
+ * out at `width`, or at its own ideal width when null. Measured in place,
852
+ * so the content sees the device's tokens and fonts; the styles it needs
853
+ * for the measurement are restored right after.
854
+ */
855
+ private measureHostContent(content: string, width: number | null): { width: number; height: number } {
856
+ const key = `${content}\u0000${width ?? ''}`;
857
+ const cached = this.hostMeasures.get(key);
858
+ if (cached) return cached;
859
+ const el = hostContent(content);
860
+ if (!el) return { width: 0, height: 0 };
861
+ const { style } = el;
862
+ const saved = { width: style.width, height: style.height, maxWidth: style.maxWidth };
863
+ style.height = 'auto';
864
+ if (width === null) {
865
+ style.width = 'max-content';
866
+ style.maxWidth = 'none';
867
+ } else {
868
+ style.width = `${width}px`;
869
+ }
870
+ const rect = el.getBoundingClientRect();
871
+ const size = { width: Math.ceil(rect.width), height: Math.ceil(rect.height) };
872
+ style.width = saved.width;
873
+ style.height = saved.height;
874
+ style.maxWidth = saved.maxWidth;
875
+ this.hostMeasures.set(key, size);
876
+ return size;
743
877
  }
744
878
 
745
879
  /** Apply a batch of ops in order. Throws `RendererError` on an invalid op. */
@@ -818,10 +952,10 @@ export class Renderer {
818
952
  const op = ops[index]!;
819
953
  if (op.op === 'commit') return op.animation != null;
820
954
  if (op.op !== 'update') continue;
821
- const token = (op.props as { style?: { animationToken?: number | null } } | null)?.style?.animationToken;
822
- if (token == null) continue;
823
955
  const el = this.elements.get(op.id);
824
- if (!el || el.dataset[ANIMATION_TOKEN] !== String(token)) return true;
956
+ const token = animationTokenOf(el?.dataset[KIND_ATTR] as ElementKind | undefined, op.props);
957
+ if (token == null) continue;
958
+ if (!el || el.dataset[ANIMATION_TOKEN] !== token) return true;
825
959
  }
826
960
  return true;
827
961
  }
@@ -946,6 +1080,7 @@ export class Renderer {
946
1080
  devicePixelRatio: typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1,
947
1081
  colorScheme: this.environment.colorScheme,
948
1082
  assets: this.assets,
1083
+ ...(this.contentHeight ? { contentHeight: true } : {}),
949
1084
  };
950
1085
  }
951
1086
 
@@ -968,6 +1103,7 @@ export class Renderer {
968
1103
  layout(): BoxChange[] {
969
1104
  const root = this.nodes.get(ROOT_ID);
970
1105
  if (!root) return [];
1106
+ this.hostMeasures.clear();
971
1107
  const env = this.layoutEnvironment();
972
1108
  const envKey = JSON.stringify({ ...env, assets: undefined });
973
1109
  if (!this.engine || envKey !== this.engineEnvKey) {
@@ -991,6 +1127,15 @@ export class Renderer {
991
1127
  };
992
1128
  const changes = applyLayout(ctx, result);
993
1129
  this.boxes = ctx.boxes;
1130
+ if (this.contentHeight) {
1131
+ // The island's root is as tall as its content.
1132
+ let bottom = 0;
1133
+ for (const child of root.children) {
1134
+ const frame = result.frames.get(child.id);
1135
+ if (frame) bottom = Math.max(bottom, frame.y + frame.height);
1136
+ }
1137
+ this.root.style.height = `${bottom + env.safeArea.bottom}px`;
1138
+ }
994
1139
  this.collectGeometry(result);
995
1140
  this.collectLayouts(result);
996
1141
  if (this.applying === 0) this.flushGeometry();
@@ -1004,17 +1149,35 @@ export class Renderer {
1004
1149
  * (whole px) differs from the one last reported. Nothing is queued for an
1005
1150
  * unchanged size, which is what lets the Swift re-render loop converge.
1006
1151
  */
1007
- private collectGeometry(result: LayoutResult): void {
1152
+ private collectGeometry(result: LayoutResult, framesOnly = false): void {
1153
+ let rootRect: DOMRect | null = null;
1008
1154
  for (const [id, node] of this.nodes) {
1009
1155
  if (node.kind !== 'geometry' && node.props['reportsSize'] !== true) continue;
1156
+ const reportsFrame = node.props['reportsFrame'] === true;
1157
+ if (framesOnly && !reportsFrame) continue;
1010
1158
  const frame = result.frames.get(id);
1011
1159
  if (!frame) continue; // e.g. inside a hidden tab
1012
1160
  const width = Math.round(frame.width);
1013
1161
  const height = Math.round(frame.height);
1162
+ const event: GeometryEvent = { type: 'geometry', id, width, height };
1163
+ let key = `${width}x${height}`;
1164
+ if (reportsFrame) {
1165
+ // Where it is drawn now (scrolled), in the root's coordinates.
1166
+ const el = this.elements.get(id);
1167
+ rootRect ??= this.root.getBoundingClientRect();
1168
+ const rect = el?.getBoundingClientRect();
1169
+ const x = rect ? Math.round(rect.left - rootRect.left) : Math.round(frame.x);
1170
+ const y = rect ? Math.round(rect.top - rootRect.top) : Math.round(frame.y);
1171
+ const env = this.layoutEnvironment();
1172
+ event.x = x;
1173
+ event.y = y;
1174
+ event.safeArea = safeAreaOverlap({ x, y, width, height }, env.screen, env.safeArea);
1175
+ key += `@${x},${y}/${event.safeArea.top},${event.safeArea.leading},${event.safeArea.bottom},${event.safeArea.trailing}`;
1176
+ }
1014
1177
  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 });
1178
+ if (last && last.key === key) continue;
1179
+ this.reportedGeometry.set(id, { width, height, key });
1180
+ this.pendingGeometry.push(event);
1018
1181
  }
1019
1182
  }
1020
1183
 
@@ -1193,8 +1356,13 @@ export class Renderer {
1193
1356
  this.root.removeEventListener('focusout', this.onFocusChange);
1194
1357
  this.root.removeEventListener('scroll', this.onScrollDismissKeyboard, true);
1195
1358
  this.interaction.dispose();
1359
+ this.externalDrops.dispose();
1196
1360
  this.videoEvents.dispose();
1197
1361
  this.resizeObserver?.disconnect();
1362
+ this.hostObserver?.disconnect();
1363
+ this.stopHostChanges();
1364
+ this.hover.dispose();
1365
+ this.root.removeEventListener('scroll', this.queueFrameCheck, true);
1198
1366
  this.root.removeEventListener('change', this.onChange);
1199
1367
  this.root.removeEventListener('pointerdown', this.onSliderPointerDown);
1200
1368
  this.reset();
@@ -1218,6 +1386,24 @@ export class Renderer {
1218
1386
  applyProps(el, kind, props, this.assets);
1219
1387
  this.noteScroll(id, kind, props);
1220
1388
  if (kind === 'lazyspacer') this.lazySpacers.add(id);
1389
+ if (kind === 'host') this.adoptHostContent(id, el, props as HostProps);
1390
+ }
1391
+
1392
+ /** A `host` element takes the page's content element (src/hostContent.ts) and watches its size. */
1393
+ private adoptHostContent(id: ElementId, el: HTMLElement, props: HostProps): void {
1394
+ const content = hostContent(props.content);
1395
+ const previous = this.hostContents.get(id);
1396
+ if (previous && previous !== content) {
1397
+ this.hostObserver?.unobserve(previous);
1398
+ previous.remove();
1399
+ this.hostContents.delete(id);
1400
+ }
1401
+ if (!content) return;
1402
+ if (content.parentElement !== el) el.appendChild(content);
1403
+ if (previous !== content) {
1404
+ this.hostContents.set(id, content);
1405
+ this.hostObserver?.observe(content);
1406
+ }
1221
1407
  }
1222
1408
 
1223
1409
  /** After a scroll, the lazy spacers are checked on the next frame (once per frame). */
@@ -1291,6 +1477,24 @@ export class Renderer {
1291
1477
  if (!el) throw new RendererError(`Cannot update unknown element ${id}`, op);
1292
1478
  const kind = el.dataset[KIND_ATTR] as ElementKind;
1293
1479
  const node = this.nodes.get(id);
1480
+ // `.contentTransition`: a text changing under one keeps a copy of how it
1481
+ // looked, for the Animator to play the change with (when the pass animates).
1482
+ if (kind === 'text' && node && this.passAnimates !== false && (props as TextProps).text !== node.props['text']) {
1483
+ const host = el.closest<HTMLElement>('[data-sb-content-transition]');
1484
+ if (host && this.root.contains(host)) {
1485
+ this.passRootRect ??= this.animator.rootRect();
1486
+ const rect = this.animator.rectOf(el, this.passRootRect);
1487
+ if (rect) {
1488
+ this.pass.contentChanges.push({
1489
+ el,
1490
+ clone: ghostClone(el),
1491
+ rect,
1492
+ kind: host.dataset['sbContentTransition'] as 'numericText' | 'opacity' | 'interpolate',
1493
+ countsDown: countsDown(host.dataset['sbCountsDown'], String(node.props['text'] ?? ''), String((props as TextProps).text ?? '')),
1494
+ });
1495
+ }
1496
+ }
1497
+ }
1294
1498
  if (node) {
1295
1499
  node.props = asRecord(props);
1296
1500
  // Phase 9: the next layout re-measures this subtree and its ancestors;
@@ -1324,9 +1528,11 @@ export class Renderer {
1324
1528
  if (kind === 'text') el.textContent = '';
1325
1529
  applyProps(el, kind, props, this.assets);
1326
1530
  this.noteScroll(id, kind, props);
1531
+ if (kind === 'host') this.adoptHostContent(id, el, props as HostProps);
1327
1532
  const newToken = el.dataset[ANIMATION_TOKEN];
1328
1533
  const tokenChanged = (previous?.tokenChanged ?? false) || (newToken !== undefined && newToken !== oldToken);
1329
1534
  const record: UpdatedRecord = { el, before, tokenChanged };
1535
+ if (IMPLICITLY_ANIMATED.has(kind)) record.implicit = true;
1330
1536
  const animation = ANIMATIONS.get(el);
1331
1537
  if (animation) record.animation = animation;
1332
1538
  this.pass.updated.set(el, record);
@@ -1450,11 +1656,19 @@ export class Renderer {
1450
1656
  const ancestors: HTMLElement[] = [];
1451
1657
  for (let a = parent; a && a !== this.root; a = a.parentElement!) ancestors.push(a);
1452
1658
  let rect: DOMRect | null = null;
1659
+ const matched: { id: string; el: HTMLElement; rect: DOMRect }[] = [];
1453
1660
  if (this.passAnimates !== false) {
1454
1661
  this.passRootRect ??= this.animator.rootRect();
1455
1662
  rect = this.animator.rectOf(el, this.passRootRect);
1663
+ // `.matchedGeometryEffect`: where the matched boxes in the subtree were.
1664
+ const candidates = el.dataset['sbMatched'] !== undefined ? [el] : [];
1665
+ candidates.push(...el.querySelectorAll<HTMLElement>('[data-sb-matched]'));
1666
+ for (const box of candidates) {
1667
+ const boxRect = this.animator.rectOf(box, this.passRootRect);
1668
+ if (boxRect) matched.push({ id: box.dataset['sbMatched']!, el: box, rect: boxRect });
1669
+ }
1456
1670
  }
1457
- this.pass.removed.push({ el, ancestors, rect, transition: transitionOf(el) });
1671
+ this.pass.removed.push({ el, ancestors, rect, transition: transitionOf(el), ...(matched.length ? { matched } : {}) });
1458
1672
  }
1459
1673
  this.pass.inserted.delete(el);
1460
1674
  this.pass.updated.delete(el);
@@ -1480,6 +1694,11 @@ export class Renderer {
1480
1694
 
1481
1695
  /** Forget an element in every map; detaches its node from the tree. */
1482
1696
  private forget(id: ElementId): void {
1697
+ const content = this.hostContents.get(id);
1698
+ if (content) {
1699
+ this.hostObserver?.unobserve(content);
1700
+ this.hostContents.delete(id);
1701
+ }
1483
1702
  this.elements.delete(id);
1484
1703
  this.boxes.delete(id);
1485
1704
  this.dirty.delete(id);
@@ -1705,6 +1924,9 @@ function div(className: string): HTMLDivElement {
1705
1924
  /** Create the element and any chrome it owns; props are applied separately. */
1706
1925
  function createShell(kind: ElementKind): HTMLElement {
1707
1926
  switch (kind) {
1927
+ case 'host':
1928
+ // The page's content element is adopted by the renderer (`adoptHostContent`).
1929
+ return div('sb-host');
1708
1930
  case 'button': {
1709
1931
  const el = document.createElement('button');
1710
1932
  el.type = 'button';
@@ -1912,6 +2134,15 @@ function createShell(kind: ElementKind): HTMLElement {
1912
2134
  }
1913
2135
  case 'tapgesture':
1914
2136
  return document.createElement('div');
2137
+ case 'inspector': {
2138
+ // The content and the panel go into the slot; the panel draws the
2139
+ // hairline before it (styles.css), so it slides with the panel.
2140
+ const el = document.createElement('div');
2141
+ const slot = div('sb-slot sb-inspector-columns');
2142
+ el.append(slot);
2143
+ SLOTS.set(el, slot);
2144
+ return el;
2145
+ }
1915
2146
  case 'navsplit': {
1916
2147
  // Phase 7: the two columns go into the slot; the separator is chrome
1917
2148
  // positioned from the engine's `separator` rect.
@@ -1919,15 +2150,25 @@ function createShell(kind: ElementKind): HTMLElement {
1919
2150
  const slot = div('sb-slot sb-navsplit-columns');
1920
2151
  const separator = div('sb-navsplit-separator');
1921
2152
  separator.setAttribute('aria-hidden', 'true');
1922
- el.append(slot, separator);
2153
+ // iPadOS: the sidebar toggle, at the engine's `toggle` rect; a tap
2154
+ // asks Swift to show or hide the sidebar.
2155
+ const toggle = document.createElement('button');
2156
+ toggle.type = 'button';
2157
+ toggle.className = 'sb-navsplit-toggle';
2158
+ toggle.setAttribute('aria-label', 'Toggle Sidebar');
2159
+ const symbol = renderSymbol('sidebar.left');
2160
+ if (symbol) toggle.append(symbol);
2161
+ el.append(slot, separator, toggle);
1923
2162
  SLOTS.set(el, slot);
1924
2163
  return el;
1925
2164
  }
1926
2165
  case 'viewthatfits':
1927
2166
  return document.createElement('div');
1928
- // Phase 8: gestures, rows, navigation
2167
+ // Phase 8: gestures, rows, navigation; drag and drop
1929
2168
  case 'gesture':
1930
2169
  case 'contextmenu':
2170
+ case 'draggable':
2171
+ case 'dropdestination':
1931
2172
  return document.createElement('div');
1932
2173
  case 'swipeactions':
1933
2174
  case 'listrow': {
@@ -1958,6 +2199,15 @@ function createShell(kind: ElementKind): HTMLElement {
1958
2199
  }
1959
2200
  control.append(circle);
1960
2201
  el.append(control);
2202
+ // The reorder grip of a movable row (`chrome.reorderControl`), at the
2203
+ // trailing edge: three lines, as iOS draws it. It lives in the content
2204
+ // slot, so it slides with the content when the minus (or a swipe)
2205
+ // reveals Delete; the control above stays, as on iOS.
2206
+ const grip = div('sb-listrow-grip');
2207
+ grip.hidden = true;
2208
+ grip.setAttribute('aria-label', 'Reorder');
2209
+ grip.append(gripGlyph());
2210
+ slot.append(grip);
1961
2211
  }
1962
2212
  return el;
1963
2213
  }
@@ -2064,6 +2314,22 @@ function isSectionSupplement(el: HTMLElement): boolean {
2064
2314
  return el.classList.contains('sb-sectionheader') || el.classList.contains('sb-sectionfooter');
2065
2315
  }
2066
2316
 
2317
+ /** The reorder grip as iOS draws it: three 21 pt lines, 1.33 thick, 4.67 apart. */
2318
+ function gripGlyph(): SVGSVGElement {
2319
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
2320
+ svg.setAttribute('viewBox', '0 0 21 11');
2321
+ svg.setAttribute('aria-hidden', 'true');
2322
+ for (const y of [0.67, 5.33, 10]) {
2323
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
2324
+ line.setAttribute('x1', '0');
2325
+ line.setAttribute('x2', '21');
2326
+ line.setAttribute('y1', String(y));
2327
+ line.setAttribute('y2', String(y));
2328
+ svg.append(line);
2329
+ }
2330
+ return svg;
2331
+ }
2332
+
2067
2333
  function span(className: string): HTMLSpanElement {
2068
2334
  const el = document.createElement('span');
2069
2335
  el.className = className;
@@ -2413,6 +2679,9 @@ function applyTextEditor(area: HTMLTextAreaElement, props: TextEditorProps): voi
2413
2679
 
2414
2680
  function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, assets: AssetCatalog): void {
2415
2681
  switch (kind) {
2682
+ case 'host':
2683
+ el.dataset['sbSizing'] = (props as HostProps).sizing;
2684
+ break;
2416
2685
  case 'text':
2417
2686
  applyText(el, props as TextProps, assets);
2418
2687
  break;
@@ -2447,8 +2716,15 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
2447
2716
  case 'navstack':
2448
2717
  case 'geometry':
2449
2718
  case 'menu':
2719
+ case 'inspector':
2720
+ // The engine reads `presented`. Showing or hiding the panel animates
2721
+ // by default, as in SwiftUI (`animationTokenOf`).
2722
+ el.dataset[ANIMATION_TOKEN] = animationTokenOf(kind, props)!;
2723
+ break;
2724
+ case 'inspectorpanel':
2725
+ break;
2450
2726
  case 'navsplit':
2451
- // No props.
2727
+ // No props (a navsplit's `sidebarVisible` is read by the engine and layoutDom).
2452
2728
  break;
2453
2729
  case 'navlink':
2454
2730
  // Phase 7: a selected row (inside a `List(selection:)`) gets a rounded fill.
@@ -2477,6 +2753,7 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
2477
2753
  // `.scrollContentBackground(.hidden)`: no grouped background (styles.css).
2478
2754
  if (list.background === 'hidden') el.dataset['sbBackground'] = 'hidden';
2479
2755
  if (list.dismissesKeyboard === true) el.dataset['sbDismissesKeyboard'] = 'true';
2756
+ applyEdgeEffect(el, list.edgeEffect);
2480
2757
  break;
2481
2758
  }
2482
2759
  case 'tabview': {
@@ -2505,6 +2782,7 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
2505
2782
  if (scroll.snap === 'viewAligned' || scroll.snap === 'paging') el.dataset['sbSnap'] = scroll.snap;
2506
2783
  else delete el.dataset['sbSnap'];
2507
2784
  if (scroll.dismissesKeyboard === true) el.dataset['sbDismissesKeyboard'] = 'true';
2785
+ applyEdgeEffect(el, scroll.edgeEffect);
2508
2786
  break;
2509
2787
  }
2510
2788
  case 'section':
@@ -2587,11 +2865,19 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
2587
2865
  }
2588
2866
  case 'contextmenu':
2589
2867
  break;
2868
+ case 'draggable':
2869
+ break;
2870
+ case 'dropdestination': {
2871
+ const accepts = (props as DropDestinationProps | undefined)?.accepts;
2872
+ el.dataset['sbAccepts'] = Array.isArray(accepts) ? accepts.filter((a) => typeof a === 'string').join(' ') : '';
2873
+ break;
2874
+ }
2590
2875
  case 'listrow': {
2591
2876
  const row = (props as ListRowProps | undefined) ?? {};
2592
2877
  el.toggleAttribute('data-sb-selectable', row.selectable === true);
2593
2878
  el.toggleAttribute('data-sb-selected', row.selected === true);
2594
2879
  el.toggleAttribute('data-sb-deletable', row.deletable === true);
2880
+ el.toggleAttribute('data-sb-movable', row.movable === true);
2595
2881
  if (row.selectable) el.setAttribute('aria-selected', String(row.selected === true));
2596
2882
  else el.removeAttribute('aria-selected');
2597
2883
  const control = el.querySelector<HTMLElement>(':scope > .sb-listrow-control');
@@ -3051,7 +3337,7 @@ function applyNavScreen(el: HTMLElement, props: NavScreenProps): void {
3051
3337
  el.dataset['sbDepth'] = String(depth);
3052
3338
  el.dataset['sbDisplay'] = large ? 'large' : 'inline';
3053
3339
  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');
3340
+ const largeTitle = largeTitleOf(el);
3055
3341
  // A large title that scrolls away collapses into the inline one (shown
3056
3342
  // with `data-sb-collapsed`, src/largeTitle.ts).
3057
3343
  if (inlineTitle) inlineTitle.textContent = title;
@@ -3201,7 +3487,7 @@ export function applyStyle(el: HTMLElement, style: Style): void {
3201
3487
  // typography.ts); the fallbacks are the Large sizes.
3202
3488
  const large = resolveTextStyle(textStyle, DEFAULT_TYPE_SIZE);
3203
3489
  s.fontSize = `var(${textStyleVar(textStyle, 'size')}, ${large.fontSize}px)`;
3204
- s.lineHeight = `var(${textStyleVar(textStyle, 'lh')}, ${large.lineHeight}px)`;
3490
+ s.lineHeight = `var(${textStyleVar(textStyle, 'lh')}, ${Math.round(large.lineHeight * 100) / 100}px)`;
3205
3491
  el.dataset['sbTextStyle'] = textStyle;
3206
3492
  const implied = TEXT_STYLE_WEIGHT[textStyle];
3207
3493
  if (!weight && implied) s.fontWeight = FONT_WEIGHT[implied];
@@ -3309,6 +3595,8 @@ export function applyStyle(el: HTMLElement, style: Style): void {
3309
3595
  s.pointerEvents = 'none';
3310
3596
  el.dataset['sbHitTesting'] = 'false';
3311
3597
  }
3598
+ // Hover, tooltips, cursors, symbol effects and matched geometry ids (pointerEffects.ts).
3599
+ applyPointerAndEffects(el, style);
3312
3600
 
3313
3601
  let opacity = style.opacity;
3314
3602
  if (style.disabled) {
@@ -3439,3 +3727,16 @@ function lazySpacerIsNear(el: HTMLElement): boolean {
3439
3727
  }
3440
3728
  return rect.top < view.bottom + view.height && rect.bottom > view.top - view.height;
3441
3729
  }
3730
+
3731
+ /**
3732
+ * Which way a `.numericText` change rolls: the direction it names, or else
3733
+ * down when the new text reads as a smaller number than the old one (as
3734
+ * SwiftUI's `numericText(value:)` decides from the value).
3735
+ */
3736
+ export function countsDown(named: string | undefined, before: string, after: string): boolean {
3737
+ if (named !== undefined) return named === 'true';
3738
+ const number = (text: string): number => Number.parseFloat(text.replace(/[^\d.+-]/g, ''));
3739
+ const from = number(before);
3740
+ const to = number(after);
3741
+ return Number.isFinite(from) && Number.isFinite(to) && to < from;
3742
+ }