@swiftbrowser/web 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +411 -158
- 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 +313 -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,47 @@ 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
|
+
const LIST_ROW_MIN_HEIGHT = 52;
|
|
155
|
+
const LIST_TOP = 18;
|
|
124
156
|
const LIST_BOTTOM = 20;
|
|
125
|
-
const
|
|
126
|
-
const
|
|
157
|
+
const LIST_HEADER_LIFT = 9;
|
|
158
|
+
const LIST_HEADER_GAP = 10;
|
|
159
|
+
const LIST_FOOTER_GAP = 6;
|
|
127
160
|
const LIST_SECTION_GAP = 35;
|
|
128
161
|
const LIST_CHEVRON_WIDTH = 20;
|
|
129
162
|
// Phase 4.
|
|
130
|
-
|
|
163
|
+
// iOS 26: the tab bar is a 62-tall glass capsule floating 21 from the sides
|
|
164
|
+
// and 22 above the bottom edge (8 when the tab view ends above the screen
|
|
165
|
+
// bottom), its items in equal slots inside a 4 inset.
|
|
166
|
+
const TAB_BAR_HEIGHT = 62;
|
|
167
|
+
const TAB_BAR_MARGIN_H = 21;
|
|
168
|
+
const TAB_BAR_MARGIN_BOTTOM = 22;
|
|
169
|
+
const TAB_BAR_MARGIN_BOTTOM_INSET = 8;
|
|
170
|
+
const TAB_BAR_ITEM_INSET = 4;
|
|
131
171
|
const PICKER_SEGMENTED_HEIGHT = 32;
|
|
132
172
|
const PICKER_SEGMENT_PAD = 20;
|
|
133
173
|
const PICKER_SEGMENT_FONT_SIZE = 13;
|
|
@@ -139,6 +179,9 @@ const PICKER_MENU_GAP = 12;
|
|
|
139
179
|
// Phase 6.
|
|
140
180
|
const GAUGE_BAR_HEIGHT = 4;
|
|
141
181
|
const GAUGE_LABEL_GAP = 4;
|
|
182
|
+
/** A ProgressView's label to its bar, and its bar to its current value label. */
|
|
183
|
+
const PROGRESS_LABEL_GAP = 8;
|
|
184
|
+
const PROGRESS_VALUE_GAP = 6;
|
|
142
185
|
const GAUGE_DEFAULT_WIDTH = 100;
|
|
143
186
|
/** A circular gauge's side (`accessoryCircular` / `accessoryCircularCapacity`). */
|
|
144
187
|
const GAUGE_RING_SIZE = 58;
|
|
@@ -153,12 +196,22 @@ const PICKER_INLINE_CHECK_WIDTH = 28;
|
|
|
153
196
|
const BADGE_GAP = 8;
|
|
154
197
|
const NAV_ITEM_SPACING = 16;
|
|
155
198
|
const NAV_ITEM_MARGIN = 16;
|
|
199
|
+
// iOS 26: the back button is a 44pt glass circle 16 from the leading edge,
|
|
200
|
+
// and the bar's leading and trailing items sit in glass capsules 44 tall,
|
|
201
|
+
// each item in a slot at least 44 wide (an icon: a circle; text: 12 either
|
|
202
|
+
// side), the groups 8 apart.
|
|
203
|
+
const NAV_GLASS_SIZE = 44;
|
|
204
|
+
const NAV_GLASS_PAD_H = 12;
|
|
205
|
+
const NAV_GROUP_SPACING = 8;
|
|
156
206
|
const BOTTOM_BAR_HEIGHT = 49;
|
|
157
207
|
const SEARCH_FIELD_HEIGHT = 36;
|
|
158
208
|
const SEARCH_FIELD_MARGIN_H = 16;
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
209
|
+
// iOS 26: a navigation stack's search field is a 48-tall glass capsule
|
|
210
|
+
// floating 16 from the sides and 28 above the screen bottom (8 above a view
|
|
211
|
+
// that ends higher); the content stops above it.
|
|
212
|
+
const SEARCH_BOTTOM_HEIGHT = 48;
|
|
213
|
+
const SEARCH_BOTTOM_MARGIN = 28;
|
|
214
|
+
const SEARCH_BOTTOM_MARGIN_INSET = 8;
|
|
162
215
|
const SEARCH_FIELD_DEFAULT_WIDTH = 200;
|
|
163
216
|
const IMAGE_SCALES: Readonly<Record<string, number>> = { small: 0.8, medium: 1, large: 1.25 };
|
|
164
217
|
// Phase 7 (docs/ops-protocol.md, "NavigationSplitView").
|
|
@@ -172,10 +225,12 @@ const EPSILON = 1e-6;
|
|
|
172
225
|
const STEPPER_WIDTH = 94;
|
|
173
226
|
const STEPPER_HEIGHT = 32;
|
|
174
227
|
const STEPPER_GAP = 8;
|
|
175
|
-
/** A `slider` is
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
const
|
|
228
|
+
/** A `slider` is 35 high (its label row, when present, sits above). */
|
|
229
|
+
// iOS 26: a 6pt track and a 38 × 24 pill knob in a 35 row.
|
|
230
|
+
const SLIDER_HEIGHT = 35;
|
|
231
|
+
const SLIDER_TRACK_HEIGHT = 6;
|
|
232
|
+
const SLIDER_THUMB = 38;
|
|
233
|
+
const SLIDER_THUMB_HEIGHT = 24;
|
|
179
234
|
const SLIDER_DEFAULT_WIDTH = 200;
|
|
180
235
|
/** Gap between a min / max value label and the track. */
|
|
181
236
|
const SLIDER_LABEL_GAP = 8;
|
|
@@ -186,14 +241,26 @@ const DATEPICKER_CAPSULE_GAP = 8;
|
|
|
186
241
|
const DATEPICKER_GAP = 8;
|
|
187
242
|
/** A `texteditor` fills the proposal; 100 high when proposed nil. */
|
|
188
243
|
const TEXTEDITOR_DEFAULT_HEIGHT = 100;
|
|
189
|
-
/**
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
244
|
+
/**
|
|
245
|
+
* `alert` style (iOS 26): a 320 wide card centered in the safe area, its
|
|
246
|
+
* title and message leading-aligned 30 in, its buttons 46 tall capsules 16
|
|
247
|
+
* in from the card's sides, side by side 8 apart for two, stacked 8 apart
|
|
248
|
+
* otherwise.
|
|
249
|
+
*/
|
|
250
|
+
const ALERT_WIDTH = 320;
|
|
251
|
+
const ALERT_PAD_TOP = 21;
|
|
252
|
+
const ALERT_PAD_H = 30;
|
|
253
|
+
const ALERT_BUTTON_INSET = 16;
|
|
254
|
+
const ALERT_BUTTONS_GAP = 20;
|
|
255
|
+
const ALERT_PAD_BOTTOM = 16;
|
|
256
|
+
const ALERT_BUTTON_SPACING = 8;
|
|
194
257
|
/** Gap between the alert title and its message. */
|
|
195
|
-
const ALERT_TITLE_GAP =
|
|
196
|
-
const ALERT_BUTTON_HEIGHT =
|
|
258
|
+
const ALERT_TITLE_GAP = 6;
|
|
259
|
+
const ALERT_BUTTON_HEIGHT = 46;
|
|
260
|
+
/** Gap between an action sheet's title and its message. */
|
|
261
|
+
const DIALOG_TITLE_GAP = 4;
|
|
262
|
+
/** An action sheet's button labels are inset this much from its rows. */
|
|
263
|
+
const DIALOG_LABEL_INSET = 16;
|
|
197
264
|
/** `dialog` style (action sheet): cards inset 8 from the screen edges and the bottom safe area. */
|
|
198
265
|
const DIALOG_MARGIN = 8;
|
|
199
266
|
const DIALOG_BUTTON_HEIGHT = 57;
|
|
@@ -643,6 +710,11 @@ function alignChild(container: Size, child: Size, alignment: string, node: Layou
|
|
|
643
710
|
return offsets;
|
|
644
711
|
}
|
|
645
712
|
|
|
713
|
+
/** A `Spacer` (a lazy stack's too): a stack sizes it after its other children. */
|
|
714
|
+
function isSpacer(node: LayoutNode): boolean {
|
|
715
|
+
return node.kind === 'spacer' || node.kind === 'lazyspacer';
|
|
716
|
+
}
|
|
717
|
+
|
|
646
718
|
function safeOffset(container: number, child: number, anchor: number): number {
|
|
647
719
|
if (!Number.isFinite(container) || !Number.isFinite(child)) return 0;
|
|
648
720
|
return (container - child) * anchor;
|
|
@@ -844,6 +916,8 @@ interface GaugePlan {
|
|
|
844
916
|
/** Height of the label row (0 without labels). */
|
|
845
917
|
labelRow: number;
|
|
846
918
|
labelCtx: Context;
|
|
919
|
+
/** A ProgressView's label context (the gauge's own font, not the caption). */
|
|
920
|
+
labelOwnCtx?: Context;
|
|
847
921
|
/** A circular gauge: the view in the middle of the ring (the current value label, else the label) and its size. */
|
|
848
922
|
ring?: { center: LayoutNode | null; centerSize: Size };
|
|
849
923
|
}
|
|
@@ -896,6 +970,12 @@ export class LayoutPass {
|
|
|
896
970
|
readonly layouts = new Map<number, LayoutRecord>();
|
|
897
971
|
/** Previous screen title per navscreen node, for the back button label. */
|
|
898
972
|
private readonly previousTitles = new Map<LayoutNode, string>();
|
|
973
|
+
/**
|
|
974
|
+
* A large-title screen's root list or vertical scroll view starts under the
|
|
975
|
+
* bar and insets its content by the title row, which scrolls with it
|
|
976
|
+
* (iOS): the inset, by scroll node, set by `placeNavScreen`.
|
|
977
|
+
*/
|
|
978
|
+
private readonly titleInsets = new WeakMap<LayoutNode, Rect>();
|
|
899
979
|
|
|
900
980
|
private readonly memo = new Map<LayoutNode, Map<string, unknown>>();
|
|
901
981
|
private readonly implicit = new Map<LayoutNode, LayoutNode>();
|
|
@@ -947,9 +1027,12 @@ export class LayoutPass {
|
|
|
947
1027
|
// Phase 8: an `alert` is a full-screen layer too (scrim + card).
|
|
948
1028
|
this.place(root, ctx, { width: screen.width, height: screen.height }, 0, 0);
|
|
949
1029
|
} else if (root.kind === 'sheet') {
|
|
950
|
-
const
|
|
1030
|
+
const floats = this.sheetFloats(root);
|
|
1031
|
+
const insetH = floats ? SHEET_FLOAT_INSET_H : 0;
|
|
1032
|
+
const insetBottom = floats ? SHEET_FLOAT_INSET_BOTTOM : 0;
|
|
1033
|
+
const proposal: Proposal = { width: screen.width - 2 * insetH, height: null };
|
|
951
1034
|
const size = this.measure(root, ctx, proposal);
|
|
952
|
-
this.place(root, ctx, proposal,
|
|
1035
|
+
this.place(root, ctx, proposal, insetH, screen.height - insetBottom - size.height);
|
|
953
1036
|
} else {
|
|
954
1037
|
const safe: Size = {
|
|
955
1038
|
width: Math.max(0, screen.width - safeArea.leading - safeArea.trailing),
|
|
@@ -1428,19 +1511,31 @@ export class LayoutPass {
|
|
|
1428
1511
|
|
|
1429
1512
|
private measureButton(node: LayoutNode, ctx: Context, p: Proposal): Size {
|
|
1430
1513
|
const label = this.contentOf(node);
|
|
1431
|
-
|
|
1432
|
-
|
|
1514
|
+
if (!this.buttonIsBordered(node)) return label ? this.measure(label, this.childContext(node, ctx), p) : ZERO;
|
|
1515
|
+
const cctx = this.borderedLabelContext(node, ctx);
|
|
1516
|
+
const { padV, padH, minHeight } = this.borderedMetrics(node);
|
|
1433
1517
|
const inner: Proposal = {
|
|
1434
|
-
width: shrink(p.width, 2 *
|
|
1435
|
-
height: shrink(p.height, 2 *
|
|
1518
|
+
width: shrink(p.width, 2 * padH),
|
|
1519
|
+
height: shrink(p.height, 2 * padV),
|
|
1436
1520
|
};
|
|
1437
1521
|
const size = label ? this.measure(label, cctx, inner) : ZERO;
|
|
1438
1522
|
return {
|
|
1439
|
-
width: size.width + 2 *
|
|
1440
|
-
height: Math.max(
|
|
1523
|
+
width: size.width + 2 * padH,
|
|
1524
|
+
height: Math.max(minHeight, size.height + 2 * padV),
|
|
1441
1525
|
};
|
|
1442
1526
|
}
|
|
1443
1527
|
|
|
1528
|
+
private borderedMetrics(node: LayoutNode): { padV: number; padH: number; minHeight: number; textStyle?: string } {
|
|
1529
|
+
return BORDERED_SIZES[str(node.props['controlSize']) ?? ''] ?? { padV: BORDERED_PAD_V, padH: BORDERED_PAD_H, minHeight: BORDERED_MIN_HEIGHT };
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/** A bordered button's label context: a small or mini one sets a smaller text style. */
|
|
1533
|
+
private borderedLabelContext(node: LayoutNode, ctx: Context): Context {
|
|
1534
|
+
const cctx = this.childContext(node, ctx);
|
|
1535
|
+
const textStyle = this.borderedMetrics(node).textStyle;
|
|
1536
|
+
return textStyle ? { ...cctx, font: resolveFont({ textStyle }, cctx.font, this.env) } : cctx;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1444
1539
|
private toggleLabelProposal(p: Proposal): Proposal {
|
|
1445
1540
|
return { width: shrink(p.width, SWITCH_WIDTH + TOGGLE_GAP), height: p.height };
|
|
1446
1541
|
}
|
|
@@ -1473,7 +1568,7 @@ export class LayoutPass {
|
|
|
1473
1568
|
const detents = node.props['detents'];
|
|
1474
1569
|
const first = Array.isArray(detents) ? str(detents[0]) : null;
|
|
1475
1570
|
const { screen, safeArea } = this.env;
|
|
1476
|
-
if (first === 'medium') return screen.height
|
|
1571
|
+
if (first === 'medium') return Math.min(SHEET_MEDIUM_HEIGHT, screen.height - safeArea.top - SHEET_TOP_GAP);
|
|
1477
1572
|
// `.height(h)` / `.fraction(f)` detents, at most as tall as `large`.
|
|
1478
1573
|
const large = screen.height - safeArea.top - SHEET_TOP_GAP;
|
|
1479
1574
|
if (first?.startsWith('height:')) return Math.min(large, Number(first.slice(7)) || large);
|
|
@@ -1489,6 +1584,19 @@ export class LayoutPass {
|
|
|
1489
1584
|
return Array.isArray(detents) && str(detents[0]) === 'full';
|
|
1490
1585
|
}
|
|
1491
1586
|
|
|
1587
|
+
/** iOS 26: a sheet whose first detent is a partial one floats inset from the screen edges. */
|
|
1588
|
+
private sheetFloats(node: LayoutNode): boolean {
|
|
1589
|
+
const detents = node.props['detents'];
|
|
1590
|
+
const first = Array.isArray(detents) ? str(detents[0]) : null;
|
|
1591
|
+
return first === 'medium' || first?.startsWith('height:') === true || first?.startsWith('fraction:') === true;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
/** iOS 26 shows the grabber only for a sheet with more than one detent. */
|
|
1595
|
+
private sheetHasGrabber(node: LayoutNode): boolean {
|
|
1596
|
+
const detents = node.props['detents'];
|
|
1597
|
+
return Array.isArray(detents) && detents.length > 1 && !this.sheetIsFull(node);
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1492
1600
|
// -------------------------------------------------------------------------
|
|
1493
1601
|
// Picker (Phase 4)
|
|
1494
1602
|
// -------------------------------------------------------------------------
|
|
@@ -1615,11 +1723,15 @@ export class LayoutPass {
|
|
|
1615
1723
|
* least flexible first. A child is offered `remaining / childrenLeft`, where
|
|
1616
1724
|
* `remaining` excludes the minimum sizes of every lower-priority child and
|
|
1617
1725
|
* `childrenLeft` counts only the unsized children of its own priority group
|
|
1618
|
-
* with *finite* flexibility: infinitely flexible children (
|
|
1726
|
+
* with *finite* flexibility: infinitely flexible children (colors,
|
|
1619
1727
|
* `maxWidth: infinity` frames, text fields) only reserve their minimum and
|
|
1620
1728
|
* share whatever is left once everything else is sized, which is why a
|
|
1621
|
-
* `Spacer` never makes its sibling text wrap.
|
|
1622
|
-
*
|
|
1729
|
+
* `Spacer` never makes its sibling text wrap. Spacers come after those: they
|
|
1730
|
+
* reserve their minimum while the other infinitely flexible children share,
|
|
1731
|
+
* and take what is left (iOS gives `HStack { VStack { … }.frame(maxWidth:
|
|
1732
|
+
* .infinity); Spacer() }`'s stack all but the spacer's 8 points, where an
|
|
1733
|
+
* even split gave Ice Cubes' quoted posts half their card). With a `null`
|
|
1734
|
+
* axis proposal every child is proposed `null` and the stack sizes to the sum.
|
|
1623
1735
|
*/
|
|
1624
1736
|
private layoutStack(
|
|
1625
1737
|
children: readonly LayoutNode[],
|
|
@@ -1646,6 +1758,7 @@ export class LayoutPass {
|
|
|
1646
1758
|
const mins = new Array<number>(n);
|
|
1647
1759
|
const flex = new Array<number>(n);
|
|
1648
1760
|
const priorities = new Array<number>(n);
|
|
1761
|
+
const spacers = children.map(isSpacer);
|
|
1649
1762
|
const minProposal = makeProposal(axis, 0, cross);
|
|
1650
1763
|
const maxProposal = makeProposal(axis, Infinity, cross);
|
|
1651
1764
|
for (let i = 0; i < n; i++) {
|
|
@@ -1663,6 +1776,7 @@ export class LayoutPass {
|
|
|
1663
1776
|
const fa = flex[a] as number;
|
|
1664
1777
|
const fb = flex[b] as number;
|
|
1665
1778
|
if (fa !== fb) return fa < fb ? -1 : 1;
|
|
1779
|
+
if (spacers[a] !== spacers[b]) return spacers[a] ? 1 : -1;
|
|
1666
1780
|
return a - b;
|
|
1667
1781
|
});
|
|
1668
1782
|
|
|
@@ -1679,10 +1793,15 @@ export class LayoutPass {
|
|
|
1679
1793
|
let finiteLeft = 0;
|
|
1680
1794
|
let infiniteLeft = 0;
|
|
1681
1795
|
let infiniteMin = 0;
|
|
1796
|
+
let spacersLeft = 0;
|
|
1797
|
+
let spacerMin = 0;
|
|
1682
1798
|
for (let k = i; k < end; k++) {
|
|
1683
1799
|
const index = order[k] as number;
|
|
1684
1800
|
if (Number.isFinite(flex[index])) finiteLeft++;
|
|
1685
|
-
else {
|
|
1801
|
+
else if (spacers[index]) {
|
|
1802
|
+
spacersLeft++;
|
|
1803
|
+
spacerMin += mins[index] as number;
|
|
1804
|
+
} else {
|
|
1686
1805
|
infiniteLeft++;
|
|
1687
1806
|
infiniteMin += mins[index] as number;
|
|
1688
1807
|
}
|
|
@@ -1690,16 +1809,21 @@ export class LayoutPass {
|
|
|
1690
1809
|
for (let k = i; k < end; k++) {
|
|
1691
1810
|
const index = order[k] as number;
|
|
1692
1811
|
const isInfinite = !Number.isFinite(flex[index]);
|
|
1812
|
+
const isSpacerChild = isInfinite && spacers[index] === true;
|
|
1693
1813
|
let offer: number;
|
|
1694
1814
|
if (!Number.isFinite(remaining)) offer = Infinity;
|
|
1695
|
-
else if (
|
|
1696
|
-
else offer = Math.max(0, remaining - lowerMin -
|
|
1815
|
+
else if (isSpacerChild) offer = Math.max(0, remaining - lowerMin) / spacersLeft;
|
|
1816
|
+
else if (isInfinite) offer = Math.max(0, remaining - lowerMin - spacerMin) / infiniteLeft;
|
|
1817
|
+
else offer = Math.max(0, remaining - lowerMin - infiniteMin - spacerMin) / finiteLeft;
|
|
1697
1818
|
const proposal = makeProposal(axis, offer, cross);
|
|
1698
1819
|
const size = this.measure(children[index] as LayoutNode, cctx, proposal);
|
|
1699
1820
|
childProposals[index] = proposal;
|
|
1700
1821
|
childSizes[index] = size;
|
|
1701
1822
|
if (Number.isFinite(remaining)) remaining = Math.max(0, remaining - axisOf(size, axis));
|
|
1702
|
-
if (
|
|
1823
|
+
if (isSpacerChild) {
|
|
1824
|
+
spacersLeft--;
|
|
1825
|
+
spacerMin -= mins[index] as number;
|
|
1826
|
+
} else if (isInfinite) {
|
|
1703
1827
|
infiniteLeft--;
|
|
1704
1828
|
infiniteMin -= mins[index] as number;
|
|
1705
1829
|
} else finiteLeft--;
|
|
@@ -1989,7 +2113,7 @@ export class LayoutPass {
|
|
|
1989
2113
|
// sections are full width. `plain` also drops the top/bottom gaps and
|
|
1990
2114
|
// the space between sections.
|
|
1991
2115
|
const listStyle = str(node.props['style']) ?? 'insetGrouped';
|
|
1992
|
-
// `.environment(\.defaultMinListRowHeight, _)`;
|
|
2116
|
+
// `.environment(\.defaultMinListRowHeight, _)`; 52 by default.
|
|
1993
2117
|
const minRowHeight = num(node.props['minRowHeight']) ?? LIST_ROW_MIN_HEIGHT;
|
|
1994
2118
|
const inset = listStyle === 'insetGrouped' ? LIST_INSET : 0;
|
|
1995
2119
|
const plain = listStyle === 'plain';
|
|
@@ -2000,7 +2124,13 @@ export class LayoutPass {
|
|
|
2000
2124
|
const cardWidth = Math.max(0, layoutWidth - 2 * inset);
|
|
2001
2125
|
const contentX = inset + LIST_CELL_PADDING;
|
|
2002
2126
|
const contentWidth = Math.max(0, layoutWidth - 2 * (inset + LIST_CELL_PADDING));
|
|
2127
|
+
// iOS 26: headers in the headline size (semibold), footers in footnote.
|
|
2003
2128
|
const headerFont = makeFont(
|
|
2129
|
+
'system',
|
|
2130
|
+
textStyleSize('headline', this.env.dynamicTypeSize),
|
|
2131
|
+
600,
|
|
2132
|
+
);
|
|
2133
|
+
const footerFont = makeFont(
|
|
2004
2134
|
'system',
|
|
2005
2135
|
textStyleSize('footnote', this.env.dynamicTypeSize),
|
|
2006
2136
|
400,
|
|
@@ -2026,22 +2156,24 @@ export class LayoutPass {
|
|
|
2026
2156
|
else groupsSource.push({ section: null, rows: [child], headerView: null, footerView: null });
|
|
2027
2157
|
}
|
|
2028
2158
|
}
|
|
2029
|
-
// View headers and footers: the content width, the
|
|
2030
|
-
|
|
2031
|
-
const
|
|
2159
|
+
// View headers and footers: the content width, the header's and the
|
|
2160
|
+
// footer's font.
|
|
2161
|
+
const headerCtx: Context = { ...rowCtx, listRow: false, stackAxis: null, font: headerFont };
|
|
2162
|
+
const footerCtx: Context = { ...headerCtx, font: footerFont };
|
|
2163
|
+
const supplement = (view: LayoutNode | null, y: number, ctx: Context): ListSupplementPlan | null => {
|
|
2032
2164
|
if (!view) return null;
|
|
2033
2165
|
const proposal: Proposal = { width: contentWidth, height: null };
|
|
2034
|
-
const size = this.measure(view,
|
|
2035
|
-
return { node: view, ctx
|
|
2166
|
+
const size = this.measure(view, ctx, proposal);
|
|
2167
|
+
return { node: view, ctx, proposal, rect: { x: contentX, y, width: size.width, height: size.height } };
|
|
2036
2168
|
};
|
|
2037
2169
|
|
|
2038
2170
|
const groups: ListGroupPlan[] = [];
|
|
2039
2171
|
let y = listTop;
|
|
2040
|
-
const makeText = (text: string): { layout: TextLayout; size: Size } => {
|
|
2041
|
-
const wrapped = wrapText(text
|
|
2172
|
+
const makeText = (text: string, font: ResolvedFont): { layout: TextLayout; size: Size } => {
|
|
2173
|
+
const wrapped = wrapText(text, font, contentWidth, null, this.measurer);
|
|
2042
2174
|
return {
|
|
2043
|
-
layout: { font
|
|
2044
|
-
size: { width: wrapped.width, height: wrapped.lines.length *
|
|
2175
|
+
layout: { font, lines: wrapped.lines, alignment: 'leading', truncated: false },
|
|
2176
|
+
size: { width: wrapped.width, height: wrapped.lines.length * font.lineHeight },
|
|
2045
2177
|
};
|
|
2046
2178
|
};
|
|
2047
2179
|
for (let g = 0; g < groupsSource.length; g++) {
|
|
@@ -2050,14 +2182,18 @@ export class LayoutPass {
|
|
|
2050
2182
|
const top = y;
|
|
2051
2183
|
let header: ListGroupPlan['header'] = null;
|
|
2052
2184
|
let footer: ListGroupPlan['footer'] = null;
|
|
2053
|
-
|
|
2054
|
-
|
|
2185
|
+
// A header sits in the gap above its card (from LIST_HEADER_LIFT
|
|
2186
|
+
// into it); the card then starts LIST_HEADER_GAP below the header.
|
|
2187
|
+
const headerLift = plain ? 0 : LIST_HEADER_LIFT;
|
|
2188
|
+
const headerView = supplement(source.headerView, y - headerLift, headerCtx);
|
|
2189
|
+
if (headerView && headerView.rect.height > 0) y += headerView.rect.height + LIST_HEADER_GAP - headerLift;
|
|
2055
2190
|
const headerText = source.section ? str(source.section.props['header']) : null;
|
|
2056
2191
|
const footerText = source.section ? str(source.section.props['footer']) : null;
|
|
2057
2192
|
if (headerText !== null && headerText.length > 0) {
|
|
2058
|
-
const { layout, size } = makeText(headerText);
|
|
2059
|
-
|
|
2060
|
-
y
|
|
2193
|
+
const { layout, size } = makeText(headerText, headerFont);
|
|
2194
|
+
const headerY = headerView && headerView.rect.height > 0 ? y : y - headerLift;
|
|
2195
|
+
header = { rect: { x: contentX, y: headerY, width: contentWidth, height: size.height }, layout };
|
|
2196
|
+
y = headerY + size.height + LIST_HEADER_GAP;
|
|
2061
2197
|
}
|
|
2062
2198
|
const cardTop = y;
|
|
2063
2199
|
const rows: ListRowPlan[] = [];
|
|
@@ -2068,12 +2204,14 @@ export class LayoutPass {
|
|
|
2068
2204
|
rows.push({ node: rowNode, proposal: { width: cardWidth, height: 0 }, contentSize: ZERO, row: rect, content: rect, badge: null, edit: null });
|
|
2069
2205
|
continue;
|
|
2070
2206
|
}
|
|
2071
|
-
// A picker already reports its row height (44 for
|
|
2072
|
-
// option inline); other rows
|
|
2207
|
+
// A menu or inline picker already reports its row height (44 for
|
|
2208
|
+
// a menu, 44 per option inline); other rows (a segmented picker
|
|
2209
|
+
// too) get the 13px vertical padding. Phase 6
|
|
2073
2210
|
// `listRowInsets` replaces all four insets; a `badge` reserves its
|
|
2074
2211
|
// width (plus a gap) at the trailing edge.
|
|
2075
2212
|
const hints = rowHintsOf(rowNode);
|
|
2076
|
-
const
|
|
2213
|
+
const unwrappedRow = unwrapStyled(rowNode);
|
|
2214
|
+
const defaultVPad = unwrappedRow.kind === 'picker' && str(unwrappedRow.props['style']) !== 'segmented' ? 0 : LIST_ROW_VPAD;
|
|
2077
2215
|
const insets: EdgeInsets = hints.insets ?? {
|
|
2078
2216
|
top: defaultVPad,
|
|
2079
2217
|
leading: LIST_CELL_PADDING,
|
|
@@ -2116,11 +2254,11 @@ export class LayoutPass {
|
|
|
2116
2254
|
const card: Rect = { x: cardX, y: cardTop, width: cardWidth, height: y - cardTop };
|
|
2117
2255
|
if (footerText !== null && footerText.length > 0) {
|
|
2118
2256
|
y += LIST_FOOTER_GAP;
|
|
2119
|
-
const { layout, size } = makeText(footerText);
|
|
2257
|
+
const { layout, size } = makeText(footerText, footerFont);
|
|
2120
2258
|
footer = { rect: { x: contentX, y, width: contentWidth, height: size.height }, layout };
|
|
2121
2259
|
y += size.height;
|
|
2122
2260
|
}
|
|
2123
|
-
let footerView = supplement(source.footerView, y + LIST_FOOTER_GAP);
|
|
2261
|
+
let footerView = supplement(source.footerView, y + LIST_FOOTER_GAP, footerCtx);
|
|
2124
2262
|
if (footerView && footerView.rect.height > 0) y += LIST_FOOTER_GAP + footerView.rect.height;
|
|
2125
2263
|
else if (footerView) footerView = { ...footerView, rect: { ...footerView.rect, y } };
|
|
2126
2264
|
groups.push({
|
|
@@ -2265,13 +2403,15 @@ export class LayoutPass {
|
|
|
2265
2403
|
this.place(label, cctx, p, x, y);
|
|
2266
2404
|
return;
|
|
2267
2405
|
}
|
|
2406
|
+
const metrics = this.borderedMetrics(node);
|
|
2407
|
+
const labelCtx = this.borderedLabelContext(node, ctx);
|
|
2268
2408
|
const inner: Proposal = {
|
|
2269
|
-
width: shrink(p.width, 2 *
|
|
2270
|
-
height: shrink(p.height, 2 *
|
|
2409
|
+
width: shrink(p.width, 2 * metrics.padH),
|
|
2410
|
+
height: shrink(p.height, 2 * metrics.padV),
|
|
2271
2411
|
};
|
|
2272
|
-
const labelSize = this.measure(label,
|
|
2412
|
+
const labelSize = this.measure(label, labelCtx, inner);
|
|
2273
2413
|
const { dx, dy } = alignIn(size, labelSize, 'center');
|
|
2274
|
-
this.place(label,
|
|
2414
|
+
this.place(label, labelCtx, inner, x + dx, y + dy);
|
|
2275
2415
|
// Keep the list row chrome (`row`, `badge`) a list set before placing.
|
|
2276
2416
|
const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
|
|
2277
2417
|
this.setChrome(node, {
|
|
@@ -2339,14 +2479,19 @@ export class LayoutPass {
|
|
|
2339
2479
|
// centered on the other axes when smaller than the viewport.
|
|
2340
2480
|
const dx = axes.horizontal ? 0 : safeOffset(size.width, plan.contentSize.width, 0.5);
|
|
2341
2481
|
const dy = axes.vertical ? 0 : safeOffset(size.height, plan.contentSize.height, 0.5);
|
|
2482
|
+
// Under a large title that scrolls with it, the content starts below it.
|
|
2483
|
+
const titleRect = axes.vertical ? this.titleInsets.get(node) : undefined;
|
|
2342
2484
|
const content: Rect = {
|
|
2343
2485
|
x: x + dx,
|
|
2344
|
-
y: y + dy,
|
|
2486
|
+
y: y + dy + (titleRect?.height ?? 0),
|
|
2345
2487
|
width: plan.contentSize.width,
|
|
2346
2488
|
height: plan.contentSize.height,
|
|
2347
2489
|
};
|
|
2348
2490
|
const covered = this.coveredBottom(ctx, x, y, size);
|
|
2349
|
-
|
|
2491
|
+
const scrollChrome: Record<string, Rect> = { content };
|
|
2492
|
+
if (covered) scrollChrome['bottomInset'] = covered;
|
|
2493
|
+
if (titleRect) scrollChrome['largeTitle'] = titleRect;
|
|
2494
|
+
this.setChrome(node, scrollChrome);
|
|
2350
2495
|
if (plan.content) this.place(plan.content, cctx, plan.contentProposal, content.x, content.y);
|
|
2351
2496
|
return;
|
|
2352
2497
|
}
|
|
@@ -2486,9 +2631,16 @@ export class LayoutPass {
|
|
|
2486
2631
|
private placeList(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
|
|
2487
2632
|
const plan = this.listPlan(node, ctx, p);
|
|
2488
2633
|
const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true };
|
|
2489
|
-
if (node.id >= 0) this.contentSizes.set(node.id, plan.contentSize);
|
|
2490
2634
|
const covered = this.coveredBottom(ctx, x, y, plan.size);
|
|
2491
|
-
|
|
2635
|
+
// Under a large title that scrolls with it, the rows start below it.
|
|
2636
|
+
const titleRect = this.titleInsets.get(node);
|
|
2637
|
+
const listChrome: Record<string, Rect> = {};
|
|
2638
|
+
if (covered) listChrome['bottomInset'] = covered;
|
|
2639
|
+
if (titleRect) listChrome['largeTitle'] = titleRect;
|
|
2640
|
+
if (Object.keys(listChrome).length > 0) this.setChrome(node, listChrome);
|
|
2641
|
+
const inset = titleRect?.height ?? 0;
|
|
2642
|
+
if (node.id >= 0) this.contentSizes.set(node.id, { width: plan.contentSize.width, height: plan.contentSize.height + inset });
|
|
2643
|
+
y += inset;
|
|
2492
2644
|
const offset = (r: Rect): Rect => ({ x: x + r.x, y: y + r.y, width: r.width, height: r.height });
|
|
2493
2645
|
for (const group of plan.groups) {
|
|
2494
2646
|
if (group.section) {
|
|
@@ -2538,40 +2690,63 @@ export class LayoutPass {
|
|
|
2538
2690
|
: undefined;
|
|
2539
2691
|
this.setChrome(row.node, own ? { ...own, ...chrome } : chrome);
|
|
2540
2692
|
this.place(row.node, rowCtx, row.proposal, rowX, rowY);
|
|
2693
|
+
// iOS 26: the separator under a row starts where its first text
|
|
2694
|
+
// starts (after a Label's icon), else at the row's content.
|
|
2695
|
+
const placed = row.node.id >= 0 ? this.chrome.get(row.node.id) : undefined;
|
|
2696
|
+
if (placed) {
|
|
2697
|
+
const textX = this.firstTextX(row.node);
|
|
2698
|
+
const leading = (textX ?? rowX) - (x + row.row.x);
|
|
2699
|
+
placed['separator'] = { x: Math.max(0, leading), y: 0, width: 0, height: 0 };
|
|
2700
|
+
}
|
|
2541
2701
|
}
|
|
2542
2702
|
}
|
|
2543
2703
|
}
|
|
2544
2704
|
|
|
2705
|
+
/** The x of the first text (in tree order) placed under `node`, if any. */
|
|
2706
|
+
private firstTextX(node: LayoutNode): number | null {
|
|
2707
|
+
if (node.kind === 'text' && node.id >= 0) {
|
|
2708
|
+
const frame = this.frames.get(node.id);
|
|
2709
|
+
if (frame && frame.width > 0) return frame.x;
|
|
2710
|
+
}
|
|
2711
|
+
for (const child of node.children) {
|
|
2712
|
+
const x = this.firstTextX(child);
|
|
2713
|
+
if (x !== null) return x;
|
|
2714
|
+
}
|
|
2715
|
+
return null;
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2545
2718
|
/**
|
|
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
|
-
*
|
|
2719
|
+
* The navigation bar is the status bar plus a 44 row (plus 62 for a large
|
|
2720
|
+
* title); a Phase 6 `searchfield` is a capsule floating above the bottom
|
|
2721
|
+
* (iOS 26); a `bottom` toolbar is a 49 bar plus the bottom safe area; the
|
|
2722
|
+
* content is proposed the rest. Toolbar
|
|
2723
|
+
* items are measured at their ideal size and laid out in iOS 26 glass
|
|
2724
|
+
* capsules (44-tall slots, groups 8 apart) in the 44 row (`leading` after the back button, `trailing` 16 from the
|
|
2551
2725
|
* trailing edge, `principal` centered in place of the title) or spread
|
|
2552
2726
|
* evenly across the bottom bar. The inline title (`chrome.title`) is
|
|
2553
2727
|
* centered and truncated to the room the bar items leave.
|
|
2554
2728
|
*/
|
|
2555
2729
|
private placeNavScreen(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
2556
2730
|
const { safeArea, screen, dynamicTypeSize } = this.env;
|
|
2557
|
-
const statusBar = y <= 0 ? safeArea.top : 0;
|
|
2731
|
+
const statusBar = y <= 0 ? safeArea.top : (cctx.barInset ?? 0);
|
|
2558
2732
|
const displayMode = str(node.props['displayMode']) ?? 'automatic';
|
|
2559
2733
|
const depth = num(node.props['depth']) ?? 0;
|
|
2560
2734
|
const large = displayMode === 'large' || (displayMode === 'automatic' && depth === 0);
|
|
2561
2735
|
const barHeight = statusBar + NAV_BAR_HEIGHT;
|
|
2562
2736
|
const largeHeight = large ? LARGE_TITLE_HEIGHT : 0;
|
|
2563
2737
|
const parts = this.navScreenParts(node);
|
|
2564
|
-
const searchHeight = parts.search ? SEARCH_AREA_HEIGHT : 0;
|
|
2565
2738
|
const reachesBottom = y + size.height >= screen.height - EPSILON;
|
|
2566
2739
|
const bottomToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'bottom');
|
|
2567
2740
|
const bottomBarHeight =
|
|
2568
2741
|
bottomToolbars.length > 0 ? Math.min(size.height, BOTTOM_BAR_HEIGHT + (reachesBottom ? safeArea.bottom : 0)) : 0;
|
|
2569
|
-
const
|
|
2742
|
+
const searchMargin = reachesBottom ? SEARCH_BOTTOM_MARGIN : SEARCH_BOTTOM_MARGIN_INSET;
|
|
2743
|
+
const searchBottom = parts.search ? SEARCH_BOTTOM_HEIGHT + searchMargin : 0;
|
|
2744
|
+
const contentTop = barHeight + largeHeight;
|
|
2570
2745
|
const content: Rect = {
|
|
2571
2746
|
x,
|
|
2572
2747
|
y: y + contentTop,
|
|
2573
2748
|
width: size.width,
|
|
2574
|
-
height: Math.max(0, size.height - contentTop - bottomBarHeight),
|
|
2749
|
+
height: Math.max(0, size.height - contentTop - Math.max(bottomBarHeight, searchBottom)),
|
|
2575
2750
|
};
|
|
2576
2751
|
const chrome: Record<string, Rect> = {
|
|
2577
2752
|
bar: { x, y, width: size.width, height: barHeight },
|
|
@@ -2581,9 +2756,9 @@ export class LayoutPass {
|
|
|
2581
2756
|
if (parts.search) {
|
|
2582
2757
|
chrome['searchField'] = {
|
|
2583
2758
|
x: x + SEARCH_FIELD_MARGIN_H,
|
|
2584
|
-
y: y +
|
|
2759
|
+
y: y + size.height - searchBottom,
|
|
2585
2760
|
width: Math.max(0, size.width - 2 * SEARCH_FIELD_MARGIN_H),
|
|
2586
|
-
height:
|
|
2761
|
+
height: SEARCH_BOTTOM_HEIGHT,
|
|
2587
2762
|
};
|
|
2588
2763
|
}
|
|
2589
2764
|
if (bottomBarHeight > 0) {
|
|
@@ -2600,60 +2775,56 @@ export class LayoutPass {
|
|
|
2600
2775
|
toolbar: LayoutNode;
|
|
2601
2776
|
placement: string;
|
|
2602
2777
|
sizes: Size[];
|
|
2778
|
+
/** Leading and trailing groups: each item's glass slot width. */
|
|
2779
|
+
slots: number[] | null;
|
|
2603
2780
|
total: number;
|
|
2604
2781
|
}
|
|
2605
2782
|
const groups: Group[] = parts.toolbars.map((toolbar) => {
|
|
2606
2783
|
const itemCtx = this.childContext(toolbar, cctx);
|
|
2607
2784
|
const sizes = toolbar.children.map((item) => this.measure(item, itemCtx, { width: null, height: null }));
|
|
2785
|
+
const placement = str(toolbar.props['placement']) ?? 'trailing';
|
|
2786
|
+
if (placement === 'leading' || placement === 'trailing') {
|
|
2787
|
+
const slots = sizes.map((s) => Math.max(NAV_GLASS_SIZE, s.width + 2 * NAV_GLASS_PAD_H));
|
|
2788
|
+
return { toolbar, placement, sizes, slots, total: slots.reduce((sum, w) => sum + w, 0) };
|
|
2789
|
+
}
|
|
2608
2790
|
const total = sizes.reduce((sum, s) => sum + s.width, 0) + Math.max(0, sizes.length - 1) * NAV_ITEM_SPACING;
|
|
2609
|
-
return { toolbar, placement
|
|
2791
|
+
return { toolbar, placement, sizes, slots: null, total };
|
|
2610
2792
|
});
|
|
2611
2793
|
const groupWidth = (placement: string): number =>
|
|
2612
|
-
groups.filter((g) => g.placement === placement).reduce((sum, g) => sum + g.total +
|
|
2794
|
+
groups.filter((g) => g.placement === placement).reduce((sum, g) => sum + g.total + NAV_GROUP_SPACING, 0);
|
|
2613
2795
|
const leadingWidth = groupWidth('leading');
|
|
2614
2796
|
const trailingWidth = groupWidth('trailing');
|
|
2615
2797
|
const principal = groups.some((g) => g.placement === 'principal');
|
|
2616
2798
|
|
|
2617
|
-
// Back button,
|
|
2618
|
-
//
|
|
2619
|
-
|
|
2799
|
+
// Back button, iOS 26: the chevron alone in a glass circle (the previous
|
|
2800
|
+
// title no longer shows).
|
|
2801
|
+
const backLabel = '';
|
|
2620
2802
|
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 };
|
|
2803
|
+
chrome['back'] = { x: x + NAV_ITEM_MARGIN, y: rowY, width: NAV_GLASS_SIZE, height: NAV_GLASS_SIZE };
|
|
2636
2804
|
}
|
|
2637
2805
|
|
|
2638
2806
|
// Place the toolbar groups and remember how much of the row they occupy.
|
|
2639
2807
|
const back = chrome['back'];
|
|
2640
|
-
let leadingCursor = back ? back.x + back.width +
|
|
2808
|
+
let leadingCursor = back ? back.x + back.width + NAV_GROUP_SPACING : x + NAV_ITEM_MARGIN;
|
|
2641
2809
|
let trailingEdge = x + size.width - NAV_ITEM_MARGIN;
|
|
2642
2810
|
const placeGroup = (group: Group, startX: number, top: number, rowHeight: number, spacing: number): void => {
|
|
2643
2811
|
const itemCtx = this.childContext(group.toolbar, cctx);
|
|
2644
2812
|
let cursor = startX;
|
|
2645
2813
|
group.toolbar.children.forEach((item, i) => {
|
|
2646
2814
|
const itemSize = group.sizes[i] as Size;
|
|
2647
|
-
|
|
2648
|
-
|
|
2815
|
+
// In a glass slot the item is centered in it.
|
|
2816
|
+
const slot = group.slots?.[i];
|
|
2817
|
+
const itemX = slot !== undefined ? cursor + (slot - itemSize.width) / 2 : cursor;
|
|
2818
|
+
this.place(item, itemCtx, { width: null, height: null }, itemX, top + (rowHeight - itemSize.height) / 2);
|
|
2819
|
+
cursor += (slot ?? itemSize.width) + spacing;
|
|
2649
2820
|
});
|
|
2650
2821
|
};
|
|
2651
2822
|
for (const group of groups) {
|
|
2652
2823
|
switch (group.placement) {
|
|
2653
2824
|
case 'leading': {
|
|
2654
2825
|
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 +
|
|
2826
|
+
placeGroup(group, leadingCursor, rowY, NAV_BAR_HEIGHT, 0);
|
|
2827
|
+
leadingCursor += group.total + NAV_GROUP_SPACING;
|
|
2657
2828
|
break;
|
|
2658
2829
|
}
|
|
2659
2830
|
case 'principal': {
|
|
@@ -2678,15 +2849,17 @@ export class LayoutPass {
|
|
|
2678
2849
|
default: {
|
|
2679
2850
|
const startX = trailingEdge - group.total;
|
|
2680
2851
|
this.setFrame(group.toolbar, startX, rowY, { width: group.total, height: NAV_BAR_HEIGHT });
|
|
2681
|
-
placeGroup(group, startX, rowY, NAV_BAR_HEIGHT,
|
|
2682
|
-
trailingEdge = startX -
|
|
2852
|
+
placeGroup(group, startX, rowY, NAV_BAR_HEIGHT, 0);
|
|
2853
|
+
trailingEdge = startX - NAV_GROUP_SPACING;
|
|
2683
2854
|
break;
|
|
2684
2855
|
}
|
|
2685
2856
|
}
|
|
2686
2857
|
}
|
|
2687
2858
|
if (parts.search) {
|
|
2859
|
+
// The field is the whole capsule (its own measure is a 36pt field).
|
|
2688
2860
|
const field = chrome['searchField'] as Rect;
|
|
2689
2861
|
this.place(parts.search, cctx, { width: field.width, height: field.height }, field.x, field.y);
|
|
2862
|
+
this.setFrame(parts.search, field.x, field.y, { width: field.width, height: field.height });
|
|
2690
2863
|
}
|
|
2691
2864
|
|
|
2692
2865
|
// The inline title: centered, no wider than 56% of the bar, and clear of
|
|
@@ -2695,9 +2868,16 @@ export class LayoutPass {
|
|
|
2695
2868
|
// takes up to a pixel off a fractional width at a fractional origin, and
|
|
2696
2869
|
// the title truncates with an ellipsis ("Animati…") when its box is any
|
|
2697
2870
|
// narrower than its text.
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2871
|
+
const scroller = parts.content ? unwrapStyled(parts.content) : null;
|
|
2872
|
+
const titleScrolls =
|
|
2873
|
+
large &&
|
|
2874
|
+
scroller !== null &&
|
|
2875
|
+
(scroller.kind === 'list' || (scroller.kind === 'scrollview' && this.scrollAxes(scroller).vertical));
|
|
2876
|
+
// A large title that scrolls away collapses into the inline title, which
|
|
2877
|
+
// the renderer shows once it has (`data-sb-collapsed`).
|
|
2878
|
+
if ((!large || titleScrolls) && !principal) {
|
|
2879
|
+
const leftUsed = Math.max(back ? back.x + back.width - x : 0, leadingWidth > 0 ? leadingCursor - NAV_GROUP_SPACING - x : 0);
|
|
2880
|
+
const rightUsed = trailingWidth > 0 ? x + size.width - (trailingEdge + NAV_GROUP_SPACING) : 0;
|
|
2701
2881
|
const side = Math.max(leftUsed, rightUsed) + 8;
|
|
2702
2882
|
const available = Math.max(0, size.width - 2 * side);
|
|
2703
2883
|
const titleWidth = Math.min(Math.ceil(this.measurer.measureWidth(title, titleFont) - 0.001), Math.floor(size.width * 0.56), Math.floor(available));
|
|
@@ -2731,8 +2911,17 @@ export class LayoutPass {
|
|
|
2731
2911
|
bottomBarHeight === 0 && y + size.height >= screen.height ? Math.max(safeArea.bottom, cctx.bottomBar) : 0;
|
|
2732
2912
|
// Phase 8: `.ignoresSafeArea` content grows up under the bar (and the
|
|
2733
2913
|
// large title / search field), which is transparent over it.
|
|
2734
|
-
const contentCtx: Context = { ...cctx, safeTop: content.y };
|
|
2735
|
-
if (
|
|
2914
|
+
const contentCtx: Context = { ...cctx, safeTop: content.y, barInset: 0 };
|
|
2915
|
+
if (scroller && titleScrolls) {
|
|
2916
|
+
// The scroll view starts under the bar; its content starts under the
|
|
2917
|
+
// large title, which moves with it.
|
|
2918
|
+
const top = content.y - largeHeight;
|
|
2919
|
+
this.titleInsets.set(scroller, chrome['largeTitle'] as Rect);
|
|
2920
|
+
this.place(child, contentCtx, { width: content.width, height: content.height + largeHeight }, content.x, top);
|
|
2921
|
+
return;
|
|
2922
|
+
}
|
|
2923
|
+
if (scroller) this.titleInsets.delete(scroller);
|
|
2924
|
+
if (scroller && (scroller.kind === 'list' || scroller.kind === 'scrollview')) {
|
|
2736
2925
|
this.place(child, contentCtx, { width: content.width, height: content.height }, content.x, content.y);
|
|
2737
2926
|
return;
|
|
2738
2927
|
}
|
|
@@ -2744,25 +2933,38 @@ export class LayoutPass {
|
|
|
2744
2933
|
}
|
|
2745
2934
|
|
|
2746
2935
|
/**
|
|
2747
|
-
* The tab bar
|
|
2748
|
-
*
|
|
2749
|
-
* the
|
|
2750
|
-
*
|
|
2936
|
+
* The tab bar (iOS 26) is a glass capsule floating over the bottom of the
|
|
2937
|
+
* content (TAB_BAR_* above), with one equal-width item per tab; what it
|
|
2938
|
+
* covers (the capsule and the gap under it) is the content's bottom inset.
|
|
2939
|
+
* Every `tab` gets the tab view's frame; only the selected one lays its
|
|
2940
|
+
* content out, the others are reported in `hidden`.
|
|
2751
2941
|
*/
|
|
2752
2942
|
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
|
|
2943
|
+
const { screen } = this.env;
|
|
2944
|
+
const marginBottom = y + size.height >= screen.height - EPSILON ? TAB_BAR_MARGIN_BOTTOM : TAB_BAR_MARGIN_BOTTOM_INSET;
|
|
2945
|
+
const barHeight = Math.min(size.height, TAB_BAR_HEIGHT + marginBottom);
|
|
2946
|
+
const capsuleHeight = Math.min(TAB_BAR_HEIGHT, barHeight);
|
|
2947
|
+
const bar: Rect = {
|
|
2948
|
+
x: x + TAB_BAR_MARGIN_H,
|
|
2949
|
+
y: y + size.height - barHeight,
|
|
2950
|
+
width: Math.max(0, size.width - 2 * TAB_BAR_MARGIN_H),
|
|
2951
|
+
height: capsuleHeight,
|
|
2952
|
+
};
|
|
2757
2953
|
const chrome: Record<string, Rect> = {
|
|
2758
2954
|
bar,
|
|
2759
2955
|
content: { x, y, width: size.width, height: size.height - barHeight },
|
|
2760
2956
|
};
|
|
2761
2957
|
const tabs = node.children;
|
|
2762
2958
|
const n = tabs.length;
|
|
2959
|
+
const inner = Math.max(0, bar.width - 2 * TAB_BAR_ITEM_INSET);
|
|
2763
2960
|
for (let i = 0; i < n; i++) {
|
|
2764
|
-
const itemWidth =
|
|
2765
|
-
chrome[`item${i}`] = {
|
|
2961
|
+
const itemWidth = inner / n;
|
|
2962
|
+
chrome[`item${i}`] = {
|
|
2963
|
+
x: bar.x + TAB_BAR_ITEM_INSET + i * itemWidth,
|
|
2964
|
+
y: bar.y + TAB_BAR_ITEM_INSET,
|
|
2965
|
+
width: itemWidth,
|
|
2966
|
+
height: Math.max(0, capsuleHeight - 2 * TAB_BAR_ITEM_INSET),
|
|
2967
|
+
};
|
|
2766
2968
|
}
|
|
2767
2969
|
this.setChrome(node, chrome);
|
|
2768
2970
|
const selected = readSelected(node, n);
|
|
@@ -3008,9 +3210,9 @@ export class LayoutPass {
|
|
|
3008
3210
|
fill: { x: trackX, y: trackY, width: Math.max(0, thumbCenter - trackX), height: SLIDER_TRACK_HEIGHT },
|
|
3009
3211
|
thumb: {
|
|
3010
3212
|
x: thumbCenter - SLIDER_THUMB / 2,
|
|
3011
|
-
y: rowY + (SLIDER_HEIGHT -
|
|
3213
|
+
y: rowY + (SLIDER_HEIGHT - SLIDER_THUMB_HEIGHT) / 2,
|
|
3012
3214
|
width: SLIDER_THUMB,
|
|
3013
|
-
height:
|
|
3215
|
+
height: SLIDER_THUMB_HEIGHT,
|
|
3014
3216
|
},
|
|
3015
3217
|
});
|
|
3016
3218
|
}
|
|
@@ -3080,9 +3282,10 @@ export class LayoutPass {
|
|
|
3080
3282
|
}
|
|
3081
3283
|
|
|
3082
3284
|
/**
|
|
3083
|
-
* `alert` style: a
|
|
3084
|
-
* (headline)
|
|
3085
|
-
*
|
|
3285
|
+
* `alert` style (iOS 26): a card centered in the safe area with the title
|
|
3286
|
+
* (headline) and the message (subheadline), leading-aligned, and capsule
|
|
3287
|
+
* buttons: two side by side (a `cancel` one first), otherwise stacked; no
|
|
3288
|
+
* buttons → an `ok` rect the renderer draws.
|
|
3086
3289
|
* `dialog` style: an action sheet of 57 rows in a card 8 from the screen
|
|
3087
3290
|
* edges, above a separate card for the `cancel` button, 8 above the bottom
|
|
3088
3291
|
* safe area; the title and message are a footnote caption on top. Button
|
|
@@ -3098,40 +3301,45 @@ export class LayoutPass {
|
|
|
3098
3301
|
const texts: Record<string, TextLayout> = {};
|
|
3099
3302
|
const roleOf = (child: LayoutNode): string | null => str(unwrapStyled(child).props['role']);
|
|
3100
3303
|
|
|
3101
|
-
const
|
|
3304
|
+
const block = (text: string, font: ResolvedFont, width: number, alignment: 'center' | 'leading'): { layout: TextLayout; height: number } => {
|
|
3102
3305
|
const wrapped = wrapText(text, font, width, null, this.measurer);
|
|
3103
3306
|
return {
|
|
3104
|
-
layout: { font, lines: wrapped.lines, alignment
|
|
3307
|
+
layout: { font, lines: wrapped.lines, alignment, truncated: wrapped.truncated ?? false },
|
|
3105
3308
|
height: wrapped.lines.length * font.lineHeight,
|
|
3106
3309
|
};
|
|
3107
3310
|
};
|
|
3311
|
+
const centered = (text: string, font: ResolvedFont, width: number) => block(text, font, width, 'center');
|
|
3108
3312
|
|
|
3109
3313
|
if (style === 'alert') {
|
|
3110
3314
|
const cardWidth = Math.min(ALERT_WIDTH, size.width - 2 * DIALOG_MARGIN);
|
|
3111
3315
|
const textWidth = cardWidth - 2 * ALERT_PAD_H;
|
|
3112
3316
|
const titleFont = resolveFont({ textStyle: 'headline' }, ctx.font, this.env);
|
|
3113
|
-
const messageFont = resolveFont({ textStyle: '
|
|
3317
|
+
const messageFont = resolveFont({ textStyle: 'subheadline' }, ctx.font, this.env);
|
|
3114
3318
|
// Heights first (the card is centered), then the rects.
|
|
3115
3319
|
let h = ALERT_PAD_TOP;
|
|
3116
3320
|
let titleBlock: { layout: TextLayout; height: number; top: number } | null = null;
|
|
3117
3321
|
let messageBlock: { layout: TextLayout; height: number; top: number } | null = null;
|
|
3118
3322
|
if (title.length > 0) {
|
|
3119
|
-
const
|
|
3120
|
-
titleBlock = { ...
|
|
3121
|
-
h +=
|
|
3323
|
+
const b = block(title, titleFont, textWidth, 'leading');
|
|
3324
|
+
titleBlock = { ...b, top: h };
|
|
3325
|
+
h += b.height;
|
|
3122
3326
|
}
|
|
3123
3327
|
if (message.length > 0) {
|
|
3124
3328
|
if (titleBlock) h += ALERT_TITLE_GAP;
|
|
3125
|
-
const
|
|
3126
|
-
messageBlock = { ...
|
|
3127
|
-
h +=
|
|
3329
|
+
const b = block(message, messageFont, textWidth, 'leading');
|
|
3330
|
+
messageBlock = { ...b, top: h };
|
|
3331
|
+
h += b.height;
|
|
3128
3332
|
}
|
|
3129
|
-
h +=
|
|
3333
|
+
h += ALERT_BUTTONS_GAP;
|
|
3130
3334
|
const headerHeight = h;
|
|
3131
3335
|
const buttons = node.children;
|
|
3132
3336
|
const rows = buttons.length === 0 ? 1 : buttons.length === 2 ? 1 : buttons.length;
|
|
3133
|
-
h += rows * ALERT_BUTTON_HEIGHT;
|
|
3134
|
-
|
|
3337
|
+
h += rows * ALERT_BUTTON_HEIGHT + (rows - 1) * ALERT_BUTTON_SPACING + ALERT_PAD_BOTTOM;
|
|
3338
|
+
// Centered in the safe area, as iOS does.
|
|
3339
|
+
const { safeArea: inset } = this.env;
|
|
3340
|
+
const top = y + (y <= 0 ? inset.top : 0);
|
|
3341
|
+
const bottom = y + size.height - (y + size.height >= this.env.screen.height - EPSILON ? inset.bottom : 0);
|
|
3342
|
+
const card: Rect = { x: x + (size.width - cardWidth) / 2, y: top + (bottom - top - h) / 2, width: cardWidth, height: h };
|
|
3135
3343
|
chrome['card'] = card;
|
|
3136
3344
|
if (titleBlock) {
|
|
3137
3345
|
chrome['title'] = { x: card.x + ALERT_PAD_H, y: card.y + titleBlock.top, width: textWidth, height: titleBlock.height };
|
|
@@ -3142,18 +3350,28 @@ export class LayoutPass {
|
|
|
3142
3350
|
texts['message'] = messageBlock.layout;
|
|
3143
3351
|
}
|
|
3144
3352
|
const buttonsTop = card.y + headerHeight;
|
|
3353
|
+
const buttonsX = card.x + ALERT_BUTTON_INSET;
|
|
3354
|
+
const buttonsWidth = Math.max(0, cardWidth - 2 * ALERT_BUTTON_INSET);
|
|
3145
3355
|
if (buttons.length === 0) {
|
|
3146
|
-
chrome['ok'] = { x:
|
|
3356
|
+
chrome['ok'] = { x: buttonsX, y: buttonsTop, width: buttonsWidth, height: ALERT_BUTTON_HEIGHT };
|
|
3147
3357
|
} else if (buttons.length === 2) {
|
|
3148
|
-
|
|
3149
|
-
buttons
|
|
3150
|
-
|
|
3358
|
+
// A `cancel` button goes first (leading), as iOS orders them.
|
|
3359
|
+
const ordered = roleOf(buttons[1] as LayoutNode) === 'cancel' ? [buttons[1], buttons[0]] : [buttons[0], buttons[1]];
|
|
3360
|
+
const half = (buttonsWidth - ALERT_BUTTON_SPACING) / 2;
|
|
3361
|
+
ordered.forEach((button, slot) => {
|
|
3362
|
+
const i = buttons.indexOf(button as LayoutNode);
|
|
3363
|
+
const rect: Rect = { x: buttonsX + slot * (half + ALERT_BUTTON_SPACING), y: buttonsTop, width: half, height: ALERT_BUTTON_HEIGHT };
|
|
3151
3364
|
chrome[`button${i}`] = rect;
|
|
3152
|
-
this.placeAlertButton(button, cctx, rect);
|
|
3365
|
+
this.placeAlertButton(button as LayoutNode, cctx, rect);
|
|
3153
3366
|
});
|
|
3154
3367
|
} else {
|
|
3155
3368
|
buttons.forEach((button, i) => {
|
|
3156
|
-
const rect: Rect = {
|
|
3369
|
+
const rect: Rect = {
|
|
3370
|
+
x: buttonsX,
|
|
3371
|
+
y: buttonsTop + i * (ALERT_BUTTON_HEIGHT + ALERT_BUTTON_SPACING),
|
|
3372
|
+
width: buttonsWidth,
|
|
3373
|
+
height: ALERT_BUTTON_HEIGHT,
|
|
3374
|
+
};
|
|
3157
3375
|
chrome[`button${i}`] = rect;
|
|
3158
3376
|
this.placeAlertButton(button, cctx, rect);
|
|
3159
3377
|
});
|
|
@@ -3176,7 +3394,7 @@ export class LayoutPass {
|
|
|
3176
3394
|
h += block.height;
|
|
3177
3395
|
}
|
|
3178
3396
|
if (message.length > 0) {
|
|
3179
|
-
if (titleBlock) h +=
|
|
3397
|
+
if (titleBlock) h += DIALOG_TITLE_GAP;
|
|
3180
3398
|
const block = centered(message, messageFont, textWidth);
|
|
3181
3399
|
messageBlock = { ...block, top: h };
|
|
3182
3400
|
h += block.height;
|
|
@@ -3194,7 +3412,7 @@ export class LayoutPass {
|
|
|
3194
3412
|
const cancelCard: Rect = { x: cardX, y: bottom - DIALOG_BUTTON_HEIGHT, width: cardWidth, height: DIALOG_BUTTON_HEIGHT };
|
|
3195
3413
|
chrome['cancelCard'] = cancelCard;
|
|
3196
3414
|
chrome[`button${node.children.indexOf(cancel)}`] = cancelCard;
|
|
3197
|
-
this.placeAlertButton(cancel, buttonCtx, cancelCard);
|
|
3415
|
+
this.placeAlertButton(cancel, buttonCtx, cancelCard, true);
|
|
3198
3416
|
cardBottom = cancelCard.y - DIALOG_CARD_GAP;
|
|
3199
3417
|
}
|
|
3200
3418
|
const card: Rect = { x: cardX, y: cardBottom - h, width: cardWidth, height: h };
|
|
@@ -3214,7 +3432,7 @@ export class LayoutPass {
|
|
|
3214
3432
|
others.forEach((button, i) => {
|
|
3215
3433
|
const rect: Rect = { x: card.x, y: buttonsTop + i * DIALOG_BUTTON_HEIGHT, width: cardWidth, height: DIALOG_BUTTON_HEIGHT };
|
|
3216
3434
|
chrome[`button${node.children.indexOf(button)}`] = rect;
|
|
3217
|
-
this.placeAlertButton(button, buttonCtx, rect);
|
|
3435
|
+
this.placeAlertButton(button, buttonCtx, rect, true);
|
|
3218
3436
|
});
|
|
3219
3437
|
}
|
|
3220
3438
|
this.setChrome(node, chrome);
|
|
@@ -3222,10 +3440,11 @@ export class LayoutPass {
|
|
|
3222
3440
|
}
|
|
3223
3441
|
|
|
3224
3442
|
/** 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
|
-
|
|
3443
|
+
private placeAlertButton(child: LayoutNode, ctx: Context, rect: Rect, boldCancel = false): void {
|
|
3444
|
+
const proposal: Proposal = { width: Math.max(0, rect.width - 2 * DIALOG_LABEL_INSET), height: rect.height };
|
|
3445
|
+
// An action sheet's `cancel` button is bold: measure (and so draw) its
|
|
3446
|
+
// label semibold. iOS 26 alerts draw it regular.
|
|
3447
|
+
const cancel = boldCancel && str(unwrapStyled(child).props['role']) === 'cancel';
|
|
3229
3448
|
const inherited = this.childContext(child, ctx);
|
|
3230
3449
|
const cctx: Context = {
|
|
3231
3450
|
...inherited,
|
|
@@ -3250,9 +3469,16 @@ export class LayoutPass {
|
|
|
3250
3469
|
}
|
|
3251
3470
|
|
|
3252
3471
|
private placeSheet(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
3253
|
-
//
|
|
3472
|
+
// A grabber gets its row; without one the content starts a little below
|
|
3473
|
+
// the sheet's top (iOS 26), except in a `full` one (Phase 8,
|
|
3474
|
+
// `.fullScreenCover`). A navigation stack fills the sheet from its top
|
|
3475
|
+
// instead, its bar taking that room (`barInset`), so its background
|
|
3476
|
+
// reaches the sheet's edge; a list or scroll view fills it below the
|
|
3477
|
+
// grabber row.
|
|
3478
|
+
const grabber = this.sheetHasGrabber(node);
|
|
3479
|
+
const floats = this.sheetFloats(node);
|
|
3254
3480
|
const full = this.sheetIsFull(node);
|
|
3255
|
-
const grabberRow = full ? 0 :
|
|
3481
|
+
const grabberRow = grabber ? SHEET_GRABBER_ROW : full ? 0 : SHEET_CONTENT_TOP;
|
|
3256
3482
|
const content: Rect = {
|
|
3257
3483
|
x,
|
|
3258
3484
|
y: y + grabberRow,
|
|
@@ -3260,7 +3486,8 @@ export class LayoutPass {
|
|
|
3260
3486
|
height: Math.max(0, size.height - grabberRow),
|
|
3261
3487
|
};
|
|
3262
3488
|
const chrome: Record<string, Rect> = { content };
|
|
3263
|
-
if (
|
|
3489
|
+
if (floats) chrome['floating'] = { x, y, width: size.width, height: size.height };
|
|
3490
|
+
if (grabber) {
|
|
3264
3491
|
chrome['grabber'] = {
|
|
3265
3492
|
x: x + (size.width - SHEET_GRABBER.width) / 2,
|
|
3266
3493
|
y: y + SHEET_GRABBER_TOP,
|
|
@@ -3273,8 +3500,14 @@ export class LayoutPass {
|
|
|
3273
3500
|
if (!child) return;
|
|
3274
3501
|
const proposal: Proposal = { width: content.width, height: content.height };
|
|
3275
3502
|
const kind = unwrapStyled(child).kind;
|
|
3276
|
-
if (kind === '
|
|
3277
|
-
this.place(child, cctx,
|
|
3503
|
+
if (kind === 'navstack') {
|
|
3504
|
+
this.place(child, { ...cctx, barInset: grabberRow }, { width: size.width, height: size.height }, x, y);
|
|
3505
|
+
return;
|
|
3506
|
+
}
|
|
3507
|
+
if (kind === 'list' || kind === 'scrollview') {
|
|
3508
|
+
// A scrolling view fills the sheet below the grabber row, if any.
|
|
3509
|
+
const top = grabber ? SHEET_GRABBER_ROW : 0;
|
|
3510
|
+
this.place(child, cctx, { width: size.width, height: size.height - top }, x, y + top);
|
|
3278
3511
|
return;
|
|
3279
3512
|
}
|
|
3280
3513
|
const childSize = this.measure(child, cctx, proposal);
|
|
@@ -3642,6 +3875,15 @@ export class LayoutPass {
|
|
|
3642
3875
|
return { size: square(GAUGE_RING_SIZE), label, current, labelSize: ZERO, currentSize: ZERO, labelRow: 0, labelCtx, ring: { center, centerSize } };
|
|
3643
3876
|
}
|
|
3644
3877
|
const proposal: Proposal = { width, height: null };
|
|
3878
|
+
if (style === 'progress') {
|
|
3879
|
+
// A ProgressView: the label in the context font above the bar, the
|
|
3880
|
+
// current value label (caption) under it.
|
|
3881
|
+
const labelSize = label ? this.measure(label, cctx, proposal) : ZERO;
|
|
3882
|
+
const currentSize = current ? this.measure(current, labelCtx, proposal) : ZERO;
|
|
3883
|
+
const labelRow = label ? labelSize.height + PROGRESS_LABEL_GAP : 0;
|
|
3884
|
+
const height = labelRow + GAUGE_BAR_HEIGHT + (current ? PROGRESS_VALUE_GAP + currentSize.height : 0);
|
|
3885
|
+
return { size: { width, height }, label, current, labelSize, currentSize, labelRow, labelCtx, labelOwnCtx: cctx };
|
|
3886
|
+
}
|
|
3645
3887
|
const labelSize = label ? this.measure(label, labelCtx, proposal) : ZERO;
|
|
3646
3888
|
const currentSize = current ? this.measure(current, labelCtx, proposal) : ZERO;
|
|
3647
3889
|
const labelRow = label || current ? Math.max(labelSize.height, currentSize.height) : 0;
|
|
@@ -3665,6 +3907,17 @@ export class LayoutPass {
|
|
|
3665
3907
|
return;
|
|
3666
3908
|
}
|
|
3667
3909
|
const proposal: Proposal = { width: size.width, height: null };
|
|
3910
|
+
if (str(node.props['style']) === 'progress') {
|
|
3911
|
+
if (plan.label) this.place(plan.label, plan.labelOwnCtx ?? plan.labelCtx, proposal, x, y);
|
|
3912
|
+
const barY = y + plan.labelRow;
|
|
3913
|
+
if (plan.current) this.place(plan.current, plan.labelCtx, proposal, x, barY + GAUGE_BAR_HEIGHT + PROGRESS_VALUE_GAP);
|
|
3914
|
+
const fraction = Math.min(1, Math.max(0, num(node.props['value']) ?? 0));
|
|
3915
|
+
this.setChrome(node, {
|
|
3916
|
+
track: { x, y: barY, width: size.width, height: GAUGE_BAR_HEIGHT },
|
|
3917
|
+
fill: { x, y: barY, width: size.width * fraction, height: GAUGE_BAR_HEIGHT },
|
|
3918
|
+
});
|
|
3919
|
+
return;
|
|
3920
|
+
}
|
|
3668
3921
|
if (plan.label) {
|
|
3669
3922
|
this.place(plan.label, plan.labelCtx, proposal, x, y + (plan.labelRow - plan.labelSize.height) / 2);
|
|
3670
3923
|
}
|