@swiftbrowser/web 0.18.3 → 0.19.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.
@@ -413,6 +413,15 @@ const IMAGE_SCALES: Readonly<Record<string, number>> = { small: 0.8, medium: 1,
413
413
  const NAVSPLIT_SIDEBAR_WIDTH = 320;
414
414
  /** From this root width a window is regular (an iPad, a desktop browser), as `@swiftbrowser/react`'s size class. */
415
415
  const REGULAR_WIDTH = 700;
416
+ /** Whether a screen is the regular size class (an iPad, a desktop window): a `sidebar` list then draws as one. */
417
+ export const isRegularWidth = (screen: { width: number }): boolean => screen.width >= REGULAR_WIDTH;
418
+ /**
419
+ * From this root height a window is the regular vertical size class: below
420
+ * it, as an iPhone on its side (at most 440 tall, less under Safari's bars),
421
+ * it is compact, and UIKit shows no large titles. An iPad is regular either
422
+ * way (744 tall at the least).
423
+ */
424
+ const REGULAR_HEIGHT = 500;
416
425
  /** The sidebar never takes more than this share of the split view's width. */
417
426
  const NAVSPLIT_SIDEBAR_MAX_FRACTION = 0.4;
418
427
  const NAVSPLIT_SEPARATOR_WIDTH = 1;
@@ -1139,14 +1148,12 @@ interface TextPlan {
1139
1148
  // Phase 8 controls.
1140
1149
  interface SliderPlan {
1141
1150
  size: Size;
1151
+ /** The `label` child, hidden (iOS draws no slider label). */
1142
1152
  label: LayoutNode | null;
1143
1153
  min: LayoutNode | null;
1144
1154
  max: LayoutNode | null;
1145
- labelSize: Size;
1146
1155
  minSize: Size;
1147
1156
  maxSize: Size;
1148
- /** Height of the label row above the 44 track row (0 without a label). */
1149
- labelRow: number;
1150
1157
  cctx: Context;
1151
1158
  }
1152
1159
 
@@ -1638,9 +1645,17 @@ export class LayoutPass {
1638
1645
  const imageScale = str(style['imageScale']);
1639
1646
  if (imageScale !== null) next = { ...next, imageScale: IMAGE_SCALES[imageScale] ?? 1 };
1640
1647
  if (typeof style['scrollDisabled'] === 'boolean') next = { ...next, scrollDisabled: style['scrollDisabled'] };
1648
+ // `.ignoresSafeArea` on the bottom: the content inside has no bottom
1649
+ // safe area, so a scroll view in it reaching the screen's bottom is
1650
+ // not inset by the bar there (a photo viewer's, under a bottom toolbar).
1651
+ const ignored = style['ignoresSafeArea'];
1652
+ if (ignored === 'all' || (Array.isArray(ignored) && ignored.includes('bottom'))) {
1653
+ if (next.bottomBar !== 0 || next.bottomEdge !== undefined) next = { ...next, bottomBar: 0, bottomEdge: undefined };
1654
+ }
1641
1655
  return next;
1642
1656
  }
1643
1657
  case 'vstack':
1658
+ case 'disclosuregroup':
1644
1659
  return { ...ctx, stackAxis: 'vertical', listRow: false };
1645
1660
  case 'hstack':
1646
1661
  return { ...ctx, stackAxis: 'horizontal', listRow: false };
@@ -1744,6 +1759,7 @@ export class LayoutPass {
1744
1759
  if (ctx.stackAxis === 'horizontal') return { width: 1, height: finite(p.height) ? p.height : 0 };
1745
1760
  return { width: finite(p.width) ? p.width : 0, height: 1 };
1746
1761
  case 'vstack':
1762
+ case 'disclosuregroup':
1747
1763
  return this.stackPlan(node, ctx, p, 'vertical').size;
1748
1764
  case 'hstack': {
1749
1765
  const label = ctx.labelColumn ? labelColumnOf(node) : null;
@@ -2319,10 +2335,19 @@ export class LayoutPass {
2319
2335
  private stackPlan(node: LayoutNode, ctx: Context, p: Proposal, axis: Axis): StackPlan {
2320
2336
  return this.plan(node, ctx, p, () => {
2321
2337
  const cctx = this.childContext(node, ctx);
2322
- return this.layoutStack(node.children, cctx, p, axis, this.stackGaps(node, cctx, axis));
2338
+ return this.layoutStack(this.stackChildren(node), cctx, p, axis, this.stackGaps(node, cctx, axis));
2323
2339
  });
2324
2340
  }
2325
2341
 
2342
+ /**
2343
+ * The children a stack lays out: all of them, but a collapsed
2344
+ * `disclosuregroup`'s label alone (the first child; its content is in
2345
+ * the tree whatever the state, as the native renderer wants it).
2346
+ */
2347
+ private stackChildren(node: LayoutNode): readonly LayoutNode[] {
2348
+ return node.kind === 'disclosuregroup' && node.props['expanded'] !== true ? node.children.slice(0, 1) : node.children;
2349
+ }
2350
+
2326
2351
  /**
2327
2352
  * The space between a stack's children: its `spacing`, else the system's.
2328
2353
  * A horizontal stack's default is 8; in a vertical one iOS 26 spaces text
@@ -2334,14 +2359,16 @@ export class LayoutPass {
2334
2359
  * 0.6em + 1.13 above it (caption 8.33, body 11.33); other views are 8 apart.
2335
2360
  */
2336
2361
  private stackGaps(node: LayoutNode, cctx: Context, axis: Axis): number[] {
2337
- const n = node.children.length;
2362
+ const children = this.stackChildren(node);
2363
+ const n = children.length;
2338
2364
  if (n < 2) return [];
2339
- const spacing = num(node.props['spacing']);
2365
+ // A disclosure group's content sits 8 under its label, as SwiftUI's does.
2366
+ const spacing = num(node.props['spacing']) ?? (node.kind === 'disclosuregroup' ? DEFAULT_SPACING : null);
2340
2367
  if (spacing !== null || axis === 'horizontal') return new Array<number>(n - 1).fill(spacing ?? DEFAULT_SPACING);
2341
2368
  const gaps: number[] = [];
2342
2369
  for (let i = 0; i < n - 1; i++) {
2343
- const above = this.textEdge(node.children[i] as LayoutNode, cctx, 'bottom');
2344
- const below = this.textEdge(node.children[i + 1] as LayoutNode, cctx, 'top');
2370
+ const above = this.textEdge(children[i] as LayoutNode, cctx, 'bottom');
2371
+ const below = this.textEdge(children[i + 1] as LayoutNode, cctx, 'top');
2345
2372
  let gap = DEFAULT_SPACING;
2346
2373
  if (above && below) gap = below.leading - below.lineHeight + 1 / 3;
2347
2374
  else if (below) gap = 0.409 * below.size - 1.24;
@@ -2372,8 +2399,10 @@ export class LayoutPass {
2372
2399
  const content = node.children[0];
2373
2400
  return content ? this.textEdge(content, this.childContext(node, ctx), edge, depth + 1) : null;
2374
2401
  }
2375
- case 'vstack': {
2376
- const child = edge === 'top' ? node.children[0] : node.children[node.children.length - 1];
2402
+ case 'vstack':
2403
+ case 'disclosuregroup': {
2404
+ const children = this.stackChildren(node);
2405
+ const child = edge === 'top' ? children[0] : children[children.length - 1];
2377
2406
  return child && !isSpacer(child) ? this.textEdge(child, this.childContext(node, ctx), edge, depth + 1) : null;
2378
2407
  }
2379
2408
  case 'hstack': {
@@ -2808,10 +2837,12 @@ export class LayoutPass {
2808
2837
  // the space between sections. `sidebar` (iPadOS) is laid out like
2809
2838
  // `insetGrouped` with 44pt rows (the page draws no cards or hairlines).
2810
2839
  const listStyle = str(node.props['style']) ?? 'insetGrouped';
2811
- const sidebarStyle = listStyle === 'sidebar';
2840
+ // In a compact width (an iPhone) a `sidebar` list looks like `insetGrouped`
2841
+ // (cards, chevrons, 52pt rows) and only its sections' folding remains.
2842
+ const sidebarStyle = listStyle === 'sidebar' && isRegularWidth(screen);
2812
2843
  // `.environment(\.defaultMinListRowHeight, _)`; 52 by default (44 in a sidebar).
2813
2844
  const minRowHeight = num(node.props['minRowHeight']) ?? (sidebarStyle ? LIST_SIDEBAR_ROW_MIN_HEIGHT : LIST_ROW_MIN_HEIGHT);
2814
- const inset = listStyle === 'insetGrouped' || sidebarStyle ? LIST_INSET : 0;
2845
+ const inset = listStyle === 'insetGrouped' || listStyle === 'sidebar' ? LIST_INSET : 0;
2815
2846
  const plain = listStyle === 'plain';
2816
2847
  const listTop = plain ? 0 : LIST_TOP;
2817
2848
  const listBottom = plain ? 0 : LIST_BOTTOM;
@@ -2843,7 +2874,9 @@ export class LayoutPass {
2843
2874
  const groupsSource: GroupSource[] = [];
2844
2875
  for (const child of node.children) {
2845
2876
  if (child.kind === 'section') {
2846
- const rows = child.children.filter((row) => row.kind !== 'sectionheader' && row.kind !== 'sectionfooter');
2877
+ // `Section(isExpanded:)`: collapsed, its rows are not laid out, in a sidebar-style list (iOS shows the rows in the other styles, whatever the binding).
2878
+ const collapsed = child.props['collapsible'] === true && child.props['expanded'] === false && rowCtx.sidebarList === true;
2879
+ const rows = collapsed ? [] : child.children.filter((row) => row.kind !== 'sectionheader' && row.kind !== 'sectionfooter');
2847
2880
  const headerView = child.children.find((row) => row.kind === 'sectionheader') ?? null;
2848
2881
  const footerView = child.children.find((row) => row.kind === 'sectionfooter') ?? null;
2849
2882
  groupsSource.push({ section: child, rows, headerView, footerView });
@@ -2857,11 +2890,12 @@ export class LayoutPass {
2857
2890
  // footer's font.
2858
2891
  const headerCtx: Context = { ...rowCtx, listRow: false, stackAxis: null, font: headerFont };
2859
2892
  const footerCtx: Context = { ...headerCtx, font: footerFont };
2860
- const supplement = (view: LayoutNode | null, y: number, ctx: Context): ListSupplementPlan | null => {
2893
+ const supplement = (view: LayoutNode | null, y: number, ctx: Context, full = false): ListSupplementPlan | null => {
2861
2894
  if (!view) return null;
2862
2895
  const proposal: Proposal = { width: contentWidth, height: null };
2863
2896
  const size = this.measure(view, ctx, proposal);
2864
- return { node: view, ctx, proposal, rect: { x: contentX, y, width: size.width, height: size.height } };
2897
+ // A collapsible section's header spans the content width: its chevron sits at the trailing edge.
2898
+ return { node: view, ctx, proposal, rect: { x: contentX, y, width: full ? contentWidth : size.width, height: size.height } };
2865
2899
  };
2866
2900
 
2867
2901
  const groups: ListGroupPlan[] = [];
@@ -2886,7 +2920,7 @@ export class LayoutPass {
2886
2920
  // A header sits in the gap above its card (from LIST_HEADER_LIFT
2887
2921
  // into it); the card then starts LIST_HEADER_GAP below the header.
2888
2922
  const headerLift = plain ? 0 : LIST_HEADER_LIFT;
2889
- const headerView = supplement(source.headerView, y - headerLift, headerCtx);
2923
+ const headerView = supplement(source.headerView, y - headerLift, headerCtx, source.section?.props['collapsible'] === true);
2890
2924
  if (headerView && headerView.rect.height > 0) y += headerView.rect.height + LIST_HEADER_GAP - headerLift;
2891
2925
  const headerText = source.section ? str(source.section.props['header']) : null;
2892
2926
  const footerText = source.section ? str(source.section.props['footer']) : null;
@@ -3070,6 +3104,7 @@ export class LayoutPass {
3070
3104
  case 'host':
3071
3105
  return;
3072
3106
  case 'vstack':
3107
+ case 'disclosuregroup':
3073
3108
  case 'hstack': {
3074
3109
  const label = node.kind === 'hstack' && ctx.labelColumn ? labelColumnOf(node) : null;
3075
3110
  if (label) {
@@ -3079,10 +3114,13 @@ export class LayoutPass {
3079
3114
  this.place(label.title, cctx, plan.titleProposal, x + LIST_LABEL_TITLE_X, y + (size.height - plan.title.height) / 2);
3080
3115
  return;
3081
3116
  }
3082
- const axis: Axis = node.kind === 'vstack' ? 'vertical' : 'horizontal';
3117
+ const axis: Axis = node.kind === 'hstack' ? 'horizontal' : 'vertical';
3083
3118
  const plan = this.stackPlan(node, ctx, p, axis);
3084
- const alignment = str(node.props['alignment']) ?? 'center';
3085
- this.placeStack(node.children, cctx, plan, axis, alignment, x, y, size);
3119
+ const alignment = str(node.props['alignment']) ?? (node.kind === 'disclosuregroup' ? 'leading' : 'center');
3120
+ const shown = this.stackChildren(node);
3121
+ this.placeStack(shown, cctx, plan, axis, alignment, x, y, size);
3122
+ // A collapsed `disclosuregroup`: its content is hidden, without frames.
3123
+ for (const child of node.children) if (!shown.includes(child)) this.hide(child, node);
3086
3124
  return;
3087
3125
  }
3088
3126
  case 'zstack': {
@@ -3224,7 +3262,7 @@ export class LayoutPass {
3224
3262
  // (its label still reserves the chevron's width so selecting a row
3225
3263
  // never reflows it); elsewhere `selected` keeps the chevron. No row
3226
3264
  // of a `sidebar`-style list has one (iPadOS).
3227
- if (!(node.props['selected'] === true && ctx.splitSidebar) && !ctx.sidebarList) {
3265
+ if (!(node.props['selected'] === true && ctx.splitSidebar) && !(ctx.sidebarList && isRegularWidth(this.env.screen))) {
3228
3266
  chrome['chevron'] = {
3229
3267
  x: x + size.width - LIST_CHEVRON_WIDTH,
3230
3268
  y,
@@ -3521,12 +3559,22 @@ export class LayoutPass {
3521
3559
  this.setFrame(group.section, x + group.frame.x, y + group.frame.y, group.frame);
3522
3560
  this.setChrome(group.section, chrome);
3523
3561
  for (const view of [group.headerView, group.footerView]) {
3524
- if (view) this.place(view.node, view.ctx, view.proposal, x + view.rect.x, y + view.rect.y);
3562
+ if (!view) continue;
3563
+ this.place(view.node, view.ctx, view.proposal, x + view.rect.x, y + view.rect.y);
3564
+ // A collapsible section's header spans the content width (its chevron is at the trailing edge); its content stays put.
3565
+ const placed = view.node.id >= 0 ? this.frames.get(view.node.id) : undefined;
3566
+ if (placed && placed.width < view.rect.width) this.setFrame(view.node, placed.x, placed.y, { width: view.rect.width, height: placed.height });
3525
3567
  }
3526
3568
  if (group.section.id >= 0) {
3527
3569
  this.fonts.set(group.section.id, group.header?.layout.font ?? group.footer?.layout.font ?? makeFont('system', textStyleSize('footnote', this.env.dynamicTypeSize), 400));
3528
3570
  if (Object.keys(texts).length > 0) this.chromeText.set(group.section.id, texts);
3529
3571
  }
3572
+ // A collapsed `Section(isExpanded:)` (sidebar-style list): its rows are hidden, without frames.
3573
+ if (group.rows.length === 0) {
3574
+ for (const child of group.section.children) {
3575
+ if (child.kind !== 'sectionheader' && child.kind !== 'sectionfooter') this.hide(child, group.section);
3576
+ }
3577
+ }
3530
3578
  }
3531
3579
  for (const row of group.rows) {
3532
3580
  // Set the row chrome before placing so a navlink can merge its own keys.
@@ -3613,18 +3661,43 @@ export class LayoutPass {
3613
3661
 
3614
3662
  private placeNavScreen(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
3615
3663
  const { safeArea, screen, dynamicTypeSize } = this.env;
3664
+ // The horizontal safe area (a phone on its side: the notch on one side,
3665
+ // the corner on the other): the bar's background runs edge to edge, as
3666
+ // the screen does; its items, the titles, the search field, the bottom
3667
+ // toolbars and the content stay clear of it. A split view's columns are
3668
+ // inset already (`placeNavSplit`), so they do not reach the edges here.
3669
+ const barX = x;
3670
+ const barWidth = size.width;
3671
+ const insetLeading = x <= EPSILON ? safeArea.leading : 0;
3672
+ const insetTrailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
3673
+ x += insetLeading;
3674
+ size = { width: Math.max(0, size.width - insetLeading - insetTrailing), height: size.height };
3616
3675
  const statusBar = y <= 0 ? safeArea.top : (cctx.barInset ?? 0);
3617
3676
  const displayMode = str(node.props['displayMode']) ?? 'automatic';
3618
3677
  const depth = num(node.props['depth']) ?? 0;
3619
- const large = displayMode === 'large' || (displayMode === 'automatic' && depth === 0);
3620
- const barHeight = statusBar + NAV_BAR_HEIGHT + (!large && y <= 0 ? NAV_BAR_INLINE_BOTTOM : 0);
3678
+ // `.toolbar(.hidden, for: .navigationBar)`: no bar, title, back button or
3679
+ // bar items; the content starts under the status bar, which stays a safe
3680
+ // area. `.bottomBar` hidden: the bottom toolbars draw nothing.
3681
+ const barHidden = node.props['navigationBarHidden'] === true;
3682
+ const bottomBarHidden = node.props['bottomBarHidden'] === true;
3683
+ // A compact height (a phone on its side) has no large titles, whatever the
3684
+ // display mode, as UIKit: the title is the bar's inline one.
3685
+ const large = !barHidden && screen.height >= REGULAR_HEIGHT && (displayMode === 'large' || (displayMode === 'automatic' && depth === 0));
3686
+ const barHeight = barHidden ? statusBar : statusBar + NAV_BAR_HEIGHT + (!large && y <= 0 ? NAV_BAR_INLINE_BOTTOM : 0);
3621
3687
  const parts = this.navScreenParts(node);
3622
3688
  const reachesBottom = y + size.height >= screen.height - EPSILON;
3623
3689
  const isSpacer = (t: LayoutNode): boolean => str(t.props['spacer']) !== null;
3690
+ const placementOf = (t: LayoutNode): string | null => str(t.props['placement']);
3691
+ const inHiddenBar = (t: LayoutNode): boolean => {
3692
+ const placement = placementOf(t);
3693
+ if (placement === 'keyboard') return false;
3694
+ return placement === 'bottom' ? bottomBarHidden : barHidden;
3695
+ };
3696
+ for (const toolbar of parts.toolbars.filter(inHiddenBar)) this.hide(toolbar, node);
3624
3697
  // `.largeTitle` items size the large title row; the text title is 62.
3625
3698
  const titleItemsHeight = large ? this.largeTitleItemsHeight(parts.toolbars, cctx, size.width) : null;
3626
3699
  const largeHeight = !large ? 0 : titleItemsHeight !== null ? LARGE_TITLE_ITEM_TOP + titleItemsHeight : LARGE_TITLE_HEIGHT;
3627
- const bottomToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'bottom');
3700
+ const bottomToolbars = bottomBarHidden ? [] : parts.toolbars.filter((t) => placementOf(t) === 'bottom');
3628
3701
  // `keyboard` toolbars: a glass capsule floating above the keyboard while a
3629
3702
  // text input of this screen is being edited (as Notes' format bar on
3630
3703
  // iOS 26; SwiftUI's input accessory view); nothing otherwise.
@@ -3657,12 +3730,12 @@ export class LayoutPass {
3657
3730
  width: size.width,
3658
3731
  height: Math.max(0, size.height - contentTop - Math.max(bottomBarHeight, searchBottom)),
3659
3732
  };
3660
- const chrome: Record<string, Rect> = {
3661
- bar: { x, y, width: size.width, height: barHeight },
3733
+ const chrome: Record<string, Rect> = { content };
3734
+ if (!barHidden) {
3735
+ chrome['bar'] = { x: barX, y, width: barWidth, height: barHeight };
3662
3736
  // The 44 row the bar's items sit in, under the status bar.
3663
- barRow: { x, y: y + statusBar, width: size.width, height: NAV_BAR_HEIGHT },
3664
- content,
3665
- };
3737
+ chrome['barRow'] = { x, y: y + statusBar, width: size.width, height: NAV_BAR_HEIGHT };
3738
+ }
3666
3739
  if (large) chrome['largeTitle'] = { x, y: y + barHeight, width: size.width, height: largeHeight };
3667
3740
  if (parts.search) {
3668
3741
  const marginH = searchOnTop ? SEARCH_FIELD_MARGIN_H : SEARCH_BOTTOM_MARGIN_H;
@@ -3700,11 +3773,13 @@ export class LayoutPass {
3700
3773
  // The navigation bar's groups; spacers there only separate them (the
3701
3774
  // groups are 8 apart anyway) and draw nothing.
3702
3775
  for (const spacer of parts.toolbars.filter(isSpacer)) this.hide(spacer, node);
3703
- const barToolbars = parts.toolbars.filter((t) => {
3704
- const placement = str(t.props['placement']);
3705
- return placement !== 'bottom' && placement !== 'secondary' && placement !== 'largeTitle' && placement !== 'keyboard' && !isSpacer(t);
3706
- });
3707
- const secondaryToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'secondary' && !isSpacer(t));
3776
+ const barToolbars = barHidden
3777
+ ? []
3778
+ : parts.toolbars.filter((t) => {
3779
+ const placement = placementOf(t);
3780
+ return placement !== 'bottom' && placement !== 'secondary' && placement !== 'largeTitle' && placement !== 'keyboard' && !isSpacer(t);
3781
+ });
3782
+ const secondaryToolbars = barHidden ? [] : parts.toolbars.filter((t) => placementOf(t) === 'secondary' && !isSpacer(t));
3708
3783
  const groups: Group[] = barToolbars.map((toolbar) => {
3709
3784
  const itemCtx = this.childContext(toolbar, cctx);
3710
3785
  const sizes = toolbar.children.map((item) => this.measure(item, itemCtx, { width: null, height: null }));
@@ -3826,7 +3901,7 @@ export class LayoutPass {
3826
3901
  // title no longer shows).
3827
3902
  const backLabel = '';
3828
3903
  const leadingStart = x + NAV_ITEM_MARGIN + (cctx.barLeading ?? 0);
3829
- if (depth > 0) {
3904
+ if (depth > 0 && !barHidden) {
3830
3905
  chrome['back'] = { x: leadingStart, y: rowY, width: NAV_GLASS_SIZE, height: NAV_GLASS_SIZE };
3831
3906
  }
3832
3907
 
@@ -3969,7 +4044,7 @@ export class LayoutPass {
3969
4044
  const titleScrolls = large && scroller !== null;
3970
4045
  // A large title that scrolls away collapses into the inline title, which
3971
4046
  // the renderer shows once it has (`data-sb-collapsed`).
3972
- if ((!large || titleScrolls) && !principal) {
4047
+ if ((!large || titleScrolls) && !principal && !barHidden) {
3973
4048
  const leftUsed = Math.max(
3974
4049
  back ? back.x + back.width - x : 0,
3975
4050
  leadingWidth > 0 ? leadingCursor - NAV_GROUP_SPACING - x : 0,
@@ -4299,10 +4374,14 @@ export class LayoutPass {
4299
4374
  // The keyboard (device mode) covers the tab bar: the content stops above
4300
4375
  // whichever is taller (a `navstack` handles the keyboard in its screens).
4301
4376
  const keyboard = y + size.height >= screen.height - EPSILON ? this.keyboard : 0;
4377
+ // Clear of the horizontal safe area (a phone on its side); a `navstack`
4378
+ // insets its own screens' items and content.
4379
+ const leading = x <= EPSILON ? safeArea.leading : 0;
4380
+ const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
4302
4381
  const area: Rect = {
4303
- x,
4382
+ x: x + leading,
4304
4383
  y: y + topInset,
4305
- width: size.width,
4384
+ width: Math.max(0, size.width - leading - trailing),
4306
4385
  height: Math.max(0, size.height - topInset - Math.max(barHeight, keyboard)),
4307
4386
  };
4308
4387
  this.setChrome(tab, { content: area });
@@ -4316,7 +4395,7 @@ export class LayoutPass {
4316
4395
  if (kind === 'list' || kind === 'scrollview') {
4317
4396
  const height = Math.max(0, size.height - topInset - keyboard);
4318
4397
  const ctx: Context = keyboard > 0 ? { ...cctx, bottomBar: Math.max(0, barHeight - keyboard), bottomEdge: y + size.height - keyboard } : cctx;
4319
- this.place(child, ctx, { width: size.width, height }, x, y + topInset);
4398
+ this.place(child, ctx, { width: area.width, height }, area.x, y + topInset);
4320
4399
  return;
4321
4400
  }
4322
4401
  const proposal: Proposal = { width: area.width, height: area.height };
@@ -4535,10 +4614,11 @@ export class LayoutPass {
4535
4614
  }
4536
4615
 
4537
4616
  /**
4538
- * A slider is the proposed width (200 when none) and 44 high, plus a label
4539
- * row above when a `label` child exists (its own height, leading). The
4540
- * `min` / `max` value labels sit at the track's ends in the 44 row, 8 from
4541
- * the track. Children are named by `labels` in order.
4617
+ * A slider is the proposed width (200 when none) and 44 high. The `min` /
4618
+ * `max` value labels sit at the track's ends, 8 from the track. Its
4619
+ * `label` child is not drawn, as iOS does not draw a slider's label (it
4620
+ * names the slider for VoiceOver): the page hides it and names the slider
4621
+ * from it (`aria-labelledby`). Children are named by `labels` in order.
4542
4622
  */
4543
4623
  private sliderPlan(node: LayoutNode, ctx: Context, p: Proposal): SliderPlan {
4544
4624
  return this.plan(node, ctx, p, () => {
@@ -4554,19 +4634,17 @@ export class LayoutPass {
4554
4634
  else if (name === 'min') min = child;
4555
4635
  else if (name === 'max') max = child;
4556
4636
  });
4557
- const labelSize = label ? this.measure(label, cctx, { width, height: null }) : ZERO;
4558
4637
  const minSize = min ? this.measure(min, cctx, NIL_PROPOSAL) : ZERO;
4559
4638
  const maxSize = max ? this.measure(max, cctx, NIL_PROPOSAL) : ZERO;
4560
- const labelRow = label ? labelSize.height : 0;
4561
- return { size: { width, height: labelRow + SLIDER_HEIGHT }, label, min, max, labelSize, minSize, maxSize, labelRow, cctx };
4639
+ return { size: { width, height: SLIDER_HEIGHT }, label, min, max, minSize, maxSize, cctx };
4562
4640
  });
4563
4641
  }
4564
4642
 
4565
4643
  private placeSlider(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number, size: Size): void {
4566
4644
  const plan = this.sliderPlan(node, ctx, p);
4567
4645
  const { cctx } = plan;
4568
- if (plan.label) this.place(plan.label, cctx, { width: size.width, height: null }, x, y);
4569
- const rowY = y + plan.labelRow;
4646
+ if (plan.label) this.hide(plan.label, node);
4647
+ const rowY = y;
4570
4648
  let trackX = x;
4571
4649
  let trackEnd = x + size.width;
4572
4650
  if (plan.min) {
package/src/layoutDom.ts CHANGED
@@ -36,6 +36,7 @@
36
36
  import type { LayoutEnvironment, LayoutNode, LayoutResult, Rect, ResolvedFont, TextLayout } from './layout';
37
37
  import { hostContentInset } from './hostInset';
38
38
  import { listRowBackgroundOf } from './layout';
39
+ import { isRegularWidth } from './layout/engine';
39
40
  import { styleToCSS } from './styles';
40
41
  import type { ShapeStyle } from './protocol';
41
42
  import { applyChartChrome } from './chartDom';
@@ -159,6 +160,8 @@ function applyFrames(ctx: LayoutDomContext, result: LayoutResult, changes: BoxCh
159
160
  if (el.hidden) el.hidden = false;
160
161
  applyChrome(result, node, el, box, frame, scrollBottomInset(ctx, result, node, frame), previous === null, changes);
161
162
  if (node.kind === 'tab') setTabHidden(el, result.hidden.has(id));
163
+ // A `sidebar` list draws as one (no cards or hairlines) in a regular width; in a compact one it looks like `insetGrouped`.
164
+ if (node.kind === 'list') setFlag(el, 'sbListLook', node.props['style'] === 'sidebar' && isRegularWidth(ctx.env.screen) ? 'sidebar' : null);
162
165
  // A child the engine did not touch kept its screen frame, but if this
163
166
  // element's box moved, the child's `left`/`top` (relative to it) did
164
167
  // not: the plus button at the bottom of a chat's composer stays put on
@@ -636,14 +639,15 @@ function applyChrome(
636
639
  setFlag(el, 'sbSeparatorBottom', chrome?.['separatorBottomHidden'] ? 'hidden' : null);
637
640
  setFlag(el, 'sbSeparatorTop', chrome?.['separatorTopHidden'] ? 'hidden' : null);
638
641
  // `.listRowBackground(_:)`: the row's cell paint (styles.css reads it, and
639
- // a card with such a row leaves its own background to its rows).
642
+ // a card with such a row leaves its own background to its rows; a card of
643
+ // clear rows alone, marked `clear`, clips nothing either).
640
644
  const rowBackground = chrome?.['row'] ? listRowBackgroundOf(node) : undefined;
641
645
  const rowPaint = rowBackground !== undefined ? styleToCSS(rowBackground as ShapeStyle, 'paint').value : '';
642
646
  if (el.style.getPropertyValue('--sb-row-background') !== rowPaint) {
643
647
  if (rowPaint) el.style.setProperty('--sb-row-background', rowPaint);
644
648
  else el.style.removeProperty('--sb-row-background');
645
649
  }
646
- setFlag(el, 'sbRowBackground', rowPaint ? '' : null);
650
+ setFlag(el, 'sbRowBackground', rowPaint ? (rowPaint === 'transparent' ? 'clear' : '') : null);
647
651
  // iOS 26: the separator under a list row starts at its first text (styles.css).
648
652
  const separator = chrome?.['separator'];
649
653
  const leading = separator ? `${separator.x}px` : '';
@@ -699,11 +703,22 @@ function applyChrome(
699
703
  case 'stepper':
700
704
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-stepper-control'), chrome?.['control'], box);
701
705
  return;
702
- case 'slider':
706
+ case 'slider': {
707
+ // The `label` child is hidden (iOS draws none) and names the slider, as VoiceOver reads it.
708
+ const names = Array.isArray(node.props['labels']) ? node.props['labels'] : [];
709
+ const labelNode = node.children[names.indexOf('label')];
710
+ const labelEl = labelNode && names.includes('label') ? el.querySelector<HTMLElement>(`[data-sb-id="${labelNode.id}"]`) : null;
711
+ if (labelEl) {
712
+ if (!labelEl.id) labelEl.id = `sb-slider-label-${labelNode!.id}`;
713
+ el.setAttribute('aria-labelledby', labelEl.id);
714
+ } else {
715
+ el.removeAttribute('aria-labelledby');
716
+ }
703
717
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-slider-track'), chrome?.['track'], box);
704
718
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-slider-fill'), chrome?.['fill'], box);
705
719
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-slider-thumb'), chrome?.['thumb'], box);
706
720
  return;
721
+ }
707
722
  case 'datepicker':
708
723
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-datepicker-date'), chrome?.['date'], box);
709
724
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-datepicker-time'), chrome?.['time'], box);
@@ -745,6 +760,20 @@ function applyChrome(
745
760
  placeChrome(sw, rect, box);
746
761
  return;
747
762
  }
763
+ case 'disclosuregroup': {
764
+ // The chevron at the label's trailing edge, the toggle over the label (the first child's frame).
765
+ const label = node.children[0] ? result.frames.get(node.children[0].id) : undefined;
766
+ const chevron = el.querySelector<HTMLElement>(':scope > .sb-disclosure-chevron');
767
+ const toggle = el.querySelector<HTMLElement>(':scope > .sb-disclosure-toggle');
768
+ if (label) {
769
+ placeChrome(chevron, { x: frame.x + frame.width - CHEVRON_SIZE - 2, y: label.y + (label.height - CHEVRON_SIZE) / 2, width: CHEVRON_SIZE, height: CHEVRON_SIZE }, box);
770
+ placeChrome(toggle, { x: frame.x, y: label.y, width: frame.width, height: label.height }, box);
771
+ } else {
772
+ placeChrome(chevron, undefined, box);
773
+ placeChrome(toggle, undefined, box);
774
+ }
775
+ return;
776
+ }
748
777
  case 'navlink': {
749
778
  // A link row's badge sits on its label (`NavigationLink { row.badge(n) }`).
750
779
  applyBadge(result, node, el, box);
@@ -839,6 +868,12 @@ function applyChrome(
839
868
  const inlineTitle = el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-title');
840
869
  const texts = result.chromeText.get(node.id);
841
870
  const barRect = chrome?.['bar'];
871
+ // `.toolbar(.hidden, for: .navigationBar)`: the bar fades out where it
872
+ // was, its title, back button and More button with it (styles.css);
873
+ // `.statusBarHidden`: the frame's drawn status bar fades (renderer.ts).
874
+ const barHidden = node.props['navigationBarHidden'] === true;
875
+ setFlag(el, 'sbBarHidden', barHidden ? '' : null);
876
+ setFlag(el, 'sbStatusHidden', node.props['statusBarHidden'] === true ? '' : null);
842
877
  if (bar && barRect) {
843
878
  placeChrome(bar, barRect, frame);
844
879
  // The 44px bar row sits under the status bar portion of the rect
@@ -871,6 +906,11 @@ function applyChrome(
871
906
  largeTitle.hidden = true;
872
907
  }
873
908
  }
909
+ if (barHidden) return;
910
+ // The engine's choice, not the display mode's: a compact height (a
911
+ // phone on its side) has no large title row, and the bar shows the
912
+ // inline title as UIKit does (styles.css hides it under a large one).
913
+ el.dataset['sbDisplay'] = largeRect ? 'large' : 'inline';
874
914
  if (inlineTitle) {
875
915
  // Phase 6: the engine centers the inline title in the room the back
876
916
  // button and the toolbar items leave (no rect: large title or a
package/src/protocol.ts CHANGED
@@ -359,6 +359,8 @@ export interface Style {
359
359
  animationToken?: number;
360
360
  /** This box is a `Label`'s icon: a list row tints it (its title keeps the row's color). */
361
361
  labelIcon?: boolean;
362
+ /** `.textSelection(.enabled)`: the text below can be selected (iOS shows none selectable otherwise). */
363
+ textSelection?: boolean;
362
364
  /** How this subtree appears and disappears inside an animated commit. */
363
365
  transition?: Transition;
364
366
  // Phase 5
@@ -612,13 +614,21 @@ export interface ScrollViewProps {
612
614
  * Phase 4: `insetGrouped` (the default when omitted, as Phase 2 modules send
613
615
  * `{}`), `grouped` (full-width sections with hairlines; `Form`) or `plain`.
614
616
  */
615
- export type ListStyle = 'plain' | 'insetGrouped' | 'grouped';
617
+ /** `sidebar`: a `NavigationSplitView`'s sidebar list (and `.listStyle(.sidebar)`): rows on the background, collapsible sections. */
618
+ export type ListStyle = 'plain' | 'insetGrouped' | 'grouped' | 'sidebar';
616
619
  export interface ListProps {
617
620
  style?: ListStyle;
618
621
  }
619
622
  export interface SectionProps {
620
623
  header: string | null;
621
624
  footer: string | null;
625
+ /** `Section(isExpanded:)`: the section collapses in a sidebar-style list (as on iOS); a `tap` on it toggles. The rows come whatever the state. */
626
+ collapsible?: boolean;
627
+ expanded?: boolean;
628
+ }
629
+ /** `DisclosureGroup`: open or not; its children are the label (first, a `zstack`) and the content, laid out when expanded; a `tap` toggles. */
630
+ export interface DisclosureGroupProps {
631
+ expanded: boolean;
622
632
  }
623
633
  export type TextFieldStyle = 'plain' | 'roundedBorder';
624
634
  export interface TextFieldProps {
@@ -667,6 +677,16 @@ export interface NavScreenProps {
667
677
  displayMode: NavDisplayMode;
668
678
  /** 0 for the root screen; the renderer draws a back button when > 0. */
669
679
  depth: number;
680
+ /**
681
+ * `.toolbar(.hidden, for: .navigationBar)`: no navigation bar (nor large
682
+ * title, back button or bar items); the content starts under the status
683
+ * bar. A change fades the bar in an animated commit.
684
+ */
685
+ navigationBarHidden?: boolean;
686
+ /** `.toolbar(.hidden, for: .bottomBar)`: the `bottom` toolbars draw nothing. */
687
+ bottomBarHidden?: boolean;
688
+ /** `.statusBarHidden()`: natively the status bar hides with the screen; the page's frame mode hides the status bar it draws (the safe area stays). */
689
+ statusBarHidden?: boolean;
670
690
  }
671
691
  /** Phase 7 adds `selected` (see the block at the end of this file). */
672
692
  export interface NavLinkProps {
@@ -1017,6 +1037,7 @@ export interface ElementPropsByKind {
1017
1037
  scrollview: ScrollViewProps;
1018
1038
  list: ListProps;
1019
1039
  section: SectionProps;
1040
+ disclosuregroup: DisclosureGroupProps;
1020
1041
  textfield: TextFieldProps;
1021
1042
  toggle: ToggleProps;
1022
1043
  navstack: NavStackProps;
@@ -2513,8 +2534,12 @@ export interface TextFieldProps {
2513
2534
  export interface ListProps {
2514
2535
  /** `.scrollContentBackground(.hidden)`: the list draws no background of its own (rows keep theirs). */
2515
2536
  background?: 'hidden';
2516
- /** `.scrollDismissesKeyboard(.immediately | .interactively)`: scrolling blurs the text input being edited inside. */
2517
- dismissesKeyboard?: boolean;
2537
+ /**
2538
+ * `.scrollDismissesKeyboard(_:)`: `true` or `immediately` dismisses the
2539
+ * keyboard as scrolling starts, `interactively` lets it follow the finger
2540
+ * down (natively; a page blurs the text input being edited inside either way).
2541
+ */
2542
+ dismissesKeyboard?: boolean | 'immediately' | 'interactively';
2518
2543
  /**
2519
2544
  * `.scrollEdgeEffectStyle(_:for:)` / `.scrollEdgeEffectHidden(_:for:)`: the
2520
2545
  * scroll edge effect where the list runs under its screen's bar (`top`) or
@@ -2530,7 +2555,7 @@ export interface EdgeEffectProps {
2530
2555
 
2531
2556
  export interface ScrollViewProps {
2532
2557
  /** As for `list`. */
2533
- dismissesKeyboard?: boolean;
2558
+ dismissesKeyboard?: boolean | 'immediately' | 'interactively';
2534
2559
  /** As for `list`. */
2535
2560
  edgeEffect?: EdgeEffectProps;
2536
2561
  /**