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