@swiftbrowser/web 0.18.3 → 0.21.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
@@ -116,6 +116,7 @@ import {
116
116
  type ScrollViewProps,
117
117
  type SearchFieldProps,
118
118
  type SectionProps,
119
+ type DisclosureGroupProps,
119
120
  type ShapeProps,
120
121
  type SheetProps,
121
122
  type SpacerProps,
@@ -125,6 +126,7 @@ import {
125
126
  type TabProps,
126
127
  type TabViewProps,
127
128
  type HostProps,
129
+ type RegisteredProps,
128
130
  type TextFieldProps,
129
131
  type TextProps,
130
132
  type TextRun,
@@ -170,8 +172,9 @@ import { TouchPaging } from './touchPaging';
170
172
  import { isScrollVisible } from './scrollVisibility';
171
173
  import { applyDefaultScrollAnchor, keepScrollAnchors } from './scrollAnchor';
172
174
  import { EMPTY_CATALOG, assetColorVar, type AssetCatalog } from './assets';
173
- import { HIERARCHY_OPACITY, colorToCSS, effectsCSS, styleToCSS, type PaintCSS } from './styles';
175
+ import { HIERARCHY_OPACITY, colorToCSS, effectsCSS, shadowCSS, styleToCSS, type PaintCSS } from './styles';
174
176
  import { hostContent, onHostContentChange } from './hostContent';
177
+ import { type RegisteredViewInstance, ViewRegistry } from './viewRegistry';
175
178
  import { HoverTracker, applyPointerAndEffects, safeAreaOverlap } from './pointerEffects';
176
179
  import { colorMultiplyFilter } from './colorMultiply';
177
180
  import { largeTitleOf, largeTitleTextOf } from './largeTitle';
@@ -192,6 +195,14 @@ export class RendererError extends Error {
192
195
  }
193
196
 
194
197
  const KIND_ATTR = 'sbKind';
198
+ /**
199
+ * The kinds that draw what they show themselves: a shadow of a box around one falls on it as a whole (castShadows,
200
+ * styles.css), where a container hands it on to the views inside.
201
+ */
202
+ const DRAWN_KINDS: ReadonlySet<string> = new Set([
203
+ 'text', 'image', 'divider', 'color', 'shape', 'textfield', 'toggle', 'picker', 'gauge', 'searchfield', 'registered', 'chart',
204
+ 'stepper', 'slider', 'datepicker', 'texteditor', 'video', 'canvas',
205
+ ]);
195
206
  const ID_ATTR = 'sbId';
196
207
  /** `animationToken` of a `styled` box, kept across `update` to detect changes. */
197
208
  const ANIMATION_TOKEN = 'sbAnimationToken';
@@ -251,6 +262,7 @@ export { SHEET_DISMISS_DRAG } from './interaction';
251
262
 
252
263
  const ALL_KINDS: ReadonlySet<string> = new Set<ElementKind>([
253
264
  'host',
265
+ 'registered',
254
266
  'text',
255
267
  'vstack',
256
268
  'hstack',
@@ -304,6 +316,8 @@ const ALL_KINDS: ReadonlySet<string> = new Set<ElementKind>([
304
316
  // A `Section`'s view header / footer
305
317
  'sectionheader',
306
318
  'sectionfooter',
319
+ // `DisclosureGroup`: its label with a chevron, its content below once expanded
320
+ 'disclosuregroup',
307
321
  // An `AVPlayer` shown by a view (src/video.ts)
308
322
  'video',
309
323
  'lazyspacer',
@@ -320,10 +334,14 @@ const SLIDER_THUMB = 38;
320
334
  export const PICKER_MENU_WIDTH = 250;
321
335
  /** Row height inside the picker menu. */
322
336
  export const PICKER_MENU_ROW = 44;
337
+ /** A row with a subtitle under its title (styles.css, `[data-sb-subtitled]`). */
338
+ const MENU_SUBTITLED_ROW = 58;
323
339
  /** Height of a section header caption row in the popover (Phase 6). */
324
- const MENU_CAPTION_HEIGHT = 32;
325
- /** Thickness of a group separator in the popover (Phase 6). */
326
- const MENU_SEPARATOR_HEIGHT = 8;
340
+ const MENU_CAPTION_HEIGHT = 30;
341
+ /** A group separator in the popover: a hairline with 9px above and below (iOS 26). */
342
+ const MENU_SEPARATOR_HEIGHT = 19;
343
+ /** The popover's padding above its first row and below its last (iOS 26's inset menu); keep in sync with styles.css. */
344
+ const MENU_PADDING = 10;
327
345
  /** Gap between the control and its menu. */
328
346
  const PICKER_MENU_GAP = 4;
329
347
  /** Minimum distance between the menu and the screen edge. */
@@ -415,6 +433,8 @@ export class Renderer {
415
433
  private readonly hostContents = new Map<ElementId, HTMLElement>();
416
434
  /** The `host` elements, adopted or still waiting for their content. */
417
435
  private readonly hosts = new Set<ElementId>();
436
+ /** The registered views (src/viewRegistry.ts) built for `registered` elements, by element: the name each was built for. */
437
+ private readonly registeredViews = new Map<ElementId, { name: string; instance: RegisteredViewInstance | undefined }>();
418
438
  private readonly hostSizes = new WeakMap<HTMLElement, { width: number; height: number }>();
419
439
  private readonly hostMeasures = new Map<string, { width: number; height: number }>();
420
440
  private readonly stopHostChanges: () => void;
@@ -433,6 +453,12 @@ export class Renderer {
433
453
  private readonly pressEffects: PressEffects;
434
454
  /** Text, links and files dragged in from outside the page (src/dragDrop.ts). */
435
455
  private readonly externalDrops: ExternalDrops;
456
+ /**
457
+ * How a dropped file reaches the app: base64 `data` in the event (a Swift
458
+ * app) or the `File` by reference (a React app in the same page sets
459
+ * `reference`; a dropped folder then comes as its files by path).
460
+ */
461
+ fileDrops: 'bytes' | 'reference' = 'bytes';
436
462
  private readonly pinches: PinchController;
437
463
  private readonly touches: TouchesController;
438
464
  private readonly touchPaging: TouchPaging;
@@ -515,16 +541,24 @@ export class Renderer {
515
541
  // `tapgesture` (or a menu label) handles the click alone. Elsewhere in
516
542
  // a list row, the row's one button (`rowAction`).
517
543
  const hit = event.target as HTMLElement | null;
518
- const target = hit?.closest<HTMLElement>(
544
+ let target = hit?.closest<HTMLElement>(
519
545
  '.sb-button, .sb-navlink, .sb-navbar-back, .sb-navbar-more, .sb-navsplit-toggle, .sb-switch, .sb-sheet-scrim, ' +
520
546
  '.sb-toggle[data-sb-toggle-style="button"], .sb-toolbar .sb-toggle:not([data-sb-toggle-style]), ' +
521
547
  '.sb-tabbar-item, .sb-segment, .sb-picker-option, .sb-picker-menu-item, .sb-picker[data-sb-style="menu"], ' +
522
548
  '.sb-menu, .sb-tapgesture, .sb-stepper-button, .sb-alert-scrim, .sb-alert-ok, ' +
523
549
  // Phase 8: swipe action buttons, the edit-mode row control and selectable rows (the row sends `tap` itself)
524
550
  '.sb-swipe-action, .sb-listrow-control, .sb-listrow[data-sb-selectable], ' +
551
+ // A disclosure group's label, a collapsible section's header: a `tap` on the element toggles it
552
+ '.sb-disclosure-toggle, .sb-section[data-sb-collapsible] > .sb-section-header, .sb-section[data-sb-collapsible] .sb-sectionheader, ' +
525
553
  // A link in a rich text: innermost, so it wins over a tappable row around the text
526
554
  '.sb-link',
527
555
  ) ?? (hit ? rowAction(hit, this.root) : null);
556
+ // A link or button row of a list in edit mode is selected, not followed
557
+ // or pressed: the tap is the row's (its `tag`), as iOS selects the row then.
558
+ if (target?.classList.contains('sb-navlink') || target?.classList.contains('sb-button')) {
559
+ const row = target.closest<HTMLElement>('.sb-listrow[data-sb-selectable]');
560
+ if (row && row.closest('.sb-list')?.hasAttribute('data-sb-editing')) target = row;
561
+ }
528
562
  if (!target || !this.root.contains(target)) return;
529
563
  if (target.classList.contains('sb-link')) {
530
564
  const text = target.closest<HTMLElement>('.sb-text');
@@ -535,6 +569,19 @@ export class Renderer {
535
569
  this.onEvent({ type: 'link', id, value: url });
536
570
  return;
537
571
  }
572
+ const sectionHeader = target.classList.contains('sb-section-header') || target.classList.contains('sb-sectionheader');
573
+ if (target.classList.contains('sb-disclosure-toggle') || sectionHeader) {
574
+ // `DisclosureGroup` and `Section(isExpanded:)`: the tap toggles the
575
+ // element (a section's only in a sidebar-style list, where it collapses).
576
+ const section = sectionHeader;
577
+ const owner = target.closest<HTMLElement>(section ? '.sb-section' : '.sb-disclosuregroup');
578
+ const id = Number(owner?.dataset[ID_ATTR]);
579
+ if (!owner || !Number.isInteger(id)) return;
580
+ if (section && owner.closest<HTMLElement>('.sb-list')?.dataset['sbListStyle'] !== 'sidebar') return;
581
+ event.preventDefault();
582
+ this.onEvent({ type: 'tap', id });
583
+ return;
584
+ }
538
585
  if (this.interaction.handleClick(target)) {
539
586
  event.preventDefault();
540
587
  return;
@@ -588,7 +635,7 @@ export class Renderer {
588
635
  return;
589
636
  }
590
637
  if (target.classList.contains('sb-picker-menu-item')) {
591
- // A popover row: a picker option (`select`) or a button / navlink (`tap`).
638
+ // A popover row: a picker option (`select`), a toggle, or a button / navlink (`tap`).
592
639
  if (!this.menu) return;
593
640
  const action = target.dataset['sbAction'];
594
641
  const id = Number(target.dataset['sbTarget']);
@@ -597,6 +644,7 @@ export class Renderer {
597
644
  event.preventDefault();
598
645
  this.closeMenu();
599
646
  if (action === 'tap') this.onEvent({ type: 'tap', id });
647
+ else if (action === 'toggle') this.onEvent({ type: 'toggle', id, value: target.getAttribute('aria-checked') !== 'true' });
600
648
  else if (Number.isInteger(index)) this.onEvent({ type: 'select', id, value: index });
601
649
  return;
602
650
  }
@@ -996,6 +1044,7 @@ export class Renderer {
996
1044
  root,
997
1045
  send: (event) => this.onEvent(event),
998
1046
  nodeOf: (id) => this.nodes.get(id),
1047
+ fileDrops: () => this.fileDrops,
999
1048
  openContextMenu: (anchor, id) => this.openContextMenu(anchor, id),
1000
1049
  revealScreen: (screen) => this.revealScreen(screen),
1001
1050
  isMenuOpen: (id) => this.menu?.anchorId === id,
@@ -1229,6 +1278,7 @@ export class Renderer {
1229
1278
  this.syncFocus();
1230
1279
  // The focus the app moved shows or hides a screen's keyboard bar.
1231
1280
  if (this.focusedFieldId() !== focusedBefore && this.hasKeyboardToolbars()) this.layout();
1281
+ this.castShadows();
1232
1282
  this.performScrolls(op.animation != null);
1233
1283
  this.performOffsetScrolls(op.animation != null);
1234
1284
  this.checkLazySpacers();
@@ -1246,6 +1296,55 @@ export class Renderer {
1246
1296
  }
1247
1297
  }
1248
1298
 
1299
+ /**
1300
+ * SwiftUI's `.shadow` on a container falls on each view it draws (its texts, shapes, images, controls and
1301
+ * backgrounds), on their own shadows too, and not on the container as a whole unless a `.compositingGroup()` in
1302
+ * between draws the views as one. Each box with a shadow (`data-sb-shadow`) hands its views the shadows to cast:
1303
+ * its own, then those of the boxes around it, up to a compositing group (`--sb-shadow-filter`, which styles.css
1304
+ * gives each drawn view's `filter`, and which a box that clips, masks or composites takes for itself). A box's own
1305
+ * background, painted with its children inside, casts them as `box-shadow` instead.
1306
+ */
1307
+ private castShadows(): void {
1308
+ const sources = this.root.querySelectorAll<HTMLElement>('[data-sb-shadow]');
1309
+ const cast = new Set(this.root.querySelectorAll<HTMLElement>('[data-sb-shadow-cast]'));
1310
+ if (sources.length === 0 && cast.size === 0) return;
1311
+ const filters = new Map<HTMLElement, { filter: string; box: string }>();
1312
+ // The shadows that reach the views inside `el` from the boxes around it, up to a compositing group or a drawn
1313
+ // view, which takes those around it as one and hands none on.
1314
+ const above = (el: HTMLElement): { filter: string; box: string } => {
1315
+ for (let up = el.parentElement; up && up !== this.root; up = up.parentElement) {
1316
+ if (up.dataset['sbComposite'] !== undefined || DRAWN_KINDS.has(up.dataset[KIND_ATTR] ?? '')) break;
1317
+ const found = filters.get(up);
1318
+ if (found) return found;
1319
+ }
1320
+ return { filter: '', box: '' };
1321
+ };
1322
+ // Document order: a box comes before the boxes inside it.
1323
+ for (const el of sources) {
1324
+ const outer = above(el);
1325
+ const filter = `${el.dataset['sbShadow']} ${outer.filter}`.trim();
1326
+ const box = [el.dataset['sbShadowBox'], outer.box].filter(Boolean).join(', ');
1327
+ filters.set(el, { filter, box });
1328
+ if (el.style.getPropertyValue('--sb-shadow-filter') !== filter) el.style.setProperty('--sb-shadow-filter', filter);
1329
+ }
1330
+ // A background painted on a box (its children inside) casts them as a box-shadow.
1331
+ if (sources.length > 0) {
1332
+ // A box that clips, masks or composites casts them for its background already, with what it holds.
1333
+ for (const el of this.root.querySelectorAll<HTMLElement>('.sb-styled[data-sb-background]:not([data-sb-as-one])')) {
1334
+ const { box } = filters.get(el) ?? above(el);
1335
+ if (!box) continue;
1336
+ if (el.style.boxShadow !== box) el.style.boxShadow = box;
1337
+ el.dataset['sbShadowCast'] = '';
1338
+ cast.delete(el);
1339
+ }
1340
+ }
1341
+ // Those no shadow reaches any more.
1342
+ for (const el of cast) {
1343
+ el.style.boxShadow = '';
1344
+ delete el.dataset['sbShadowCast'];
1345
+ }
1346
+ }
1347
+
1249
1348
  /**
1250
1349
  * `withAnimation(_:completionCriteria:_:completion:)`: the pass's `commit`
1251
1350
  * carried a completion serial; once the animations its commit started have
@@ -1691,6 +1790,11 @@ export class Renderer {
1691
1790
  this.collectLayouts(result);
1692
1791
  if (this.applying === 0) this.flushGeometry();
1693
1792
  this.queueVisibilityCheck();
1793
+ // `.statusBarHidden` on a screen that shows: the frame's drawn status bar fades (styles.css).
1794
+ const statusHidden = this.root.querySelector('.sb-navscreen[data-sb-status-hidden]:not([hidden])') !== null;
1795
+ if (document.documentElement.hasAttribute('data-sb-status-hidden') !== statusHidden) {
1796
+ document.documentElement.toggleAttribute('data-sb-status-hidden', statusHidden);
1797
+ }
1694
1798
  this.afterLayout?.();
1695
1799
  return changes;
1696
1800
  }
@@ -1818,10 +1922,23 @@ export class Renderer {
1818
1922
  const menu = div('sb-picker-menu');
1819
1923
  menu.setAttribute('role', 'menu');
1820
1924
  menu.dataset['sbFor'] = String(id);
1821
- let height = 0;
1925
+ // No hairline at either end (a section first or last).
1926
+ while (rows[0]?.kind === 'separator') rows = rows.slice(1);
1927
+ while (rows[rows.length - 1]?.kind === 'separator') rows = rows.slice(0, -1);
1928
+ const checks = rows.some((row) => row.kind === 'select' || row.kind === 'toggle');
1929
+ // iOS indents the whole menu around its check column: section titles start with the plain titles.
1930
+ if (checks) menu.dataset['sbChecks'] = '';
1931
+ let height = 2 * MENU_PADDING;
1822
1932
  for (const row of rows) {
1823
- menu.append(menuRowElement(row));
1824
- height += row.kind === 'caption' ? MENU_CAPTION_HEIGHT : row.kind === 'separator' ? MENU_SEPARATOR_HEIGHT : PICKER_MENU_ROW;
1933
+ menu.append(menuRowElement(row, checks));
1934
+ height +=
1935
+ row.kind === 'caption'
1936
+ ? MENU_CAPTION_HEIGHT
1937
+ : row.kind === 'separator'
1938
+ ? MENU_SEPARATOR_HEIGHT
1939
+ : (row.kind === 'tap' || row.kind === 'toggle') && row.subtitle
1940
+ ? MENU_SUBTITLED_ROW
1941
+ : PICKER_MENU_ROW;
1825
1942
  }
1826
1943
  // Anchor to the control: trailing edges aligned, below it when there is
1827
1944
  // room, else above; clamped inside the screen. A menu taller than the
@@ -1832,7 +1949,10 @@ export class Renderer {
1832
1949
  const width = Math.min(PICKER_MENU_WIDTH, Math.max(0, rootRect.width - 2 * PICKER_MENU_MARGIN));
1833
1950
  let left = anchorRect.right - rootRect.left - width;
1834
1951
  left = Math.max(PICKER_MENU_MARGIN, Math.min(left, rootRect.width - width - PICKER_MENU_MARGIN));
1835
- const belowTop = anchorRect.bottom - rootRect.top + PICKER_MENU_GAP;
1952
+ // A toolbar button's menu grows out of the button and covers it (iOS 26);
1953
+ // any other control's opens just under it.
1954
+ const inToolbar = anchor.closest('.sb-toolbar') !== null;
1955
+ const belowTop = inToolbar ? anchorRect.top - rootRect.top : anchorRect.bottom - rootRect.top + PICKER_MENU_GAP;
1836
1956
  const roomBelow = rootRect.height - PICKER_MENU_MARGIN - belowTop;
1837
1957
  const roomAbove = anchorRect.top - rootRect.top - PICKER_MENU_GAP - PICKER_MENU_MARGIN;
1838
1958
  const below = height <= roomBelow || (height > roomAbove && roomBelow >= roomAbove);
@@ -2007,6 +2127,34 @@ export class Renderer {
2007
2127
  this.hosts.add(id);
2008
2128
  this.adoptHostContent(id, el, props as HostProps);
2009
2129
  }
2130
+ if (kind === 'registered') this.adoptRegistered(id, el, props as RegisteredProps);
2131
+ }
2132
+
2133
+ /**
2134
+ * A `registered` element's view (src/viewRegistry.ts): built for its name,
2135
+ * given the new props after; a name nothing registered shows itself, as an
2136
+ * unknown kind does. The element's own children are the native view's: on
2137
+ * a page the stylesheet hides them.
2138
+ */
2139
+ private adoptRegistered(id: ElementId, el: HTMLElement, props: RegisteredProps): void {
2140
+ const values = asRecord(props.props);
2141
+ const current = this.registeredViews.get(id);
2142
+ if (current && current.name === props.name) {
2143
+ current.instance?.update?.(values);
2144
+ return;
2145
+ }
2146
+ current?.instance?.dispose?.();
2147
+ const name = props.name;
2148
+ const instance = ViewRegistry.make(name, { id, props: values, send: (value) => this.onEvent({ type: 'registered', id, name, value }) });
2149
+ this.registeredViews.set(id, { name, instance });
2150
+ let content = instance?.element;
2151
+ if (!content) {
2152
+ content = div('sb-registered-missing');
2153
+ content.textContent = name;
2154
+ }
2155
+ content.classList.add('sb-registered-content');
2156
+ el.querySelector(':scope > .sb-registered-content')?.remove();
2157
+ el.prepend(content);
2010
2158
  }
2011
2159
 
2012
2160
  /** A toolbar whose placement changed to or from `largeTitle` moves between its screen's content slot and large title. */
@@ -2419,6 +2567,7 @@ export class Renderer {
2419
2567
  this.noteScroll(id, kind, props);
2420
2568
  this.noteVisibility(id, kind, props);
2421
2569
  if (kind === 'host') this.adoptHostContent(id, el, props as HostProps);
2570
+ if (kind === 'registered') this.adoptRegistered(id, el, props as RegisteredProps);
2422
2571
  if (kind === 'toolbar') this.rehomeToolbar(id, el, op);
2423
2572
  const newToken = el.dataset[ANIMATION_TOKEN];
2424
2573
  const tokenChanged = (previous?.tokenChanged ?? false) || (newToken !== undefined && newToken !== oldToken);
@@ -2654,6 +2803,8 @@ export class Renderer {
2654
2803
  this.hostContents.delete(id);
2655
2804
  }
2656
2805
  this.hosts.delete(id);
2806
+ this.registeredViews.get(id)?.instance?.dispose?.();
2807
+ this.registeredViews.delete(id);
2657
2808
  this.elements.delete(id);
2658
2809
  this.boxes.delete(id);
2659
2810
  this.dirty.delete(id);
@@ -2684,7 +2835,8 @@ interface OpenMenu {
2684
2835
 
2685
2836
  /** One row of a popover menu. */
2686
2837
  type MenuRow =
2687
- | { kind: 'tap'; id: ElementId; title: string; symbol: string | null; destructive: boolean; disabled: boolean }
2838
+ | { kind: 'tap'; id: ElementId; title: string; subtitle: string | null; symbol: string | null; destructive: boolean; disabled: boolean }
2839
+ | { kind: 'toggle'; id: ElementId; title: string; subtitle: string | null; symbol: string | null; isOn: boolean; disabled: boolean }
2688
2840
  | { kind: 'select'; id: ElementId; index: number; title: string; checked: boolean }
2689
2841
  | { kind: 'caption'; text: string }
2690
2842
  | { kind: 'text'; text: string }
@@ -2704,6 +2856,20 @@ function disabledOf(node: LayoutNode): boolean {
2704
2856
  return false;
2705
2857
  }
2706
2858
 
2859
+ /**
2860
+ * A menu item's title and subtitle, as SwiftUI reads a menu label: its
2861
+ * first text is the title, any after it the subtitle under it.
2862
+ */
2863
+ function titlesOf(node: LayoutNode): { title: string; subtitle: string | null } {
2864
+ const texts: string[] = [];
2865
+ const visit = (n: LayoutNode): void => {
2866
+ if (n.kind === 'text' && typeof n.props['text'] === 'string' && n.props['text'].trim().length > 0) texts.push(n.props['text'].trim());
2867
+ n.children.forEach(visit);
2868
+ };
2869
+ visit(node);
2870
+ return { title: texts[0] ?? '', subtitle: texts.length > 1 ? texts.slice(1).join(' ') : null };
2871
+ }
2872
+
2707
2873
  /**
2708
2874
  * Turn a `menu`'s items into popover rows. A picker's options form their
2709
2875
  * own group, separated from neighbouring rows like iOS does; explicit
@@ -2726,12 +2892,16 @@ function collectMenuRows(items: readonly LayoutNode[], rows: MenuRow[] = []): Me
2726
2892
  rows.push({
2727
2893
  kind: 'tap',
2728
2894
  id: base.id,
2729
- title: textOf(base),
2895
+ ...titlesOf(base),
2730
2896
  symbol: symbolOf(base),
2731
2897
  destructive: base.props['role'] === 'destructive',
2732
2898
  disabled: disabledOf(item),
2733
2899
  });
2734
2900
  break;
2901
+ case 'toggle':
2902
+ // A toggle in a menu is a checkable row (SwiftUI's), its label the title.
2903
+ rows.push({ kind: 'toggle', id: base.id, ...titlesOf(base), symbol: symbolOf(base), isOn: base.props['isOn'] === true, disabled: disabledOf(item) });
2904
+ break;
2735
2905
  case 'picker': {
2736
2906
  const raw = base.props['options'];
2737
2907
  const options = Array.isArray(raw) ? raw.map(String) : [];
@@ -2745,9 +2915,12 @@ function collectMenuRows(items: readonly LayoutNode[], rows: MenuRow[] = []): Me
2745
2915
  separate();
2746
2916
  break;
2747
2917
  case 'section': {
2918
+ // An inline section, as iOS draws one: a hairline before and after it, its header a caption.
2919
+ separate();
2748
2920
  const header = base.props['header'];
2749
2921
  if (typeof header === 'string' && header.length > 0) rows.push({ kind: 'caption', text: header });
2750
2922
  collectMenuRows(base.children, rows);
2923
+ pendingSeparator = true;
2751
2924
  break;
2752
2925
  }
2753
2926
  case 'sectionheader': {
@@ -2777,44 +2950,60 @@ function collectMenuRows(items: readonly LayoutNode[], rows: MenuRow[] = []): Me
2777
2950
  return rows;
2778
2951
  }
2779
2952
 
2780
- /** Build the DOM for one popover row (the renderer's click handler reads the data attributes). */
2781
- function menuRowElement(row: MenuRow): HTMLElement {
2953
+ /**
2954
+ * Build the DOM for one popover row (the renderer's click handler reads the
2955
+ * data attributes). In a menu with checkable rows (`checks`), every item row
2956
+ * keeps a leading column for the checkmark, as iOS does, so titles line up;
2957
+ * an item's symbol follows the check, its subtitle sits under its title.
2958
+ */
2959
+ function menuRowElement(row: MenuRow, checks: boolean): HTMLElement {
2782
2960
  switch (row.kind) {
2783
- case 'select': {
2784
- const item = document.createElement('button');
2785
- item.type = 'button';
2786
- item.className = 'sb-picker-menu-item';
2787
- item.setAttribute('role', 'menuitemradio');
2788
- item.setAttribute('aria-checked', String(row.checked));
2789
- item.dataset['sbAction'] = 'select';
2790
- item.dataset['sbTarget'] = String(row.id);
2791
- item.dataset['sbIndex'] = String(row.index);
2792
- const check = span('sb-picker-menu-check');
2793
- const glyph = renderSymbol('checkmark');
2794
- if (glyph) check.append(glyph);
2795
- const title = span('sb-picker-menu-title');
2796
- title.textContent = row.title;
2797
- item.append(check, title);
2798
- return item;
2799
- }
2961
+ case 'select':
2962
+ case 'toggle':
2800
2963
  case 'tap': {
2801
2964
  const item = document.createElement('button');
2802
2965
  item.type = 'button';
2803
2966
  item.className = 'sb-picker-menu-item';
2804
- item.setAttribute('role', 'menuitem');
2805
- item.dataset['sbAction'] = 'tap';
2806
2967
  item.dataset['sbTarget'] = String(row.id);
2807
- if (row.destructive) item.dataset['sbRole'] = 'destructive';
2808
- if (row.disabled) item.disabled = true;
2968
+ item.dataset['sbAction'] = row.kind;
2969
+ if (row.kind === 'select') {
2970
+ item.setAttribute('role', 'menuitemradio');
2971
+ item.setAttribute('aria-checked', String(row.checked));
2972
+ item.dataset['sbIndex'] = String(row.index);
2973
+ } else if (row.kind === 'toggle') {
2974
+ item.setAttribute('role', 'menuitemcheckbox');
2975
+ item.setAttribute('aria-checked', String(row.isOn));
2976
+ } else {
2977
+ item.setAttribute('role', 'menuitem');
2978
+ if (row.destructive) item.dataset['sbRole'] = 'destructive';
2979
+ }
2980
+ if (row.kind !== 'select' && row.disabled) item.disabled = true;
2981
+ if (checks) {
2982
+ const check = span('sb-picker-menu-check');
2983
+ const glyph = renderSymbol('checkmark');
2984
+ if (glyph) check.append(glyph);
2985
+ item.append(check);
2986
+ }
2987
+ const symbol = row.kind === 'select' ? null : row.symbol;
2988
+ if (symbol) {
2989
+ const holder = span('sb-picker-menu-symbol');
2990
+ const glyph = renderSymbol(symbol);
2991
+ if (glyph) holder.append(glyph);
2992
+ holder.dataset['sbSymbol'] = symbol;
2993
+ item.append(holder);
2994
+ }
2809
2995
  const title = span('sb-picker-menu-title');
2810
2996
  title.textContent = row.title;
2811
- item.append(title);
2812
- if (row.symbol) {
2813
- const symbol = span('sb-picker-menu-symbol');
2814
- const glyph = renderSymbol(row.symbol);
2815
- if (glyph) symbol.append(glyph);
2816
- symbol.dataset['sbSymbol'] = row.symbol;
2817
- item.append(symbol);
2997
+ const subtitle = row.kind === 'select' ? null : row.subtitle;
2998
+ if (subtitle) {
2999
+ const titles = span('sb-picker-menu-titles');
3000
+ const sub = span('sb-picker-menu-subtitle');
3001
+ sub.textContent = subtitle;
3002
+ titles.append(title, sub);
3003
+ item.append(titles);
3004
+ item.dataset['sbSubtitled'] = '';
3005
+ } else {
3006
+ item.append(title);
2818
3007
  }
2819
3008
  return item;
2820
3009
  }
@@ -2837,6 +3026,11 @@ function menuRowElement(row: MenuRow): HTMLElement {
2837
3026
  }
2838
3027
  }
2839
3028
 
3029
+ /** Whether scrolling dismisses the keyboard; a page cannot drag it down, so `interactively` blurs as `immediately` does. */
3030
+ function dismissesKeyboard(mode: unknown): boolean {
3031
+ return mode === true || mode === 'immediately' || mode === 'interactively';
3032
+ }
3033
+
2840
3034
  /** Props as the engine reads them (untrusted JSON, so a plain record). */
2841
3035
  function asRecord(props: unknown): Record<string, unknown> {
2842
3036
  return typeof props === 'object' && props !== null ? (props as Record<string, unknown>) : {};
@@ -2886,6 +3080,9 @@ function createShell(kind: ElementKind): HTMLElement {
2886
3080
  case 'host':
2887
3081
  // The page's content element is adopted by the renderer (`adoptHostContent`).
2888
3082
  return div('sb-host');
3083
+ case 'registered':
3084
+ // Its view is built by the renderer (`adoptRegistered`).
3085
+ return div('sb-registered');
2889
3086
  case 'button': {
2890
3087
  const el = document.createElement('button');
2891
3088
  el.type = 'button';
@@ -2973,6 +3170,25 @@ function createShell(kind: ElementKind): HTMLElement {
2973
3170
  SLOTS.set(el, slot);
2974
3171
  return el;
2975
3172
  }
3173
+ case 'disclosuregroup': {
3174
+ // The label (the first child) and the content below it, laid out as
3175
+ // a stack; a button over the label and a chevron at its trailing
3176
+ // edge, placed by layoutDom from the label's frame.
3177
+ const el = document.createElement('div');
3178
+ const slot = div('sb-slot');
3179
+ const toggle = document.createElement('button');
3180
+ toggle.type = 'button';
3181
+ toggle.className = 'sb-disclosure-toggle';
3182
+ toggle.setAttribute('aria-expanded', 'false');
3183
+ el.append(slot, toggle);
3184
+ const chevron = renderSymbol('chevron.right');
3185
+ if (chevron) {
3186
+ chevron.classList.add('sb-disclosure-chevron');
3187
+ el.append(chevron);
3188
+ }
3189
+ SLOTS.set(el, slot);
3190
+ return el;
3191
+ }
2976
3192
  case 'toggle': {
2977
3193
  const el = document.createElement('div');
2978
3194
  const label = div('sb-slot sb-toggle-label');
@@ -3828,7 +4044,7 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
3828
4044
  applyRefreshable(el, list.refreshable === true, list.refreshing === true);
3829
4045
  // `.scrollContentBackground(.hidden)`: no grouped background (styles.css).
3830
4046
  if (list.background === 'hidden') el.dataset['sbBackground'] = 'hidden';
3831
- if (list.dismissesKeyboard === true) el.dataset['sbDismissesKeyboard'] = 'true';
4047
+ if (dismissesKeyboard(list.dismissesKeyboard)) el.dataset['sbDismissesKeyboard'] = 'true';
3832
4048
  applyEdgeEffect(el, list.edgeEffect);
3833
4049
  break;
3834
4050
  }
@@ -3859,7 +4075,7 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
3859
4075
  applyRefreshable(el, scroll.refreshable === true, scroll.refreshing === true);
3860
4076
  if (scroll.snap === 'viewAligned' || scroll.snap === 'paging') el.dataset['sbSnap'] = scroll.snap;
3861
4077
  else delete el.dataset['sbSnap'];
3862
- if (scroll.dismissesKeyboard === true) el.dataset['sbDismissesKeyboard'] = 'true';
4078
+ if (dismissesKeyboard(scroll.dismissesKeyboard)) el.dataset['sbDismissesKeyboard'] = 'true';
3863
4079
  if (scroll.reportsPosition === true) el.dataset['sbReportsPosition'] = '';
3864
4080
  else delete el.dataset['sbReportsPosition'];
3865
4081
  applyEdgeEffect(el, scroll.edgeEffect);
@@ -3869,6 +4085,12 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
3869
4085
  case 'section':
3870
4086
  applySection(el, props as SectionProps);
3871
4087
  break;
4088
+ case 'disclosuregroup': {
4089
+ const expanded = (props as DisclosureGroupProps).expanded === true;
4090
+ el.toggleAttribute('data-sb-expanded', expanded);
4091
+ el.querySelector<HTMLElement>(':scope > .sb-disclosure-toggle')?.setAttribute('aria-expanded', String(expanded));
4092
+ break;
4093
+ }
3872
4094
  case 'textfield':
3873
4095
  applyTextField(el as HTMLInputElement | HTMLTextAreaElement, props as TextFieldProps);
3874
4096
  break;
@@ -4018,6 +4240,9 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
4018
4240
  case 'lazyspacer':
4019
4241
  el.dataset['axis'] = (props as LazySpacerProps).axis === 'horizontal' ? 'horizontal' : 'vertical';
4020
4242
  break;
4243
+ case 'registered':
4244
+ // The renderer builds its view (`adoptRegistered`).
4245
+ break;
4021
4246
  default: {
4022
4247
  const unknown: never = kind;
4023
4248
  throw new RendererError(`Unknown element kind ${String(unknown)}`);
@@ -4164,9 +4389,10 @@ function applyShape(el: HTMLElement, props: ShapeProps): void {
4164
4389
  s.border = `${width}px ${stroke.dash?.length ? 'dashed' : 'solid'} ${color}`;
4165
4390
  el.dataset['sbStroke'] = stroke.inset ? 'inset' : 'center';
4166
4391
  }
4167
- // A fill with a drop shadow (`.shadow(.drop(…))` on the style).
4392
+ // A fill with a drop shadow (`.shadow(.drop(…))` on the style), then the shadows of the boxes around the shape
4393
+ // (castShadows), which styles.css gives a shape without one of its own.
4168
4394
  const shadow = (props.fill as { shadow?: ShapeShadow } | null)?.shadow;
4169
- s.filter = shadow && typeof shadow === 'object' ? shapeShadowCSS(shadow) : '';
4395
+ s.filter = shadow && typeof shadow === 'object' ? `${shapeShadowCSS(shadow)} var(--sb-shadow-filter, )` : '';
4170
4396
  const mask = shapeTrimMask(props.trim);
4171
4397
  if (mask) {
4172
4398
  s.setProperty('mask-image', mask);
@@ -4409,6 +4635,10 @@ function applySection(el: HTMLElement, props: SectionProps): void {
4409
4635
  header.textContent = props.header ?? '';
4410
4636
  header.hidden = !props.header;
4411
4637
  }
4638
+ // `Section(isExpanded:)`: a chevron on the header (a string one, or the
4639
+ // `sectionheader` view) in a sidebar-style list (styles.css); its tap toggles.
4640
+ el.toggleAttribute('data-sb-collapsible', props.collapsible === true);
4641
+ el.toggleAttribute('data-sb-expanded', props.collapsible === true && props.expanded !== false);
4412
4642
  if (footer) {
4413
4643
  footer.textContent = props.footer ?? '';
4414
4644
  footer.hidden = !props.footer;
@@ -4771,15 +5001,29 @@ export function applyStyle(el: HTMLElement, style: Style): void {
4771
5001
  }
4772
5002
 
4773
5003
  // Phase 6 visual effects: CSS only, the layout engine never sees them.
4774
- const effects = effectsCSS(style);
5004
+ // A shadow is not the box's own filter: as SwiftUI's `.shadow` on a container, it falls on each view the box draws
5005
+ // (castShadows). A box that clips, masks or composites what it holds draws it as one, so the shadows around it are
5006
+ // cast by it too (`--sb-shadow-filter`, which castShadows sets).
5007
+ const effects = effectsCSS(style, { shadow: false });
4775
5008
  if (effects.transform) s.transform = effects.transform;
4776
5009
  if (effects.transformOrigin) s.transformOrigin = effects.transformOrigin;
4777
- if (effects.filter) s.filter = effects.filter;
4778
- // `.colorMultiply`: an SVG filter, first so the other filters see its colors.
4779
- if (style.colorMultiply) {
4780
- const multiply = colorMultiplyFilter(style.colorMultiply, el);
4781
- s.filter = effects.filter ? `${multiply} ${effects.filter}` : multiply;
4782
- }
5010
+ const multiply = style.colorMultiply ? colorMultiplyFilter(style.colorMultiply, el) : '';
5011
+ const asOne = style.compositingGroup === true || style.cornerRadius != null || style.clipped === true || (style.mask != null && typeof style.mask === 'object');
5012
+ const filter = [multiply, effects.filter ?? '', asOne ? 'var(--sb-shadow-filter, )' : ''].filter(Boolean).join(' ');
5013
+ if (filter) s.filter = filter;
5014
+ if (asOne) el.dataset['sbAsOne'] = '';
5015
+ else delete el.dataset['sbAsOne'];
5016
+ if (style.shadow) {
5017
+ el.dataset['sbShadow'] = `drop-shadow(${shadowCSS(style.shadow)})`;
5018
+ el.dataset['sbShadowBox'] = shadowCSS(style.shadow);
5019
+ } else if (el.dataset['sbShadow'] !== undefined) {
5020
+ delete el.dataset['sbShadow'];
5021
+ delete el.dataset['sbShadowBox'];
5022
+ s.removeProperty('--sb-shadow-filter');
5023
+ s.removeProperty('--sb-shadow-box');
5024
+ }
5025
+ if (style.compositingGroup) el.dataset['sbComposite'] = '';
5026
+ else delete el.dataset['sbComposite'];
4783
5027
  // Phase 7: `.hidden()` keeps the box (the engine lays it out) but draws
4784
5028
  // nothing and takes no input; `.mask { }` clips the box to a style's alpha.
4785
5029
  if (effects.hidden) {
@@ -4864,6 +5108,8 @@ export function applyStyle(el: HTMLElement, style: Style): void {
4864
5108
  if (style.animationToken != null) el.dataset[ANIMATION_TOKEN] = String(style.animationToken);
4865
5109
  // A `Label`'s icon: tinted in list rows (styles.css).
4866
5110
  if (style.labelIcon) el.dataset['sbLabelIcon'] = '';
5111
+ // `.textSelection(.enabled)`: the text below can be selected (styles.css).
5112
+ el.toggleAttribute('data-sb-text-selection', style.textSelection === true);
4867
5113
  if (style.animation) ANIMATIONS.set(el, style.animation);
4868
5114
  if (style.transition) setTransition(el, style.transition);
4869
5115
 
@@ -35,7 +35,8 @@ export function setScrollTransition(el: HTMLElement, style: Style): void {
35
35
  }
36
36
  return;
37
37
  }
38
- const own = effectsCSS(style);
38
+ // Its shadow is cast by the views it draws (renderer.ts castShadows), not by it.
39
+ const own = effectsCSS(style, { shadow: false });
39
40
  registered.set(el, {
40
41
  spec,
41
42
  base: { transform: own.transform ?? '', filter: own.filter ?? '', opacity: style.opacity ?? 1 },