@swiftbrowser/web 0.2.0 → 0.3.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.
- package/README.md +98 -57
- package/app/index.html +10 -10
- package/node/dist/node/examples.d.ts.map +1 -1
- package/node/dist/node/examples.js +1 -0
- package/node/dist/node/examples.js.map +1 -1
- package/node/dist/plugins/asset-catalogs.js +10 -10
- package/node/dist/plugins/asset-catalogs.js.map +1 -1
- package/package.json +1 -1
- package/plugins/asset-catalogs.ts +10 -10
- package/src/interaction.ts +50 -2
- package/src/largeTitle.ts +79 -0
- package/src/layout/engine.ts +491 -164
- package/src/layoutDom.ts +13 -3
- package/src/main.ts +4 -1
- package/src/mock.ts +2 -2
- package/src/protocol.ts +22 -2
- package/src/renderer.ts +19 -8
- package/src/scanner.ts +1 -1
- package/src/styles.css +330 -97
- package/src/styles.ts +30 -0
- package/src/symbols.ts +97 -10
- package/src/webAuth.ts +1 -1
package/src/layout/engine.ts
CHANGED
|
@@ -79,6 +79,12 @@ export interface Context {
|
|
|
79
79
|
* from here.
|
|
80
80
|
*/
|
|
81
81
|
safeTop: number;
|
|
82
|
+
/**
|
|
83
|
+
* The inset a sheet gives the bar of the navigation stack that fills it
|
|
84
|
+
* (its grabber row, or iOS 26's room above a sheet's bar), as the status
|
|
85
|
+
* bar does at the root. Only the stack's own screens see it.
|
|
86
|
+
*/
|
|
87
|
+
barInset?: number;
|
|
82
88
|
/**
|
|
83
89
|
* Phase 12: the size of the nearest container (`containerRelativeFrame`):
|
|
84
90
|
* the screen at the root, a scroll view's viewport inside one.
|
|
@@ -97,11 +103,23 @@ const DEFAULT_SPACER_MIN = 8;
|
|
|
97
103
|
/** Phase 5: the square an `image` whose catalog name is unknown measures. */
|
|
98
104
|
const UNKNOWN_IMAGE_SIDE = 44;
|
|
99
105
|
const SHAPE_DEFAULT = 10;
|
|
100
|
-
|
|
101
|
-
|
|
106
|
+
// iOS 26 metrics (measured in the iOS 26.2 simulator, Examples/Fidelity): a
|
|
107
|
+
// bordered button is 34 tall (50 at `.controlSize(.large)`), a switch 63 × 28.
|
|
108
|
+
const BORDERED_PAD_V = 6;
|
|
109
|
+
const BORDERED_PAD_H = 12;
|
|
102
110
|
const BORDERED_MIN_HEIGHT = 34;
|
|
103
|
-
|
|
104
|
-
|
|
111
|
+
/**
|
|
112
|
+
* A bordered button's padding, least height and (smaller sizes) label text
|
|
113
|
+
* style per `controlSize` (props); `regular` is the constants above.
|
|
114
|
+
*/
|
|
115
|
+
const BORDERED_SIZES: Record<string, { padV: number; padH: number; minHeight: number; textStyle?: string }> = {
|
|
116
|
+
mini: { padV: 3, padH: 8, minHeight: 24, textStyle: 'footnote' },
|
|
117
|
+
small: { padV: 4, padH: 10, minHeight: 28, textStyle: 'subheadline' },
|
|
118
|
+
large: { padV: 14, padH: 20, minHeight: 50 },
|
|
119
|
+
extraLarge: { padV: 18, padH: 24, minHeight: 58 },
|
|
120
|
+
};
|
|
121
|
+
const SWITCH_WIDTH = 63;
|
|
122
|
+
const SWITCH_HEIGHT = 28;
|
|
105
123
|
const TOGGLE_GAP = 8;
|
|
106
124
|
const TEXTFIELD_DEFAULT_WIDTH = 200;
|
|
107
125
|
const TEXTFIELD_HEIGHT_ROUNDED = 34;
|
|
@@ -109,25 +127,53 @@ const TEXTFIELD_HEIGHT_PLAIN = 22;
|
|
|
109
127
|
/** A vertical `roundedBorder` field's text inset: 7 + the 1px border across, (34 - 22) / 2 down. */
|
|
110
128
|
const TEXTFIELD_ROUNDED_INSET = { horizontal: 8, vertical: 6 };
|
|
111
129
|
const NAV_BAR_HEIGHT = 44;
|
|
112
|
-
|
|
130
|
+
// iOS 26: the large title's row is 62 (52 before).
|
|
131
|
+
const LARGE_TITLE_HEIGHT = 62;
|
|
113
132
|
/** Chevron glyph plus its gap, in px. */
|
|
114
|
-
const BACK_CHEVRON_WIDTH = 24;
|
|
115
133
|
const SHEET_TOP_GAP = 10;
|
|
116
134
|
const SHEET_GRABBER_ROW = 20;
|
|
117
135
|
const SHEET_GRABBER: Size = { width: 36, height: 5 };
|
|
118
136
|
const SHEET_GRABBER_TOP = 8;
|
|
137
|
+
// iOS 26: a sheet at a partial detent (medium, a height or a fraction)
|
|
138
|
+
// floats 8 from the screen's sides and 15 above its bottom; the medium card
|
|
139
|
+
// is 433 tall. Its grabber shows only when it has more than one detent; its
|
|
140
|
+
// content then starts 14 below the card's top.
|
|
141
|
+
const SHEET_FLOAT_INSET_H = 8;
|
|
142
|
+
const SHEET_FLOAT_INSET_BOTTOM = 15;
|
|
143
|
+
const SHEET_MEDIUM_HEIGHT = 433;
|
|
144
|
+
// iOS 26: without a grabber a sheet's content (and a navigation bar's row)
|
|
145
|
+
// starts 16 below its top.
|
|
146
|
+
const SHEET_CONTENT_TOP = 16;
|
|
119
147
|
const LIST_INSET = 16;
|
|
120
148
|
const LIST_CELL_PADDING = 16;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
149
|
+
// iOS 26 (measured in the iOS 26.2 simulator, Examples/Fidelity): rows are
|
|
150
|
+
// at least 52 tall with 13 above and below their content; a section's header
|
|
151
|
+
// is a sentence-case headline-sized line that starts 9 into the 35 gap
|
|
152
|
+
// between cards and ends 10 above its card.
|
|
153
|
+
const LIST_ROW_VPAD = 13;
|
|
154
|
+
// A row holding just a toggle or a stepper has 10 above and below it (so
|
|
155
|
+
// both come out 52 tall); a segmented picker has 14 (60 tall).
|
|
156
|
+
const LIST_ROW_CONTROL_VPAD = 10;
|
|
157
|
+
const LIST_ROW_SEGMENTED_VPAD = 14;
|
|
158
|
+
const LIST_ROW_MIN_HEIGHT = 52;
|
|
159
|
+
const LIST_TOP = 18;
|
|
124
160
|
const LIST_BOTTOM = 20;
|
|
125
|
-
const
|
|
126
|
-
const
|
|
161
|
+
const LIST_HEADER_LIFT = 9;
|
|
162
|
+
const LIST_HEADER_GAP = 10;
|
|
163
|
+
const LIST_FOOTER_GAP = 6;
|
|
127
164
|
const LIST_SECTION_GAP = 35;
|
|
165
|
+
// Below a section's footer the gap to the next section is 12 shorter.
|
|
166
|
+
const LIST_SECTION_GAP_AFTER_FOOTER = 23;
|
|
128
167
|
const LIST_CHEVRON_WIDTH = 20;
|
|
129
168
|
// Phase 4.
|
|
130
|
-
|
|
169
|
+
// iOS 26: the tab bar is a 62-tall glass capsule floating 21 from the sides
|
|
170
|
+
// and 22 above the bottom edge (8 when the tab view ends above the screen
|
|
171
|
+
// bottom), its items in equal slots inside a 4 inset.
|
|
172
|
+
const TAB_BAR_HEIGHT = 62;
|
|
173
|
+
const TAB_BAR_MARGIN_H = 21;
|
|
174
|
+
const TAB_BAR_MARGIN_BOTTOM = 22;
|
|
175
|
+
const TAB_BAR_MARGIN_BOTTOM_INSET = 8;
|
|
176
|
+
const TAB_BAR_ITEM_INSET = 4;
|
|
131
177
|
const PICKER_SEGMENTED_HEIGHT = 32;
|
|
132
178
|
const PICKER_SEGMENT_PAD = 20;
|
|
133
179
|
const PICKER_SEGMENT_FONT_SIZE = 13;
|
|
@@ -139,6 +185,9 @@ const PICKER_MENU_GAP = 12;
|
|
|
139
185
|
// Phase 6.
|
|
140
186
|
const GAUGE_BAR_HEIGHT = 4;
|
|
141
187
|
const GAUGE_LABEL_GAP = 4;
|
|
188
|
+
/** A ProgressView's label to its bar, and its bar to its current value label. */
|
|
189
|
+
const PROGRESS_LABEL_GAP = 8;
|
|
190
|
+
const PROGRESS_VALUE_GAP = 6;
|
|
142
191
|
const GAUGE_DEFAULT_WIDTH = 100;
|
|
143
192
|
/** A circular gauge's side (`accessoryCircular` / `accessoryCircularCapacity`). */
|
|
144
193
|
const GAUGE_RING_SIZE = 58;
|
|
@@ -153,12 +202,22 @@ const PICKER_INLINE_CHECK_WIDTH = 28;
|
|
|
153
202
|
const BADGE_GAP = 8;
|
|
154
203
|
const NAV_ITEM_SPACING = 16;
|
|
155
204
|
const NAV_ITEM_MARGIN = 16;
|
|
205
|
+
// iOS 26: the back button is a 44pt glass circle 16 from the leading edge,
|
|
206
|
+
// and the bar's leading and trailing items sit in glass capsules 44 tall,
|
|
207
|
+
// each item in a slot at least 44 wide (an icon: a circle; text: 12 either
|
|
208
|
+
// side), the groups 8 apart.
|
|
209
|
+
const NAV_GLASS_SIZE = 44;
|
|
210
|
+
const NAV_GLASS_PAD_H = 12;
|
|
211
|
+
const NAV_GROUP_SPACING = 8;
|
|
156
212
|
const BOTTOM_BAR_HEIGHT = 49;
|
|
157
213
|
const SEARCH_FIELD_HEIGHT = 36;
|
|
158
214
|
const SEARCH_FIELD_MARGIN_H = 16;
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
215
|
+
// iOS 26: a navigation stack's search field is a 48-tall glass capsule
|
|
216
|
+
// floating 16 from the sides and 28 above the screen bottom (8 above a view
|
|
217
|
+
// that ends higher); the content stops above it.
|
|
218
|
+
const SEARCH_BOTTOM_HEIGHT = 48;
|
|
219
|
+
const SEARCH_BOTTOM_MARGIN = 28;
|
|
220
|
+
const SEARCH_BOTTOM_MARGIN_INSET = 8;
|
|
162
221
|
const SEARCH_FIELD_DEFAULT_WIDTH = 200;
|
|
163
222
|
const IMAGE_SCALES: Readonly<Record<string, number>> = { small: 0.8, medium: 1, large: 1.25 };
|
|
164
223
|
// Phase 7 (docs/ops-protocol.md, "NavigationSplitView").
|
|
@@ -172,10 +231,12 @@ const EPSILON = 1e-6;
|
|
|
172
231
|
const STEPPER_WIDTH = 94;
|
|
173
232
|
const STEPPER_HEIGHT = 32;
|
|
174
233
|
const STEPPER_GAP = 8;
|
|
175
|
-
/** A `slider` is
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
const
|
|
234
|
+
/** A `slider` is 35 high (its label row, when present, sits above). */
|
|
235
|
+
// iOS 26: a 6pt track and a 38 × 24 pill knob in a 35 row.
|
|
236
|
+
const SLIDER_HEIGHT = 35;
|
|
237
|
+
const SLIDER_TRACK_HEIGHT = 6;
|
|
238
|
+
const SLIDER_THUMB = 38;
|
|
239
|
+
const SLIDER_THUMB_HEIGHT = 24;
|
|
179
240
|
const SLIDER_DEFAULT_WIDTH = 200;
|
|
180
241
|
/** Gap between a min / max value label and the track. */
|
|
181
242
|
const SLIDER_LABEL_GAP = 8;
|
|
@@ -186,14 +247,26 @@ const DATEPICKER_CAPSULE_GAP = 8;
|
|
|
186
247
|
const DATEPICKER_GAP = 8;
|
|
187
248
|
/** A `texteditor` fills the proposal; 100 high when proposed nil. */
|
|
188
249
|
const TEXTEDITOR_DEFAULT_HEIGHT = 100;
|
|
189
|
-
/**
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
250
|
+
/**
|
|
251
|
+
* `alert` style (iOS 26): a 320 wide card centered in the safe area, its
|
|
252
|
+
* title and message leading-aligned 30 in, its buttons 46 tall capsules 16
|
|
253
|
+
* in from the card's sides, side by side 8 apart for two, stacked 8 apart
|
|
254
|
+
* otherwise.
|
|
255
|
+
*/
|
|
256
|
+
const ALERT_WIDTH = 320;
|
|
257
|
+
const ALERT_PAD_TOP = 21;
|
|
258
|
+
const ALERT_PAD_H = 30;
|
|
259
|
+
const ALERT_BUTTON_INSET = 16;
|
|
260
|
+
const ALERT_BUTTONS_GAP = 20;
|
|
261
|
+
const ALERT_PAD_BOTTOM = 16;
|
|
262
|
+
const ALERT_BUTTON_SPACING = 8;
|
|
194
263
|
/** Gap between the alert title and its message. */
|
|
195
|
-
const ALERT_TITLE_GAP =
|
|
196
|
-
const ALERT_BUTTON_HEIGHT =
|
|
264
|
+
const ALERT_TITLE_GAP = 6;
|
|
265
|
+
const ALERT_BUTTON_HEIGHT = 46;
|
|
266
|
+
/** Gap between an action sheet's title and its message. */
|
|
267
|
+
const DIALOG_TITLE_GAP = 4;
|
|
268
|
+
/** An action sheet's button labels are inset this much from its rows. */
|
|
269
|
+
const DIALOG_LABEL_INSET = 16;
|
|
197
270
|
/** `dialog` style (action sheet): cards inset 8 from the screen edges and the bottom safe area. */
|
|
198
271
|
const DIALOG_MARGIN = 8;
|
|
199
272
|
const DIALOG_BUTTON_HEIGHT = 57;
|
|
@@ -393,6 +466,19 @@ function cellStyle(node: LayoutNode, key: string): unknown {
|
|
|
393
466
|
return undefined;
|
|
394
467
|
}
|
|
395
468
|
|
|
469
|
+
/** The default vertical padding above and below a list row's content. */
|
|
470
|
+
function listRowVPad(row: LayoutNode): number {
|
|
471
|
+
switch (row.kind) {
|
|
472
|
+
case 'picker':
|
|
473
|
+
return str(row.props['style']) === 'segmented' ? LIST_ROW_SEGMENTED_VPAD : 0;
|
|
474
|
+
case 'toggle':
|
|
475
|
+
case 'stepper':
|
|
476
|
+
return LIST_ROW_CONTROL_VPAD;
|
|
477
|
+
default:
|
|
478
|
+
return LIST_ROW_VPAD;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
396
482
|
/** `gridCellColumns` of a grid cell (at least 1). */
|
|
397
483
|
function gridSpanOf(node: LayoutNode): number {
|
|
398
484
|
const span = num(cellStyle(node, 'gridCellColumns'));
|
|
@@ -405,6 +491,20 @@ function gridAnchorOf(node: LayoutNode): [number, number] | null {
|
|
|
405
491
|
return point ? [point.x, point.y] : null;
|
|
406
492
|
}
|
|
407
493
|
|
|
494
|
+
/** `gridColumnAlignment` of a grid cell as a horizontal unit anchor. */
|
|
495
|
+
function gridColumnAnchorOf(node: LayoutNode): number | null {
|
|
496
|
+
switch (str(cellStyle(node, 'gridColumnAlignment'))) {
|
|
497
|
+
case 'leading':
|
|
498
|
+
return 0;
|
|
499
|
+
case 'center':
|
|
500
|
+
return 0.5;
|
|
501
|
+
case 'trailing':
|
|
502
|
+
return 1;
|
|
503
|
+
default:
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
408
508
|
/** Vertical anchor of a `gridrow` alignment (null → the grid's). */
|
|
409
509
|
function rowAnchor(alignment: string | null, gridAlignment: string): number {
|
|
410
510
|
switch (alignment) {
|
|
@@ -643,6 +743,11 @@ function alignChild(container: Size, child: Size, alignment: string, node: Layou
|
|
|
643
743
|
return offsets;
|
|
644
744
|
}
|
|
645
745
|
|
|
746
|
+
/** A `Spacer` (a lazy stack's too): a stack sizes it after its other children. */
|
|
747
|
+
function isSpacer(node: LayoutNode): boolean {
|
|
748
|
+
return node.kind === 'spacer' || node.kind === 'lazyspacer';
|
|
749
|
+
}
|
|
750
|
+
|
|
646
751
|
function safeOffset(container: number, child: number, anchor: number): number {
|
|
647
752
|
if (!Number.isFinite(container) || !Number.isFinite(child)) return 0;
|
|
648
753
|
return (container - child) * anchor;
|
|
@@ -820,6 +925,8 @@ interface GridPlan {
|
|
|
820
925
|
size: Size;
|
|
821
926
|
columns: number[];
|
|
822
927
|
columnX: number[];
|
|
928
|
+
/** Horizontal unit anchor per column from a cell's `gridColumnAlignment` (null → the grid's). */
|
|
929
|
+
columnAnchors: (number | null)[];
|
|
823
930
|
rows: GridRowPlan[];
|
|
824
931
|
}
|
|
825
932
|
|
|
@@ -844,6 +951,8 @@ interface GaugePlan {
|
|
|
844
951
|
/** Height of the label row (0 without labels). */
|
|
845
952
|
labelRow: number;
|
|
846
953
|
labelCtx: Context;
|
|
954
|
+
/** A ProgressView's label context (the gauge's own font, not the caption). */
|
|
955
|
+
labelOwnCtx?: Context;
|
|
847
956
|
/** A circular gauge: the view in the middle of the ring (the current value label, else the label) and its size. */
|
|
848
957
|
ring?: { center: LayoutNode | null; centerSize: Size };
|
|
849
958
|
}
|
|
@@ -896,6 +1005,12 @@ export class LayoutPass {
|
|
|
896
1005
|
readonly layouts = new Map<number, LayoutRecord>();
|
|
897
1006
|
/** Previous screen title per navscreen node, for the back button label. */
|
|
898
1007
|
private readonly previousTitles = new Map<LayoutNode, string>();
|
|
1008
|
+
/**
|
|
1009
|
+
* A large-title screen's root list or vertical scroll view starts under the
|
|
1010
|
+
* bar and insets its content by the title row, which scrolls with it
|
|
1011
|
+
* (iOS): the inset, by scroll node, set by `placeNavScreen`.
|
|
1012
|
+
*/
|
|
1013
|
+
private readonly titleInsets = new WeakMap<LayoutNode, Rect>();
|
|
899
1014
|
|
|
900
1015
|
private readonly memo = new Map<LayoutNode, Map<string, unknown>>();
|
|
901
1016
|
private readonly implicit = new Map<LayoutNode, LayoutNode>();
|
|
@@ -947,9 +1062,12 @@ export class LayoutPass {
|
|
|
947
1062
|
// Phase 8: an `alert` is a full-screen layer too (scrim + card).
|
|
948
1063
|
this.place(root, ctx, { width: screen.width, height: screen.height }, 0, 0);
|
|
949
1064
|
} else if (root.kind === 'sheet') {
|
|
950
|
-
const
|
|
1065
|
+
const floats = this.sheetFloats(root);
|
|
1066
|
+
const insetH = floats ? SHEET_FLOAT_INSET_H : 0;
|
|
1067
|
+
const insetBottom = floats ? SHEET_FLOAT_INSET_BOTTOM : 0;
|
|
1068
|
+
const proposal: Proposal = { width: screen.width - 2 * insetH, height: null };
|
|
951
1069
|
const size = this.measure(root, ctx, proposal);
|
|
952
|
-
this.place(root, ctx, proposal,
|
|
1070
|
+
this.place(root, ctx, proposal, insetH, screen.height - insetBottom - size.height);
|
|
953
1071
|
} else {
|
|
954
1072
|
const safe: Size = {
|
|
955
1073
|
width: Math.max(0, screen.width - safeArea.leading - safeArea.trailing),
|
|
@@ -1227,8 +1345,15 @@ export class LayoutPass {
|
|
|
1227
1345
|
height: style === 'plain' ? TEXTFIELD_HEIGHT_PLAIN : TEXTFIELD_HEIGHT_ROUNDED,
|
|
1228
1346
|
};
|
|
1229
1347
|
}
|
|
1230
|
-
case 'color':
|
|
1231
1348
|
case 'shape':
|
|
1349
|
+
if (node.props['shape'] === 'circle') {
|
|
1350
|
+
// SwiftUI's `Circle` is the largest square in the proposal (so in a
|
|
1351
|
+
// stack it gives the leftover width to its siblings).
|
|
1352
|
+
// An open axis counts as SHAPE_DEFAULT, as for the other shapes.
|
|
1353
|
+
return square(Math.min(p.width ?? SHAPE_DEFAULT, p.height ?? SHAPE_DEFAULT));
|
|
1354
|
+
}
|
|
1355
|
+
return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
|
|
1356
|
+
case 'color':
|
|
1232
1357
|
case 'canvas':
|
|
1233
1358
|
return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
|
|
1234
1359
|
case 'video':
|
|
@@ -1428,19 +1553,31 @@ export class LayoutPass {
|
|
|
1428
1553
|
|
|
1429
1554
|
private measureButton(node: LayoutNode, ctx: Context, p: Proposal): Size {
|
|
1430
1555
|
const label = this.contentOf(node);
|
|
1431
|
-
|
|
1432
|
-
|
|
1556
|
+
if (!this.buttonIsBordered(node)) return label ? this.measure(label, this.childContext(node, ctx), p) : ZERO;
|
|
1557
|
+
const cctx = this.borderedLabelContext(node, ctx);
|
|
1558
|
+
const { padV, padH, minHeight } = this.borderedMetrics(node);
|
|
1433
1559
|
const inner: Proposal = {
|
|
1434
|
-
width: shrink(p.width, 2 *
|
|
1435
|
-
height: shrink(p.height, 2 *
|
|
1560
|
+
width: shrink(p.width, 2 * padH),
|
|
1561
|
+
height: shrink(p.height, 2 * padV),
|
|
1436
1562
|
};
|
|
1437
1563
|
const size = label ? this.measure(label, cctx, inner) : ZERO;
|
|
1438
1564
|
return {
|
|
1439
|
-
width: size.width + 2 *
|
|
1440
|
-
height: Math.max(
|
|
1565
|
+
width: size.width + 2 * padH,
|
|
1566
|
+
height: Math.max(minHeight, size.height + 2 * padV),
|
|
1441
1567
|
};
|
|
1442
1568
|
}
|
|
1443
1569
|
|
|
1570
|
+
private borderedMetrics(node: LayoutNode): { padV: number; padH: number; minHeight: number; textStyle?: string } {
|
|
1571
|
+
return BORDERED_SIZES[str(node.props['controlSize']) ?? ''] ?? { padV: BORDERED_PAD_V, padH: BORDERED_PAD_H, minHeight: BORDERED_MIN_HEIGHT };
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
/** A bordered button's label context: a small or mini one sets a smaller text style. */
|
|
1575
|
+
private borderedLabelContext(node: LayoutNode, ctx: Context): Context {
|
|
1576
|
+
const cctx = this.childContext(node, ctx);
|
|
1577
|
+
const textStyle = this.borderedMetrics(node).textStyle;
|
|
1578
|
+
return textStyle ? { ...cctx, font: resolveFont({ textStyle }, cctx.font, this.env) } : cctx;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1444
1581
|
private toggleLabelProposal(p: Proposal): Proposal {
|
|
1445
1582
|
return { width: shrink(p.width, SWITCH_WIDTH + TOGGLE_GAP), height: p.height };
|
|
1446
1583
|
}
|
|
@@ -1473,7 +1610,7 @@ export class LayoutPass {
|
|
|
1473
1610
|
const detents = node.props['detents'];
|
|
1474
1611
|
const first = Array.isArray(detents) ? str(detents[0]) : null;
|
|
1475
1612
|
const { screen, safeArea } = this.env;
|
|
1476
|
-
if (first === 'medium') return screen.height
|
|
1613
|
+
if (first === 'medium') return Math.min(SHEET_MEDIUM_HEIGHT, screen.height - safeArea.top - SHEET_TOP_GAP);
|
|
1477
1614
|
// `.height(h)` / `.fraction(f)` detents, at most as tall as `large`.
|
|
1478
1615
|
const large = screen.height - safeArea.top - SHEET_TOP_GAP;
|
|
1479
1616
|
if (first?.startsWith('height:')) return Math.min(large, Number(first.slice(7)) || large);
|
|
@@ -1489,6 +1626,19 @@ export class LayoutPass {
|
|
|
1489
1626
|
return Array.isArray(detents) && str(detents[0]) === 'full';
|
|
1490
1627
|
}
|
|
1491
1628
|
|
|
1629
|
+
/** iOS 26: a sheet whose first detent is a partial one floats inset from the screen edges. */
|
|
1630
|
+
private sheetFloats(node: LayoutNode): boolean {
|
|
1631
|
+
const detents = node.props['detents'];
|
|
1632
|
+
const first = Array.isArray(detents) ? str(detents[0]) : null;
|
|
1633
|
+
return first === 'medium' || first?.startsWith('height:') === true || first?.startsWith('fraction:') === true;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
/** iOS 26 shows the grabber only for a sheet with more than one detent. */
|
|
1637
|
+
private sheetHasGrabber(node: LayoutNode): boolean {
|
|
1638
|
+
const detents = node.props['detents'];
|
|
1639
|
+
return Array.isArray(detents) && detents.length > 1 && !this.sheetIsFull(node);
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1492
1642
|
// -------------------------------------------------------------------------
|
|
1493
1643
|
// Picker (Phase 4)
|
|
1494
1644
|
// -------------------------------------------------------------------------
|
|
@@ -1615,11 +1765,15 @@ export class LayoutPass {
|
|
|
1615
1765
|
* least flexible first. A child is offered `remaining / childrenLeft`, where
|
|
1616
1766
|
* `remaining` excludes the minimum sizes of every lower-priority child and
|
|
1617
1767
|
* `childrenLeft` counts only the unsized children of its own priority group
|
|
1618
|
-
* with *finite* flexibility: infinitely flexible children (
|
|
1768
|
+
* with *finite* flexibility: infinitely flexible children (colors,
|
|
1619
1769
|
* `maxWidth: infinity` frames, text fields) only reserve their minimum and
|
|
1620
1770
|
* share whatever is left once everything else is sized, which is why a
|
|
1621
|
-
* `Spacer` never makes its sibling text wrap.
|
|
1622
|
-
*
|
|
1771
|
+
* `Spacer` never makes its sibling text wrap. Spacers come after those: they
|
|
1772
|
+
* reserve their minimum while the other infinitely flexible children share,
|
|
1773
|
+
* and take what is left (iOS gives `HStack { VStack { … }.frame(maxWidth:
|
|
1774
|
+
* .infinity); Spacer() }`'s stack all but the spacer's 8 points, where an
|
|
1775
|
+
* even split gave Ice Cubes' quoted posts half their card). With a `null`
|
|
1776
|
+
* axis proposal every child is proposed `null` and the stack sizes to the sum.
|
|
1623
1777
|
*/
|
|
1624
1778
|
private layoutStack(
|
|
1625
1779
|
children: readonly LayoutNode[],
|
|
@@ -1646,6 +1800,7 @@ export class LayoutPass {
|
|
|
1646
1800
|
const mins = new Array<number>(n);
|
|
1647
1801
|
const flex = new Array<number>(n);
|
|
1648
1802
|
const priorities = new Array<number>(n);
|
|
1803
|
+
const spacers = children.map(isSpacer);
|
|
1649
1804
|
const minProposal = makeProposal(axis, 0, cross);
|
|
1650
1805
|
const maxProposal = makeProposal(axis, Infinity, cross);
|
|
1651
1806
|
for (let i = 0; i < n; i++) {
|
|
@@ -1663,6 +1818,7 @@ export class LayoutPass {
|
|
|
1663
1818
|
const fa = flex[a] as number;
|
|
1664
1819
|
const fb = flex[b] as number;
|
|
1665
1820
|
if (fa !== fb) return fa < fb ? -1 : 1;
|
|
1821
|
+
if (spacers[a] !== spacers[b]) return spacers[a] ? 1 : -1;
|
|
1666
1822
|
return a - b;
|
|
1667
1823
|
});
|
|
1668
1824
|
|
|
@@ -1679,10 +1835,15 @@ export class LayoutPass {
|
|
|
1679
1835
|
let finiteLeft = 0;
|
|
1680
1836
|
let infiniteLeft = 0;
|
|
1681
1837
|
let infiniteMin = 0;
|
|
1838
|
+
let spacersLeft = 0;
|
|
1839
|
+
let spacerMin = 0;
|
|
1682
1840
|
for (let k = i; k < end; k++) {
|
|
1683
1841
|
const index = order[k] as number;
|
|
1684
1842
|
if (Number.isFinite(flex[index])) finiteLeft++;
|
|
1685
|
-
else {
|
|
1843
|
+
else if (spacers[index]) {
|
|
1844
|
+
spacersLeft++;
|
|
1845
|
+
spacerMin += mins[index] as number;
|
|
1846
|
+
} else {
|
|
1686
1847
|
infiniteLeft++;
|
|
1687
1848
|
infiniteMin += mins[index] as number;
|
|
1688
1849
|
}
|
|
@@ -1690,16 +1851,21 @@ export class LayoutPass {
|
|
|
1690
1851
|
for (let k = i; k < end; k++) {
|
|
1691
1852
|
const index = order[k] as number;
|
|
1692
1853
|
const isInfinite = !Number.isFinite(flex[index]);
|
|
1854
|
+
const isSpacerChild = isInfinite && spacers[index] === true;
|
|
1693
1855
|
let offer: number;
|
|
1694
1856
|
if (!Number.isFinite(remaining)) offer = Infinity;
|
|
1695
|
-
else if (
|
|
1696
|
-
else offer = Math.max(0, remaining - lowerMin -
|
|
1857
|
+
else if (isSpacerChild) offer = Math.max(0, remaining - lowerMin) / spacersLeft;
|
|
1858
|
+
else if (isInfinite) offer = Math.max(0, remaining - lowerMin - spacerMin) / infiniteLeft;
|
|
1859
|
+
else offer = Math.max(0, remaining - lowerMin - infiniteMin - spacerMin) / finiteLeft;
|
|
1697
1860
|
const proposal = makeProposal(axis, offer, cross);
|
|
1698
1861
|
const size = this.measure(children[index] as LayoutNode, cctx, proposal);
|
|
1699
1862
|
childProposals[index] = proposal;
|
|
1700
1863
|
childSizes[index] = size;
|
|
1701
1864
|
if (Number.isFinite(remaining)) remaining = Math.max(0, remaining - axisOf(size, axis));
|
|
1702
|
-
if (
|
|
1865
|
+
if (isSpacerChild) {
|
|
1866
|
+
spacersLeft--;
|
|
1867
|
+
spacerMin -= mins[index] as number;
|
|
1868
|
+
} else if (isInfinite) {
|
|
1703
1869
|
infiniteLeft--;
|
|
1704
1870
|
infiniteMin -= mins[index] as number;
|
|
1705
1871
|
} else finiteLeft--;
|
|
@@ -1989,7 +2155,7 @@ export class LayoutPass {
|
|
|
1989
2155
|
// sections are full width. `plain` also drops the top/bottom gaps and
|
|
1990
2156
|
// the space between sections.
|
|
1991
2157
|
const listStyle = str(node.props['style']) ?? 'insetGrouped';
|
|
1992
|
-
// `.environment(\.defaultMinListRowHeight, _)`;
|
|
2158
|
+
// `.environment(\.defaultMinListRowHeight, _)`; 52 by default.
|
|
1993
2159
|
const minRowHeight = num(node.props['minRowHeight']) ?? LIST_ROW_MIN_HEIGHT;
|
|
1994
2160
|
const inset = listStyle === 'insetGrouped' ? LIST_INSET : 0;
|
|
1995
2161
|
const plain = listStyle === 'plain';
|
|
@@ -2000,7 +2166,13 @@ export class LayoutPass {
|
|
|
2000
2166
|
const cardWidth = Math.max(0, layoutWidth - 2 * inset);
|
|
2001
2167
|
const contentX = inset + LIST_CELL_PADDING;
|
|
2002
2168
|
const contentWidth = Math.max(0, layoutWidth - 2 * (inset + LIST_CELL_PADDING));
|
|
2169
|
+
// iOS 26: headers in the headline size (semibold), footers in footnote.
|
|
2003
2170
|
const headerFont = makeFont(
|
|
2171
|
+
'system',
|
|
2172
|
+
textStyleSize('headline', this.env.dynamicTypeSize),
|
|
2173
|
+
600,
|
|
2174
|
+
);
|
|
2175
|
+
const footerFont = makeFont(
|
|
2004
2176
|
'system',
|
|
2005
2177
|
textStyleSize('footnote', this.env.dynamicTypeSize),
|
|
2006
2178
|
400,
|
|
@@ -2026,38 +2198,48 @@ export class LayoutPass {
|
|
|
2026
2198
|
else groupsSource.push({ section: null, rows: [child], headerView: null, footerView: null });
|
|
2027
2199
|
}
|
|
2028
2200
|
}
|
|
2029
|
-
// View headers and footers: the content width, the
|
|
2030
|
-
|
|
2031
|
-
const
|
|
2201
|
+
// View headers and footers: the content width, the header's and the
|
|
2202
|
+
// footer's font.
|
|
2203
|
+
const headerCtx: Context = { ...rowCtx, listRow: false, stackAxis: null, font: headerFont };
|
|
2204
|
+
const footerCtx: Context = { ...headerCtx, font: footerFont };
|
|
2205
|
+
const supplement = (view: LayoutNode | null, y: number, ctx: Context): ListSupplementPlan | null => {
|
|
2032
2206
|
if (!view) return null;
|
|
2033
2207
|
const proposal: Proposal = { width: contentWidth, height: null };
|
|
2034
|
-
const size = this.measure(view,
|
|
2035
|
-
return { node: view, ctx
|
|
2208
|
+
const size = this.measure(view, ctx, proposal);
|
|
2209
|
+
return { node: view, ctx, proposal, rect: { x: contentX, y, width: size.width, height: size.height } };
|
|
2036
2210
|
};
|
|
2037
2211
|
|
|
2038
2212
|
const groups: ListGroupPlan[] = [];
|
|
2039
2213
|
let y = listTop;
|
|
2040
|
-
const makeText = (text: string): { layout: TextLayout; size: Size } => {
|
|
2041
|
-
const wrapped = wrapText(text
|
|
2214
|
+
const makeText = (text: string, font: ResolvedFont): { layout: TextLayout; size: Size } => {
|
|
2215
|
+
const wrapped = wrapText(text, font, contentWidth, null, this.measurer);
|
|
2042
2216
|
return {
|
|
2043
|
-
layout: { font
|
|
2044
|
-
size: { width: wrapped.width, height: wrapped.lines.length *
|
|
2217
|
+
layout: { font, lines: wrapped.lines, alignment: 'leading', truncated: false },
|
|
2218
|
+
size: { width: wrapped.width, height: wrapped.lines.length * font.lineHeight },
|
|
2045
2219
|
};
|
|
2046
2220
|
};
|
|
2047
2221
|
for (let g = 0; g < groupsSource.length; g++) {
|
|
2048
2222
|
const source = groupsSource[g] as GroupSource;
|
|
2049
|
-
|
|
2223
|
+
const previous = groups[g - 1];
|
|
2224
|
+
if (previous) {
|
|
2225
|
+
const footed = previous.footer !== null || (previous.footerView !== null && previous.footerView.rect.height > 0);
|
|
2226
|
+
y += plain ? sectionGap : footed ? LIST_SECTION_GAP_AFTER_FOOTER : sectionGap;
|
|
2227
|
+
}
|
|
2050
2228
|
const top = y;
|
|
2051
2229
|
let header: ListGroupPlan['header'] = null;
|
|
2052
2230
|
let footer: ListGroupPlan['footer'] = null;
|
|
2053
|
-
|
|
2054
|
-
|
|
2231
|
+
// A header sits in the gap above its card (from LIST_HEADER_LIFT
|
|
2232
|
+
// into it); the card then starts LIST_HEADER_GAP below the header.
|
|
2233
|
+
const headerLift = plain ? 0 : LIST_HEADER_LIFT;
|
|
2234
|
+
const headerView = supplement(source.headerView, y - headerLift, headerCtx);
|
|
2235
|
+
if (headerView && headerView.rect.height > 0) y += headerView.rect.height + LIST_HEADER_GAP - headerLift;
|
|
2055
2236
|
const headerText = source.section ? str(source.section.props['header']) : null;
|
|
2056
2237
|
const footerText = source.section ? str(source.section.props['footer']) : null;
|
|
2057
2238
|
if (headerText !== null && headerText.length > 0) {
|
|
2058
|
-
const { layout, size } = makeText(headerText);
|
|
2059
|
-
|
|
2060
|
-
y
|
|
2239
|
+
const { layout, size } = makeText(headerText, headerFont);
|
|
2240
|
+
const headerY = headerView && headerView.rect.height > 0 ? y : y - headerLift;
|
|
2241
|
+
header = { rect: { x: contentX, y: headerY, width: contentWidth, height: size.height }, layout };
|
|
2242
|
+
y = headerY + size.height + LIST_HEADER_GAP;
|
|
2061
2243
|
}
|
|
2062
2244
|
const cardTop = y;
|
|
2063
2245
|
const rows: ListRowPlan[] = [];
|
|
@@ -2068,12 +2250,13 @@ export class LayoutPass {
|
|
|
2068
2250
|
rows.push({ node: rowNode, proposal: { width: cardWidth, height: 0 }, contentSize: ZERO, row: rect, content: rect, badge: null, edit: null });
|
|
2069
2251
|
continue;
|
|
2070
2252
|
}
|
|
2071
|
-
// A picker already reports its row height (44 for
|
|
2072
|
-
// option inline);
|
|
2253
|
+
// A menu or inline picker already reports its row height (44 for
|
|
2254
|
+
// a menu, 44 per option inline); a toggle, stepper or segmented
|
|
2255
|
+
// picker has its own vertical padding, other rows 13. Phase 6
|
|
2073
2256
|
// `listRowInsets` replaces all four insets; a `badge` reserves its
|
|
2074
2257
|
// width (plus a gap) at the trailing edge.
|
|
2075
2258
|
const hints = rowHintsOf(rowNode);
|
|
2076
|
-
const defaultVPad = unwrapStyled(rowNode)
|
|
2259
|
+
const defaultVPad = listRowVPad(unwrapStyled(rowNode));
|
|
2077
2260
|
const insets: EdgeInsets = hints.insets ?? {
|
|
2078
2261
|
top: defaultVPad,
|
|
2079
2262
|
leading: LIST_CELL_PADDING,
|
|
@@ -2116,11 +2299,11 @@ export class LayoutPass {
|
|
|
2116
2299
|
const card: Rect = { x: cardX, y: cardTop, width: cardWidth, height: y - cardTop };
|
|
2117
2300
|
if (footerText !== null && footerText.length > 0) {
|
|
2118
2301
|
y += LIST_FOOTER_GAP;
|
|
2119
|
-
const { layout, size } = makeText(footerText);
|
|
2302
|
+
const { layout, size } = makeText(footerText, footerFont);
|
|
2120
2303
|
footer = { rect: { x: contentX, y, width: contentWidth, height: size.height }, layout };
|
|
2121
2304
|
y += size.height;
|
|
2122
2305
|
}
|
|
2123
|
-
let footerView = supplement(source.footerView, y + LIST_FOOTER_GAP);
|
|
2306
|
+
let footerView = supplement(source.footerView, y + LIST_FOOTER_GAP, footerCtx);
|
|
2124
2307
|
if (footerView && footerView.rect.height > 0) y += LIST_FOOTER_GAP + footerView.rect.height;
|
|
2125
2308
|
else if (footerView) footerView = { ...footerView, rect: { ...footerView.rect, y } };
|
|
2126
2309
|
groups.push({
|
|
@@ -2265,13 +2448,15 @@ export class LayoutPass {
|
|
|
2265
2448
|
this.place(label, cctx, p, x, y);
|
|
2266
2449
|
return;
|
|
2267
2450
|
}
|
|
2451
|
+
const metrics = this.borderedMetrics(node);
|
|
2452
|
+
const labelCtx = this.borderedLabelContext(node, ctx);
|
|
2268
2453
|
const inner: Proposal = {
|
|
2269
|
-
width: shrink(p.width, 2 *
|
|
2270
|
-
height: shrink(p.height, 2 *
|
|
2454
|
+
width: shrink(p.width, 2 * metrics.padH),
|
|
2455
|
+
height: shrink(p.height, 2 * metrics.padV),
|
|
2271
2456
|
};
|
|
2272
|
-
const labelSize = this.measure(label,
|
|
2457
|
+
const labelSize = this.measure(label, labelCtx, inner);
|
|
2273
2458
|
const { dx, dy } = alignIn(size, labelSize, 'center');
|
|
2274
|
-
this.place(label,
|
|
2459
|
+
this.place(label, labelCtx, inner, x + dx, y + dy);
|
|
2275
2460
|
// Keep the list row chrome (`row`, `badge`) a list set before placing.
|
|
2276
2461
|
const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
|
|
2277
2462
|
this.setChrome(node, {
|
|
@@ -2339,14 +2524,19 @@ export class LayoutPass {
|
|
|
2339
2524
|
// centered on the other axes when smaller than the viewport.
|
|
2340
2525
|
const dx = axes.horizontal ? 0 : safeOffset(size.width, plan.contentSize.width, 0.5);
|
|
2341
2526
|
const dy = axes.vertical ? 0 : safeOffset(size.height, plan.contentSize.height, 0.5);
|
|
2527
|
+
// Under a large title that scrolls with it, the content starts below it.
|
|
2528
|
+
const titleRect = axes.vertical ? this.titleInsets.get(node) : undefined;
|
|
2342
2529
|
const content: Rect = {
|
|
2343
2530
|
x: x + dx,
|
|
2344
|
-
y: y + dy,
|
|
2531
|
+
y: y + dy + (titleRect?.height ?? 0),
|
|
2345
2532
|
width: plan.contentSize.width,
|
|
2346
2533
|
height: plan.contentSize.height,
|
|
2347
2534
|
};
|
|
2348
2535
|
const covered = this.coveredBottom(ctx, x, y, size);
|
|
2349
|
-
|
|
2536
|
+
const scrollChrome: Record<string, Rect> = { content };
|
|
2537
|
+
if (covered) scrollChrome['bottomInset'] = covered;
|
|
2538
|
+
if (titleRect) scrollChrome['largeTitle'] = titleRect;
|
|
2539
|
+
this.setChrome(node, scrollChrome);
|
|
2350
2540
|
if (plan.content) this.place(plan.content, cctx, plan.contentProposal, content.x, content.y);
|
|
2351
2541
|
return;
|
|
2352
2542
|
}
|
|
@@ -2486,9 +2676,16 @@ export class LayoutPass {
|
|
|
2486
2676
|
private placeList(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
|
|
2487
2677
|
const plan = this.listPlan(node, ctx, p);
|
|
2488
2678
|
const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true };
|
|
2489
|
-
if (node.id >= 0) this.contentSizes.set(node.id, plan.contentSize);
|
|
2490
2679
|
const covered = this.coveredBottom(ctx, x, y, plan.size);
|
|
2491
|
-
|
|
2680
|
+
// Under a large title that scrolls with it, the rows start below it.
|
|
2681
|
+
const titleRect = this.titleInsets.get(node);
|
|
2682
|
+
const listChrome: Record<string, Rect> = {};
|
|
2683
|
+
if (covered) listChrome['bottomInset'] = covered;
|
|
2684
|
+
if (titleRect) listChrome['largeTitle'] = titleRect;
|
|
2685
|
+
if (Object.keys(listChrome).length > 0) this.setChrome(node, listChrome);
|
|
2686
|
+
const inset = titleRect?.height ?? 0;
|
|
2687
|
+
if (node.id >= 0) this.contentSizes.set(node.id, { width: plan.contentSize.width, height: plan.contentSize.height + inset });
|
|
2688
|
+
y += inset;
|
|
2492
2689
|
const offset = (r: Rect): Rect => ({ x: x + r.x, y: y + r.y, width: r.width, height: r.height });
|
|
2493
2690
|
for (const group of plan.groups) {
|
|
2494
2691
|
if (group.section) {
|
|
@@ -2538,40 +2735,63 @@ export class LayoutPass {
|
|
|
2538
2735
|
: undefined;
|
|
2539
2736
|
this.setChrome(row.node, own ? { ...own, ...chrome } : chrome);
|
|
2540
2737
|
this.place(row.node, rowCtx, row.proposal, rowX, rowY);
|
|
2738
|
+
// iOS 26: the separator under a row starts where its first text
|
|
2739
|
+
// starts (after a Label's icon), else at the row's content.
|
|
2740
|
+
const placed = row.node.id >= 0 ? this.chrome.get(row.node.id) : undefined;
|
|
2741
|
+
if (placed) {
|
|
2742
|
+
const textX = this.firstTextX(row.node);
|
|
2743
|
+
const leading = (textX ?? rowX) - (x + row.row.x);
|
|
2744
|
+
placed['separator'] = { x: Math.max(0, leading), y: 0, width: 0, height: 0 };
|
|
2745
|
+
}
|
|
2541
2746
|
}
|
|
2542
2747
|
}
|
|
2543
2748
|
}
|
|
2544
2749
|
|
|
2750
|
+
/** The x of the first text (in tree order) placed under `node`, if any. */
|
|
2751
|
+
private firstTextX(node: LayoutNode): number | null {
|
|
2752
|
+
if (node.kind === 'text' && node.id >= 0) {
|
|
2753
|
+
const frame = this.frames.get(node.id);
|
|
2754
|
+
if (frame && frame.width > 0) return frame.x;
|
|
2755
|
+
}
|
|
2756
|
+
for (const child of node.children) {
|
|
2757
|
+
const x = this.firstTextX(child);
|
|
2758
|
+
if (x !== null) return x;
|
|
2759
|
+
}
|
|
2760
|
+
return null;
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2545
2763
|
/**
|
|
2546
|
-
* The navigation bar is the status bar plus a 44 row (plus
|
|
2547
|
-
* title
|
|
2548
|
-
* bar plus the bottom safe area; the
|
|
2549
|
-
*
|
|
2550
|
-
*
|
|
2764
|
+
* The navigation bar is the status bar plus a 44 row (plus 62 for a large
|
|
2765
|
+
* title); a Phase 6 `searchfield` is a capsule floating above the bottom
|
|
2766
|
+
* (iOS 26); a `bottom` toolbar is a 49 bar plus the bottom safe area; the
|
|
2767
|
+
* content is proposed the rest. Toolbar
|
|
2768
|
+
* items are measured at their ideal size and laid out in iOS 26 glass
|
|
2769
|
+
* capsules (44-tall slots, groups 8 apart) in the 44 row (`leading` after the back button, `trailing` 16 from the
|
|
2551
2770
|
* trailing edge, `principal` centered in place of the title) or spread
|
|
2552
2771
|
* evenly across the bottom bar. The inline title (`chrome.title`) is
|
|
2553
2772
|
* centered and truncated to the room the bar items leave.
|
|
2554
2773
|
*/
|
|
2555
2774
|
private placeNavScreen(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
2556
2775
|
const { safeArea, screen, dynamicTypeSize } = this.env;
|
|
2557
|
-
const statusBar = y <= 0 ? safeArea.top : 0;
|
|
2776
|
+
const statusBar = y <= 0 ? safeArea.top : (cctx.barInset ?? 0);
|
|
2558
2777
|
const displayMode = str(node.props['displayMode']) ?? 'automatic';
|
|
2559
2778
|
const depth = num(node.props['depth']) ?? 0;
|
|
2560
2779
|
const large = displayMode === 'large' || (displayMode === 'automatic' && depth === 0);
|
|
2561
2780
|
const barHeight = statusBar + NAV_BAR_HEIGHT;
|
|
2562
2781
|
const largeHeight = large ? LARGE_TITLE_HEIGHT : 0;
|
|
2563
2782
|
const parts = this.navScreenParts(node);
|
|
2564
|
-
const searchHeight = parts.search ? SEARCH_AREA_HEIGHT : 0;
|
|
2565
2783
|
const reachesBottom = y + size.height >= screen.height - EPSILON;
|
|
2566
2784
|
const bottomToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'bottom');
|
|
2567
2785
|
const bottomBarHeight =
|
|
2568
2786
|
bottomToolbars.length > 0 ? Math.min(size.height, BOTTOM_BAR_HEIGHT + (reachesBottom ? safeArea.bottom : 0)) : 0;
|
|
2569
|
-
const
|
|
2787
|
+
const searchMargin = reachesBottom ? SEARCH_BOTTOM_MARGIN : SEARCH_BOTTOM_MARGIN_INSET;
|
|
2788
|
+
const searchBottom = parts.search ? SEARCH_BOTTOM_HEIGHT + searchMargin : 0;
|
|
2789
|
+
const contentTop = barHeight + largeHeight;
|
|
2570
2790
|
const content: Rect = {
|
|
2571
2791
|
x,
|
|
2572
2792
|
y: y + contentTop,
|
|
2573
2793
|
width: size.width,
|
|
2574
|
-
height: Math.max(0, size.height - contentTop - bottomBarHeight),
|
|
2794
|
+
height: Math.max(0, size.height - contentTop - Math.max(bottomBarHeight, searchBottom)),
|
|
2575
2795
|
};
|
|
2576
2796
|
const chrome: Record<string, Rect> = {
|
|
2577
2797
|
bar: { x, y, width: size.width, height: barHeight },
|
|
@@ -2581,9 +2801,9 @@ export class LayoutPass {
|
|
|
2581
2801
|
if (parts.search) {
|
|
2582
2802
|
chrome['searchField'] = {
|
|
2583
2803
|
x: x + SEARCH_FIELD_MARGIN_H,
|
|
2584
|
-
y: y +
|
|
2804
|
+
y: y + size.height - searchBottom,
|
|
2585
2805
|
width: Math.max(0, size.width - 2 * SEARCH_FIELD_MARGIN_H),
|
|
2586
|
-
height:
|
|
2806
|
+
height: SEARCH_BOTTOM_HEIGHT,
|
|
2587
2807
|
};
|
|
2588
2808
|
}
|
|
2589
2809
|
if (bottomBarHeight > 0) {
|
|
@@ -2600,60 +2820,56 @@ export class LayoutPass {
|
|
|
2600
2820
|
toolbar: LayoutNode;
|
|
2601
2821
|
placement: string;
|
|
2602
2822
|
sizes: Size[];
|
|
2823
|
+
/** Leading and trailing groups: each item's glass slot width. */
|
|
2824
|
+
slots: number[] | null;
|
|
2603
2825
|
total: number;
|
|
2604
2826
|
}
|
|
2605
2827
|
const groups: Group[] = parts.toolbars.map((toolbar) => {
|
|
2606
2828
|
const itemCtx = this.childContext(toolbar, cctx);
|
|
2607
2829
|
const sizes = toolbar.children.map((item) => this.measure(item, itemCtx, { width: null, height: null }));
|
|
2830
|
+
const placement = str(toolbar.props['placement']) ?? 'trailing';
|
|
2831
|
+
if (placement === 'leading' || placement === 'trailing') {
|
|
2832
|
+
const slots = sizes.map((s) => Math.max(NAV_GLASS_SIZE, s.width + 2 * NAV_GLASS_PAD_H));
|
|
2833
|
+
return { toolbar, placement, sizes, slots, total: slots.reduce((sum, w) => sum + w, 0) };
|
|
2834
|
+
}
|
|
2608
2835
|
const total = sizes.reduce((sum, s) => sum + s.width, 0) + Math.max(0, sizes.length - 1) * NAV_ITEM_SPACING;
|
|
2609
|
-
return { toolbar, placement
|
|
2836
|
+
return { toolbar, placement, sizes, slots: null, total };
|
|
2610
2837
|
});
|
|
2611
2838
|
const groupWidth = (placement: string): number =>
|
|
2612
|
-
groups.filter((g) => g.placement === placement).reduce((sum, g) => sum + g.total +
|
|
2839
|
+
groups.filter((g) => g.placement === placement).reduce((sum, g) => sum + g.total + NAV_GROUP_SPACING, 0);
|
|
2613
2840
|
const leadingWidth = groupWidth('leading');
|
|
2614
2841
|
const trailingWidth = groupWidth('trailing');
|
|
2615
2842
|
const principal = groups.some((g) => g.placement === 'principal');
|
|
2616
2843
|
|
|
2617
|
-
// Back button,
|
|
2618
|
-
//
|
|
2619
|
-
|
|
2844
|
+
// Back button, iOS 26: the chevron alone in a glass circle (the previous
|
|
2845
|
+
// title no longer shows).
|
|
2846
|
+
const backLabel = '';
|
|
2620
2847
|
if (depth > 0) {
|
|
2621
|
-
|
|
2622
|
-
const available =
|
|
2623
|
-
(large ? size.width - BACK_CHEVRON_WIDTH - 16 - 44 : (size.width - inlineTitleWidth) / 2 - 8 - 12) - leadingWidth;
|
|
2624
|
-
const candidates = [this.previousTitles.get(node) ?? '', 'Back', ''];
|
|
2625
|
-
let width = BACK_CHEVRON_WIDTH + 8;
|
|
2626
|
-
for (const candidate of candidates) {
|
|
2627
|
-
const labelWidth = candidate ? this.measurer.measureWidth(candidate, backFont) : 0;
|
|
2628
|
-
const needed = 8 + BACK_CHEVRON_WIDTH + labelWidth + 8;
|
|
2629
|
-
if (candidate === '' || needed <= available) {
|
|
2630
|
-
backLabel = candidate;
|
|
2631
|
-
width = needed;
|
|
2632
|
-
break;
|
|
2633
|
-
}
|
|
2634
|
-
}
|
|
2635
|
-
chrome['back'] = { x: x + 8, y: rowY, width, height: NAV_BAR_HEIGHT };
|
|
2848
|
+
chrome['back'] = { x: x + NAV_ITEM_MARGIN, y: rowY, width: NAV_GLASS_SIZE, height: NAV_GLASS_SIZE };
|
|
2636
2849
|
}
|
|
2637
2850
|
|
|
2638
2851
|
// Place the toolbar groups and remember how much of the row they occupy.
|
|
2639
2852
|
const back = chrome['back'];
|
|
2640
|
-
let leadingCursor = back ? back.x + back.width +
|
|
2853
|
+
let leadingCursor = back ? back.x + back.width + NAV_GROUP_SPACING : x + NAV_ITEM_MARGIN;
|
|
2641
2854
|
let trailingEdge = x + size.width - NAV_ITEM_MARGIN;
|
|
2642
2855
|
const placeGroup = (group: Group, startX: number, top: number, rowHeight: number, spacing: number): void => {
|
|
2643
2856
|
const itemCtx = this.childContext(group.toolbar, cctx);
|
|
2644
2857
|
let cursor = startX;
|
|
2645
2858
|
group.toolbar.children.forEach((item, i) => {
|
|
2646
2859
|
const itemSize = group.sizes[i] as Size;
|
|
2647
|
-
|
|
2648
|
-
|
|
2860
|
+
// In a glass slot the item is centered in it.
|
|
2861
|
+
const slot = group.slots?.[i];
|
|
2862
|
+
const itemX = slot !== undefined ? cursor + (slot - itemSize.width) / 2 : cursor;
|
|
2863
|
+
this.place(item, itemCtx, { width: null, height: null }, itemX, top + (rowHeight - itemSize.height) / 2);
|
|
2864
|
+
cursor += (slot ?? itemSize.width) + spacing;
|
|
2649
2865
|
});
|
|
2650
2866
|
};
|
|
2651
2867
|
for (const group of groups) {
|
|
2652
2868
|
switch (group.placement) {
|
|
2653
2869
|
case 'leading': {
|
|
2654
2870
|
this.setFrame(group.toolbar, leadingCursor, rowY, { width: group.total, height: NAV_BAR_HEIGHT });
|
|
2655
|
-
placeGroup(group, leadingCursor, rowY, NAV_BAR_HEIGHT,
|
|
2656
|
-
leadingCursor += group.total +
|
|
2871
|
+
placeGroup(group, leadingCursor, rowY, NAV_BAR_HEIGHT, 0);
|
|
2872
|
+
leadingCursor += group.total + NAV_GROUP_SPACING;
|
|
2657
2873
|
break;
|
|
2658
2874
|
}
|
|
2659
2875
|
case 'principal': {
|
|
@@ -2678,15 +2894,17 @@ export class LayoutPass {
|
|
|
2678
2894
|
default: {
|
|
2679
2895
|
const startX = trailingEdge - group.total;
|
|
2680
2896
|
this.setFrame(group.toolbar, startX, rowY, { width: group.total, height: NAV_BAR_HEIGHT });
|
|
2681
|
-
placeGroup(group, startX, rowY, NAV_BAR_HEIGHT,
|
|
2682
|
-
trailingEdge = startX -
|
|
2897
|
+
placeGroup(group, startX, rowY, NAV_BAR_HEIGHT, 0);
|
|
2898
|
+
trailingEdge = startX - NAV_GROUP_SPACING;
|
|
2683
2899
|
break;
|
|
2684
2900
|
}
|
|
2685
2901
|
}
|
|
2686
2902
|
}
|
|
2687
2903
|
if (parts.search) {
|
|
2904
|
+
// The field is the whole capsule (its own measure is a 36pt field).
|
|
2688
2905
|
const field = chrome['searchField'] as Rect;
|
|
2689
2906
|
this.place(parts.search, cctx, { width: field.width, height: field.height }, field.x, field.y);
|
|
2907
|
+
this.setFrame(parts.search, field.x, field.y, { width: field.width, height: field.height });
|
|
2690
2908
|
}
|
|
2691
2909
|
|
|
2692
2910
|
// The inline title: centered, no wider than 56% of the bar, and clear of
|
|
@@ -2695,9 +2913,16 @@ export class LayoutPass {
|
|
|
2695
2913
|
// takes up to a pixel off a fractional width at a fractional origin, and
|
|
2696
2914
|
// the title truncates with an ellipsis ("Animati…") when its box is any
|
|
2697
2915
|
// narrower than its text.
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2916
|
+
const scroller = parts.content ? unwrapStyled(parts.content) : null;
|
|
2917
|
+
const titleScrolls =
|
|
2918
|
+
large &&
|
|
2919
|
+
scroller !== null &&
|
|
2920
|
+
(scroller.kind === 'list' || (scroller.kind === 'scrollview' && this.scrollAxes(scroller).vertical));
|
|
2921
|
+
// A large title that scrolls away collapses into the inline title, which
|
|
2922
|
+
// the renderer shows once it has (`data-sb-collapsed`).
|
|
2923
|
+
if ((!large || titleScrolls) && !principal) {
|
|
2924
|
+
const leftUsed = Math.max(back ? back.x + back.width - x : 0, leadingWidth > 0 ? leadingCursor - NAV_GROUP_SPACING - x : 0);
|
|
2925
|
+
const rightUsed = trailingWidth > 0 ? x + size.width - (trailingEdge + NAV_GROUP_SPACING) : 0;
|
|
2701
2926
|
const side = Math.max(leftUsed, rightUsed) + 8;
|
|
2702
2927
|
const available = Math.max(0, size.width - 2 * side);
|
|
2703
2928
|
const titleWidth = Math.min(Math.ceil(this.measurer.measureWidth(title, titleFont) - 0.001), Math.floor(size.width * 0.56), Math.floor(available));
|
|
@@ -2731,8 +2956,17 @@ export class LayoutPass {
|
|
|
2731
2956
|
bottomBarHeight === 0 && y + size.height >= screen.height ? Math.max(safeArea.bottom, cctx.bottomBar) : 0;
|
|
2732
2957
|
// Phase 8: `.ignoresSafeArea` content grows up under the bar (and the
|
|
2733
2958
|
// large title / search field), which is transparent over it.
|
|
2734
|
-
const contentCtx: Context = { ...cctx, safeTop: content.y };
|
|
2735
|
-
if (
|
|
2959
|
+
const contentCtx: Context = { ...cctx, safeTop: content.y, barInset: 0 };
|
|
2960
|
+
if (scroller && titleScrolls) {
|
|
2961
|
+
// The scroll view starts under the bar; its content starts under the
|
|
2962
|
+
// large title, which moves with it.
|
|
2963
|
+
const top = content.y - largeHeight;
|
|
2964
|
+
this.titleInsets.set(scroller, chrome['largeTitle'] as Rect);
|
|
2965
|
+
this.place(child, contentCtx, { width: content.width, height: content.height + largeHeight }, content.x, top);
|
|
2966
|
+
return;
|
|
2967
|
+
}
|
|
2968
|
+
if (scroller) this.titleInsets.delete(scroller);
|
|
2969
|
+
if (scroller && (scroller.kind === 'list' || scroller.kind === 'scrollview')) {
|
|
2736
2970
|
this.place(child, contentCtx, { width: content.width, height: content.height }, content.x, content.y);
|
|
2737
2971
|
return;
|
|
2738
2972
|
}
|
|
@@ -2744,25 +2978,38 @@ export class LayoutPass {
|
|
|
2744
2978
|
}
|
|
2745
2979
|
|
|
2746
2980
|
/**
|
|
2747
|
-
* The tab bar
|
|
2748
|
-
*
|
|
2749
|
-
* the
|
|
2750
|
-
*
|
|
2981
|
+
* The tab bar (iOS 26) is a glass capsule floating over the bottom of the
|
|
2982
|
+
* content (TAB_BAR_* above), with one equal-width item per tab; what it
|
|
2983
|
+
* covers (the capsule and the gap under it) is the content's bottom inset.
|
|
2984
|
+
* Every `tab` gets the tab view's frame; only the selected one lays its
|
|
2985
|
+
* content out, the others are reported in `hidden`.
|
|
2751
2986
|
*/
|
|
2752
2987
|
private placeTabView(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
2753
|
-
const { screen
|
|
2754
|
-
const
|
|
2755
|
-
const barHeight = Math.min(size.height, TAB_BAR_HEIGHT +
|
|
2756
|
-
const
|
|
2988
|
+
const { screen } = this.env;
|
|
2989
|
+
const marginBottom = y + size.height >= screen.height - EPSILON ? TAB_BAR_MARGIN_BOTTOM : TAB_BAR_MARGIN_BOTTOM_INSET;
|
|
2990
|
+
const barHeight = Math.min(size.height, TAB_BAR_HEIGHT + marginBottom);
|
|
2991
|
+
const capsuleHeight = Math.min(TAB_BAR_HEIGHT, barHeight);
|
|
2992
|
+
const bar: Rect = {
|
|
2993
|
+
x: x + TAB_BAR_MARGIN_H,
|
|
2994
|
+
y: y + size.height - barHeight,
|
|
2995
|
+
width: Math.max(0, size.width - 2 * TAB_BAR_MARGIN_H),
|
|
2996
|
+
height: capsuleHeight,
|
|
2997
|
+
};
|
|
2757
2998
|
const chrome: Record<string, Rect> = {
|
|
2758
2999
|
bar,
|
|
2759
3000
|
content: { x, y, width: size.width, height: size.height - barHeight },
|
|
2760
3001
|
};
|
|
2761
3002
|
const tabs = node.children;
|
|
2762
3003
|
const n = tabs.length;
|
|
3004
|
+
const inner = Math.max(0, bar.width - 2 * TAB_BAR_ITEM_INSET);
|
|
2763
3005
|
for (let i = 0; i < n; i++) {
|
|
2764
|
-
const itemWidth =
|
|
2765
|
-
chrome[`item${i}`] = {
|
|
3006
|
+
const itemWidth = inner / n;
|
|
3007
|
+
chrome[`item${i}`] = {
|
|
3008
|
+
x: bar.x + TAB_BAR_ITEM_INSET + i * itemWidth,
|
|
3009
|
+
y: bar.y + TAB_BAR_ITEM_INSET,
|
|
3010
|
+
width: itemWidth,
|
|
3011
|
+
height: Math.max(0, capsuleHeight - 2 * TAB_BAR_ITEM_INSET),
|
|
3012
|
+
};
|
|
2766
3013
|
}
|
|
2767
3014
|
this.setChrome(node, chrome);
|
|
2768
3015
|
const selected = readSelected(node, n);
|
|
@@ -3008,9 +3255,9 @@ export class LayoutPass {
|
|
|
3008
3255
|
fill: { x: trackX, y: trackY, width: Math.max(0, thumbCenter - trackX), height: SLIDER_TRACK_HEIGHT },
|
|
3009
3256
|
thumb: {
|
|
3010
3257
|
x: thumbCenter - SLIDER_THUMB / 2,
|
|
3011
|
-
y: rowY + (SLIDER_HEIGHT -
|
|
3258
|
+
y: rowY + (SLIDER_HEIGHT - SLIDER_THUMB_HEIGHT) / 2,
|
|
3012
3259
|
width: SLIDER_THUMB,
|
|
3013
|
-
height:
|
|
3260
|
+
height: SLIDER_THUMB_HEIGHT,
|
|
3014
3261
|
},
|
|
3015
3262
|
});
|
|
3016
3263
|
}
|
|
@@ -3080,9 +3327,10 @@ export class LayoutPass {
|
|
|
3080
3327
|
}
|
|
3081
3328
|
|
|
3082
3329
|
/**
|
|
3083
|
-
* `alert` style: a
|
|
3084
|
-
* (headline)
|
|
3085
|
-
*
|
|
3330
|
+
* `alert` style (iOS 26): a card centered in the safe area with the title
|
|
3331
|
+
* (headline) and the message (subheadline), leading-aligned, and capsule
|
|
3332
|
+
* buttons: two side by side (a `cancel` one first), otherwise stacked; no
|
|
3333
|
+
* buttons → an `ok` rect the renderer draws.
|
|
3086
3334
|
* `dialog` style: an action sheet of 57 rows in a card 8 from the screen
|
|
3087
3335
|
* edges, above a separate card for the `cancel` button, 8 above the bottom
|
|
3088
3336
|
* safe area; the title and message are a footnote caption on top. Button
|
|
@@ -3098,40 +3346,45 @@ export class LayoutPass {
|
|
|
3098
3346
|
const texts: Record<string, TextLayout> = {};
|
|
3099
3347
|
const roleOf = (child: LayoutNode): string | null => str(unwrapStyled(child).props['role']);
|
|
3100
3348
|
|
|
3101
|
-
const
|
|
3349
|
+
const block = (text: string, font: ResolvedFont, width: number, alignment: 'center' | 'leading'): { layout: TextLayout; height: number } => {
|
|
3102
3350
|
const wrapped = wrapText(text, font, width, null, this.measurer);
|
|
3103
3351
|
return {
|
|
3104
|
-
layout: { font, lines: wrapped.lines, alignment
|
|
3352
|
+
layout: { font, lines: wrapped.lines, alignment, truncated: wrapped.truncated ?? false },
|
|
3105
3353
|
height: wrapped.lines.length * font.lineHeight,
|
|
3106
3354
|
};
|
|
3107
3355
|
};
|
|
3356
|
+
const centered = (text: string, font: ResolvedFont, width: number) => block(text, font, width, 'center');
|
|
3108
3357
|
|
|
3109
3358
|
if (style === 'alert') {
|
|
3110
3359
|
const cardWidth = Math.min(ALERT_WIDTH, size.width - 2 * DIALOG_MARGIN);
|
|
3111
3360
|
const textWidth = cardWidth - 2 * ALERT_PAD_H;
|
|
3112
3361
|
const titleFont = resolveFont({ textStyle: 'headline' }, ctx.font, this.env);
|
|
3113
|
-
const messageFont = resolveFont({ textStyle: '
|
|
3362
|
+
const messageFont = resolveFont({ textStyle: 'subheadline' }, ctx.font, this.env);
|
|
3114
3363
|
// Heights first (the card is centered), then the rects.
|
|
3115
3364
|
let h = ALERT_PAD_TOP;
|
|
3116
3365
|
let titleBlock: { layout: TextLayout; height: number; top: number } | null = null;
|
|
3117
3366
|
let messageBlock: { layout: TextLayout; height: number; top: number } | null = null;
|
|
3118
3367
|
if (title.length > 0) {
|
|
3119
|
-
const
|
|
3120
|
-
titleBlock = { ...
|
|
3121
|
-
h +=
|
|
3368
|
+
const b = block(title, titleFont, textWidth, 'leading');
|
|
3369
|
+
titleBlock = { ...b, top: h };
|
|
3370
|
+
h += b.height;
|
|
3122
3371
|
}
|
|
3123
3372
|
if (message.length > 0) {
|
|
3124
3373
|
if (titleBlock) h += ALERT_TITLE_GAP;
|
|
3125
|
-
const
|
|
3126
|
-
messageBlock = { ...
|
|
3127
|
-
h +=
|
|
3374
|
+
const b = block(message, messageFont, textWidth, 'leading');
|
|
3375
|
+
messageBlock = { ...b, top: h };
|
|
3376
|
+
h += b.height;
|
|
3128
3377
|
}
|
|
3129
|
-
h +=
|
|
3378
|
+
h += ALERT_BUTTONS_GAP;
|
|
3130
3379
|
const headerHeight = h;
|
|
3131
3380
|
const buttons = node.children;
|
|
3132
3381
|
const rows = buttons.length === 0 ? 1 : buttons.length === 2 ? 1 : buttons.length;
|
|
3133
|
-
h += rows * ALERT_BUTTON_HEIGHT;
|
|
3134
|
-
|
|
3382
|
+
h += rows * ALERT_BUTTON_HEIGHT + (rows - 1) * ALERT_BUTTON_SPACING + ALERT_PAD_BOTTOM;
|
|
3383
|
+
// Centered in the safe area, as iOS does.
|
|
3384
|
+
const { safeArea: inset } = this.env;
|
|
3385
|
+
const top = y + (y <= 0 ? inset.top : 0);
|
|
3386
|
+
const bottom = y + size.height - (y + size.height >= this.env.screen.height - EPSILON ? inset.bottom : 0);
|
|
3387
|
+
const card: Rect = { x: x + (size.width - cardWidth) / 2, y: top + (bottom - top - h) / 2, width: cardWidth, height: h };
|
|
3135
3388
|
chrome['card'] = card;
|
|
3136
3389
|
if (titleBlock) {
|
|
3137
3390
|
chrome['title'] = { x: card.x + ALERT_PAD_H, y: card.y + titleBlock.top, width: textWidth, height: titleBlock.height };
|
|
@@ -3142,18 +3395,28 @@ export class LayoutPass {
|
|
|
3142
3395
|
texts['message'] = messageBlock.layout;
|
|
3143
3396
|
}
|
|
3144
3397
|
const buttonsTop = card.y + headerHeight;
|
|
3398
|
+
const buttonsX = card.x + ALERT_BUTTON_INSET;
|
|
3399
|
+
const buttonsWidth = Math.max(0, cardWidth - 2 * ALERT_BUTTON_INSET);
|
|
3145
3400
|
if (buttons.length === 0) {
|
|
3146
|
-
chrome['ok'] = { x:
|
|
3401
|
+
chrome['ok'] = { x: buttonsX, y: buttonsTop, width: buttonsWidth, height: ALERT_BUTTON_HEIGHT };
|
|
3147
3402
|
} else if (buttons.length === 2) {
|
|
3148
|
-
|
|
3149
|
-
buttons
|
|
3150
|
-
|
|
3403
|
+
// A `cancel` button goes first (leading), as iOS orders them.
|
|
3404
|
+
const ordered = roleOf(buttons[1] as LayoutNode) === 'cancel' ? [buttons[1], buttons[0]] : [buttons[0], buttons[1]];
|
|
3405
|
+
const half = (buttonsWidth - ALERT_BUTTON_SPACING) / 2;
|
|
3406
|
+
ordered.forEach((button, slot) => {
|
|
3407
|
+
const i = buttons.indexOf(button as LayoutNode);
|
|
3408
|
+
const rect: Rect = { x: buttonsX + slot * (half + ALERT_BUTTON_SPACING), y: buttonsTop, width: half, height: ALERT_BUTTON_HEIGHT };
|
|
3151
3409
|
chrome[`button${i}`] = rect;
|
|
3152
|
-
this.placeAlertButton(button, cctx, rect);
|
|
3410
|
+
this.placeAlertButton(button as LayoutNode, cctx, rect);
|
|
3153
3411
|
});
|
|
3154
3412
|
} else {
|
|
3155
3413
|
buttons.forEach((button, i) => {
|
|
3156
|
-
const rect: Rect = {
|
|
3414
|
+
const rect: Rect = {
|
|
3415
|
+
x: buttonsX,
|
|
3416
|
+
y: buttonsTop + i * (ALERT_BUTTON_HEIGHT + ALERT_BUTTON_SPACING),
|
|
3417
|
+
width: buttonsWidth,
|
|
3418
|
+
height: ALERT_BUTTON_HEIGHT,
|
|
3419
|
+
};
|
|
3157
3420
|
chrome[`button${i}`] = rect;
|
|
3158
3421
|
this.placeAlertButton(button, cctx, rect);
|
|
3159
3422
|
});
|
|
@@ -3176,7 +3439,7 @@ export class LayoutPass {
|
|
|
3176
3439
|
h += block.height;
|
|
3177
3440
|
}
|
|
3178
3441
|
if (message.length > 0) {
|
|
3179
|
-
if (titleBlock) h +=
|
|
3442
|
+
if (titleBlock) h += DIALOG_TITLE_GAP;
|
|
3180
3443
|
const block = centered(message, messageFont, textWidth);
|
|
3181
3444
|
messageBlock = { ...block, top: h };
|
|
3182
3445
|
h += block.height;
|
|
@@ -3194,7 +3457,7 @@ export class LayoutPass {
|
|
|
3194
3457
|
const cancelCard: Rect = { x: cardX, y: bottom - DIALOG_BUTTON_HEIGHT, width: cardWidth, height: DIALOG_BUTTON_HEIGHT };
|
|
3195
3458
|
chrome['cancelCard'] = cancelCard;
|
|
3196
3459
|
chrome[`button${node.children.indexOf(cancel)}`] = cancelCard;
|
|
3197
|
-
this.placeAlertButton(cancel, buttonCtx, cancelCard);
|
|
3460
|
+
this.placeAlertButton(cancel, buttonCtx, cancelCard, true);
|
|
3198
3461
|
cardBottom = cancelCard.y - DIALOG_CARD_GAP;
|
|
3199
3462
|
}
|
|
3200
3463
|
const card: Rect = { x: cardX, y: cardBottom - h, width: cardWidth, height: h };
|
|
@@ -3214,7 +3477,7 @@ export class LayoutPass {
|
|
|
3214
3477
|
others.forEach((button, i) => {
|
|
3215
3478
|
const rect: Rect = { x: card.x, y: buttonsTop + i * DIALOG_BUTTON_HEIGHT, width: cardWidth, height: DIALOG_BUTTON_HEIGHT };
|
|
3216
3479
|
chrome[`button${node.children.indexOf(button)}`] = rect;
|
|
3217
|
-
this.placeAlertButton(button, buttonCtx, rect);
|
|
3480
|
+
this.placeAlertButton(button, buttonCtx, rect, true);
|
|
3218
3481
|
});
|
|
3219
3482
|
}
|
|
3220
3483
|
this.setChrome(node, chrome);
|
|
@@ -3222,10 +3485,11 @@ export class LayoutPass {
|
|
|
3222
3485
|
}
|
|
3223
3486
|
|
|
3224
3487
|
/** An alert button fills its row; its label is centered in it (any other child is centered as is). */
|
|
3225
|
-
private placeAlertButton(child: LayoutNode, ctx: Context, rect: Rect): void {
|
|
3226
|
-
const proposal: Proposal = { width: Math.max(0, rect.width - 2 *
|
|
3227
|
-
//
|
|
3228
|
-
|
|
3488
|
+
private placeAlertButton(child: LayoutNode, ctx: Context, rect: Rect, boldCancel = false): void {
|
|
3489
|
+
const proposal: Proposal = { width: Math.max(0, rect.width - 2 * DIALOG_LABEL_INSET), height: rect.height };
|
|
3490
|
+
// An action sheet's `cancel` button is bold: measure (and so draw) its
|
|
3491
|
+
// label semibold. iOS 26 alerts draw it regular.
|
|
3492
|
+
const cancel = boldCancel && str(unwrapStyled(child).props['role']) === 'cancel';
|
|
3229
3493
|
const inherited = this.childContext(child, ctx);
|
|
3230
3494
|
const cctx: Context = {
|
|
3231
3495
|
...inherited,
|
|
@@ -3250,9 +3514,16 @@ export class LayoutPass {
|
|
|
3250
3514
|
}
|
|
3251
3515
|
|
|
3252
3516
|
private placeSheet(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
3253
|
-
//
|
|
3517
|
+
// A grabber gets its row; without one the content starts a little below
|
|
3518
|
+
// the sheet's top (iOS 26), except in a `full` one (Phase 8,
|
|
3519
|
+
// `.fullScreenCover`). A navigation stack fills the sheet from its top
|
|
3520
|
+
// instead, its bar taking that room (`barInset`), so its background
|
|
3521
|
+
// reaches the sheet's edge; a list or scroll view fills it below the
|
|
3522
|
+
// grabber row.
|
|
3523
|
+
const grabber = this.sheetHasGrabber(node);
|
|
3524
|
+
const floats = this.sheetFloats(node);
|
|
3254
3525
|
const full = this.sheetIsFull(node);
|
|
3255
|
-
const grabberRow = full ? 0 :
|
|
3526
|
+
const grabberRow = grabber ? SHEET_GRABBER_ROW : full ? 0 : SHEET_CONTENT_TOP;
|
|
3256
3527
|
const content: Rect = {
|
|
3257
3528
|
x,
|
|
3258
3529
|
y: y + grabberRow,
|
|
@@ -3260,7 +3531,8 @@ export class LayoutPass {
|
|
|
3260
3531
|
height: Math.max(0, size.height - grabberRow),
|
|
3261
3532
|
};
|
|
3262
3533
|
const chrome: Record<string, Rect> = { content };
|
|
3263
|
-
if (
|
|
3534
|
+
if (floats) chrome['floating'] = { x, y, width: size.width, height: size.height };
|
|
3535
|
+
if (grabber) {
|
|
3264
3536
|
chrome['grabber'] = {
|
|
3265
3537
|
x: x + (size.width - SHEET_GRABBER.width) / 2,
|
|
3266
3538
|
y: y + SHEET_GRABBER_TOP,
|
|
@@ -3273,8 +3545,14 @@ export class LayoutPass {
|
|
|
3273
3545
|
if (!child) return;
|
|
3274
3546
|
const proposal: Proposal = { width: content.width, height: content.height };
|
|
3275
3547
|
const kind = unwrapStyled(child).kind;
|
|
3276
|
-
if (kind === '
|
|
3277
|
-
this.place(child, cctx,
|
|
3548
|
+
if (kind === 'navstack') {
|
|
3549
|
+
this.place(child, { ...cctx, barInset: grabberRow }, { width: size.width, height: size.height }, x, y);
|
|
3550
|
+
return;
|
|
3551
|
+
}
|
|
3552
|
+
if (kind === 'list' || kind === 'scrollview') {
|
|
3553
|
+
// A scrolling view fills the sheet below the grabber row, if any.
|
|
3554
|
+
const top = grabber ? SHEET_GRABBER_ROW : 0;
|
|
3555
|
+
this.place(child, cctx, { width: size.width, height: size.height - top }, x, y + top);
|
|
3278
3556
|
return;
|
|
3279
3557
|
}
|
|
3280
3558
|
const childSize = this.measure(child, cctx, proposal);
|
|
@@ -3380,8 +3658,12 @@ export class LayoutPass {
|
|
|
3380
3658
|
* whose non-spanning cells are all flexible (or that has none) is flexible;
|
|
3381
3659
|
* the others take the largest ideal width of their non-spanning cells. The
|
|
3382
3660
|
* flexible columns share what is left of the proposal width equally (their
|
|
3383
|
-
* ideal width with a nil proposal).
|
|
3384
|
-
* cell
|
|
3661
|
+
* ideal width with a nil proposal). A non-`gridrow` child is one full-span
|
|
3662
|
+
* cell; one wider than the columns (a `Divider` takes the whole proposal)
|
|
3663
|
+
* widens the grid, every column by the same amount (unless it has
|
|
3664
|
+
* `gridCellUnsizedAxes(.horizontal)`). Row heights come from
|
|
3665
|
+
* measuring every cell at its cell width. A cell's `gridColumnAlignment`
|
|
3666
|
+
* aligns its whole column.
|
|
3385
3667
|
*/
|
|
3386
3668
|
private gridPlan(node: LayoutNode, ctx: Context, p: Proposal): GridPlan {
|
|
3387
3669
|
return this.plan(node, p, () => {
|
|
@@ -3444,6 +3726,30 @@ export class LayoutPass {
|
|
|
3444
3726
|
if (flexible[j]) columns[j] = flexibleCount > 0 ? remaining / flexibleCount : 0;
|
|
3445
3727
|
}
|
|
3446
3728
|
}
|
|
3729
|
+
// Full-span children wider than the columns widen every column alike.
|
|
3730
|
+
const fullProposal: Proposal = { width: finite(p.width) ? p.width : null, height: null };
|
|
3731
|
+
let fullWidth = 0;
|
|
3732
|
+
for (const source of sources) {
|
|
3733
|
+
if (!source.full) continue;
|
|
3734
|
+
const cctx = this.gridCellContext(node, null, ctx);
|
|
3735
|
+
for (const cell of source.cells) {
|
|
3736
|
+
if (cellStyle(cell.node, 'gridCellUnsizedAxes') === true) continue;
|
|
3737
|
+
fullWidth = Math.max(fullWidth, this.measure(cell.node, cctx, fullProposal).width);
|
|
3738
|
+
}
|
|
3739
|
+
}
|
|
3740
|
+
const columnsWidth = columns.reduce((sum, w) => sum + w, 0) + hSpacing * (columnCount - 1);
|
|
3741
|
+
if (fullWidth > columnsWidth + EPSILON) {
|
|
3742
|
+
const extra = (fullWidth - columnsWidth) / columnCount;
|
|
3743
|
+
for (let j = 0; j < columnCount; j++) columns[j] = (columns[j] as number) + extra;
|
|
3744
|
+
}
|
|
3745
|
+
const columnAnchors = new Array<number | null>(columnCount).fill(null);
|
|
3746
|
+
for (const source of sources) {
|
|
3747
|
+
if (source.full) continue;
|
|
3748
|
+
for (const cell of source.cells) {
|
|
3749
|
+
const anchor = cell.span === 1 ? gridColumnAnchorOf(cell.node) : null;
|
|
3750
|
+
if (anchor !== null && columnAnchors[cell.column] === null) columnAnchors[cell.column] = anchor;
|
|
3751
|
+
}
|
|
3752
|
+
}
|
|
3447
3753
|
const columnX = new Array<number>(columnCount).fill(0);
|
|
3448
3754
|
for (let j = 1; j < columnCount; j++) {
|
|
3449
3755
|
columnX[j] = (columnX[j - 1] as number) + (columns[j - 1] as number) + hSpacing;
|
|
@@ -3480,7 +3786,7 @@ export class LayoutPass {
|
|
|
3480
3786
|
y += height + vSpacing;
|
|
3481
3787
|
}
|
|
3482
3788
|
const gridHeight = rows.length > 0 ? y - vSpacing : 0;
|
|
3483
|
-
return { size: { width: gridWidth, height: gridHeight }, columns, columnX, rows };
|
|
3789
|
+
return { size: { width: gridWidth, height: gridHeight }, columns, columnX, columnAnchors, rows };
|
|
3484
3790
|
});
|
|
3485
3791
|
}
|
|
3486
3792
|
|
|
@@ -3494,7 +3800,8 @@ export class LayoutPass {
|
|
|
3494
3800
|
const rowY = rowAnchor(row.alignment, gridAlignment);
|
|
3495
3801
|
for (const cell of row.cells) {
|
|
3496
3802
|
const box: Size = { width: cell.width, height: row.height };
|
|
3497
|
-
const
|
|
3803
|
+
const columnX = cell.full ? null : (plan.columnAnchors[cell.column] ?? null);
|
|
3804
|
+
const [ax, ay] = gridAnchorOf(cell.node) ?? [columnX ?? gridX, rowY];
|
|
3498
3805
|
const dx = safeOffset(box.width, cell.size.width, ax);
|
|
3499
3806
|
const dy = safeOffset(box.height, cell.size.height, ay);
|
|
3500
3807
|
const cellX = cell.full ? 0 : (plan.columnX[cell.column] ?? 0);
|
|
@@ -3642,6 +3949,15 @@ export class LayoutPass {
|
|
|
3642
3949
|
return { size: square(GAUGE_RING_SIZE), label, current, labelSize: ZERO, currentSize: ZERO, labelRow: 0, labelCtx, ring: { center, centerSize } };
|
|
3643
3950
|
}
|
|
3644
3951
|
const proposal: Proposal = { width, height: null };
|
|
3952
|
+
if (style === 'progress') {
|
|
3953
|
+
// A ProgressView: the label in the context font above the bar, the
|
|
3954
|
+
// current value label (caption) under it.
|
|
3955
|
+
const labelSize = label ? this.measure(label, cctx, proposal) : ZERO;
|
|
3956
|
+
const currentSize = current ? this.measure(current, labelCtx, proposal) : ZERO;
|
|
3957
|
+
const labelRow = label ? labelSize.height + PROGRESS_LABEL_GAP : 0;
|
|
3958
|
+
const height = labelRow + GAUGE_BAR_HEIGHT + (current ? PROGRESS_VALUE_GAP + currentSize.height : 0);
|
|
3959
|
+
return { size: { width, height }, label, current, labelSize, currentSize, labelRow, labelCtx, labelOwnCtx: cctx };
|
|
3960
|
+
}
|
|
3645
3961
|
const labelSize = label ? this.measure(label, labelCtx, proposal) : ZERO;
|
|
3646
3962
|
const currentSize = current ? this.measure(current, labelCtx, proposal) : ZERO;
|
|
3647
3963
|
const labelRow = label || current ? Math.max(labelSize.height, currentSize.height) : 0;
|
|
@@ -3665,6 +3981,17 @@ export class LayoutPass {
|
|
|
3665
3981
|
return;
|
|
3666
3982
|
}
|
|
3667
3983
|
const proposal: Proposal = { width: size.width, height: null };
|
|
3984
|
+
if (str(node.props['style']) === 'progress') {
|
|
3985
|
+
if (plan.label) this.place(plan.label, plan.labelOwnCtx ?? plan.labelCtx, proposal, x, y);
|
|
3986
|
+
const barY = y + plan.labelRow;
|
|
3987
|
+
if (plan.current) this.place(plan.current, plan.labelCtx, proposal, x, barY + GAUGE_BAR_HEIGHT + PROGRESS_VALUE_GAP);
|
|
3988
|
+
const fraction = Math.min(1, Math.max(0, num(node.props['value']) ?? 0));
|
|
3989
|
+
this.setChrome(node, {
|
|
3990
|
+
track: { x, y: barY, width: size.width, height: GAUGE_BAR_HEIGHT },
|
|
3991
|
+
fill: { x, y: barY, width: size.width * fraction, height: GAUGE_BAR_HEIGHT },
|
|
3992
|
+
});
|
|
3993
|
+
return;
|
|
3994
|
+
}
|
|
3668
3995
|
if (plan.label) {
|
|
3669
3996
|
this.place(plan.label, plan.labelCtx, proposal, x, y + (plan.labelRow - plan.labelSize.height) / 2);
|
|
3670
3997
|
}
|