@swiftbrowser/web 0.17.8 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -87,6 +87,8 @@ export interface Context {
87
87
  splitSidebar: boolean;
88
88
  /** Inside a leading or trailing item of a navigation bar (a segmented picker there is the bar's own capsule). */
89
89
  navBarItem?: boolean;
90
+ /** Inside an item of a bottom toolbar (a bordered or prominent button there takes the bar's sizes). */
91
+ bottomBarItem?: boolean;
90
92
  /** Rows of a `sidebar`-style list (iPadOS): its links have no chevron. */
91
93
  sidebarList?: boolean;
92
94
  /**
@@ -125,6 +127,8 @@ export interface Context {
125
127
  * a vertical text field is at least this many lines tall. Absent for 1.
126
128
  */
127
129
  minLines?: number;
130
+ /** `.scrollDisabled(true)` above: a `texteditor` is as tall as its lines. */
131
+ scrollDisabled?: boolean;
128
132
  }
129
133
 
130
134
  // Constants from the spec.
@@ -321,6 +325,11 @@ function navSlot(item: LayoutNode, width: number): number {
321
325
  // more width gives it a little more room (not text's 18 a side). Alone in its
322
326
  // group it is a circle only if it fits an icon's slot; wider, a capsule.
323
327
  const BOTTOM_GLASS_HEIGHT = 48;
328
+ /** A `keyboard` toolbar's bar (the input accessory view): 44 across the screen, directly above the keyboard, its items 16 in. */
329
+ /** The keyboard bar (iOS 26's Notes): a 48 glass capsule, 10 in from the screen's sides, 10 above the keyboard. */
330
+ const KEYBOARD_BAR_HEIGHT = 48;
331
+ const KEYBOARD_BAR_MARGIN_H = 10;
332
+ const KEYBOARD_BAR_GAP = 10;
324
333
  const BOTTOM_GLASS_MARGIN_H = 28;
325
334
  const BOTTOM_ICON_SLOT = 50;
326
335
  const BOTTOM_TEXT_PAD_H = 18;
@@ -328,6 +337,58 @@ const BOTTOM_ICON_PAD_H = 8;
328
337
  const BOTTOM_ITEM_GAP = 6;
329
338
  /** A fixed `ToolbarSpacer` between two bottom groups. */
330
339
  const BOTTOM_FIXED_SPACER = 8;
340
+ // A bordered button in a bottom bar stays in its capsule, its slot 2 wider
341
+ // and its fill the slot less 10 (5 a side), 34 tall; a prominent or glass
342
+ // one leaves the capsule for a 48 circle of its own, 12 from the next
343
+ // capsule (measured in the iOS 26.2 simulator, Native BarButtonStyleTests).
344
+ const BOTTOM_BORDERED_GROWTH = 2;
345
+ const BOTTOM_BORDERED_INSET = 10;
346
+ const BOTTOM_BORDERED_HEIGHT = 34;
347
+ const BOTTOM_GROUP_GAP = 12;
348
+ // A `.toggleStyle(.button)` toggle in a bottom bar takes a slot 24 wider
349
+ // than a button's, its pill (drawn while on) the slot less 8 and 50 tall,
350
+ // on or off; its capsule is 60 tall about the same center (iOS 26.2,
351
+ // Native ToolbarToggleTests).
352
+ const BOTTOM_TOGGLE_GROWTH = 24;
353
+ const BOTTOM_TOGGLE_INSET = 8;
354
+ const BOTTOM_TOGGLE_HEIGHT = 50;
355
+ const BOTTOM_TOGGLE_GLASS_HEIGHT = 60;
356
+
357
+ /** A toggle drawn as a button (`.toggleStyle(.button)`). */
358
+ function isButtonToggle(node: LayoutNode): boolean {
359
+ return node.kind === 'toggle' && str(node.props['style']) === 'button';
360
+ }
361
+
362
+ // A toggle of the automatic style in a bar is no switch: it takes a plain
363
+ // button's slot, and while on fills a pill under its label, 10 wider on
364
+ // each side and 36 tall, with the accent (iOS 26.2, Native
365
+ // ToolbarToggleTests: the pen's 42×36, the bar 230 wide on or off).
366
+ const BAR_TOGGLE_PAD_H = 10;
367
+ const BAR_TOGGLE_HEIGHT = 36;
368
+
369
+ /** A toggle the bar draws as its own (any style but `button` and `switch`). */
370
+ function isBarToggle(node: LayoutNode, ctx: Context): boolean {
371
+ if (node.kind !== 'toggle' || !(ctx.bottomBarItem || ctx.navBarItem)) return false;
372
+ const style = str(node.props['style']);
373
+ return style !== 'button' && style !== 'switch';
374
+ }
375
+
376
+ /** A bottom bar item's slot in its capsule, from what it draws. */
377
+ function bottomSlot(node: LayoutNode, size: Size): number {
378
+ if (!hasText(node)) return Math.max(BOTTOM_ICON_SLOT, size.width + 2 * BOTTOM_ICON_PAD_H);
379
+ return size.width < NAV_GLASS_SIZE ? BOTTOM_ICON_SLOT : size.width + 2 * BOTTOM_TEXT_PAD_H;
380
+ }
381
+
382
+ /** How a bottom bar item's button is styled: bordered, prominent (or glass), a button toggle, a bar's own toggle, or none of them. */
383
+ function bottomBarStyle(item: LayoutNode): 'bordered' | 'prominent' | 'toggle' | 'barToggle' | null {
384
+ const inner = unwrapStyled(item);
385
+ if (isButtonToggle(inner)) return 'toggle';
386
+ if (inner.kind === 'toggle' && str(inner.props['style']) !== 'switch') return 'barToggle';
387
+ if (inner.kind !== 'button') return null;
388
+ const style = str(inner.props['style']);
389
+ if (style === 'bordered') return 'bordered';
390
+ return style === 'borderedProminent' || style === 'glass' ? 'prominent' : null;
391
+ }
331
392
  const SEARCH_FIELD_HEIGHT = 36;
332
393
  const SEARCH_FIELD_MARGIN_H = 16;
333
394
  // iOS 26: a navigation stack's search field is a 48-tall glass capsule
@@ -381,6 +442,8 @@ const DATEPICKER_CAPSULE_GAP = 8;
381
442
  const DATEPICKER_GAP = 8;
382
443
  /** A `texteditor` fills the proposal; 100 high when proposed nil. */
383
444
  const TEXTEDITOR_DEFAULT_HEIGHT = 100;
445
+ /** UITextView's insets around a `texteditor`'s lines (`textContainerInset`, `lineFragmentPadding`; styles.css). */
446
+ const TEXTEDITOR_INSET = { horizontal: 5, vertical: 8 };
384
447
  /**
385
448
  * `alert` style (iOS 26): a 320 wide card centered in the safe area, its
386
449
  * title and message leading-aligned 30 in, its buttons 46 tall capsules 16
@@ -397,6 +460,10 @@ const ALERT_BUTTON_SPACING = 8;
397
460
  /** Gap between the alert title and its message. */
398
461
  const ALERT_TITLE_GAP = 6;
399
462
  const ALERT_BUTTON_HEIGHT = 46;
463
+ /** An alert's `textfield` children: 36 tall rows inset as the buttons are, 8 apart, 16 below the message. */
464
+ const ALERT_FIELD_HEIGHT = 36;
465
+ const ALERT_FIELD_SPACING = 8;
466
+ const ALERT_FIELDS_GAP = 16;
400
467
  /** Gap between an action sheet's title and its message. */
401
468
  const DIALOG_TITLE_GAP = 4;
402
469
  /** An action sheet's button labels are inset this much from its rows. */
@@ -527,6 +594,13 @@ function flexibleFrameLength(proposed: number | null, child: number, min: number
527
594
  return length;
528
595
  }
529
596
 
597
+ /** Whether `id` is the node or one of its descendants (a screen asking whether the focused text input is its own). */
598
+ function subtreeHas(node: LayoutNode, id: number): boolean {
599
+ if (node.id === id) return true;
600
+ for (const child of node.children) if (subtreeHas(child, id)) return true;
601
+ return false;
602
+ }
603
+
530
604
  /** Whether a root (through its `styled` wrappers) carries `ignoresKeyboard`: it keeps the keyboardless height. */
531
605
  function ignoresKeyboard(node: LayoutNode): boolean {
532
606
  let current = node;
@@ -664,6 +738,7 @@ function listRowVPad(row: LayoutNode, sidebar: boolean): number {
664
738
  case 'picker':
665
739
  return str(row.props['style']) === 'segmented' ? LIST_ROW_SEGMENTED_VPAD : 0;
666
740
  case 'toggle':
741
+ return isButtonToggle(row) ? (sidebar ? LIST_SIDEBAR_ROW_VPAD : LIST_ROW_VPAD) : LIST_ROW_CONTROL_VPAD;
667
742
  case 'stepper':
668
743
  return LIST_ROW_CONTROL_VPAD;
669
744
  default:
@@ -1562,6 +1637,7 @@ export class LayoutPass {
1562
1637
  }
1563
1638
  const imageScale = str(style['imageScale']);
1564
1639
  if (imageScale !== null) next = { ...next, imageScale: IMAGE_SCALES[imageScale] ?? 1 };
1640
+ if (typeof style['scrollDisabled'] === 'boolean') next = { ...next, scrollDisabled: style['scrollDisabled'] };
1565
1641
  return next;
1566
1642
  }
1567
1643
  case 'vstack':
@@ -1579,8 +1655,9 @@ export class LayoutPass {
1579
1655
  case 'toolbar': {
1580
1656
  const placement = str(node.props['placement']);
1581
1657
  const navBarItem = placement === 'leading' || placement === 'trailing';
1582
- if (ctx.stackAxis === null && !ctx.listRow && ctx.navBarItem === navBarItem) return ctx;
1583
- return { ...ctx, stackAxis: null, listRow: false, navBarItem };
1658
+ const bottomBarItem = placement === 'bottom';
1659
+ if (ctx.stackAxis === null && !ctx.listRow && ctx.navBarItem === navBarItem && ctx.bottomBarItem === bottomBarItem) return ctx;
1660
+ return { ...ctx, stackAxis: null, listRow: false, navBarItem, bottomBarItem };
1584
1661
  }
1585
1662
  case 'gesture':
1586
1663
  case 'swipeactions':
@@ -1742,7 +1819,9 @@ export class LayoutPass {
1742
1819
  case 'datepicker':
1743
1820
  return this.datePickerPlan(node, ctx, p).size;
1744
1821
  case 'texteditor':
1745
- // A textarea filling the proposal (flexible on an infinite axis, like a shape).
1822
+ // Not scrolling, as tall as its lines; else a textarea filling the
1823
+ // proposal (flexible on an infinite axis, like a shape).
1824
+ if (ctx.scrollDisabled) return this.measureFittingEditor(node, ctx, p);
1746
1825
  return { width: p.width ?? TEXTFIELD_DEFAULT_WIDTH, height: p.height ?? TEXTEDITOR_DEFAULT_HEIGHT };
1747
1826
  case 'layout':
1748
1827
  return this.measureLayout(node, ctx, p);
@@ -1812,6 +1891,41 @@ export class LayoutPass {
1812
1891
  return { width, height: textHeight(lines, ctx.font) + 2 * inset.vertical };
1813
1892
  }
1814
1893
 
1894
+ /**
1895
+ * A `texteditor` under `.scrollDisabled(true)`: the proposed width (200
1896
+ * when unconstrained) and its lines, a leading apart (as the DOM spaces
1897
+ * them), inside UITextView's insets; one line when empty.
1898
+ *
1899
+ * Every line is the context font's leading tall, whatever its runs' fonts
1900
+ * (`richTextPlan`'s own per-line heights, a line height then a leading
1901
+ * per line, with a larger run's font making its line taller, are not
1902
+ * used): the DOM gives the editor one line height, which every run's span
1903
+ * inherits (richEditor.ts `layOutRichEditor`), so its lines are evenly
1904
+ * spaced and the box is as tall as the DOM draws it.
1905
+ */
1906
+ private measureFittingEditor(node: LayoutNode, ctx: Context, p: Proposal): Size {
1907
+ const width = finite(p.width) ? p.width : TEXTFIELD_DEFAULT_WIDTH;
1908
+ const runs = node.props['runs'];
1909
+ const lines = Array.isArray(runs)
1910
+ ? this.editorRunsPlan(node, ctx, p, runs, width).layout.lines.length
1911
+ : wrapText(str(node.props['text']) ?? '', ctx.font, Math.max(1, width - 2 * TEXTEDITOR_INSET.horizontal), null, this.measurer).lines.length;
1912
+ return { width, height: Math.max(1, lines) * ctx.font.leading + 2 * TEXTEDITOR_INSET.vertical };
1913
+ }
1914
+
1915
+ /**
1916
+ * A rich `texteditor`'s runs wrapped inside the insets of a `width` wide
1917
+ * editor, with no line limit (an editor shows every line). Memoized under
1918
+ * the editor's proposal like a text's plan: the measurement of a fitting
1919
+ * editor and the layout the DOM draws (`place`) wrap the runs once per
1920
+ * layout pass, where they used to wrap them twice.
1921
+ */
1922
+ private editorRunsPlan(node: LayoutNode, ctx: Context, p: Proposal, runs: unknown[], width: number): TextPlan {
1923
+ return this.plan(node, ctx, p, () => {
1924
+ const inner = Math.max(1, width - 2 * TEXTEDITOR_INSET.horizontal);
1925
+ return this.richTextPlan(runs, { ...ctx, lineLimit: null }, { width: inner, height: null });
1926
+ });
1927
+ }
1928
+
1815
1929
  private textPlan(node: LayoutNode, ctx: Context, p: Proposal): TextPlan {
1816
1930
  return this.plan(node, ctx, p, () => {
1817
1931
  const runs = node.props['runs'];
@@ -1898,6 +2012,28 @@ export class LayoutPass {
1898
2012
  return { width, height };
1899
2013
  }
1900
2014
 
2015
+ /**
2016
+ * A link row with a badge: iOS draws the count before the chevron
2017
+ * (`Inbox 12 ›`), the chevron at the row's trailing edge. The row was laid
2018
+ * out with the badge's room at the trailing edge, after the link's
2019
+ * chevron; the two trade places.
2020
+ */
2021
+ private badgeBeforeChevron(rowNode: LayoutNode): void {
2022
+ const rowChrome = rowNode.id >= 0 ? this.chrome.get(rowNode.id) : undefined;
2023
+ const badge = rowChrome?.['badge'];
2024
+ if (!badge) return;
2025
+ // Through the row's wrappers (a context menu's content is its first child).
2026
+ const wrappers = new Set(['styled', 'listrow', 'contextmenu', 'swipeactions', 'gesture']);
2027
+ let link: LayoutNode | undefined = rowNode;
2028
+ while (link && link.kind !== 'navlink') link = wrappers.has(link.kind) ? link.children[0] : undefined;
2029
+ const linkChrome = link && link.id >= 0 ? this.chrome.get(link.id) : undefined;
2030
+ const chevron = linkChrome?.['chevron'];
2031
+ if (!chevron || chevron.x > badge.x) return;
2032
+ const end = badge.x + badge.width;
2033
+ linkChrome['chevron'] = { ...chevron, x: end - chevron.width };
2034
+ rowChrome['badge'] = { ...badge, x: end - chevron.width - badge.width };
2035
+ }
2036
+
1901
2037
  private buttonIsBordered(node: LayoutNode): boolean {
1902
2038
  const style = str(node.props['style']);
1903
2039
  return style === 'bordered' || style === 'borderedProminent' || style === 'glass';
@@ -1907,6 +2043,14 @@ export class LayoutPass {
1907
2043
  const label = this.contentOf(node);
1908
2044
  if (!this.buttonIsBordered(node)) return label ? this.measure(label, this.childContext(node, ctx), p) : ZERO;
1909
2045
  const cctx = this.borderedLabelContext(node, ctx);
2046
+ if (ctx.bottomBarItem) {
2047
+ // In a bottom bar (measured in the iOS 26.2 simulator, Native
2048
+ // BarButtonStyleTests): a prominent or glass button is its own 48
2049
+ // circle; a bordered one fills its slot less 10, 34 tall.
2050
+ if (bottomBarStyle(node) === 'prominent') return { width: BOTTOM_GLASS_HEIGHT, height: BOTTOM_GLASS_HEIGHT };
2051
+ const size = label ? this.measure(label, cctx, { width: null, height: null }) : ZERO;
2052
+ return { width: bottomSlot(label ?? node, size) + BOTTOM_BORDERED_GROWTH - BOTTOM_BORDERED_INSET, height: BOTTOM_BORDERED_HEIGHT };
2053
+ }
1910
2054
  const { padV, padH, minHeight } = this.borderedMetrics(node);
1911
2055
  const inner: Proposal = {
1912
2056
  width: shrink(p.width, 2 * padH),
@@ -1936,6 +2080,20 @@ export class LayoutPass {
1936
2080
 
1937
2081
  private measureToggle(node: LayoutNode, ctx: Context, p: Proposal): Size {
1938
2082
  const label = this.contentOf(node);
2083
+ if (isBarToggle(node, ctx)) {
2084
+ const size = label ? this.measure(label, this.childContext(node, ctx), { width: null, height: null }) : ZERO;
2085
+ return { width: size.width + 2 * BAR_TOGGLE_PAD_H, height: BAR_TOGGLE_HEIGHT };
2086
+ }
2087
+ if (isButtonToggle(node)) {
2088
+ if (ctx.bottomBarItem) {
2089
+ const size = label ? this.measure(label, this.childContext(node, ctx), { width: null, height: null }) : ZERO;
2090
+ return { width: bottomSlot(label ?? node, size) + BOTTOM_TOGGLE_GROWTH - BOTTOM_TOGGLE_INSET, height: BOTTOM_TOGGLE_HEIGHT };
2091
+ }
2092
+ // sized as a bordered button, on or off
2093
+ const inner: Proposal = { width: shrink(p.width, 2 * BORDERED_PAD_H), height: shrink(p.height, 2 * BORDERED_PAD_V) };
2094
+ const size = label ? this.measure(label, this.childContext(node, ctx), inner) : ZERO;
2095
+ return { width: size.width + 2 * BORDERED_PAD_H, height: Math.max(BORDERED_MIN_HEIGHT, size.height + 2 * BORDERED_PAD_V) };
2096
+ }
1939
2097
  const labelSize = label
1940
2098
  ? this.measure(label, this.childContext(node, ctx), this.toggleLabelProposal(p))
1941
2099
  : ZERO;
@@ -3002,6 +3160,32 @@ export class LayoutPass {
3002
3160
  }
3003
3161
  case 'toggle': {
3004
3162
  const label = this.contentOf(node);
3163
+ if (isBarToggle(node, ctx)) {
3164
+ // the label centered on its pill (the switch's element, filled while on)
3165
+ const labelSize = label ? this.measure(label, cctx, { width: null, height: null }) : ZERO;
3166
+ const { dx, dy } = alignIn(size, labelSize, 'center');
3167
+ if (label) this.place(label, cctx, { width: null, height: null }, x + dx, y + dy);
3168
+ const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
3169
+ this.setChrome(node, {
3170
+ ...existing,
3171
+ label: { x: x + dx, y: y + dy, width: labelSize.width, height: labelSize.height },
3172
+ switch: { x, y, width: size.width, height: size.height },
3173
+ });
3174
+ return;
3175
+ }
3176
+ if (isButtonToggle(node)) {
3177
+ // the label centered in the button (its fill, while on, the toggle's frame)
3178
+ if (!label) return;
3179
+ const inner: Proposal = ctx.bottomBarItem
3180
+ ? { width: null, height: null }
3181
+ : { width: shrink(p.width, 2 * BORDERED_PAD_H), height: shrink(p.height, 2 * BORDERED_PAD_V) };
3182
+ const labelSize = this.measure(label, cctx, inner);
3183
+ const { dx, dy } = alignIn(size, labelSize, 'center');
3184
+ this.place(label, cctx, inner, x + dx, y + dy);
3185
+ const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
3186
+ this.setChrome(node, { ...existing, label: { x: x + dx, y: y + dy, width: labelSize.width, height: labelSize.height } });
3187
+ return;
3188
+ }
3005
3189
  const labelProposal = this.toggleLabelProposal(p);
3006
3190
  const labelSize = label ? this.measure(label, cctx, labelProposal) : ZERO;
3007
3191
  const labelY = y + (size.height - labelSize.height) / 2;
@@ -3222,8 +3406,14 @@ export class LayoutPass {
3222
3406
  return;
3223
3407
  case 'texteditor':
3224
3408
  // It draws in the context font, as a field does (the page's own
3225
- // font otherwise: a serif where the page sets none).
3226
- if (node.id >= 0) this.fonts.set(node.id, ctx.font);
3409
+ // font otherwise: a serif where the page sets none); a rich one's
3410
+ // runs in theirs.
3411
+ if (node.id < 0) return;
3412
+ this.fonts.set(node.id, ctx.font);
3413
+ if (Array.isArray(node.props['runs'])) {
3414
+ // The plan a fitting editor was measured from (`measureFittingEditor`, memoized).
3415
+ this.text.set(node.id, this.editorRunsPlan(node, ctx, p, node.props['runs'], size.width).layout);
3416
+ }
3227
3417
  return;
3228
3418
  case 'swipeactions':
3229
3419
  case 'contextmenu': {
@@ -3367,6 +3557,7 @@ export class LayoutPass {
3367
3557
  : undefined;
3368
3558
  this.setChrome(row.node, own ? { ...own, ...chrome } : chrome);
3369
3559
  this.place(row.node, rowCtx, row.proposal, rowX, rowY);
3560
+ if (row.badge) this.badgeBeforeChevron(row.node);
3370
3561
  // iOS 26: the separator under a row starts where its first text
3371
3562
  // starts (after a Label's icon), else at the row's content.
3372
3563
  const placed = row.node.id >= 0 ? this.chrome.get(row.node.id) : undefined;
@@ -3434,6 +3625,12 @@ export class LayoutPass {
3434
3625
  const titleItemsHeight = large ? this.largeTitleItemsHeight(parts.toolbars, cctx, size.width) : null;
3435
3626
  const largeHeight = !large ? 0 : titleItemsHeight !== null ? LARGE_TITLE_ITEM_TOP + titleItemsHeight : LARGE_TITLE_HEIGHT;
3436
3627
  const bottomToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'bottom');
3628
+ // `keyboard` toolbars: a glass capsule floating above the keyboard while a
3629
+ // text input of this screen is being edited (as Notes' format bar on
3630
+ // iOS 26; SwiftUI's input accessory view); nothing otherwise.
3631
+ const keyboardToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'keyboard');
3632
+ const focusedField = this.env.focusedField;
3633
+ const keyboardBarShown = keyboardToolbars.some((t) => !isSpacer(t)) && focusedField !== undefined && subtreeHas(node, focusedField);
3437
3634
  const searchMargin = reachesBottom ? SEARCH_BOTTOM_MARGIN : SEARCH_BOTTOM_MARGIN_INSET;
3438
3635
  const bottomBarHeight = bottomToolbars.some((t) => !isSpacer(t))
3439
3636
  ? Math.min(size.height, BOTTOM_GLASS_HEIGHT + searchMargin)
@@ -3447,7 +3644,11 @@ export class LayoutPass {
3447
3644
  // bottom search field rises above it, as iOS 26 docks it on the
3448
3645
  // keyboard, so what is typed into it can be seen; the bars stay put,
3449
3646
  // covered.
3450
- const keyboard = reachesBottom ? this.keyboard : 0;
3647
+ // With a keyboard bar the keyboard is the bar and its gap taller to the
3648
+ // content; a keyboard the page did not measure (frame mode) is the bottom
3649
+ // safe area.
3650
+ const keyboardAlone = reachesBottom ? this.keyboard : 0;
3651
+ const keyboard = keyboardBarShown ? KEYBOARD_BAR_HEIGHT + KEYBOARD_BAR_GAP + (keyboardAlone > 0 ? keyboardAlone : reachesBottom ? safeArea.bottom : 0) : keyboardAlone;
3451
3652
  const searchLift = searchBottom > 0 ? keyboard : 0;
3452
3653
  const contentTop = barHeight + largeHeight + searchRow;
3453
3654
  const content: Rect = {
@@ -3478,6 +3679,9 @@ export class LayoutPass {
3478
3679
  if (bottomBarHeight > 0) {
3479
3680
  chrome['bottomBar'] = { x, y: y + size.height - bottomBarHeight, width: size.width, height: bottomBarHeight };
3480
3681
  }
3682
+ if (keyboardBarShown) {
3683
+ chrome['keyboardBar'] = { x: x + KEYBOARD_BAR_MARGIN_H, y: y + size.height - keyboard, width: Math.max(0, size.width - 2 * KEYBOARD_BAR_MARGIN_H), height: KEYBOARD_BAR_HEIGHT };
3684
+ }
3481
3685
  const title = str(node.props['title']) ?? '';
3482
3686
  const titleFont = makeFont('system', textStyleSize('headline', dynamicTypeSize), 600);
3483
3687
  const largeTitleFont = makeFont('system', textStyleSize('largeTitle', dynamicTypeSize), 700);
@@ -3498,7 +3702,7 @@ export class LayoutPass {
3498
3702
  for (const spacer of parts.toolbars.filter(isSpacer)) this.hide(spacer, node);
3499
3703
  const barToolbars = parts.toolbars.filter((t) => {
3500
3704
  const placement = str(t.props['placement']);
3501
- return placement !== 'bottom' && placement !== 'secondary' && placement !== 'largeTitle' && !isSpacer(t);
3705
+ return placement !== 'bottom' && placement !== 'secondary' && placement !== 'largeTitle' && placement !== 'keyboard' && !isSpacer(t);
3502
3706
  });
3503
3707
  const secondaryToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'secondary' && !isSpacer(t));
3504
3708
  const groups: Group[] = barToolbars.map((toolbar) => {
@@ -3742,6 +3946,9 @@ export class LayoutPass {
3742
3946
  if (bottomBarHeight > 0) {
3743
3947
  this.placeBottomToolbars(bottomToolbars, cctx, chrome, x, y + size.height - bottomBarHeight, size.width);
3744
3948
  }
3949
+ const keyboardBar = chrome['keyboardBar'];
3950
+ if (keyboardBar) this.placeKeyboardToolbars(keyboardToolbars, cctx, keyboardBar);
3951
+ else for (const toolbar of keyboardToolbars) this.hide(toolbar, node);
3745
3952
  if (parts.search) {
3746
3953
  // The field is the whole capsule (its own measure is a 36pt field).
3747
3954
  const field = chrome['searchField'] as Rect;
@@ -3904,6 +4111,29 @@ export class LayoutPass {
3904
4111
  * `bottomGlass0` … (drawn under the items); each toolbar's frame spans its
3905
4112
  * items.
3906
4113
  */
4114
+ /**
4115
+ * `keyboard` toolbars (`ToolbarItemGroup(placement: .keyboard)`): their
4116
+ * items in one row across the bar, 16 in from either edge, as SwiftUI
4117
+ * lays them out, an `HStack` of the items in the toolbars' order in which
4118
+ * a `Spacer` (a child, or a `ToolbarSpacer` of the placement) takes the
4119
+ * room left over; each toolbar's frame is the bar.
4120
+ */
4121
+ private placeKeyboardToolbars(toolbars: LayoutNode[], cctx: Context, bar: Rect): void {
4122
+ const items: LayoutNode[] = [];
4123
+ for (const toolbar of toolbars) {
4124
+ if (str(toolbar.props['spacer']) !== null) items.push({ id: -1, kind: 'spacer', props: { minLength: str(toolbar.props['spacer']) === 'fixed' ? BOTTOM_FIXED_SPACER : null }, children: [] });
4125
+ else items.push(...toolbar.children);
4126
+ }
4127
+ const row: LayoutNode = { id: -1, kind: 'hstack', props: { spacing: null, alignment: 'center' }, children: items };
4128
+ const first = toolbars.find((toolbar) => str(toolbar.props['spacer']) === null) ?? (toolbars[0] as LayoutNode);
4129
+ const itemCtx = this.childContext(first, cctx);
4130
+ const inner = Math.max(0, bar.width - 2 * NAV_ITEM_MARGIN);
4131
+ const proposal: Proposal = { width: inner, height: bar.height };
4132
+ const rowSize = this.measure(row, itemCtx, proposal);
4133
+ this.place(row, itemCtx, proposal, bar.x + NAV_ITEM_MARGIN, bar.y + (bar.height - rowSize.height) / 2);
4134
+ for (const toolbar of toolbars) this.setFrame(toolbar, bar.x, bar.y, { width: bar.width, height: bar.height });
4135
+ }
4136
+
3907
4137
  private placeBottomToolbars(
3908
4138
  toolbars: LayoutNode[],
3909
4139
  cctx: Context,
@@ -3941,28 +4171,45 @@ export class LayoutPass {
3941
4171
  const itemCtx = this.childContext(toolbar, cctx);
3942
4172
  for (const node of toolbar.children) {
3943
4173
  const size = this.measure(node, itemCtx, { width: null, height: null });
3944
- const slot = !hasText(node)
3945
- ? Math.max(BOTTOM_ICON_SLOT, size.width + 2 * BOTTOM_ICON_PAD_H)
3946
- : size.width < NAV_GLASS_SIZE
3947
- ? BOTTOM_ICON_SLOT
3948
- : size.width + 2 * BOTTOM_TEXT_PAD_H;
4174
+ const style = bottomBarStyle(node);
4175
+ if (style === 'prominent') {
4176
+ // its own circle, between the capsules of the items around it
4177
+ close();
4178
+ tokens.push({ kind: 'group', items: [{ node, toolbar, size, slot: BOTTOM_GLASS_HEIGHT }], width: BOTTOM_GLASS_HEIGHT });
4179
+ continue;
4180
+ }
4181
+ // A bar's own toggle takes its label's slot, as a plain button does.
4182
+ const slot =
4183
+ style === 'bordered'
4184
+ ? size.width + BOTTOM_BORDERED_INSET
4185
+ : style === 'toggle'
4186
+ ? size.width + BOTTOM_TOGGLE_INSET
4187
+ : style === 'barToggle'
4188
+ ? bottomSlot(node, { width: size.width - 2 * BAR_TOGGLE_PAD_H, height: size.height })
4189
+ : bottomSlot(node, size);
3949
4190
  open.push({ node, toolbar, size, slot });
3950
4191
  }
3951
4192
  }
3952
4193
  close();
3953
4194
  const inner = Math.max(0, width - 2 * BOTTOM_GLASS_MARGIN_H);
4195
+ // Two capsules with no spacer between them (a prominent item's circle
4196
+ // beside the rest) sit 12 apart.
4197
+ const gapBefore = (index: number): number =>
4198
+ index > 0 && tokens[index]?.kind === 'group' && tokens[index - 1]?.kind === 'group' ? BOTTOM_GROUP_GAP : 0;
3954
4199
  let used = 0;
3955
4200
  let flexible = 0;
3956
- for (const token of tokens) {
4201
+ tokens.forEach((token, index) => {
4202
+ used += gapBefore(index);
3957
4203
  if (token.kind === 'group') used += token.width;
3958
4204
  else if (token.kind === 'fixed') used += BOTTOM_FIXED_SPACER;
3959
4205
  else flexible += 1;
3960
- }
4206
+ });
3961
4207
  const share = flexible > 0 ? Math.max(0, inner - used) / flexible : 0;
3962
4208
  let cursor = flexible > 0 ? x + BOTTOM_GLASS_MARGIN_H : x + (width - used) / 2;
3963
4209
  const spans = new Map<LayoutNode, { start: number; end: number }>();
3964
4210
  let glass = 0;
3965
- for (const token of tokens) {
4211
+ for (const [index, token] of tokens.entries()) {
4212
+ cursor += gapBefore(index);
3966
4213
  if (token.kind === 'flexible') {
3967
4214
  cursor += share;
3968
4215
  continue;
@@ -3971,7 +4218,9 @@ export class LayoutPass {
3971
4218
  cursor += BOTTOM_FIXED_SPACER;
3972
4219
  continue;
3973
4220
  }
3974
- chrome[`bottomGlass${glass}`] = { x: cursor, y: top, width: token.width, height: BOTTOM_GLASS_HEIGHT };
4221
+ // A button toggle's capsule is taller, about the row's center.
4222
+ const height = token.items.some((item) => bottomBarStyle(item.node) === 'toggle') ? BOTTOM_TOGGLE_GLASS_HEIGHT : BOTTOM_GLASS_HEIGHT;
4223
+ chrome[`bottomGlass${glass}`] = { x: cursor, y: top - (height - BOTTOM_GLASS_HEIGHT) / 2, width: token.width, height };
3975
4224
  glass += 1;
3976
4225
  for (const item of token.items) {
3977
4226
  const itemCtx = this.childContext(item.toolbar, cctx);
@@ -4433,6 +4682,21 @@ export class LayoutPass {
4433
4682
  const chrome: Record<string, Rect> = {};
4434
4683
  const texts: Record<string, TextLayout> = {};
4435
4684
  const roleOf = (child: LayoutNode): string | null => str(unwrapStyled(child).props['role']);
4685
+ // `textfield` children (`.alert { TextField(...); Button(...) }`) go in
4686
+ // the card between the message and the buttons, in their order.
4687
+ const fields = node.children.filter((child) => unwrapStyled(child).kind === 'textfield');
4688
+ const buttons = node.children.filter((child) => !fields.includes(child));
4689
+ const placeFields = (top: number, left: number, width: number): void => {
4690
+ fields.forEach((field, i) => {
4691
+ const rect: Rect = { x: left, y: top + i * (ALERT_FIELD_HEIGHT + ALERT_FIELD_SPACING), width, height: ALERT_FIELD_HEIGHT };
4692
+ chrome[`field${i}`] = rect;
4693
+ const proposal: Proposal = { width: rect.width, height: rect.height };
4694
+ const fieldSize = this.measure(field, cctx, proposal);
4695
+ this.place(field, cctx, proposal, rect.x, rect.y + (rect.height - fieldSize.height) / 2);
4696
+ this.setFrame(field, rect.x, rect.y, { width: rect.width, height: rect.height });
4697
+ });
4698
+ };
4699
+ const fieldsHeight = fields.length > 0 ? fields.length * ALERT_FIELD_HEIGHT + (fields.length - 1) * ALERT_FIELD_SPACING : 0;
4436
4700
 
4437
4701
  const block = (text: string, font: ResolvedFont, width: number, alignment: 'center' | 'leading'): { layout: TextLayout; height: number } => {
4438
4702
  const wrapped = wrapText(text, font, width, null, this.measurer);
@@ -4463,9 +4727,14 @@ export class LayoutPass {
4463
4727
  messageBlock = { ...b, top: h };
4464
4728
  h += b.height;
4465
4729
  }
4730
+ let fieldsTop: number | null = null;
4731
+ if (fields.length > 0) {
4732
+ if (titleBlock || messageBlock) h += ALERT_FIELDS_GAP;
4733
+ fieldsTop = h;
4734
+ h += fieldsHeight;
4735
+ }
4466
4736
  h += ALERT_BUTTONS_GAP;
4467
4737
  const headerHeight = h;
4468
- const buttons = node.children;
4469
4738
  const rows = buttons.length === 0 ? 1 : buttons.length === 2 ? 1 : buttons.length;
4470
4739
  h += rows * ALERT_BUTTON_HEIGHT + (rows - 1) * ALERT_BUTTON_SPACING + ALERT_PAD_BOTTOM;
4471
4740
  // Centered in the safe area, as iOS does.
@@ -4485,6 +4754,7 @@ export class LayoutPass {
4485
4754
  const buttonsTop = card.y + headerHeight;
4486
4755
  const buttonsX = card.x + ALERT_BUTTON_INSET;
4487
4756
  const buttonsWidth = Math.max(0, cardWidth - 2 * ALERT_BUTTON_INSET);
4757
+ if (fieldsTop !== null) placeFields(card.y + fieldsTop, buttonsX, buttonsWidth);
4488
4758
  if (buttons.length === 0) {
4489
4759
  chrome['ok'] = { x: buttonsX, y: buttonsTop, width: buttonsWidth, height: ALERT_BUTTON_HEIGHT };
4490
4760
  } else if (buttons.length === 2) {
@@ -4534,9 +4804,14 @@ export class LayoutPass {
4534
4804
  }
4535
4805
  h += DIALOG_HEADER_PAD_V;
4536
4806
  }
4807
+ let fieldsTop: number | null = null;
4808
+ if (fields.length > 0) {
4809
+ fieldsTop = h;
4810
+ h += fieldsHeight + DIALOG_HEADER_PAD_V;
4811
+ }
4537
4812
  const headerHeight = h;
4538
- const cancel = node.children.find((child) => roleOf(child) === 'cancel') ?? null;
4539
- const others = node.children.filter((child) => child !== cancel);
4813
+ const cancel = buttons.find((child) => roleOf(child) === 'cancel') ?? null;
4814
+ const others = buttons.filter((child) => child !== cancel);
4540
4815
  const rows = others.length === 0 && !cancel ? 1 : others.length;
4541
4816
  h += rows * DIALOG_BUTTON_HEIGHT;
4542
4817
  const bottom = y + size.height - safeArea.bottom - DIALOG_MARGIN;
@@ -4559,6 +4834,7 @@ export class LayoutPass {
4559
4834
  texts['message'] = messageBlock.layout;
4560
4835
  }
4561
4836
  const buttonsTop = card.y + headerHeight;
4837
+ if (fieldsTop !== null) placeFields(card.y + fieldsTop, card.x + DIALOG_HEADER_PAD_H, textWidth);
4562
4838
  if (others.length === 0 && !cancel) {
4563
4839
  chrome['ok'] = { x: card.x, y: buttonsTop, width: cardWidth, height: DIALOG_BUTTON_HEIGHT };
4564
4840
  }
@@ -118,6 +118,12 @@ export interface LayoutEnvironment {
118
118
  * under it.
119
119
  */
120
120
  keyboard?: number;
121
+ /**
122
+ * The element id of the text input being edited (a `textfield`, a
123
+ * `texteditor` or a `searchfield`), when one is: a `navscreen` holding it
124
+ * shows its `keyboard` toolbars above the keyboard.
125
+ */
126
+ focusedField?: number;
121
127
  }
122
128
 
123
129
  export type TextAlignment = 'leading' | 'center' | 'trailing';
package/src/layoutDom.ts CHANGED
@@ -41,6 +41,7 @@ import type { ShapeStyle } from './protocol';
41
41
  import { applyChartChrome } from './chartDom';
42
42
  import { bitmapLaidOut } from './bitmaps';
43
43
  import { drawRichText } from './richText';
44
+ import { isRichEditor, layOutRichEditor } from './richEditor';
44
45
  import { bindScrollUnderBar, largeTitleOf, largeTitleScrolls, largeTitleTextOf } from './largeTitle';
45
46
  import { bindScrollEdgeBottom } from './scrollEdge';
46
47
  import { beginScrollSyncBatch, flushScrollSyncBatch, type ScrollExtent } from './scrollSync';
@@ -480,7 +481,10 @@ function setTextLayout(el: HTMLElement, layout: TextLayout): void {
480
481
  */
481
482
  function applyBadge(result: LayoutResult, node: LayoutNode, el: HTMLElement, box: Rect): void {
482
483
  const badgeRect = result.chrome.get(node.id)?.['badge'];
483
- let badge = el.querySelector<HTMLElement>(':scope > .sb-badge');
484
+ // A swipeable row draws its content on a layer that slides over its
485
+ // actions; the badge is part of that content.
486
+ const host = el.querySelector<HTMLElement>(':scope > .sb-swipe-content') ?? el;
487
+ let badge = host.querySelector<HTMLElement>(':scope > .sb-badge');
484
488
  if (!badgeRect) {
485
489
  badge?.remove();
486
490
  return;
@@ -489,7 +493,7 @@ function applyBadge(result: LayoutResult, node: LayoutNode, el: HTMLElement, box
489
493
  badge = el.ownerDocument.createElement('span');
490
494
  badge.className = 'sb-badge';
491
495
  badge.setAttribute('aria-hidden', 'true');
492
- el.append(badge);
496
+ host.append(badge);
493
497
  }
494
498
  placeChrome(badge, badgeRect, box);
495
499
  const layout = result.chromeText.get(node.id)?.['badge'];
@@ -673,10 +677,10 @@ function applyChrome(
673
677
  }
674
678
  case 'textfield':
675
679
  case 'texteditor': {
676
- // A field draws in its context's font (a vertical one, the font its
677
- // lines were measured in, a leading apart).
680
+ // A field draws in its context's font (a vertical one, or a rich
681
+ // editor, the font its lines were measured in, a leading apart).
678
682
  const font = result.fonts.get(node.id);
679
- setFont(el, font, el instanceof HTMLTextAreaElement ? 2 : 1);
683
+ setFont(el, font, el instanceof HTMLInputElement ? 1 : 2);
680
684
  // A one-line field's text sits where a centered Text's would (the
681
685
  // engine's `text` rect, snapped as a label beside it is): the input
682
686
  // centers its line in the frame, so move it by the difference.
@@ -687,6 +691,8 @@ function applyChrome(
687
691
  : 0;
688
692
  padIntoFrame(el, box, frame, shift);
689
693
  if (el instanceof HTMLTextAreaElement && node.kind === 'textfield' && font) fitFieldLines(el, font, box, frame);
694
+ // A rich editor's runs, each in the font it was measured in.
695
+ if (isRichEditor(el)) layOutRichEditor(el, result.text.get(node.id));
690
696
  return;
691
697
  }
692
698
  // Phase 8 controls: their chrome sits at the engine's rects inside the element's box.
@@ -844,6 +850,8 @@ function applyChrome(
844
850
  bar.style.paddingBottom = `${bottom}px`;
845
851
  }
846
852
  syncBarGlass(result, node, el, chrome, frame, isNew, changes);
853
+ // The `keyboard` toolbars' bar above the keyboard while a text input of the screen is focused.
854
+ placeChrome(el.querySelector<HTMLElement>(':scope > .sb-navscreen-body > .sb-keyboard-bar'), chrome?.['keyboardBar'], frame);
847
855
  // iOS 26 in a tab view: the search field under the title (styles.css).
848
856
  setFlag(el, 'sbSearchTop', chrome?.['searchTop'] ? '' : null);
849
857
  const largeRect = chrome?.['largeTitle'];
package/src/main.ts CHANGED
@@ -20,6 +20,7 @@ import { withBlockedHosts } from './blockedHosts';
20
20
  import { withCORSProxy } from './corsProxy';
21
21
  import { browserEnvironment, installPushService } from './hostRequests';
22
22
  import { locateBundleFiles } from './media';
23
+ import { relayUndo } from './undo';
23
24
  import { callbackPageURL } from './webAuth';
24
25
  import { bitmapStore } from './bitmaps';
25
26
  import { DEFAULT_TYPE_SIZE, applyTypeSizeVariables } from './typography';
@@ -640,6 +641,9 @@ async function boot(): Promise<void> {
640
641
  ),
641
642
  assetURL: (name) => assets.images[name]?.src,
642
643
  bitmapFile: (recipe) => bitmapStore.png(recipe),
644
+ // The app's undo manager reports its state in `undo` requests; the
645
+ // keyboard's undo and redo go back to it as events (undo.ts).
646
+ ...(browserEnvironment.undoManager ? { undoManager: relayUndo(browserEnvironment.undoManager, dispatch) } : {}),
643
647
  },
644
648
  onSwiftData: (op) => {
645
649
  swiftData?.apply(op).catch((error: unknown) => console.warn('[sb] could not save to IndexedDB', error));