@swiftbrowser/web 0.26.3 → 0.26.4
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/layout/engine.ts +306 -24
- package/src/layoutDom.ts +32 -0
- package/src/protocol.ts +4 -0
- package/src/renderer.ts +9 -1
- package/src/styles.css +99 -4
package/package.json
CHANGED
package/src/layout/engine.ts
CHANGED
|
@@ -120,6 +120,12 @@ export interface Context {
|
|
|
120
120
|
* bar, `actions`, when the sheet has one.
|
|
121
121
|
*/
|
|
122
122
|
macSheet?: { actions: Rect | null };
|
|
123
|
+
/**
|
|
124
|
+
* The Mac idiom: a row of a form in the columns style. A control whose
|
|
125
|
+
* label went to the label column (a stepper's, a date picker's, a
|
|
126
|
+
* picker's) draws without it, at the row's leading edge.
|
|
127
|
+
*/
|
|
128
|
+
formColumn?: boolean;
|
|
123
129
|
/**
|
|
124
130
|
* Phase 8: the y (screen coordinates) where content clears the bars above
|
|
125
131
|
* it: the top safe area at the root, the bottom of a `navscreen`'s bar (and
|
|
@@ -392,6 +398,21 @@ const MAC_LIST_HEADER_TOP = 4;
|
|
|
392
398
|
const MAC_LIST_HEADER_GAP = 9;
|
|
393
399
|
const MAC_LIST_FIRST_ROWS_GAP = 10;
|
|
394
400
|
const MAC_LIST_FOOTER_GAP = 8;
|
|
401
|
+
// A Mac's Form (measured in the Mac app): the columns style, with no
|
|
402
|
+
// insets of its own and centered vertically in the room it gets. The labels
|
|
403
|
+
// (13) right-aligned in a column as wide as the widest, from the leading
|
|
404
|
+
// edge; the controls in a column 10 after it that runs to the trailing
|
|
405
|
+
// edge; a section header and a footer (13 regular, the label color) in the
|
|
406
|
+
// controls column; 8 under a header, rows 7 apart, sections touching, a
|
|
407
|
+
// header 4 under the rows before it (right under a footer), a footer 2
|
|
408
|
+
// under the last row.
|
|
409
|
+
const MAC_FORM_LABEL_GAP = 10;
|
|
410
|
+
const MAC_FORM_HEADER_GAP = 8;
|
|
411
|
+
const MAC_FORM_HEADER_TOP = 4;
|
|
412
|
+
const MAC_FORM_ROW_GAP = 7;
|
|
413
|
+
const MAC_FORM_FOOTER_GAP = 2;
|
|
414
|
+
/** The row kinds whose label goes in a Mac form's label column (a stack with one is shifted to it). */
|
|
415
|
+
const MAC_FORM_LABELED_KINDS = ['textfield', 'picker', 'stepper', 'datepicker', 'slider'];
|
|
395
416
|
/** Under a Mac footer: 5 to its rule, the rule. */
|
|
396
417
|
const MAC_LIST_FOOTER_BOTTOM = 6;
|
|
397
418
|
const MAC_NAVSPLIT_SIDEBAR_WIDTH = 220;
|
|
@@ -1449,10 +1470,24 @@ interface ListGroupPlan {
|
|
|
1449
1470
|
outlines: { group: LayoutNode; frame: Rect }[];
|
|
1450
1471
|
}
|
|
1451
1472
|
|
|
1473
|
+
/** A Mac form's label column entry: text the page draws (`layout`), or a view (`node`) placed at the rect. */
|
|
1474
|
+
interface FormLabelPlan {
|
|
1475
|
+
/** Relative to the list origin at scroll 0; a view's rect is its own size, at the column's trailing edge. */
|
|
1476
|
+
rect: Rect;
|
|
1477
|
+
layout: TextLayout | null;
|
|
1478
|
+
node: LayoutNode | null;
|
|
1479
|
+
ctx: Context;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1452
1482
|
interface ListPlan {
|
|
1453
1483
|
size: Size;
|
|
1454
1484
|
contentSize: Size;
|
|
1455
1485
|
groups: ListGroupPlan[];
|
|
1486
|
+
/** The Mac idiom's columns form: its label column's entries. */
|
|
1487
|
+
labels?: FormLabelPlan[];
|
|
1488
|
+
columns?: boolean;
|
|
1489
|
+
/** The columns form's controls column: where it starts (relative to the list origin) and its width. */
|
|
1490
|
+
column?: { x: number; width: number };
|
|
1456
1491
|
}
|
|
1457
1492
|
|
|
1458
1493
|
interface ScrollPlan {
|
|
@@ -2549,8 +2584,8 @@ export class LayoutPass {
|
|
|
2549
2584
|
const font = mac ? makeFont('system', MAC_SEGMENTED_FONT_SIZE, 400) : makeFont('system', PICKER_SEGMENT_FONT_SIZE, 600);
|
|
2550
2585
|
let natural = 0;
|
|
2551
2586
|
for (const option of options) natural += this.measurer.measureWidth(option, font) + PICKER_SEGMENT_PAD;
|
|
2552
|
-
// A Mac shows the picker's label before the control.
|
|
2553
|
-
const labelText = mac ? (str(node.props['label']) ?? '') : '';
|
|
2587
|
+
// A Mac shows the picker's label before the control (in a form's row it is in the label column).
|
|
2588
|
+
const labelText = mac && !ctx.formColumn ? (str(node.props['label']) ?? '') : '';
|
|
2554
2589
|
const labelWidth = labelText.length > 0 && !ctx.navBarItem ? this.measurer.measureWidth(labelText, ctx.font) + MAC_SEGMENTED_LABEL_GAP : 0;
|
|
2555
2590
|
return {
|
|
2556
2591
|
size: { width: finite(p.width) ? p.width : natural + labelWidth, height: mac ? MAC_SEGMENTED_HEIGHT : PICKER_SEGMENTED_HEIGHT },
|
|
@@ -2564,17 +2599,18 @@ export class LayoutPass {
|
|
|
2564
2599
|
};
|
|
2565
2600
|
}
|
|
2566
2601
|
const font = ctx.font;
|
|
2567
|
-
|
|
2602
|
+
// In a Mac form's row the label is in the label column and the pop-up spans the controls column.
|
|
2603
|
+
const labelWidth = ctx.formColumn ? 0 : this.measurer.measureWidth(str(node.props['label']) ?? '', font);
|
|
2568
2604
|
const value = options[selected] ?? '';
|
|
2569
2605
|
const mac = this.env.platform === 'macos';
|
|
2570
2606
|
// The Mac's pop-up button holds the value and the chevrons in a bordered button.
|
|
2571
|
-
const popup = mac && !ctx.listRow;
|
|
2572
|
-
const valueWidth = this.measurer.measureWidth(value, font) + PICKER_MENU_CHEVRON_WIDTH + (popup ? 2 * MAC_PICKER_POPUP_PAD : 0);
|
|
2607
|
+
const popup = mac && (!ctx.listRow || ctx.formColumn === true);
|
|
2608
|
+
const valueWidth = ctx.formColumn && finite(p.width) ? p.width : this.measurer.measureWidth(value, font) + PICKER_MENU_CHEVRON_WIDTH + (popup ? 2 * MAC_PICKER_POPUP_PAD : 0);
|
|
2573
2609
|
const natural = labelWidth + PICKER_MENU_GAP + valueWidth;
|
|
2574
2610
|
return {
|
|
2575
2611
|
size: {
|
|
2576
2612
|
width: finite(p.width) ? p.width : natural,
|
|
2577
|
-
height: ctx.listRow ? (mac ? MAC_PICKER_MENU_ROW_HEIGHT : PICKER_MENU_ROW_HEIGHT) : mac ? MAC_PICKER_MENU_HEIGHT : PICKER_MENU_HEIGHT,
|
|
2613
|
+
height: ctx.listRow && !ctx.formColumn ? (mac ? MAC_PICKER_MENU_ROW_HEIGHT : PICKER_MENU_ROW_HEIGHT) : mac ? MAC_PICKER_MENU_HEIGHT : PICKER_MENU_HEIGHT,
|
|
2578
2614
|
},
|
|
2579
2615
|
style: 'menu',
|
|
2580
2616
|
font,
|
|
@@ -2635,7 +2671,8 @@ export class LayoutPass {
|
|
|
2635
2671
|
const room = Math.max(0, size.width - PICKER_MENU_GAP);
|
|
2636
2672
|
let labelWidth = plan.labelWidth;
|
|
2637
2673
|
let valueWidth = plan.valueWidth;
|
|
2638
|
-
|
|
2674
|
+
// In a Mac form's controls column the pop-up spans the column (its label is in the label column).
|
|
2675
|
+
if (!ctx.formColumn && labelWidth + valueWidth > room) {
|
|
2639
2676
|
if (labelWidth <= valueWidth) {
|
|
2640
2677
|
labelWidth = Math.min(labelWidth, room / 2);
|
|
2641
2678
|
valueWidth = room - labelWidth;
|
|
@@ -2648,7 +2685,7 @@ export class LayoutPass {
|
|
|
2648
2685
|
chrome['label'] = { x, y, width: labelWidth, height: size.height };
|
|
2649
2686
|
chrome['value'] = { x: x + size.width - valueWidth, y, width: valueWidth, height: size.height };
|
|
2650
2687
|
// The page draws the Mac's pop-up button around the value (the engine padded it).
|
|
2651
|
-
if (this.env.platform === 'macos' && !ctx.listRow) chrome['popup'] = chrome['value'];
|
|
2688
|
+
if (this.env.platform === 'macos' && (!ctx.listRow || ctx.formColumn === true)) chrome['popup'] = chrome['value'];
|
|
2652
2689
|
else delete chrome['popup'];
|
|
2653
2690
|
}
|
|
2654
2691
|
this.setChrome(node, chrome);
|
|
@@ -3161,6 +3198,7 @@ export class LayoutPass {
|
|
|
3161
3198
|
|
|
3162
3199
|
private listPlan(node: LayoutNode, ctx: Context, p: Proposal): ListPlan {
|
|
3163
3200
|
return this.plan(node, ctx, p, () => {
|
|
3201
|
+
if (this.env.platform === 'macos' && node.props['form'] === true) return this.macFormPlan(node, ctx, p);
|
|
3164
3202
|
const { screen } = this.env;
|
|
3165
3203
|
const width = finite(p.width) ? p.width : p.width === Infinity ? Infinity : screen.width;
|
|
3166
3204
|
const layoutWidth = Number.isFinite(width) ? width : screen.width;
|
|
@@ -3915,9 +3953,198 @@ export class LayoutPass {
|
|
|
3915
3953
|
return { p: { width: p.width, height: size.height + top + bottom }, y: y - top };
|
|
3916
3954
|
}
|
|
3917
3955
|
|
|
3956
|
+
/** The labeled control a form row's stack is shifted to (the form's alignment guide), if it has one. */
|
|
3957
|
+
private macFormGuide(row: LayoutNode): LayoutNode | null {
|
|
3958
|
+
const inner = unwrapStyled(row);
|
|
3959
|
+
if (inner.kind !== 'hstack') return null;
|
|
3960
|
+
return inner.children.find((child) => MAC_FORM_LABELED_KINDS.includes(unwrapStyled(child).kind)) ?? null;
|
|
3961
|
+
}
|
|
3962
|
+
|
|
3963
|
+
/**
|
|
3964
|
+
* The Mac idiom's `Form` (MAC_FORM_* above, docs/notes/macos.md): the
|
|
3965
|
+
* columns style. A row whose control carries a label (a text field's
|
|
3966
|
+
* title, a picker's, a stepper's, a date picker's, a slider's) puts it
|
|
3967
|
+
* in the label column, right-aligned, and the control in the controls
|
|
3968
|
+
* column at the control's own height; a toggle, a button, text and other
|
|
3969
|
+
* rows go in the controls column whole. A stack row with such a control
|
|
3970
|
+
* in it is shifted so the control starts at the controls column, what is
|
|
3971
|
+
* before it reaching into the label column, as the form's alignment guide
|
|
3972
|
+
* does on a Mac. Headers and footers are 13-point text in the controls
|
|
3973
|
+
* column. No cards, no separators.
|
|
3974
|
+
*/
|
|
3975
|
+
private macFormPlan(node: LayoutNode, ctx: Context, p: Proposal): ListPlan {
|
|
3976
|
+
const { screen } = this.env;
|
|
3977
|
+
const width = finite(p.width) ? p.width : p.width === Infinity ? Infinity : screen.width;
|
|
3978
|
+
const layoutWidth = Number.isFinite(width) ? width : screen.width;
|
|
3979
|
+
const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true, labelColumn: false, listCell: true, formColumn: true, sidebarList: false };
|
|
3980
|
+
const font = rowCtx.font;
|
|
3981
|
+
const textFont = makeFont('system', textStyleSize('body', this.env.dynamicTypeSize, 'macos'), 400);
|
|
3982
|
+
type Row = { node: LayoutNode; label: string | null; labelNode: LayoutNode | null; guide: LayoutNode | null };
|
|
3983
|
+
type Source = { section: LayoutNode | null; rows: Row[]; headerView: LayoutNode | null; footerView: LayoutNode | null };
|
|
3984
|
+
// The rows, each with what goes in the label column.
|
|
3985
|
+
const classify = (row: LayoutNode): Row => {
|
|
3986
|
+
const inner = unwrapStyled(row);
|
|
3987
|
+
switch (inner.kind) {
|
|
3988
|
+
case 'textfield':
|
|
3989
|
+
return { node: row, label: str(inner.props['placeholder']) || null, labelNode: null, guide: null };
|
|
3990
|
+
case 'picker':
|
|
3991
|
+
return { node: row, label: str(inner.props['label']) || null, labelNode: null, guide: null };
|
|
3992
|
+
case 'stepper':
|
|
3993
|
+
case 'datepicker':
|
|
3994
|
+
return { node: row, label: null, labelNode: inner.props['labelsHidden'] === true ? null : this.contentOf(inner), guide: null };
|
|
3995
|
+
case 'slider': {
|
|
3996
|
+
const names = Array.isArray(inner.props['labels']) ? inner.props['labels'].map(str) : [];
|
|
3997
|
+
const at = names.indexOf('label');
|
|
3998
|
+
return { node: row, label: null, labelNode: at >= 0 ? (inner.children[at] ?? null) : null, guide: null };
|
|
3999
|
+
}
|
|
4000
|
+
default:
|
|
4001
|
+
return { node: row, label: null, labelNode: null, guide: this.macFormGuide(row) };
|
|
4002
|
+
}
|
|
4003
|
+
};
|
|
4004
|
+
const sources: Source[] = [];
|
|
4005
|
+
for (const child of node.children) {
|
|
4006
|
+
if (child.kind === 'section') {
|
|
4007
|
+
const rows = child.children.filter((row) => row.kind !== 'sectionheader' && row.kind !== 'sectionfooter').map(classify);
|
|
4008
|
+
sources.push({ section: child, rows, headerView: child.children.find((row) => row.kind === 'sectionheader') ?? null, footerView: child.children.find((row) => row.kind === 'sectionfooter') ?? null });
|
|
4009
|
+
} else {
|
|
4010
|
+
const last = sources[sources.length - 1];
|
|
4011
|
+
if (last && last.section === null) last.rows.push(classify(child));
|
|
4012
|
+
else sources.push({ section: null, rows: [classify(child)], headerView: null, footerView: null });
|
|
4013
|
+
}
|
|
4014
|
+
}
|
|
4015
|
+
// The label column: as wide as the widest label (a view label at its ideal size).
|
|
4016
|
+
const labelCtx: Context = { ...this.childContext(node, ctx), stackAxis: null, listRow: false, listCell: true };
|
|
4017
|
+
let labelWidth = 0;
|
|
4018
|
+
const labelSizes = new Map<LayoutNode, Size>();
|
|
4019
|
+
for (const source of sources) {
|
|
4020
|
+
for (const row of source.rows) {
|
|
4021
|
+
if (row.label !== null) labelWidth = Math.max(labelWidth, this.measurer.measureWidth(row.label, font));
|
|
4022
|
+
else if (row.labelNode) {
|
|
4023
|
+
const size = this.measure(row.labelNode, labelCtx, { width: null, height: null });
|
|
4024
|
+
labelSizes.set(row.labelNode, size);
|
|
4025
|
+
labelWidth = Math.max(labelWidth, size.width);
|
|
4026
|
+
}
|
|
4027
|
+
}
|
|
4028
|
+
}
|
|
4029
|
+
labelWidth = Math.ceil(labelWidth);
|
|
4030
|
+
const controlX = labelWidth + MAC_FORM_LABEL_GAP;
|
|
4031
|
+
const controlWidth = Math.max(0, layoutWidth - controlX);
|
|
4032
|
+
const textCtx: Context = { ...labelCtx, font: textFont };
|
|
4033
|
+
const makeText = (text: string): { layout: TextLayout; size: Size } => {
|
|
4034
|
+
const wrapped = wrapText(text, textFont, controlWidth, null, this.measurer);
|
|
4035
|
+
return { layout: { font: textFont, lines: wrapped.lines, alignment: 'leading', truncated: false }, size: { width: wrapped.width, height: textHeight(wrapped.lines.length, textFont) } };
|
|
4036
|
+
};
|
|
4037
|
+
const supplement = (view: LayoutNode | null, y: number): ListSupplementPlan | null => {
|
|
4038
|
+
if (!view) return null;
|
|
4039
|
+
const proposal: Proposal = { width: controlWidth, height: null };
|
|
4040
|
+
const size = this.measure(view, textCtx, proposal);
|
|
4041
|
+
return { node: view, ctx: textCtx, proposal, rect: { x: controlX, y, width: size.width, height: size.height } };
|
|
4042
|
+
};
|
|
4043
|
+
// Built from a top, then again centered in the room when the rows are shorter than it.
|
|
4044
|
+
const build = (startY: number): { groups: ListGroupPlan[]; labels: FormLabelPlan[]; end: number } => {
|
|
4045
|
+
const groups: ListGroupPlan[] = [];
|
|
4046
|
+
const labels: FormLabelPlan[] = [];
|
|
4047
|
+
let y = startY;
|
|
4048
|
+
for (let g = 0; g < sources.length; g++) {
|
|
4049
|
+
const source = sources[g] as Source;
|
|
4050
|
+
const previous = groups[g - 1];
|
|
4051
|
+
const headerText = source.section ? str(source.section.props['header']) : null;
|
|
4052
|
+
const footerText = source.section ? str(source.section.props['footer']) : null;
|
|
4053
|
+
const hasHeader = (headerText !== null && headerText.length > 0) || (source.headerView !== null && this.measure(source.headerView, textCtx, { width: controlWidth, height: null }).height > 0);
|
|
4054
|
+
// A header after rows is 4 under them; right under a footer.
|
|
4055
|
+
if (previous && hasHeader && previous.footer === null && !(previous.footerView !== null && previous.footerView.rect.height > 0)) y += MAC_FORM_HEADER_TOP;
|
|
4056
|
+
const top = y;
|
|
4057
|
+
let header: ListGroupPlan['header'] = null;
|
|
4058
|
+
let footer: ListGroupPlan['footer'] = null;
|
|
4059
|
+
const headerView = supplement(source.headerView, y);
|
|
4060
|
+
if (headerView && headerView.rect.height > 0) y += headerView.rect.height + MAC_FORM_HEADER_GAP;
|
|
4061
|
+
if (headerText !== null && headerText.length > 0) {
|
|
4062
|
+
const { layout, size } = makeText(headerText);
|
|
4063
|
+
header = { rect: { x: controlX, y, width: controlWidth, height: size.height }, layout };
|
|
4064
|
+
y += size.height + MAC_FORM_HEADER_GAP;
|
|
4065
|
+
}
|
|
4066
|
+
const cardTop = y;
|
|
4067
|
+
const rows: ListRowPlan[] = [];
|
|
4068
|
+
source.rows.forEach((row, i) => {
|
|
4069
|
+
if (i > 0) y += MAC_FORM_ROW_GAP;
|
|
4070
|
+
if (row.node.kind === 'lazyspacer') {
|
|
4071
|
+
const rect: Rect = { x: controlX, y, width: controlWidth, height: 0 };
|
|
4072
|
+
rows.push({ node: row.node, proposal: { width: controlWidth, height: 0 }, contentSize: ZERO, row: rect, content: rect, badge: null, edit: null });
|
|
4073
|
+
return;
|
|
4074
|
+
}
|
|
4075
|
+
// A shifted stack is proposed the whole row less the trailing inset; its place is set when the list is placed.
|
|
4076
|
+
// A shifted stack is proposed the whole row; its place and width are set when the list is placed.
|
|
4077
|
+
const proposal: Proposal = { width: row.guide ? layoutWidth : controlWidth, height: null };
|
|
4078
|
+
const contentSize = this.measure(row.node, rowCtx, proposal);
|
|
4079
|
+
const labelSize = row.labelNode ? labelSizes.get(row.labelNode) ?? ZERO : row.label !== null ? { width: this.measurer.measureWidth(row.label, font), height: font.lineHeight } : ZERO;
|
|
4080
|
+
const rowHeight = Math.max(contentSize.height, labelSize.height);
|
|
4081
|
+
// A text field's row is the field itself (the page draws the Mac's bezel on the row element); the others span the form.
|
|
4082
|
+
const field = unwrapStyled(row.node).kind === 'textfield';
|
|
4083
|
+
rows.push({
|
|
4084
|
+
node: row.node,
|
|
4085
|
+
proposal,
|
|
4086
|
+
contentSize,
|
|
4087
|
+
row: field ? { x: controlX, y, width: controlWidth, height: rowHeight } : { x: 0, y, width: layoutWidth, height: rowHeight },
|
|
4088
|
+
content: { x: row.guide ? 0 : controlX, y: y + (rowHeight - contentSize.height) / 2, width: contentSize.width, height: contentSize.height },
|
|
4089
|
+
badge: null,
|
|
4090
|
+
edit: null,
|
|
4091
|
+
separatorHidden: { top: true, bottom: true },
|
|
4092
|
+
});
|
|
4093
|
+
if (row.label !== null) {
|
|
4094
|
+
labels.push({
|
|
4095
|
+
rect: { x: 0, y: y + (rowHeight - labelSize.height) / 2, width: labelWidth, height: labelSize.height },
|
|
4096
|
+
layout: { font, lines: [row.label], alignment: 'trailing', truncated: false },
|
|
4097
|
+
node: null,
|
|
4098
|
+
ctx: labelCtx,
|
|
4099
|
+
});
|
|
4100
|
+
} else if (row.labelNode) {
|
|
4101
|
+
labels.push({
|
|
4102
|
+
rect: { x: labelWidth - labelSize.width, y: y + (rowHeight - labelSize.height) / 2, width: labelSize.width, height: labelSize.height },
|
|
4103
|
+
layout: null,
|
|
4104
|
+
node: row.labelNode,
|
|
4105
|
+
ctx: labelCtx,
|
|
4106
|
+
});
|
|
4107
|
+
}
|
|
4108
|
+
y += rowHeight;
|
|
4109
|
+
});
|
|
4110
|
+
// The rows' card spans the form: the label column is drawn in it too.
|
|
4111
|
+
const card: Rect = { x: 0, y: cardTop, width: layoutWidth, height: y - cardTop };
|
|
4112
|
+
if (footerText !== null && footerText.length > 0) {
|
|
4113
|
+
y += MAC_FORM_FOOTER_GAP;
|
|
4114
|
+
const { layout, size } = makeText(footerText);
|
|
4115
|
+
footer = { rect: { x: controlX, y, width: controlWidth, height: size.height }, layout };
|
|
4116
|
+
y += size.height;
|
|
4117
|
+
}
|
|
4118
|
+
let footerView = supplement(source.footerView, y + MAC_FORM_FOOTER_GAP);
|
|
4119
|
+
if (footerView && footerView.rect.height > 0) y += MAC_FORM_FOOTER_GAP + footerView.rect.height;
|
|
4120
|
+
else if (footerView) footerView = { ...footerView, rect: { ...footerView.rect, y } };
|
|
4121
|
+
groups.push({ section: source.section, frame: { x: 0, y: top, width: layoutWidth, height: y - top }, header, footer, headerView, footerView, card, rows, outlines: [] });
|
|
4122
|
+
}
|
|
4123
|
+
return { groups, labels, end: y };
|
|
4124
|
+
};
|
|
4125
|
+
let built = build(0);
|
|
4126
|
+
// Centered in the room under the bar when shorter than it (the Mac's columns form does not scroll).
|
|
4127
|
+
const scrollInset = this.scrollInsets.get(node);
|
|
4128
|
+
const inset = scrollInset ? scrollInset.bar + (scrollInset.largeTitle?.height ?? 0) + (scrollInset.search ?? 0) : 0;
|
|
4129
|
+
const room = finite(p.height) ? p.height - inset : null;
|
|
4130
|
+
if (room !== null && room > built.end) built = build((room - built.end) / 2);
|
|
4131
|
+
const contentHeight = built.end;
|
|
4132
|
+
return {
|
|
4133
|
+
size: { width, height: p.height === null ? contentHeight : p.height },
|
|
4134
|
+
contentSize: { width: layoutWidth, height: contentHeight },
|
|
4135
|
+
groups: built.groups,
|
|
4136
|
+
labels: built.labels,
|
|
4137
|
+
columns: true,
|
|
4138
|
+
column: { x: controlX, width: controlWidth },
|
|
4139
|
+
};
|
|
4140
|
+
}
|
|
4141
|
+
|
|
3918
4142
|
private placeList(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
|
|
3919
4143
|
const plan = this.listPlan(node, ctx, p);
|
|
3920
|
-
const
|
|
4144
|
+
const columns = plan.columns === true;
|
|
4145
|
+
const rowCtx: Context = columns
|
|
4146
|
+
? { ...this.childContext(node, ctx), listRow: true, labelColumn: false, listCell: true, formColumn: true, sidebarList: false }
|
|
4147
|
+
: { ...this.childContext(node, ctx), listRow: true, labelColumn: this.env.platform !== 'macos', listCell: this.env.platform === 'macos', sidebarList: str(node.props['style']) === 'sidebar' };
|
|
3921
4148
|
const mac = this.env.platform === 'macos';
|
|
3922
4149
|
const cellPadding = mac ? MAC_LIST_CELL_PADDING : LIST_CELL_PADDING;
|
|
3923
4150
|
const covered = this.coveredBottom(ctx, x, y, plan.size);
|
|
@@ -3925,14 +4152,27 @@ export class LayoutPass {
|
|
|
3925
4152
|
// rows start below them.
|
|
3926
4153
|
const scrollInset = this.scrollInsets.get(node);
|
|
3927
4154
|
const listChrome: Record<string, Rect> = {};
|
|
4155
|
+
const listTexts: Record<string, TextLayout> = {};
|
|
3928
4156
|
if (covered) listChrome['bottomInset'] = covered;
|
|
3929
4157
|
if (scrollInset) listChrome['topInset'] = { x, y, width: plan.size.width, height: scrollInset.bar };
|
|
3930
4158
|
if (scrollInset?.largeTitle) listChrome['largeTitle'] = scrollInset.largeTitle;
|
|
3931
|
-
if (Object.keys(listChrome).length > 0) this.setChrome(node, listChrome);
|
|
3932
4159
|
const inset = scrollInset ? scrollInset.bar + (scrollInset.largeTitle?.height ?? 0) + (scrollInset.search ?? 0) : 0;
|
|
3933
4160
|
if (node.id >= 0) this.contentSizes.set(node.id, { width: plan.contentSize.width, height: plan.contentSize.height + inset });
|
|
3934
4161
|
y += inset;
|
|
3935
4162
|
const offset = (r: Rect): Rect => ({ x: x + r.x, y: y + r.y, width: r.width, height: r.height });
|
|
4163
|
+
// The Mac idiom's columns form: the label column (chrome `formLabel<i>`
|
|
4164
|
+
// with its text, or a view placed there) and the flag for the page.
|
|
4165
|
+
if (columns) {
|
|
4166
|
+
listChrome['columns'] = { x, y, width: plan.size.width, height: plan.size.height };
|
|
4167
|
+
(plan.labels ?? []).forEach((label, i) => {
|
|
4168
|
+
if (label.layout) {
|
|
4169
|
+
listChrome[`formLabel${i}`] = offset(label.rect);
|
|
4170
|
+
listTexts[`formLabel${i}`] = label.layout;
|
|
4171
|
+
}
|
|
4172
|
+
});
|
|
4173
|
+
}
|
|
4174
|
+
if (Object.keys(listChrome).length > 0) this.setChrome(node, listChrome);
|
|
4175
|
+
if (node.id >= 0 && Object.keys(listTexts).length > 0) this.chromeText.set(node.id, listTexts);
|
|
3936
4176
|
for (const group of plan.groups) {
|
|
3937
4177
|
if (group.section) {
|
|
3938
4178
|
const chrome: Record<string, Rect> = { rows: offset(group.card) };
|
|
@@ -4004,15 +4244,31 @@ export class LayoutPass {
|
|
|
4004
4244
|
if (row.edit.listRow === row.node) Object.assign(chrome, controls);
|
|
4005
4245
|
else this.setChrome(row.edit.listRow, controls);
|
|
4006
4246
|
}
|
|
4007
|
-
|
|
4247
|
+
let rowX = x + row.content.x;
|
|
4008
4248
|
const rowY = y + row.content.y;
|
|
4249
|
+
// A columns form's shifted stack: placed, then moved so its labeled
|
|
4250
|
+
// control starts at the controls column (the form's alignment guide).
|
|
4251
|
+
// Placed once to find the control's offset in it, then proposed the
|
|
4252
|
+
// width that ends it at the trailing edge, from where the control
|
|
4253
|
+
// lands at the column.
|
|
4254
|
+
const guide = columns ? this.macFormGuide(row.node) : null;
|
|
4255
|
+
let proposal = row.proposal;
|
|
4256
|
+
if (guide && plan.column) {
|
|
4257
|
+
this.place(row.node, rowCtx, row.proposal, rowX, rowY);
|
|
4258
|
+
const placed = this.frames.get(unwrapStyled(guide).id);
|
|
4259
|
+
if (placed) {
|
|
4260
|
+
const offset = placed.x - rowX;
|
|
4261
|
+
rowX = x + plan.column.x - offset;
|
|
4262
|
+
proposal = { width: Math.max(0, plan.size.width - (plan.column.x - offset)), height: row.proposal.height };
|
|
4263
|
+
}
|
|
4264
|
+
}
|
|
4009
4265
|
// A row that is skipped keeps the chrome it set itself when it was
|
|
4010
4266
|
// placed (a link's chevron and label); only the list's keys are new.
|
|
4011
|
-
const own = this.placedAlready(row.node, rowCtx,
|
|
4267
|
+
const own = this.placedAlready(row.node, rowCtx, proposal, rowX, rowY) && row.node.id >= 0
|
|
4012
4268
|
? this.chrome.get(row.node.id)
|
|
4013
4269
|
: undefined;
|
|
4014
4270
|
this.setChrome(row.node, own ? { ...own, ...chrome } : chrome);
|
|
4015
|
-
this.place(row.node, rowCtx,
|
|
4271
|
+
this.place(row.node, rowCtx, proposal, rowX, rowY);
|
|
4016
4272
|
if (row.badge) this.badgeBeforeChevron(row.node);
|
|
4017
4273
|
// iOS 26: the separator under a row starts where its first text
|
|
4018
4274
|
// starts (after a Label's icon), else at the row's content.
|
|
@@ -4032,6 +4288,12 @@ export class LayoutPass {
|
|
|
4032
4288
|
}
|
|
4033
4289
|
}
|
|
4034
4290
|
}
|
|
4291
|
+
// The label column's views (a stepper's, a slider's, a date picker's label) go where the plan put them, after the rows (which do not draw them).
|
|
4292
|
+
if (columns) {
|
|
4293
|
+
for (const label of plan.labels ?? []) {
|
|
4294
|
+
if (label.node) this.place(label.node, label.ctx, { width: null, height: null }, x + label.rect.x, y + label.rect.y);
|
|
4295
|
+
}
|
|
4296
|
+
}
|
|
4035
4297
|
}
|
|
4036
4298
|
|
|
4037
4299
|
/** The x of the first text (in tree order) placed under `node`, if any. */
|
|
@@ -4199,7 +4461,12 @@ export class LayoutPass {
|
|
|
4199
4461
|
chrome['keyboardBar'] = { x: x + KEYBOARD_BAR_MARGIN_H, y: y + size.height - keyboard, width: Math.max(0, size.width - 2 * KEYBOARD_BAR_MARGIN_H), height: KEYBOARD_BAR_HEIGHT };
|
|
4200
4462
|
}
|
|
4201
4463
|
const title = str(node.props['title']) ?? '';
|
|
4464
|
+
// `.navigationSubtitle`: a second line under the title in a Mac's unified
|
|
4465
|
+
// title bar (11 regular, secondary); after the title in a plain one.
|
|
4202
4466
|
const { platform } = this.env;
|
|
4467
|
+
const subtitle = mac ? (str(node.props['subtitle']) ?? '') : '';
|
|
4468
|
+
const shownTitle = subtitle.length > 0 && !unified && !sheetBar ? `${title} — ${subtitle}` : title;
|
|
4469
|
+
const subtitleFont = makeFont('system', textStyleSize('subheadline', dynamicTypeSize, platform), 400);
|
|
4203
4470
|
// A unified title bar's title is the window title (15 semibold on macOS 14); a plain bar's and iOS's is the headline.
|
|
4204
4471
|
const titleFont = makeFont('system', textStyleSize(unified ? 'title3' : 'headline', dynamicTypeSize, platform), sheetBar ? 700 : 600);
|
|
4205
4472
|
const largeTitleFont = makeFont('system', textStyleSize('largeTitle', dynamicTypeSize, platform), 700);
|
|
@@ -4537,7 +4804,16 @@ export class LayoutPass {
|
|
|
4537
4804
|
const titleX = Math.max(leadingStart + (unified && windowButtons && !back && leadingWidth === 0 && (cctx.barLeading ?? 0) === 0 ? MAC_TITLE_INSET : 0), leadingCursor);
|
|
4538
4805
|
const roomEnd = trailingUsed ? trailingEdge + NAV_GROUP_SPACING - 8 : x + size.width - NAV_ITEM_MARGIN;
|
|
4539
4806
|
const titleWidth = Math.min(Math.ceil(this.measurer.measureWidth(title, titleFont) - 0.001), Math.floor(Math.max(0, roomEnd - titleX)));
|
|
4540
|
-
|
|
4807
|
+
if (subtitle.length > 0) {
|
|
4808
|
+
// The title and the subtitle stacked, centered in the row.
|
|
4809
|
+
const block = titleFont.lineHeight + subtitleFont.lineHeight;
|
|
4810
|
+
const top = rowY + (rowHeight - block) / 2;
|
|
4811
|
+
chrome['title'] = { x: titleX, y: top, width: titleWidth, height: titleFont.lineHeight };
|
|
4812
|
+
const subtitleWidth = Math.min(Math.ceil(this.measurer.measureWidth(subtitle, subtitleFont) - 0.001), Math.floor(Math.max(0, roomEnd - titleX)));
|
|
4813
|
+
chrome['subtitle'] = { x: titleX, y: top + titleFont.lineHeight, width: subtitleWidth, height: subtitleFont.lineHeight };
|
|
4814
|
+
} else {
|
|
4815
|
+
chrome['title'] = { x: titleX, y: rowY, width: titleWidth, height: rowHeight };
|
|
4816
|
+
}
|
|
4541
4817
|
} else if ((!large || titleScrolls) && !principal && !barHidden) {
|
|
4542
4818
|
const leftUsed = Math.max(
|
|
4543
4819
|
back ? back.x + back.width - x : 0,
|
|
@@ -4553,7 +4829,7 @@ export class LayoutPass {
|
|
|
4553
4829
|
const roomEnd = x + size.width - rightUsed - 8;
|
|
4554
4830
|
const compactCap = screen.width < REGULAR_WIDTH ? Math.floor(size.width * 0.56) : Infinity;
|
|
4555
4831
|
const titleWidth = Math.min(
|
|
4556
|
-
Math.ceil(this.measurer.measureWidth(
|
|
4832
|
+
Math.ceil(this.measurer.measureWidth(shownTitle, titleFont) - 0.001),
|
|
4557
4833
|
compactCap,
|
|
4558
4834
|
Math.floor(Math.max(0, roomEnd - roomStart)),
|
|
4559
4835
|
);
|
|
@@ -4566,8 +4842,9 @@ export class LayoutPass {
|
|
|
4566
4842
|
if (node.id >= 0) {
|
|
4567
4843
|
this.fonts.set(node.id, large ? largeTitleFont : titleFont);
|
|
4568
4844
|
const texts: Record<string, TextLayout> = {
|
|
4569
|
-
title: { font: titleFont, lines: [
|
|
4845
|
+
title: { font: titleFont, lines: [shownTitle], alignment: unified || sheetBar ? 'leading' : 'center', truncated: false },
|
|
4570
4846
|
};
|
|
4847
|
+
if (chrome['subtitle']) texts['subtitle'] = { font: subtitleFont, lines: [subtitle], alignment: 'leading', truncated: false };
|
|
4571
4848
|
if (depth > 0) texts['back'] = { font: backFont, lines: [backLabel], alignment: 'leading', truncated: false };
|
|
4572
4849
|
if (large) {
|
|
4573
4850
|
const wrapped = wrapText(title, largeTitleFont, size.width - 32, 1, this.measurer);
|
|
@@ -5157,6 +5434,8 @@ export class LayoutPass {
|
|
|
5157
5434
|
private measureStepper(node: LayoutNode, ctx: Context, p: Proposal): Size {
|
|
5158
5435
|
const label = this.contentOf(node);
|
|
5159
5436
|
const control = this.stepperMetrics();
|
|
5437
|
+
// A Mac form's row: the label is in the label column; the control alone.
|
|
5438
|
+
if (ctx.formColumn) return { width: control.width, height: control.height };
|
|
5160
5439
|
const labelSize = label ? this.measure(label, this.childContext(node, ctx), this.stepperLabelProposal(p)) : ZERO;
|
|
5161
5440
|
return {
|
|
5162
5441
|
width: finite(p.width) ? p.width : labelSize.width + control.gap + control.width,
|
|
@@ -5168,11 +5447,12 @@ export class LayoutPass {
|
|
|
5168
5447
|
const cctx = this.childContext(node, ctx);
|
|
5169
5448
|
const label = this.contentOf(node);
|
|
5170
5449
|
const labelProposal = this.stepperLabelProposal(p);
|
|
5171
|
-
const labelSize = label ? this.measure(label, cctx, labelProposal) : ZERO;
|
|
5450
|
+
const labelSize = label && !ctx.formColumn ? this.measure(label, cctx, labelProposal) : ZERO;
|
|
5172
5451
|
const labelY = y + (size.height - labelSize.height) / 2;
|
|
5173
|
-
|
|
5452
|
+
// A Mac form's row: the label is in the label column (the form places it), the control at the leading edge.
|
|
5453
|
+
if (label && !ctx.formColumn) this.place(label, cctx, labelProposal, x, labelY);
|
|
5174
5454
|
const control = this.stepperMetrics();
|
|
5175
|
-
const controlX = x + size.width - control.width;
|
|
5455
|
+
const controlX = ctx.formColumn ? x : x + size.width - control.width;
|
|
5176
5456
|
const controlY = y + (size.height - control.height) / 2;
|
|
5177
5457
|
const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
|
|
5178
5458
|
// iOS: − and + side by side; a Mac: up (increment) over down.
|
|
@@ -5232,7 +5512,8 @@ export class LayoutPass {
|
|
|
5232
5512
|
private placeSlider(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number, size: Size): void {
|
|
5233
5513
|
const plan = this.sliderPlan(node, ctx, p);
|
|
5234
5514
|
const { cctx } = plan;
|
|
5235
|
-
|
|
5515
|
+
// The label is not drawn (a Mac form places it in its label column).
|
|
5516
|
+
if (plan.label && !ctx.formColumn) this.hide(plan.label, node);
|
|
5236
5517
|
const m = this.sliderMetrics();
|
|
5237
5518
|
const rowY = y;
|
|
5238
5519
|
let trackX = x;
|
|
@@ -5289,10 +5570,11 @@ export class LayoutPass {
|
|
|
5289
5570
|
const dateWidth = capsule(dateText);
|
|
5290
5571
|
const timeWidth = capsule(timeText);
|
|
5291
5572
|
const capsulesWidth = dateWidth + timeWidth + (dateText !== null && timeText !== null ? capsuleGap : 0);
|
|
5292
|
-
|
|
5573
|
+
// A Mac form's row: the label is in the label column; the fields alone.
|
|
5574
|
+
const label = node.props['labelsHidden'] === true || ctx.formColumn ? null : this.contentOf(node);
|
|
5293
5575
|
const labelProposal: Proposal = { width: shrink(p.width, capsulesWidth + DATEPICKER_GAP), height: p.height };
|
|
5294
5576
|
const labelSize = label ? this.measure(label, cctx, labelProposal) : ZERO;
|
|
5295
|
-
const width = finite(p.width) ? p.width : labelSize.width + (label ? DATEPICKER_GAP : 0) + capsulesWidth;
|
|
5577
|
+
const width = ctx.formColumn ? capsulesWidth : finite(p.width) ? p.width : labelSize.width + (label ? DATEPICKER_GAP : 0) + capsulesWidth;
|
|
5296
5578
|
return {
|
|
5297
5579
|
size: { width, height: Math.max(labelSize.height, mac ? MAC_DATEPICKER_HEIGHT : DATEPICKER_HEIGHT) },
|
|
5298
5580
|
label,
|
|
@@ -5308,8 +5590,8 @@ export class LayoutPass {
|
|
|
5308
5590
|
|
|
5309
5591
|
private placeDatePicker(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number, size: Size): void {
|
|
5310
5592
|
const plan = this.datePickerPlan(node, ctx, p);
|
|
5311
|
-
if (!plan.label) {
|
|
5312
|
-
// `labelsHidden`: the label child exists but is not shown.
|
|
5593
|
+
if (!plan.label && (!ctx.formColumn || node.props['labelsHidden'] === true)) {
|
|
5594
|
+
// `labelsHidden`: the label child exists but is not shown (a Mac form places it in its label column).
|
|
5313
5595
|
for (const child of node.children) this.hide(child, node);
|
|
5314
5596
|
}
|
|
5315
5597
|
const labelY = y + (size.height - plan.labelSize.height) / 2;
|
package/src/layoutDom.ts
CHANGED
|
@@ -877,6 +877,27 @@ function applyChrome(
|
|
|
877
877
|
}
|
|
878
878
|
bindScrollUnderBar(el, frame, chrome?.['topInset'], chrome?.['largeTitle']);
|
|
879
879
|
bindScrollEdgeBottom(el, chrome?.['bottomInset'], extent);
|
|
880
|
+
// The Mac idiom's columns form (`data-sb-form-columns`, styles.css):
|
|
881
|
+
// the label column's texts (chrome `formLabel<i>`) as spans in the
|
|
882
|
+
// content, as many as the engine placed.
|
|
883
|
+
setFlag(el, 'sbFormColumns', chrome?.['columns'] ? '' : null);
|
|
884
|
+
if (content) {
|
|
885
|
+
const texts = result.chromeText.get(node.id);
|
|
886
|
+
const labels = content.querySelectorAll<HTMLElement>(':scope > .sb-form-label');
|
|
887
|
+
let i = 0;
|
|
888
|
+
for (; chrome?.[`formLabel${i}`]; i++) {
|
|
889
|
+
let label = labels[i] ?? null;
|
|
890
|
+
if (!label) {
|
|
891
|
+
label = document.createElement('span');
|
|
892
|
+
label.className = 'sb-form-label';
|
|
893
|
+
content.append(label);
|
|
894
|
+
}
|
|
895
|
+
placeChrome(label, chrome[`formLabel${i}`], frame);
|
|
896
|
+
const layout = texts?.[`formLabel${i}`];
|
|
897
|
+
if (layout) setTextLayout(label, layout);
|
|
898
|
+
}
|
|
899
|
+
for (let extra = i; extra < labels.length; extra++) labels[extra]!.remove();
|
|
900
|
+
}
|
|
880
901
|
return;
|
|
881
902
|
}
|
|
882
903
|
case 'scrollview': {
|
|
@@ -976,6 +997,17 @@ function applyChrome(
|
|
|
976
997
|
if (inlineTitle.style.lineHeight !== lineHeight) inlineTitle.style.lineHeight = lineHeight;
|
|
977
998
|
}
|
|
978
999
|
}
|
|
1000
|
+
// The Mac idiom: the subtitle under the title in a unified title bar (chrome `subtitle`, hidden otherwise).
|
|
1001
|
+
const subtitleEl = el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-subtitle');
|
|
1002
|
+
if (subtitleEl) {
|
|
1003
|
+
const subtitleRect = chrome?.['subtitle'];
|
|
1004
|
+
placeChrome(subtitleEl, subtitleRect, frame);
|
|
1005
|
+
if (subtitleRect) {
|
|
1006
|
+
if (texts?.['subtitle']) setFont(subtitleEl, texts['subtitle'].font);
|
|
1007
|
+
const lineHeight = `${subtitleRect.height}px`;
|
|
1008
|
+
if (subtitleEl.style.lineHeight !== lineHeight) subtitleEl.style.lineHeight = lineHeight;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
979
1011
|
// The More button of the collapsed `secondary` items, at the trailing end.
|
|
980
1012
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-more'), chrome?.['more'], frame);
|
|
981
1013
|
const back = el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-back');
|
package/src/protocol.ts
CHANGED
|
@@ -623,6 +623,8 @@ export interface ScrollViewProps {
|
|
|
623
623
|
export type ListStyle = 'plain' | 'insetGrouped' | 'grouped' | 'sidebar';
|
|
624
624
|
export interface ListProps {
|
|
625
625
|
style?: ListStyle;
|
|
626
|
+
/** A `Form`: on a Mac the page lays its rows out in the columns style (labels in a column before their controls). */
|
|
627
|
+
form?: boolean;
|
|
626
628
|
}
|
|
627
629
|
export interface SectionProps {
|
|
628
630
|
header: string | null;
|
|
@@ -679,6 +681,8 @@ export type NavStackProps = Record<string, never>;
|
|
|
679
681
|
export type NavDisplayMode = 'automatic' | 'large' | 'inline';
|
|
680
682
|
export interface NavScreenProps {
|
|
681
683
|
title: string;
|
|
684
|
+
/** `.navigationSubtitle`: a second line under the title in a Mac's unified title bar, after it in a plain one. */
|
|
685
|
+
subtitle?: string;
|
|
682
686
|
displayMode: NavDisplayMode;
|
|
683
687
|
/** 0 for the root screen; the renderer draws a back button when > 0. */
|
|
684
688
|
depth: number;
|
package/src/renderer.ts
CHANGED
|
@@ -3163,6 +3163,9 @@ function createShell(kind: ElementKind): HTMLElement {
|
|
|
3163
3163
|
if (chevron) back.append(chevron);
|
|
3164
3164
|
back.append(span('sb-navbar-back-label'));
|
|
3165
3165
|
const inlineTitle = span('sb-navbar-title');
|
|
3166
|
+
// `.navigationSubtitle`: under the title in a Mac's unified title bar (chrome.subtitle).
|
|
3167
|
+
const subtitle = span('sb-navbar-subtitle');
|
|
3168
|
+
subtitle.hidden = true;
|
|
3166
3169
|
// The More button of the collapsed `secondary` items (chrome.more).
|
|
3167
3170
|
const more = document.createElement('button');
|
|
3168
3171
|
more.type = 'button';
|
|
@@ -3172,7 +3175,7 @@ function createShell(kind: ElementKind): HTMLElement {
|
|
|
3172
3175
|
more.hidden = true;
|
|
3173
3176
|
const ellipsis = renderSymbol('ellipsis');
|
|
3174
3177
|
if (ellipsis) more.append(ellipsis);
|
|
3175
|
-
bar.append(back, inlineTitle, div('sb-navbar-trailing'), more);
|
|
3178
|
+
bar.append(back, inlineTitle, subtitle, div('sb-navbar-trailing'), more);
|
|
3176
3179
|
const body = div('sb-navscreen-body');
|
|
3177
3180
|
const slot = div('sb-slot sb-navscreen-content');
|
|
3178
3181
|
// The `keyboard` toolbars' bar (chrome `keyboardBar`), under their items.
|
|
@@ -4787,6 +4790,11 @@ function applyNavScreen(el: HTMLElement, props: NavScreenProps): void {
|
|
|
4787
4790
|
const largeTitle = largeTitleOf(el);
|
|
4788
4791
|
// A large title that scrolls away collapses into the inline one (shown
|
|
4789
4792
|
// with `data-sb-collapsed`, src/largeTitle.ts).
|
|
4793
|
+
const subtitleEl = el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-subtitle');
|
|
4794
|
+
if (subtitleEl) {
|
|
4795
|
+
const subtitle = props.subtitle ?? '';
|
|
4796
|
+
if (subtitleEl.textContent !== subtitle) subtitleEl.textContent = subtitle;
|
|
4797
|
+
}
|
|
4790
4798
|
if (inlineTitle) {
|
|
4791
4799
|
const appearing = inlineTitle.textContent === '' && title !== '' && inlineTitle.isConnected;
|
|
4792
4800
|
inlineTitle.textContent = title;
|
package/src/styles.css
CHANGED
|
@@ -3944,7 +3944,8 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
3944
3944
|
.sb-datepicker > [hidden],
|
|
3945
3945
|
.sb-stepper > [hidden],
|
|
3946
3946
|
.sb-slider > [hidden],
|
|
3947
|
-
.sb-slider-labels > [hidden]
|
|
3947
|
+
.sb-slider-labels > [hidden],
|
|
3948
|
+
.sb-picker > .sb-picker-label[hidden] {
|
|
3948
3949
|
display: none;
|
|
3949
3950
|
}
|
|
3950
3951
|
|
|
@@ -4659,7 +4660,8 @@ html[data-sb-platform="macos"] .device[data-theme="light"] {
|
|
|
4659
4660
|
--sb-mac-control-shadow:
|
|
4660
4661
|
0 0 0 0.5px rgba(0, 0, 0, 0.12),
|
|
4661
4662
|
0 1px 1px rgba(0, 0, 0, 0.08);
|
|
4662
|
-
/* A text field
|
|
4663
|
+
/* A text field: the text background, its edges light at the sides and top, darker at the bottom. */
|
|
4664
|
+
--sb-mac-field: #ffffff;
|
|
4663
4665
|
--sb-mac-field-shadow:
|
|
4664
4666
|
inset 0 0 0 1px rgba(0, 0, 0, 0.11),
|
|
4665
4667
|
inset 0 -1px 0 0 rgba(0, 0, 0, 0.27);
|
|
@@ -4682,6 +4684,7 @@ html[data-sb-platform="macos"] .device[data-theme="dark"] {
|
|
|
4682
4684
|
--sb-mac-control-shadow:
|
|
4683
4685
|
0 0 0 0.5px rgba(0, 0, 0, 0.4),
|
|
4684
4686
|
0 1px 1px rgba(0, 0, 0, 0.2);
|
|
4687
|
+
--sb-mac-field: #333333;
|
|
4685
4688
|
--sb-mac-field-shadow:
|
|
4686
4689
|
inset 0 0 0 1px rgba(255, 255, 255, 0.12),
|
|
4687
4690
|
inset 0 -1px 0 0 rgba(255, 255, 255, 0.2);
|
|
@@ -4769,6 +4772,89 @@ html[data-sb-platform="macos"] .sb-navscreen[data-sb-unified] > .sb-navbar > .sb
|
|
|
4769
4772
|
font-size: var(--sb-ts-title3-size, 15px);
|
|
4770
4773
|
}
|
|
4771
4774
|
|
|
4775
|
+
/* `.navigationSubtitle`: a second line under the title in a unified title
|
|
4776
|
+
bar (the engine's `subtitle` rect; in a plain bar it joins the title). */
|
|
4777
|
+
.sb-navbar-subtitle {
|
|
4778
|
+
position: absolute;
|
|
4779
|
+
display: block;
|
|
4780
|
+
overflow: hidden;
|
|
4781
|
+
text-overflow: ellipsis;
|
|
4782
|
+
white-space: nowrap;
|
|
4783
|
+
text-align: start;
|
|
4784
|
+
font-size: 11px;
|
|
4785
|
+
color: var(--sb-color-secondary);
|
|
4786
|
+
}
|
|
4787
|
+
|
|
4788
|
+
.sb-navbar-subtitle[hidden] {
|
|
4789
|
+
display: none;
|
|
4790
|
+
}
|
|
4791
|
+
|
|
4792
|
+
/* A `Form` is the columns style (the engine's `columns` plan): on the
|
|
4793
|
+
window background with no cards or separators; the labels (chrome
|
|
4794
|
+
`formLabel<i>`) right-aligned in their column, the label color; a
|
|
4795
|
+
section's header and footer 13 regular, the label color; a text field's
|
|
4796
|
+
title moved to the column shows no placeholder. */
|
|
4797
|
+
html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] {
|
|
4798
|
+
background: var(--sb-color-system-background);
|
|
4799
|
+
}
|
|
4800
|
+
|
|
4801
|
+
/* The rows' card spans the form (the label column is drawn in it): no card look, nothing clipped. */
|
|
4802
|
+
html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] .sb-section-rows {
|
|
4803
|
+
border-radius: 0;
|
|
4804
|
+
background: transparent;
|
|
4805
|
+
overflow: visible;
|
|
4806
|
+
}
|
|
4807
|
+
|
|
4808
|
+
/* A text field's row is the field (the engine's row rect): the Mac's bezel, its text 3 in. */
|
|
4809
|
+
html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] .sb-textfield[data-sb-style] {
|
|
4810
|
+
box-sizing: border-box;
|
|
4811
|
+
padding: 0 3px;
|
|
4812
|
+
border-radius: 5px;
|
|
4813
|
+
background: var(--sb-mac-field);
|
|
4814
|
+
box-shadow: var(--sb-mac-field-shadow);
|
|
4815
|
+
}
|
|
4816
|
+
|
|
4817
|
+
html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] .sb-textfield[data-sb-style]:focus {
|
|
4818
|
+
box-shadow:
|
|
4819
|
+
var(--sb-mac-field-shadow),
|
|
4820
|
+
0 0 0 3.5px color-mix(in srgb, var(--sb-tint, var(--sb-color-accent)) 50%, transparent);
|
|
4821
|
+
}
|
|
4822
|
+
|
|
4823
|
+
html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] .sb-section-rows > ::after,
|
|
4824
|
+
html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] > .sb-list-content > :not(.sb-section)::after,
|
|
4825
|
+
html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] .sb-section-header::after,
|
|
4826
|
+
html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] .sb-section-footer::after {
|
|
4827
|
+
display: none;
|
|
4828
|
+
}
|
|
4829
|
+
|
|
4830
|
+
html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] .sb-section-header,
|
|
4831
|
+
html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] .sb-section-footer {
|
|
4832
|
+
color: var(--sb-color-primary);
|
|
4833
|
+
}
|
|
4834
|
+
|
|
4835
|
+
html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] .sb-form-label {
|
|
4836
|
+
position: absolute;
|
|
4837
|
+
white-space: pre;
|
|
4838
|
+
text-align: right;
|
|
4839
|
+
color: var(--sb-color-primary);
|
|
4840
|
+
}
|
|
4841
|
+
|
|
4842
|
+
@supports (text-box: trim-start cap alphabetic) {
|
|
4843
|
+
html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] .sb-form-label {
|
|
4844
|
+
text-box: trim-start cap alphabetic;
|
|
4845
|
+
padding-top: var(--sb-cap-inset);
|
|
4846
|
+
}
|
|
4847
|
+
}
|
|
4848
|
+
|
|
4849
|
+
html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] input.sb-textfield::placeholder {
|
|
4850
|
+
color: transparent;
|
|
4851
|
+
}
|
|
4852
|
+
|
|
4853
|
+
/* A pop-up button spanning the controls column: its value at the leading end, the chevrons at the trailing. */
|
|
4854
|
+
html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] .sb-picker[data-sb-popup] > .sb-picker-value {
|
|
4855
|
+
justify-content: space-between;
|
|
4856
|
+
}
|
|
4857
|
+
|
|
4772
4858
|
/* The tab bar: a segmented control at the top of the content (the engine's
|
|
4773
4859
|
Mac tab view), each tab a segment with its title alone, the selected one
|
|
4774
4860
|
raised; no icons, no glass. */
|
|
@@ -4987,11 +5073,15 @@ html[data-sb-platform="macos"] .sb-section-footer::after {
|
|
|
4987
5073
|
background: var(--sb-color-row-separator);
|
|
4988
5074
|
}
|
|
4989
5075
|
|
|
4990
|
-
/* The rule under a footer only where another section follows. */
|
|
5076
|
+
/* The rule under a footer only where another section follows (a columns form has none). */
|
|
4991
5077
|
html[data-sb-platform="macos"] .sb-section:has(+ .sb-section) > .sb-section-footer::after {
|
|
4992
5078
|
display: block;
|
|
4993
5079
|
}
|
|
4994
5080
|
|
|
5081
|
+
html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] .sb-section:has(+ .sb-section) > .sb-section-footer::after {
|
|
5082
|
+
display: none;
|
|
5083
|
+
}
|
|
5084
|
+
|
|
4995
5085
|
/* A button in a list row is a push button at its own size (the engine's
|
|
4996
5086
|
`bordered` rect, `data-sb-bordered="inset"`), on the row, not the row
|
|
4997
5087
|
itself; a destructive one is not tinted. */
|
|
@@ -5118,7 +5208,7 @@ html[data-sb-platform="macos"] .sb-textfield:not([data-sb-style="plain"]) {
|
|
|
5118
5208
|
padding: 0 6px;
|
|
5119
5209
|
border: 0;
|
|
5120
5210
|
border-radius: 5px;
|
|
5121
|
-
background: var(--sb-mac-
|
|
5211
|
+
background: var(--sb-mac-field);
|
|
5122
5212
|
box-shadow: var(--sb-mac-field-shadow);
|
|
5123
5213
|
font-size: 13px;
|
|
5124
5214
|
}
|
|
@@ -5149,6 +5239,11 @@ html[data-sb-platform="macos"] .sb-stepper[data-sb-arrows] > .sb-stepper-control
|
|
|
5149
5239
|
line-height: 0;
|
|
5150
5240
|
}
|
|
5151
5241
|
|
|
5242
|
+
/* The arrows stand in for the plus and minus glyphs. */
|
|
5243
|
+
html[data-sb-platform="macos"] .sb-stepper[data-sb-arrows] > .sb-stepper-control > .sb-stepper-button > svg {
|
|
5244
|
+
display: none;
|
|
5245
|
+
}
|
|
5246
|
+
|
|
5152
5247
|
html[data-sb-platform="macos"] .sb-stepper[data-sb-arrows] > .sb-stepper-control > .sb-stepper-button::after {
|
|
5153
5248
|
content: "";
|
|
5154
5249
|
width: 0;
|