@swiftbrowser/web 0.18.3 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/index.html +11 -7
- package/package.json +1 -1
- package/src/appFiles.ts +12 -3
- package/src/audioEngine.ts +1467 -0
- package/src/chartDom.ts +1 -1
- package/src/deviceFrame.ts +188 -0
- package/src/dragDrop.ts +66 -10
- package/src/files.ts +68 -13
- package/src/hostRequests.ts +53 -3
- package/src/interaction.ts +6 -1
- package/src/languageModel.ts +309 -0
- package/src/layout/engine.ts +128 -48
- package/src/layoutDom.ts +43 -3
- package/src/main.ts +12 -1
- package/src/page.css +65 -0
- package/src/pinnedViews.ts +38 -1
- package/src/protocol.ts +70 -5
- package/src/renderer.ts +299 -53
- package/src/scrollTransition.ts +2 -1
- package/src/styles.css +374 -42
- package/src/styles.ts +13 -15
- package/src/symbols.ts +6 -0
- package/src/viewRegistry.ts +58 -0
package/src/layout/engine.ts
CHANGED
|
@@ -413,6 +413,15 @@ const IMAGE_SCALES: Readonly<Record<string, number>> = { small: 0.8, medium: 1,
|
|
|
413
413
|
const NAVSPLIT_SIDEBAR_WIDTH = 320;
|
|
414
414
|
/** From this root width a window is regular (an iPad, a desktop browser), as `@swiftbrowser/react`'s size class. */
|
|
415
415
|
const REGULAR_WIDTH = 700;
|
|
416
|
+
/** Whether a screen is the regular size class (an iPad, a desktop window): a `sidebar` list then draws as one. */
|
|
417
|
+
export const isRegularWidth = (screen: { width: number }): boolean => screen.width >= REGULAR_WIDTH;
|
|
418
|
+
/**
|
|
419
|
+
* From this root height a window is the regular vertical size class: below
|
|
420
|
+
* it, as an iPhone on its side (at most 440 tall, less under Safari's bars),
|
|
421
|
+
* it is compact, and UIKit shows no large titles. An iPad is regular either
|
|
422
|
+
* way (744 tall at the least).
|
|
423
|
+
*/
|
|
424
|
+
const REGULAR_HEIGHT = 500;
|
|
416
425
|
/** The sidebar never takes more than this share of the split view's width. */
|
|
417
426
|
const NAVSPLIT_SIDEBAR_MAX_FRACTION = 0.4;
|
|
418
427
|
const NAVSPLIT_SEPARATOR_WIDTH = 1;
|
|
@@ -1139,14 +1148,12 @@ interface TextPlan {
|
|
|
1139
1148
|
// Phase 8 controls.
|
|
1140
1149
|
interface SliderPlan {
|
|
1141
1150
|
size: Size;
|
|
1151
|
+
/** The `label` child, hidden (iOS draws no slider label). */
|
|
1142
1152
|
label: LayoutNode | null;
|
|
1143
1153
|
min: LayoutNode | null;
|
|
1144
1154
|
max: LayoutNode | null;
|
|
1145
|
-
labelSize: Size;
|
|
1146
1155
|
minSize: Size;
|
|
1147
1156
|
maxSize: Size;
|
|
1148
|
-
/** Height of the label row above the 44 track row (0 without a label). */
|
|
1149
|
-
labelRow: number;
|
|
1150
1157
|
cctx: Context;
|
|
1151
1158
|
}
|
|
1152
1159
|
|
|
@@ -1638,9 +1645,17 @@ export class LayoutPass {
|
|
|
1638
1645
|
const imageScale = str(style['imageScale']);
|
|
1639
1646
|
if (imageScale !== null) next = { ...next, imageScale: IMAGE_SCALES[imageScale] ?? 1 };
|
|
1640
1647
|
if (typeof style['scrollDisabled'] === 'boolean') next = { ...next, scrollDisabled: style['scrollDisabled'] };
|
|
1648
|
+
// `.ignoresSafeArea` on the bottom: the content inside has no bottom
|
|
1649
|
+
// safe area, so a scroll view in it reaching the screen's bottom is
|
|
1650
|
+
// not inset by the bar there (a photo viewer's, under a bottom toolbar).
|
|
1651
|
+
const ignored = style['ignoresSafeArea'];
|
|
1652
|
+
if (ignored === 'all' || (Array.isArray(ignored) && ignored.includes('bottom'))) {
|
|
1653
|
+
if (next.bottomBar !== 0 || next.bottomEdge !== undefined) next = { ...next, bottomBar: 0, bottomEdge: undefined };
|
|
1654
|
+
}
|
|
1641
1655
|
return next;
|
|
1642
1656
|
}
|
|
1643
1657
|
case 'vstack':
|
|
1658
|
+
case 'disclosuregroup':
|
|
1644
1659
|
return { ...ctx, stackAxis: 'vertical', listRow: false };
|
|
1645
1660
|
case 'hstack':
|
|
1646
1661
|
return { ...ctx, stackAxis: 'horizontal', listRow: false };
|
|
@@ -1744,6 +1759,7 @@ export class LayoutPass {
|
|
|
1744
1759
|
if (ctx.stackAxis === 'horizontal') return { width: 1, height: finite(p.height) ? p.height : 0 };
|
|
1745
1760
|
return { width: finite(p.width) ? p.width : 0, height: 1 };
|
|
1746
1761
|
case 'vstack':
|
|
1762
|
+
case 'disclosuregroup':
|
|
1747
1763
|
return this.stackPlan(node, ctx, p, 'vertical').size;
|
|
1748
1764
|
case 'hstack': {
|
|
1749
1765
|
const label = ctx.labelColumn ? labelColumnOf(node) : null;
|
|
@@ -1803,6 +1819,7 @@ export class LayoutPass {
|
|
|
1803
1819
|
return this.pickerPlan(node, ctx, p).size;
|
|
1804
1820
|
case 'host':
|
|
1805
1821
|
return this.measureHost(node, p);
|
|
1822
|
+
case 'registered':
|
|
1806
1823
|
case 'geometry':
|
|
1807
1824
|
// GeometryReader: the proposal, 10 on unconstrained axes.
|
|
1808
1825
|
return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
|
|
@@ -2319,10 +2336,19 @@ export class LayoutPass {
|
|
|
2319
2336
|
private stackPlan(node: LayoutNode, ctx: Context, p: Proposal, axis: Axis): StackPlan {
|
|
2320
2337
|
return this.plan(node, ctx, p, () => {
|
|
2321
2338
|
const cctx = this.childContext(node, ctx);
|
|
2322
|
-
return this.layoutStack(node
|
|
2339
|
+
return this.layoutStack(this.stackChildren(node), cctx, p, axis, this.stackGaps(node, cctx, axis));
|
|
2323
2340
|
});
|
|
2324
2341
|
}
|
|
2325
2342
|
|
|
2343
|
+
/**
|
|
2344
|
+
* The children a stack lays out: all of them, but a collapsed
|
|
2345
|
+
* `disclosuregroup`'s label alone (the first child; its content is in
|
|
2346
|
+
* the tree whatever the state, as the native renderer wants it).
|
|
2347
|
+
*/
|
|
2348
|
+
private stackChildren(node: LayoutNode): readonly LayoutNode[] {
|
|
2349
|
+
return node.kind === 'disclosuregroup' && node.props['expanded'] !== true ? node.children.slice(0, 1) : node.children;
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2326
2352
|
/**
|
|
2327
2353
|
* The space between a stack's children: its `spacing`, else the system's.
|
|
2328
2354
|
* A horizontal stack's default is 8; in a vertical one iOS 26 spaces text
|
|
@@ -2334,14 +2360,16 @@ export class LayoutPass {
|
|
|
2334
2360
|
* 0.6em + 1.13 above it (caption 8.33, body 11.33); other views are 8 apart.
|
|
2335
2361
|
*/
|
|
2336
2362
|
private stackGaps(node: LayoutNode, cctx: Context, axis: Axis): number[] {
|
|
2337
|
-
const
|
|
2363
|
+
const children = this.stackChildren(node);
|
|
2364
|
+
const n = children.length;
|
|
2338
2365
|
if (n < 2) return [];
|
|
2339
|
-
|
|
2366
|
+
// A disclosure group's content sits 8 under its label, as SwiftUI's does.
|
|
2367
|
+
const spacing = num(node.props['spacing']) ?? (node.kind === 'disclosuregroup' ? DEFAULT_SPACING : null);
|
|
2340
2368
|
if (spacing !== null || axis === 'horizontal') return new Array<number>(n - 1).fill(spacing ?? DEFAULT_SPACING);
|
|
2341
2369
|
const gaps: number[] = [];
|
|
2342
2370
|
for (let i = 0; i < n - 1; i++) {
|
|
2343
|
-
const above = this.textEdge(
|
|
2344
|
-
const below = this.textEdge(
|
|
2371
|
+
const above = this.textEdge(children[i] as LayoutNode, cctx, 'bottom');
|
|
2372
|
+
const below = this.textEdge(children[i + 1] as LayoutNode, cctx, 'top');
|
|
2345
2373
|
let gap = DEFAULT_SPACING;
|
|
2346
2374
|
if (above && below) gap = below.leading - below.lineHeight + 1 / 3;
|
|
2347
2375
|
else if (below) gap = 0.409 * below.size - 1.24;
|
|
@@ -2372,8 +2400,10 @@ export class LayoutPass {
|
|
|
2372
2400
|
const content = node.children[0];
|
|
2373
2401
|
return content ? this.textEdge(content, this.childContext(node, ctx), edge, depth + 1) : null;
|
|
2374
2402
|
}
|
|
2375
|
-
case 'vstack':
|
|
2376
|
-
|
|
2403
|
+
case 'vstack':
|
|
2404
|
+
case 'disclosuregroup': {
|
|
2405
|
+
const children = this.stackChildren(node);
|
|
2406
|
+
const child = edge === 'top' ? children[0] : children[children.length - 1];
|
|
2377
2407
|
return child && !isSpacer(child) ? this.textEdge(child, this.childContext(node, ctx), edge, depth + 1) : null;
|
|
2378
2408
|
}
|
|
2379
2409
|
case 'hstack': {
|
|
@@ -2808,10 +2838,12 @@ export class LayoutPass {
|
|
|
2808
2838
|
// the space between sections. `sidebar` (iPadOS) is laid out like
|
|
2809
2839
|
// `insetGrouped` with 44pt rows (the page draws no cards or hairlines).
|
|
2810
2840
|
const listStyle = str(node.props['style']) ?? 'insetGrouped';
|
|
2811
|
-
|
|
2841
|
+
// In a compact width (an iPhone) a `sidebar` list looks like `insetGrouped`
|
|
2842
|
+
// (cards, chevrons, 52pt rows) and only its sections' folding remains.
|
|
2843
|
+
const sidebarStyle = listStyle === 'sidebar' && isRegularWidth(screen);
|
|
2812
2844
|
// `.environment(\.defaultMinListRowHeight, _)`; 52 by default (44 in a sidebar).
|
|
2813
2845
|
const minRowHeight = num(node.props['minRowHeight']) ?? (sidebarStyle ? LIST_SIDEBAR_ROW_MIN_HEIGHT : LIST_ROW_MIN_HEIGHT);
|
|
2814
|
-
const inset = listStyle === 'insetGrouped' ||
|
|
2846
|
+
const inset = listStyle === 'insetGrouped' || listStyle === 'sidebar' ? LIST_INSET : 0;
|
|
2815
2847
|
const plain = listStyle === 'plain';
|
|
2816
2848
|
const listTop = plain ? 0 : LIST_TOP;
|
|
2817
2849
|
const listBottom = plain ? 0 : LIST_BOTTOM;
|
|
@@ -2843,7 +2875,9 @@ export class LayoutPass {
|
|
|
2843
2875
|
const groupsSource: GroupSource[] = [];
|
|
2844
2876
|
for (const child of node.children) {
|
|
2845
2877
|
if (child.kind === 'section') {
|
|
2846
|
-
|
|
2878
|
+
// `Section(isExpanded:)`: collapsed, its rows are not laid out, in a sidebar-style list (iOS shows the rows in the other styles, whatever the binding).
|
|
2879
|
+
const collapsed = child.props['collapsible'] === true && child.props['expanded'] === false && rowCtx.sidebarList === true;
|
|
2880
|
+
const rows = collapsed ? [] : child.children.filter((row) => row.kind !== 'sectionheader' && row.kind !== 'sectionfooter');
|
|
2847
2881
|
const headerView = child.children.find((row) => row.kind === 'sectionheader') ?? null;
|
|
2848
2882
|
const footerView = child.children.find((row) => row.kind === 'sectionfooter') ?? null;
|
|
2849
2883
|
groupsSource.push({ section: child, rows, headerView, footerView });
|
|
@@ -2857,11 +2891,12 @@ export class LayoutPass {
|
|
|
2857
2891
|
// footer's font.
|
|
2858
2892
|
const headerCtx: Context = { ...rowCtx, listRow: false, stackAxis: null, font: headerFont };
|
|
2859
2893
|
const footerCtx: Context = { ...headerCtx, font: footerFont };
|
|
2860
|
-
const supplement = (view: LayoutNode | null, y: number, ctx: Context): ListSupplementPlan | null => {
|
|
2894
|
+
const supplement = (view: LayoutNode | null, y: number, ctx: Context, full = false): ListSupplementPlan | null => {
|
|
2861
2895
|
if (!view) return null;
|
|
2862
2896
|
const proposal: Proposal = { width: contentWidth, height: null };
|
|
2863
2897
|
const size = this.measure(view, ctx, proposal);
|
|
2864
|
-
|
|
2898
|
+
// A collapsible section's header spans the content width: its chevron sits at the trailing edge.
|
|
2899
|
+
return { node: view, ctx, proposal, rect: { x: contentX, y, width: full ? contentWidth : size.width, height: size.height } };
|
|
2865
2900
|
};
|
|
2866
2901
|
|
|
2867
2902
|
const groups: ListGroupPlan[] = [];
|
|
@@ -2886,7 +2921,7 @@ export class LayoutPass {
|
|
|
2886
2921
|
// A header sits in the gap above its card (from LIST_HEADER_LIFT
|
|
2887
2922
|
// into it); the card then starts LIST_HEADER_GAP below the header.
|
|
2888
2923
|
const headerLift = plain ? 0 : LIST_HEADER_LIFT;
|
|
2889
|
-
const headerView = supplement(source.headerView, y - headerLift, headerCtx);
|
|
2924
|
+
const headerView = supplement(source.headerView, y - headerLift, headerCtx, source.section?.props['collapsible'] === true);
|
|
2890
2925
|
if (headerView && headerView.rect.height > 0) y += headerView.rect.height + LIST_HEADER_GAP - headerLift;
|
|
2891
2926
|
const headerText = source.section ? str(source.section.props['header']) : null;
|
|
2892
2927
|
const footerText = source.section ? str(source.section.props['footer']) : null;
|
|
@@ -3068,8 +3103,10 @@ export class LayoutPass {
|
|
|
3068
3103
|
case 'canvas':
|
|
3069
3104
|
case 'video':
|
|
3070
3105
|
case 'host':
|
|
3106
|
+
case 'registered':
|
|
3071
3107
|
return;
|
|
3072
3108
|
case 'vstack':
|
|
3109
|
+
case 'disclosuregroup':
|
|
3073
3110
|
case 'hstack': {
|
|
3074
3111
|
const label = node.kind === 'hstack' && ctx.labelColumn ? labelColumnOf(node) : null;
|
|
3075
3112
|
if (label) {
|
|
@@ -3079,10 +3116,13 @@ export class LayoutPass {
|
|
|
3079
3116
|
this.place(label.title, cctx, plan.titleProposal, x + LIST_LABEL_TITLE_X, y + (size.height - plan.title.height) / 2);
|
|
3080
3117
|
return;
|
|
3081
3118
|
}
|
|
3082
|
-
const axis: Axis = node.kind === '
|
|
3119
|
+
const axis: Axis = node.kind === 'hstack' ? 'horizontal' : 'vertical';
|
|
3083
3120
|
const plan = this.stackPlan(node, ctx, p, axis);
|
|
3084
|
-
const alignment = str(node.props['alignment']) ?? 'center';
|
|
3085
|
-
this.
|
|
3121
|
+
const alignment = str(node.props['alignment']) ?? (node.kind === 'disclosuregroup' ? 'leading' : 'center');
|
|
3122
|
+
const shown = this.stackChildren(node);
|
|
3123
|
+
this.placeStack(shown, cctx, plan, axis, alignment, x, y, size);
|
|
3124
|
+
// A collapsed `disclosuregroup`: its content is hidden, without frames.
|
|
3125
|
+
for (const child of node.children) if (!shown.includes(child)) this.hide(child, node);
|
|
3086
3126
|
return;
|
|
3087
3127
|
}
|
|
3088
3128
|
case 'zstack': {
|
|
@@ -3224,7 +3264,7 @@ export class LayoutPass {
|
|
|
3224
3264
|
// (its label still reserves the chevron's width so selecting a row
|
|
3225
3265
|
// never reflows it); elsewhere `selected` keeps the chevron. No row
|
|
3226
3266
|
// of a `sidebar`-style list has one (iPadOS).
|
|
3227
|
-
if (!(node.props['selected'] === true && ctx.splitSidebar) && !ctx.sidebarList) {
|
|
3267
|
+
if (!(node.props['selected'] === true && ctx.splitSidebar) && !(ctx.sidebarList && isRegularWidth(this.env.screen))) {
|
|
3228
3268
|
chrome['chevron'] = {
|
|
3229
3269
|
x: x + size.width - LIST_CHEVRON_WIDTH,
|
|
3230
3270
|
y,
|
|
@@ -3521,12 +3561,22 @@ export class LayoutPass {
|
|
|
3521
3561
|
this.setFrame(group.section, x + group.frame.x, y + group.frame.y, group.frame);
|
|
3522
3562
|
this.setChrome(group.section, chrome);
|
|
3523
3563
|
for (const view of [group.headerView, group.footerView]) {
|
|
3524
|
-
if (view)
|
|
3564
|
+
if (!view) continue;
|
|
3565
|
+
this.place(view.node, view.ctx, view.proposal, x + view.rect.x, y + view.rect.y);
|
|
3566
|
+
// A collapsible section's header spans the content width (its chevron is at the trailing edge); its content stays put.
|
|
3567
|
+
const placed = view.node.id >= 0 ? this.frames.get(view.node.id) : undefined;
|
|
3568
|
+
if (placed && placed.width < view.rect.width) this.setFrame(view.node, placed.x, placed.y, { width: view.rect.width, height: placed.height });
|
|
3525
3569
|
}
|
|
3526
3570
|
if (group.section.id >= 0) {
|
|
3527
3571
|
this.fonts.set(group.section.id, group.header?.layout.font ?? group.footer?.layout.font ?? makeFont('system', textStyleSize('footnote', this.env.dynamicTypeSize), 400));
|
|
3528
3572
|
if (Object.keys(texts).length > 0) this.chromeText.set(group.section.id, texts);
|
|
3529
3573
|
}
|
|
3574
|
+
// A collapsed `Section(isExpanded:)` (sidebar-style list): its rows are hidden, without frames.
|
|
3575
|
+
if (group.rows.length === 0) {
|
|
3576
|
+
for (const child of group.section.children) {
|
|
3577
|
+
if (child.kind !== 'sectionheader' && child.kind !== 'sectionfooter') this.hide(child, group.section);
|
|
3578
|
+
}
|
|
3579
|
+
}
|
|
3530
3580
|
}
|
|
3531
3581
|
for (const row of group.rows) {
|
|
3532
3582
|
// Set the row chrome before placing so a navlink can merge its own keys.
|
|
@@ -3613,18 +3663,43 @@ export class LayoutPass {
|
|
|
3613
3663
|
|
|
3614
3664
|
private placeNavScreen(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
3615
3665
|
const { safeArea, screen, dynamicTypeSize } = this.env;
|
|
3666
|
+
// The horizontal safe area (a phone on its side: the notch on one side,
|
|
3667
|
+
// the corner on the other): the bar's background runs edge to edge, as
|
|
3668
|
+
// the screen does; its items, the titles, the search field, the bottom
|
|
3669
|
+
// toolbars and the content stay clear of it. A split view's columns are
|
|
3670
|
+
// inset already (`placeNavSplit`), so they do not reach the edges here.
|
|
3671
|
+
const barX = x;
|
|
3672
|
+
const barWidth = size.width;
|
|
3673
|
+
const insetLeading = x <= EPSILON ? safeArea.leading : 0;
|
|
3674
|
+
const insetTrailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
|
|
3675
|
+
x += insetLeading;
|
|
3676
|
+
size = { width: Math.max(0, size.width - insetLeading - insetTrailing), height: size.height };
|
|
3616
3677
|
const statusBar = y <= 0 ? safeArea.top : (cctx.barInset ?? 0);
|
|
3617
3678
|
const displayMode = str(node.props['displayMode']) ?? 'automatic';
|
|
3618
3679
|
const depth = num(node.props['depth']) ?? 0;
|
|
3619
|
-
|
|
3620
|
-
|
|
3680
|
+
// `.toolbar(.hidden, for: .navigationBar)`: no bar, title, back button or
|
|
3681
|
+
// bar items; the content starts under the status bar, which stays a safe
|
|
3682
|
+
// area. `.bottomBar` hidden: the bottom toolbars draw nothing.
|
|
3683
|
+
const barHidden = node.props['navigationBarHidden'] === true;
|
|
3684
|
+
const bottomBarHidden = node.props['bottomBarHidden'] === true;
|
|
3685
|
+
// A compact height (a phone on its side) has no large titles, whatever the
|
|
3686
|
+
// display mode, as UIKit: the title is the bar's inline one.
|
|
3687
|
+
const large = !barHidden && screen.height >= REGULAR_HEIGHT && (displayMode === 'large' || (displayMode === 'automatic' && depth === 0));
|
|
3688
|
+
const barHeight = barHidden ? statusBar : statusBar + NAV_BAR_HEIGHT + (!large && y <= 0 ? NAV_BAR_INLINE_BOTTOM : 0);
|
|
3621
3689
|
const parts = this.navScreenParts(node);
|
|
3622
3690
|
const reachesBottom = y + size.height >= screen.height - EPSILON;
|
|
3623
3691
|
const isSpacer = (t: LayoutNode): boolean => str(t.props['spacer']) !== null;
|
|
3692
|
+
const placementOf = (t: LayoutNode): string | null => str(t.props['placement']);
|
|
3693
|
+
const inHiddenBar = (t: LayoutNode): boolean => {
|
|
3694
|
+
const placement = placementOf(t);
|
|
3695
|
+
if (placement === 'keyboard') return false;
|
|
3696
|
+
return placement === 'bottom' ? bottomBarHidden : barHidden;
|
|
3697
|
+
};
|
|
3698
|
+
for (const toolbar of parts.toolbars.filter(inHiddenBar)) this.hide(toolbar, node);
|
|
3624
3699
|
// `.largeTitle` items size the large title row; the text title is 62.
|
|
3625
3700
|
const titleItemsHeight = large ? this.largeTitleItemsHeight(parts.toolbars, cctx, size.width) : null;
|
|
3626
3701
|
const largeHeight = !large ? 0 : titleItemsHeight !== null ? LARGE_TITLE_ITEM_TOP + titleItemsHeight : LARGE_TITLE_HEIGHT;
|
|
3627
|
-
const bottomToolbars = parts.toolbars.filter((t) =>
|
|
3702
|
+
const bottomToolbars = bottomBarHidden ? [] : parts.toolbars.filter((t) => placementOf(t) === 'bottom');
|
|
3628
3703
|
// `keyboard` toolbars: a glass capsule floating above the keyboard while a
|
|
3629
3704
|
// text input of this screen is being edited (as Notes' format bar on
|
|
3630
3705
|
// iOS 26; SwiftUI's input accessory view); nothing otherwise.
|
|
@@ -3657,12 +3732,12 @@ export class LayoutPass {
|
|
|
3657
3732
|
width: size.width,
|
|
3658
3733
|
height: Math.max(0, size.height - contentTop - Math.max(bottomBarHeight, searchBottom)),
|
|
3659
3734
|
};
|
|
3660
|
-
const chrome: Record<string, Rect> = {
|
|
3661
|
-
|
|
3735
|
+
const chrome: Record<string, Rect> = { content };
|
|
3736
|
+
if (!barHidden) {
|
|
3737
|
+
chrome['bar'] = { x: barX, y, width: barWidth, height: barHeight };
|
|
3662
3738
|
// The 44 row the bar's items sit in, under the status bar.
|
|
3663
|
-
barRow
|
|
3664
|
-
|
|
3665
|
-
};
|
|
3739
|
+
chrome['barRow'] = { x, y: y + statusBar, width: size.width, height: NAV_BAR_HEIGHT };
|
|
3740
|
+
}
|
|
3666
3741
|
if (large) chrome['largeTitle'] = { x, y: y + barHeight, width: size.width, height: largeHeight };
|
|
3667
3742
|
if (parts.search) {
|
|
3668
3743
|
const marginH = searchOnTop ? SEARCH_FIELD_MARGIN_H : SEARCH_BOTTOM_MARGIN_H;
|
|
@@ -3700,11 +3775,13 @@ export class LayoutPass {
|
|
|
3700
3775
|
// The navigation bar's groups; spacers there only separate them (the
|
|
3701
3776
|
// groups are 8 apart anyway) and draw nothing.
|
|
3702
3777
|
for (const spacer of parts.toolbars.filter(isSpacer)) this.hide(spacer, node);
|
|
3703
|
-
const barToolbars =
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3778
|
+
const barToolbars = barHidden
|
|
3779
|
+
? []
|
|
3780
|
+
: parts.toolbars.filter((t) => {
|
|
3781
|
+
const placement = placementOf(t);
|
|
3782
|
+
return placement !== 'bottom' && placement !== 'secondary' && placement !== 'largeTitle' && placement !== 'keyboard' && !isSpacer(t);
|
|
3783
|
+
});
|
|
3784
|
+
const secondaryToolbars = barHidden ? [] : parts.toolbars.filter((t) => placementOf(t) === 'secondary' && !isSpacer(t));
|
|
3708
3785
|
const groups: Group[] = barToolbars.map((toolbar) => {
|
|
3709
3786
|
const itemCtx = this.childContext(toolbar, cctx);
|
|
3710
3787
|
const sizes = toolbar.children.map((item) => this.measure(item, itemCtx, { width: null, height: null }));
|
|
@@ -3826,7 +3903,7 @@ export class LayoutPass {
|
|
|
3826
3903
|
// title no longer shows).
|
|
3827
3904
|
const backLabel = '';
|
|
3828
3905
|
const leadingStart = x + NAV_ITEM_MARGIN + (cctx.barLeading ?? 0);
|
|
3829
|
-
if (depth > 0) {
|
|
3906
|
+
if (depth > 0 && !barHidden) {
|
|
3830
3907
|
chrome['back'] = { x: leadingStart, y: rowY, width: NAV_GLASS_SIZE, height: NAV_GLASS_SIZE };
|
|
3831
3908
|
}
|
|
3832
3909
|
|
|
@@ -3969,7 +4046,7 @@ export class LayoutPass {
|
|
|
3969
4046
|
const titleScrolls = large && scroller !== null;
|
|
3970
4047
|
// A large title that scrolls away collapses into the inline title, which
|
|
3971
4048
|
// the renderer shows once it has (`data-sb-collapsed`).
|
|
3972
|
-
if ((!large || titleScrolls) && !principal) {
|
|
4049
|
+
if ((!large || titleScrolls) && !principal && !barHidden) {
|
|
3973
4050
|
const leftUsed = Math.max(
|
|
3974
4051
|
back ? back.x + back.width - x : 0,
|
|
3975
4052
|
leadingWidth > 0 ? leadingCursor - NAV_GROUP_SPACING - x : 0,
|
|
@@ -4299,10 +4376,14 @@ export class LayoutPass {
|
|
|
4299
4376
|
// The keyboard (device mode) covers the tab bar: the content stops above
|
|
4300
4377
|
// whichever is taller (a `navstack` handles the keyboard in its screens).
|
|
4301
4378
|
const keyboard = y + size.height >= screen.height - EPSILON ? this.keyboard : 0;
|
|
4379
|
+
// Clear of the horizontal safe area (a phone on its side); a `navstack`
|
|
4380
|
+
// insets its own screens' items and content.
|
|
4381
|
+
const leading = x <= EPSILON ? safeArea.leading : 0;
|
|
4382
|
+
const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
|
|
4302
4383
|
const area: Rect = {
|
|
4303
|
-
x,
|
|
4384
|
+
x: x + leading,
|
|
4304
4385
|
y: y + topInset,
|
|
4305
|
-
width: size.width,
|
|
4386
|
+
width: Math.max(0, size.width - leading - trailing),
|
|
4306
4387
|
height: Math.max(0, size.height - topInset - Math.max(barHeight, keyboard)),
|
|
4307
4388
|
};
|
|
4308
4389
|
this.setChrome(tab, { content: area });
|
|
@@ -4316,7 +4397,7 @@ export class LayoutPass {
|
|
|
4316
4397
|
if (kind === 'list' || kind === 'scrollview') {
|
|
4317
4398
|
const height = Math.max(0, size.height - topInset - keyboard);
|
|
4318
4399
|
const ctx: Context = keyboard > 0 ? { ...cctx, bottomBar: Math.max(0, barHeight - keyboard), bottomEdge: y + size.height - keyboard } : cctx;
|
|
4319
|
-
this.place(child, ctx, { width:
|
|
4400
|
+
this.place(child, ctx, { width: area.width, height }, area.x, y + topInset);
|
|
4320
4401
|
return;
|
|
4321
4402
|
}
|
|
4322
4403
|
const proposal: Proposal = { width: area.width, height: area.height };
|
|
@@ -4535,10 +4616,11 @@ export class LayoutPass {
|
|
|
4535
4616
|
}
|
|
4536
4617
|
|
|
4537
4618
|
/**
|
|
4538
|
-
* A slider is the proposed width (200 when none) and 44 high
|
|
4539
|
-
*
|
|
4540
|
-
* `
|
|
4541
|
-
* the
|
|
4619
|
+
* A slider is the proposed width (200 when none) and 44 high. The `min` /
|
|
4620
|
+
* `max` value labels sit at the track's ends, 8 from the track. Its
|
|
4621
|
+
* `label` child is not drawn, as iOS does not draw a slider's label (it
|
|
4622
|
+
* names the slider for VoiceOver): the page hides it and names the slider
|
|
4623
|
+
* from it (`aria-labelledby`). Children are named by `labels` in order.
|
|
4542
4624
|
*/
|
|
4543
4625
|
private sliderPlan(node: LayoutNode, ctx: Context, p: Proposal): SliderPlan {
|
|
4544
4626
|
return this.plan(node, ctx, p, () => {
|
|
@@ -4554,19 +4636,17 @@ export class LayoutPass {
|
|
|
4554
4636
|
else if (name === 'min') min = child;
|
|
4555
4637
|
else if (name === 'max') max = child;
|
|
4556
4638
|
});
|
|
4557
|
-
const labelSize = label ? this.measure(label, cctx, { width, height: null }) : ZERO;
|
|
4558
4639
|
const minSize = min ? this.measure(min, cctx, NIL_PROPOSAL) : ZERO;
|
|
4559
4640
|
const maxSize = max ? this.measure(max, cctx, NIL_PROPOSAL) : ZERO;
|
|
4560
|
-
|
|
4561
|
-
return { size: { width, height: labelRow + SLIDER_HEIGHT }, label, min, max, labelSize, minSize, maxSize, labelRow, cctx };
|
|
4641
|
+
return { size: { width, height: SLIDER_HEIGHT }, label, min, max, minSize, maxSize, cctx };
|
|
4562
4642
|
});
|
|
4563
4643
|
}
|
|
4564
4644
|
|
|
4565
4645
|
private placeSlider(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number, size: Size): void {
|
|
4566
4646
|
const plan = this.sliderPlan(node, ctx, p);
|
|
4567
4647
|
const { cctx } = plan;
|
|
4568
|
-
if (plan.label) this.
|
|
4569
|
-
const rowY = y
|
|
4648
|
+
if (plan.label) this.hide(plan.label, node);
|
|
4649
|
+
const rowY = y;
|
|
4570
4650
|
let trackX = x;
|
|
4571
4651
|
let trackEnd = x + size.width;
|
|
4572
4652
|
if (plan.min) {
|
package/src/layoutDom.ts
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
import type { LayoutEnvironment, LayoutNode, LayoutResult, Rect, ResolvedFont, TextLayout } from './layout';
|
|
37
37
|
import { hostContentInset } from './hostInset';
|
|
38
38
|
import { listRowBackgroundOf } from './layout';
|
|
39
|
+
import { isRegularWidth } from './layout/engine';
|
|
39
40
|
import { styleToCSS } from './styles';
|
|
40
41
|
import type { ShapeStyle } from './protocol';
|
|
41
42
|
import { applyChartChrome } from './chartDom';
|
|
@@ -159,6 +160,8 @@ function applyFrames(ctx: LayoutDomContext, result: LayoutResult, changes: BoxCh
|
|
|
159
160
|
if (el.hidden) el.hidden = false;
|
|
160
161
|
applyChrome(result, node, el, box, frame, scrollBottomInset(ctx, result, node, frame), previous === null, changes);
|
|
161
162
|
if (node.kind === 'tab') setTabHidden(el, result.hidden.has(id));
|
|
163
|
+
// A `sidebar` list draws as one (no cards or hairlines) in a regular width; in a compact one it looks like `insetGrouped`.
|
|
164
|
+
if (node.kind === 'list') setFlag(el, 'sbListLook', node.props['style'] === 'sidebar' && isRegularWidth(ctx.env.screen) ? 'sidebar' : null);
|
|
162
165
|
// A child the engine did not touch kept its screen frame, but if this
|
|
163
166
|
// element's box moved, the child's `left`/`top` (relative to it) did
|
|
164
167
|
// not: the plus button at the bottom of a chat's composer stays put on
|
|
@@ -636,14 +639,15 @@ function applyChrome(
|
|
|
636
639
|
setFlag(el, 'sbSeparatorBottom', chrome?.['separatorBottomHidden'] ? 'hidden' : null);
|
|
637
640
|
setFlag(el, 'sbSeparatorTop', chrome?.['separatorTopHidden'] ? 'hidden' : null);
|
|
638
641
|
// `.listRowBackground(_:)`: the row's cell paint (styles.css reads it, and
|
|
639
|
-
// a card with such a row leaves its own background to its rows
|
|
642
|
+
// a card with such a row leaves its own background to its rows; a card of
|
|
643
|
+
// clear rows alone, marked `clear`, clips nothing either).
|
|
640
644
|
const rowBackground = chrome?.['row'] ? listRowBackgroundOf(node) : undefined;
|
|
641
645
|
const rowPaint = rowBackground !== undefined ? styleToCSS(rowBackground as ShapeStyle, 'paint').value : '';
|
|
642
646
|
if (el.style.getPropertyValue('--sb-row-background') !== rowPaint) {
|
|
643
647
|
if (rowPaint) el.style.setProperty('--sb-row-background', rowPaint);
|
|
644
648
|
else el.style.removeProperty('--sb-row-background');
|
|
645
649
|
}
|
|
646
|
-
setFlag(el, 'sbRowBackground', rowPaint ? '' : null);
|
|
650
|
+
setFlag(el, 'sbRowBackground', rowPaint ? (rowPaint === 'transparent' ? 'clear' : '') : null);
|
|
647
651
|
// iOS 26: the separator under a list row starts at its first text (styles.css).
|
|
648
652
|
const separator = chrome?.['separator'];
|
|
649
653
|
const leading = separator ? `${separator.x}px` : '';
|
|
@@ -699,11 +703,22 @@ function applyChrome(
|
|
|
699
703
|
case 'stepper':
|
|
700
704
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-stepper-control'), chrome?.['control'], box);
|
|
701
705
|
return;
|
|
702
|
-
case 'slider':
|
|
706
|
+
case 'slider': {
|
|
707
|
+
// The `label` child is hidden (iOS draws none) and names the slider, as VoiceOver reads it.
|
|
708
|
+
const names = Array.isArray(node.props['labels']) ? node.props['labels'] : [];
|
|
709
|
+
const labelNode = node.children[names.indexOf('label')];
|
|
710
|
+
const labelEl = labelNode && names.includes('label') ? el.querySelector<HTMLElement>(`[data-sb-id="${labelNode.id}"]`) : null;
|
|
711
|
+
if (labelEl) {
|
|
712
|
+
if (!labelEl.id) labelEl.id = `sb-slider-label-${labelNode!.id}`;
|
|
713
|
+
el.setAttribute('aria-labelledby', labelEl.id);
|
|
714
|
+
} else {
|
|
715
|
+
el.removeAttribute('aria-labelledby');
|
|
716
|
+
}
|
|
703
717
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-slider-track'), chrome?.['track'], box);
|
|
704
718
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-slider-fill'), chrome?.['fill'], box);
|
|
705
719
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-slider-thumb'), chrome?.['thumb'], box);
|
|
706
720
|
return;
|
|
721
|
+
}
|
|
707
722
|
case 'datepicker':
|
|
708
723
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-datepicker-date'), chrome?.['date'], box);
|
|
709
724
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-datepicker-time'), chrome?.['time'], box);
|
|
@@ -745,6 +760,20 @@ function applyChrome(
|
|
|
745
760
|
placeChrome(sw, rect, box);
|
|
746
761
|
return;
|
|
747
762
|
}
|
|
763
|
+
case 'disclosuregroup': {
|
|
764
|
+
// The chevron at the label's trailing edge, the toggle over the label (the first child's frame).
|
|
765
|
+
const label = node.children[0] ? result.frames.get(node.children[0].id) : undefined;
|
|
766
|
+
const chevron = el.querySelector<HTMLElement>(':scope > .sb-disclosure-chevron');
|
|
767
|
+
const toggle = el.querySelector<HTMLElement>(':scope > .sb-disclosure-toggle');
|
|
768
|
+
if (label) {
|
|
769
|
+
placeChrome(chevron, { x: frame.x + frame.width - CHEVRON_SIZE - 2, y: label.y + (label.height - CHEVRON_SIZE) / 2, width: CHEVRON_SIZE, height: CHEVRON_SIZE }, box);
|
|
770
|
+
placeChrome(toggle, { x: frame.x, y: label.y, width: frame.width, height: label.height }, box);
|
|
771
|
+
} else {
|
|
772
|
+
placeChrome(chevron, undefined, box);
|
|
773
|
+
placeChrome(toggle, undefined, box);
|
|
774
|
+
}
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
748
777
|
case 'navlink': {
|
|
749
778
|
// A link row's badge sits on its label (`NavigationLink { row.badge(n) }`).
|
|
750
779
|
applyBadge(result, node, el, box);
|
|
@@ -839,6 +868,12 @@ function applyChrome(
|
|
|
839
868
|
const inlineTitle = el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-title');
|
|
840
869
|
const texts = result.chromeText.get(node.id);
|
|
841
870
|
const barRect = chrome?.['bar'];
|
|
871
|
+
// `.toolbar(.hidden, for: .navigationBar)`: the bar fades out where it
|
|
872
|
+
// was, its title, back button and More button with it (styles.css);
|
|
873
|
+
// `.statusBarHidden`: the frame's drawn status bar fades (renderer.ts).
|
|
874
|
+
const barHidden = node.props['navigationBarHidden'] === true;
|
|
875
|
+
setFlag(el, 'sbBarHidden', barHidden ? '' : null);
|
|
876
|
+
setFlag(el, 'sbStatusHidden', node.props['statusBarHidden'] === true ? '' : null);
|
|
842
877
|
if (bar && barRect) {
|
|
843
878
|
placeChrome(bar, barRect, frame);
|
|
844
879
|
// The 44px bar row sits under the status bar portion of the rect
|
|
@@ -871,6 +906,11 @@ function applyChrome(
|
|
|
871
906
|
largeTitle.hidden = true;
|
|
872
907
|
}
|
|
873
908
|
}
|
|
909
|
+
if (barHidden) return;
|
|
910
|
+
// The engine's choice, not the display mode's: a compact height (a
|
|
911
|
+
// phone on its side) has no large title row, and the bar shows the
|
|
912
|
+
// inline title as UIKit does (styles.css hides it under a large one).
|
|
913
|
+
el.dataset['sbDisplay'] = largeRect ? 'large' : 'inline';
|
|
874
914
|
if (inlineTitle) {
|
|
875
915
|
// Phase 6: the engine centers the inline title in the room the back
|
|
876
916
|
// button and the toolbar items leave (no rect: large title or a
|
package/src/main.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { easingFor, springEasing, timingFor } from './animation';
|
|
3
3
|
import type { BuildEndPayload, BuildErrorPayload, BuildStartPayload } from './devEvents';
|
|
4
4
|
import { ChromeColor } from './chromeColor';
|
|
5
|
+
import { installDeviceFrame } from './deviceFrame';
|
|
5
6
|
import { applyPageMode, decidePageMode, installViewportReadout, readViewportInsets, watchKeyboard, watchViewport, type PageMode } from './deviceMode';
|
|
6
7
|
import { EMPTY_CATALOG, catalogPath, isAssetCatalog, type AssetCatalog } from './assets';
|
|
7
8
|
import { createMockApp } from './mock';
|
|
@@ -228,7 +229,8 @@ typeSizeSelect.addEventListener('change', () => {
|
|
|
228
229
|
|
|
229
230
|
/**
|
|
230
231
|
* Phase 7: the horizontal size class Swift sees (`regular` from 700 CSS px of
|
|
231
|
-
* screen width). The frame is
|
|
232
|
+
* screen width). The iPhone frame is compact, the iPad frame regular, and
|
|
233
|
+
* without a frame (or in device mode) it follows the window.
|
|
232
234
|
*/
|
|
233
235
|
function currentSizeClass(): HorizontalSizeClass {
|
|
234
236
|
return horizontalSizeClassFor(screen.clientWidth);
|
|
@@ -260,6 +262,15 @@ if (deviceMode) {
|
|
|
260
262
|
// `?debug=viewport` shows the numbers the measurement reads, over the app.
|
|
261
263
|
const readout = new URLSearchParams(location.search).get('debug') === 'viewport' ? installViewportReadout() : undefined;
|
|
262
264
|
watchKeyboard((height) => renderer.setKeyboard(height), { root: device, onRead: readout });
|
|
265
|
+
} else if (!document.documentElement.hasAttribute('data-sb-embed')) {
|
|
266
|
+
// A desktop preview: the toolbar's Device picker (deviceFrame.ts) draws the
|
|
267
|
+
// app in an iPhone, an iPad or no device; the renderer re-reads the
|
|
268
|
+
// screen's size when it changes, and Swift hears a new size class.
|
|
269
|
+
const sizeClassMayHaveChanged = (): void => {
|
|
270
|
+
if (sentSizeClass !== null && currentSizeClass() !== sentSizeClass) sendEnvironment();
|
|
271
|
+
};
|
|
272
|
+
installDeviceFrame(renderer, { onChange: sizeClassMayHaveChanged });
|
|
273
|
+
watchViewport(sizeClassMayHaveChanged);
|
|
263
274
|
}
|
|
264
275
|
|
|
265
276
|
// ---------------------------------------------------------------------------
|
package/src/page.css
CHANGED
|
@@ -141,6 +141,43 @@ body {
|
|
|
141
141
|
border-color: transparent;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
.toolbar-close {
|
|
145
|
+
flex: 0 0 auto;
|
|
146
|
+
display: grid;
|
|
147
|
+
place-items: center;
|
|
148
|
+
width: 28px;
|
|
149
|
+
height: 28px;
|
|
150
|
+
margin-right: -4px;
|
|
151
|
+
padding: 0;
|
|
152
|
+
border: 0;
|
|
153
|
+
border-radius: 50%;
|
|
154
|
+
background: transparent;
|
|
155
|
+
color: var(--page-fg);
|
|
156
|
+
cursor: pointer;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.toolbar-close svg circle {
|
|
160
|
+
fill: currentColor;
|
|
161
|
+
opacity: 0.12;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.toolbar-close svg path {
|
|
165
|
+
fill: none;
|
|
166
|
+
stroke: currentColor;
|
|
167
|
+
stroke-width: 1.8;
|
|
168
|
+
stroke-linecap: round;
|
|
169
|
+
opacity: 0.7;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.toolbar-close:hover svg circle {
|
|
173
|
+
opacity: 0.2;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* ?toolbar=0 (the toolbar's close button, src/deviceFrame.ts): the app alone. */
|
|
177
|
+
html[data-sb-toolbar="hidden"] .toolbar {
|
|
178
|
+
display: none;
|
|
179
|
+
}
|
|
180
|
+
|
|
144
181
|
.toolbar-select {
|
|
145
182
|
font: inherit;
|
|
146
183
|
padding: 5px 8px;
|
|
@@ -172,6 +209,34 @@ body {
|
|
|
172
209
|
word-break: break-word;
|
|
173
210
|
}
|
|
174
211
|
|
|
212
|
+
/* No device (html[data-sb-frame="none"], src/deviceFrame.ts): the page is
|
|
213
|
+
the toolbar over the app, which fills the rest of the window and lays out
|
|
214
|
+
for its size as the window is resized. */
|
|
215
|
+
html[data-sb-frame="none"]:not([data-sb-mode="device"]) body {
|
|
216
|
+
display: flex;
|
|
217
|
+
flex-direction: column;
|
|
218
|
+
height: 100dvh;
|
|
219
|
+
min-height: 0;
|
|
220
|
+
overflow: hidden;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
html[data-sb-frame="none"]:not([data-sb-mode="device"]) .stage {
|
|
224
|
+
flex: 1 1 auto;
|
|
225
|
+
min-height: 0;
|
|
226
|
+
align-items: stretch;
|
|
227
|
+
padding: 0;
|
|
228
|
+
gap: 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
html[data-sb-frame="none"]:not([data-sb-mode="device"]) .status {
|
|
232
|
+
position: fixed;
|
|
233
|
+
left: 16px;
|
|
234
|
+
right: 16px;
|
|
235
|
+
bottom: 16px;
|
|
236
|
+
z-index: 10;
|
|
237
|
+
max-width: none;
|
|
238
|
+
}
|
|
239
|
+
|
|
175
240
|
/* ---------------------------------------------------------------------------
|
|
176
241
|
Device mode (html[data-sb-mode="device"], see src/deviceMode.ts): no
|
|
177
242
|
toolbar, no stage padding, the page behind the app in the OS appearance's
|