@swiftbrowser/web 0.5.0 → 0.5.2
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 +97 -6
- package/src/layoutDom.ts +2 -0
- package/src/protocol.ts +1 -1
- package/src/renderer.ts +37 -2
- package/src/styles.css +42 -0
package/package.json
CHANGED
package/src/layout/engine.ts
CHANGED
|
@@ -270,6 +270,8 @@ const SEARCH_FIELD_DEFAULT_WIDTH = 200;
|
|
|
270
270
|
const IMAGE_SCALES: Readonly<Record<string, number>> = { small: 0.8, medium: 1, large: 1.25 };
|
|
271
271
|
// Phase 7 (docs/ops-protocol.md, "NavigationSplitView").
|
|
272
272
|
const NAVSPLIT_SIDEBAR_WIDTH = 320;
|
|
273
|
+
/** From this root width a window is regular (an iPad, a desktop browser), as `@swiftbrowser/react`'s size class. */
|
|
274
|
+
const REGULAR_WIDTH = 700;
|
|
273
275
|
/** The sidebar never takes more than this share of the split view's width. */
|
|
274
276
|
const NAVSPLIT_SIDEBAR_MAX_FRACTION = 0.4;
|
|
275
277
|
const NAVSPLIT_SEPARATOR_WIDTH = 1;
|
|
@@ -3085,7 +3087,11 @@ export class LayoutPass {
|
|
|
3085
3087
|
// The navigation bar's groups; spacers there only separate them (the
|
|
3086
3088
|
// groups are 8 apart anyway) and draw nothing.
|
|
3087
3089
|
for (const spacer of parts.toolbars.filter(isSpacer)) this.hide(spacer, node);
|
|
3088
|
-
const barToolbars = parts.toolbars.filter((t) =>
|
|
3090
|
+
const barToolbars = parts.toolbars.filter((t) => {
|
|
3091
|
+
const placement = str(t.props['placement']);
|
|
3092
|
+
return placement !== 'bottom' && placement !== 'secondary' && !isSpacer(t);
|
|
3093
|
+
});
|
|
3094
|
+
const secondaryToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'secondary' && !isSpacer(t));
|
|
3089
3095
|
const groups: Group[] = barToolbars.map((toolbar) => {
|
|
3090
3096
|
const itemCtx = this.childContext(toolbar, cctx);
|
|
3091
3097
|
const sizes = toolbar.children.map((item) => this.measure(item, itemCtx, { width: null, height: null }));
|
|
@@ -3103,6 +3109,53 @@ export class LayoutPass {
|
|
|
3103
3109
|
const trailingWidth = groupWidth('trailing');
|
|
3104
3110
|
const principal = groups.some((g) => g.placement === 'principal');
|
|
3105
3111
|
|
|
3112
|
+
// `.secondaryAction` items (placement `secondary`), as iOS treats them:
|
|
3113
|
+
// collapsed into the More button (chrome `more`: an ellipsis in a glass
|
|
3114
|
+
// circle at the trailing end, whose menu lists them). A screen with the
|
|
3115
|
+
// editor toolbar role in a regular width shows them inline before the
|
|
3116
|
+
// trailing items, as iPadOS does, clear of the title, and collapses only
|
|
3117
|
+
// those that do not fit.
|
|
3118
|
+
interface SecondaryItem {
|
|
3119
|
+
toolbar: LayoutNode;
|
|
3120
|
+
item: LayoutNode;
|
|
3121
|
+
size: Size;
|
|
3122
|
+
slot: number;
|
|
3123
|
+
}
|
|
3124
|
+
const secondaryItems: SecondaryItem[] = secondaryToolbars.flatMap((toolbar) => {
|
|
3125
|
+
const itemCtx = this.childContext(toolbar, cctx);
|
|
3126
|
+
return toolbar.children.map((item) => {
|
|
3127
|
+
const itemSize = this.measure(item, itemCtx, { width: null, height: null });
|
|
3128
|
+
return { toolbar, item, size: itemSize, slot: Math.max(NAV_GLASS_SIZE, itemSize.width + 2 * NAV_GLASS_PAD_H) };
|
|
3129
|
+
});
|
|
3130
|
+
});
|
|
3131
|
+
// Items of one toolbar share its glass capsule; separate toolbars sit 8 apart, as the trailing groups do.
|
|
3132
|
+
const secondaryGap = (previous: SecondaryItem | undefined, entry: SecondaryItem): number =>
|
|
3133
|
+
previous && previous.toolbar !== entry.toolbar ? NAV_GROUP_SPACING : 0;
|
|
3134
|
+
const secondaryWidth = (entries: readonly SecondaryItem[]): number =>
|
|
3135
|
+
entries.reduce((sum, entry, i) => sum + secondaryGap(entries[i - 1], entry) + entry.slot, 0);
|
|
3136
|
+
let inlineSecondary: SecondaryItem[] = [];
|
|
3137
|
+
if (secondaryItems.length > 0 && str(node.props['toolbarRole']) === 'editor' && screen.width >= REGULAR_WIDTH) {
|
|
3138
|
+
// The room between the title (centered, at its width up to 56% of the
|
|
3139
|
+
// bar) and the trailing groups; the More button takes a slot of it
|
|
3140
|
+
// once anything collapses.
|
|
3141
|
+
const titleWidth = principal ? 0 : Math.min(this.measurer.measureWidth(title, titleFont), size.width * 0.56);
|
|
3142
|
+
const roomStart = x + (size.width + titleWidth) / 2 + NAV_GROUP_SPACING;
|
|
3143
|
+
const roomEnd = x + size.width - NAV_ITEM_MARGIN - trailingWidth;
|
|
3144
|
+
const fitting = (room: number): SecondaryItem[] => {
|
|
3145
|
+
const out: SecondaryItem[] = [];
|
|
3146
|
+
let used = 0;
|
|
3147
|
+
for (const entry of secondaryItems) {
|
|
3148
|
+
const width = secondaryGap(out[out.length - 1], entry) + entry.slot;
|
|
3149
|
+
if (used + width > room) break;
|
|
3150
|
+
used += width;
|
|
3151
|
+
out.push(entry);
|
|
3152
|
+
}
|
|
3153
|
+
return out;
|
|
3154
|
+
};
|
|
3155
|
+
inlineSecondary = secondaryWidth(secondaryItems) <= roomEnd - roomStart ? secondaryItems : fitting(roomEnd - roomStart - NAV_GLASS_SIZE - NAV_GROUP_SPACING);
|
|
3156
|
+
}
|
|
3157
|
+
const collapsedSecondary = secondaryItems.filter((entry) => !inlineSecondary.includes(entry));
|
|
3158
|
+
|
|
3106
3159
|
// Back button, iOS 26: the chevron alone in a glass circle (the previous
|
|
3107
3160
|
// title no longer shows).
|
|
3108
3161
|
const backLabel = '';
|
|
@@ -3115,6 +3168,11 @@ export class LayoutPass {
|
|
|
3115
3168
|
const back = chrome['back'];
|
|
3116
3169
|
let leadingCursor = back ? back.x + back.width + NAV_GROUP_SPACING : leadingStart;
|
|
3117
3170
|
let trailingEdge = x + size.width - NAV_ITEM_MARGIN;
|
|
3171
|
+
// The More button is the trailing-most item.
|
|
3172
|
+
if (collapsedSecondary.length > 0) {
|
|
3173
|
+
chrome['more'] = { x: trailingEdge - NAV_GLASS_SIZE, y: rowY, width: NAV_GLASS_SIZE, height: NAV_GLASS_SIZE };
|
|
3174
|
+
trailingEdge -= NAV_GLASS_SIZE + NAV_GROUP_SPACING;
|
|
3175
|
+
}
|
|
3118
3176
|
const placeGroup = (group: Group, startX: number, top: number, rowHeight: number, spacing: number): void => {
|
|
3119
3177
|
const itemCtx = this.childContext(group.toolbar, cctx);
|
|
3120
3178
|
let cursor = startX;
|
|
@@ -3150,6 +3208,29 @@ export class LayoutPass {
|
|
|
3150
3208
|
}
|
|
3151
3209
|
}
|
|
3152
3210
|
}
|
|
3211
|
+
// The inline secondary items, right-aligned before the trailing groups,
|
|
3212
|
+
// each toolbar framed around its own; the collapsed ones are hidden (the
|
|
3213
|
+
// renderer lists the items without a frame in the More menu).
|
|
3214
|
+
if (inlineSecondary.length > 0) {
|
|
3215
|
+
const total = secondaryWidth(inlineSecondary);
|
|
3216
|
+
let cursor = trailingEdge - total;
|
|
3217
|
+
const extents = new Map<LayoutNode, { start: number; end: number }>();
|
|
3218
|
+
for (const [i, entry] of inlineSecondary.entries()) {
|
|
3219
|
+
cursor += secondaryGap(inlineSecondary[i - 1], entry);
|
|
3220
|
+
const itemCtx = this.childContext(entry.toolbar, cctx);
|
|
3221
|
+
this.place(entry.item, itemCtx, { width: null, height: null }, cursor + (entry.slot - entry.size.width) / 2, rowY + (NAV_BAR_HEIGHT - entry.size.height) / 2);
|
|
3222
|
+
const extent = extents.get(entry.toolbar);
|
|
3223
|
+
extents.set(entry.toolbar, { start: extent?.start ?? cursor, end: cursor + entry.slot });
|
|
3224
|
+
cursor += entry.slot;
|
|
3225
|
+
}
|
|
3226
|
+
for (const [toolbar, extent] of extents) this.setFrame(toolbar, extent.start, rowY, { width: extent.end - extent.start, height: NAV_BAR_HEIGHT });
|
|
3227
|
+
trailingEdge -= total + NAV_GROUP_SPACING;
|
|
3228
|
+
}
|
|
3229
|
+
for (const toolbar of secondaryToolbars) {
|
|
3230
|
+
const items = toolbar.children;
|
|
3231
|
+
if (!inlineSecondary.some((entry) => entry.toolbar === toolbar)) this.hide(toolbar, node);
|
|
3232
|
+
else for (const item of items) if (collapsedSecondary.some((entry) => entry.item === item)) this.hide(item, node);
|
|
3233
|
+
}
|
|
3153
3234
|
if (bottomBarHeight > 0) {
|
|
3154
3235
|
this.placeBottomToolbars(bottomToolbars, cctx, chrome, x, y + size.height - bottomBarHeight, size.width);
|
|
3155
3236
|
}
|
|
@@ -3179,11 +3260,21 @@ export class LayoutPass {
|
|
|
3179
3260
|
leadingWidth > 0 ? leadingCursor - NAV_GROUP_SPACING - x : 0,
|
|
3180
3261
|
cctx.barLeading ? leadingStart - NAV_GROUP_SPACING - x : 0,
|
|
3181
3262
|
);
|
|
3182
|
-
const
|
|
3183
|
-
const
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3263
|
+
const trailingUsed = trailingWidth > 0 || chrome['more'] !== undefined || inlineSecondary.length > 0;
|
|
3264
|
+
const rightUsed = trailingUsed ? x + size.width - (trailingEdge + NAV_GROUP_SPACING) : 0;
|
|
3265
|
+
// As UIKit places it: centered in the bar when it fits there; else slid
|
|
3266
|
+
// off-center into the room the items leave (between them, 8 clear of
|
|
3267
|
+
// each side), and truncated only when it does not fit even there.
|
|
3268
|
+
const roomStart = x + leftUsed + 8;
|
|
3269
|
+
const roomEnd = x + size.width - rightUsed - 8;
|
|
3270
|
+
const titleWidth = Math.min(
|
|
3271
|
+
Math.ceil(this.measurer.measureWidth(title, titleFont) - 0.001),
|
|
3272
|
+
Math.floor(size.width * 0.56),
|
|
3273
|
+
Math.floor(Math.max(0, roomEnd - roomStart)),
|
|
3274
|
+
);
|
|
3275
|
+
const centered = x + (size.width - titleWidth) / 2;
|
|
3276
|
+
const titleX = Math.max(roomStart, Math.min(centered, roomEnd - titleWidth));
|
|
3277
|
+
chrome['title'] = { x: titleX, y: rowY, width: titleWidth, height: NAV_BAR_HEIGHT };
|
|
3187
3278
|
}
|
|
3188
3279
|
this.setChrome(node, chrome);
|
|
3189
3280
|
|
package/src/layoutDom.ts
CHANGED
|
@@ -592,6 +592,8 @@ function applyChrome(
|
|
|
592
592
|
if (inlineTitle.style.lineHeight !== lineHeight) inlineTitle.style.lineHeight = lineHeight;
|
|
593
593
|
}
|
|
594
594
|
}
|
|
595
|
+
// The More button of the collapsed `secondary` items, at the trailing end.
|
|
596
|
+
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-more'), chrome?.['more'], frame);
|
|
595
597
|
const back = el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-back');
|
|
596
598
|
const backLabel = back?.querySelector<HTMLElement>('.sb-navbar-back-label');
|
|
597
599
|
const backRect = chrome?.['back'];
|
package/src/protocol.ts
CHANGED
|
@@ -860,7 +860,7 @@ export interface DecoratedProps {
|
|
|
860
860
|
export interface MenuProps {
|
|
861
861
|
deferred?: boolean;
|
|
862
862
|
}
|
|
863
|
-
export type ToolbarPlacement = 'trailing' | 'leading' | 'principal' | 'bottom';
|
|
863
|
+
export type ToolbarPlacement = 'trailing' | 'leading' | 'principal' | 'bottom' | 'secondary';
|
|
864
864
|
/**
|
|
865
865
|
* `.toolbar { ToolbarItem(placement:) { ... } }`, an extra child of a
|
|
866
866
|
* `navscreen` after its content. The children are the items, laid out at
|
package/src/renderer.ts
CHANGED
|
@@ -401,7 +401,7 @@ export class Renderer {
|
|
|
401
401
|
// `closest` picks the innermost control, so a button inside a
|
|
402
402
|
// `tapgesture` (or a menu label) handles the click alone.
|
|
403
403
|
const target = (event.target as HTMLElement | null)?.closest<HTMLElement>(
|
|
404
|
-
'.sb-button, .sb-navlink, .sb-navbar-back, .sb-navsplit-toggle, .sb-switch, .sb-sheet-scrim, ' +
|
|
404
|
+
'.sb-button, .sb-navlink, .sb-navbar-back, .sb-navbar-more, .sb-navsplit-toggle, .sb-switch, .sb-sheet-scrim, ' +
|
|
405
405
|
'.sb-tabbar-item, .sb-segment, .sb-picker-option, .sb-picker-menu-item, .sb-picker[data-sb-style="menu"], ' +
|
|
406
406
|
'.sb-menu, .sb-tapgesture, .sb-stepper-button, .sb-alert-scrim, .sb-alert-ok, ' +
|
|
407
407
|
// Phase 8: swipe action buttons, the edit-mode row control and selectable rows (the row sends `tap` itself)
|
|
@@ -510,6 +510,16 @@ export class Renderer {
|
|
|
510
510
|
this.onEvent({ type: 'tap', id });
|
|
511
511
|
return;
|
|
512
512
|
}
|
|
513
|
+
if (target.classList.contains('sb-navbar-more')) {
|
|
514
|
+
// The collapsed `secondary` items, as a menu under the button.
|
|
515
|
+
const screen = target.closest<HTMLElement>('.sb-navscreen');
|
|
516
|
+
const id = Number(screen?.dataset[ID_ATTR]);
|
|
517
|
+
if (!screen || !Number.isInteger(id)) return;
|
|
518
|
+
event.preventDefault();
|
|
519
|
+
if (this.menu?.anchorId === id) this.closeMenu();
|
|
520
|
+
else this.openMenu(target, id, this.collapsedSecondaryRows(id));
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
513
523
|
if (target.classList.contains('sb-navbar-back')) {
|
|
514
524
|
const id = Number(target.dataset['sbTarget']);
|
|
515
525
|
if (!Number.isInteger(id)) return;
|
|
@@ -1252,6 +1262,21 @@ export class Renderer {
|
|
|
1252
1262
|
* and navlinks become tap rows, pickers a checked section, dividers and
|
|
1253
1263
|
* sections separators and captions, texts disabled captions.
|
|
1254
1264
|
*/
|
|
1265
|
+
/**
|
|
1266
|
+
* A navscreen's More menu: its `secondary` toolbar items the engine
|
|
1267
|
+
* collapsed (those it gave no frame), in order.
|
|
1268
|
+
*/
|
|
1269
|
+
private collapsedSecondaryRows(id: ElementId): MenuRow[] {
|
|
1270
|
+
const screen = this.nodes.get(id);
|
|
1271
|
+
if (!screen) return [];
|
|
1272
|
+
const frames = this.layoutResult?.frames;
|
|
1273
|
+
const items = screen.children
|
|
1274
|
+
.filter((child) => child.kind === 'toolbar' && child.props['placement'] === 'secondary')
|
|
1275
|
+
.flatMap((toolbar) => toolbar.children)
|
|
1276
|
+
.filter((item) => !frames?.has(item.id));
|
|
1277
|
+
return collectMenuRows(items);
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1255
1280
|
private menuRows(id: ElementId): MenuRow[] {
|
|
1256
1281
|
const node = this.nodes.get(id);
|
|
1257
1282
|
return node ? collectMenuRows(node.children.slice(1)) : [];
|
|
@@ -2013,7 +2038,16 @@ function createShell(kind: ElementKind): HTMLElement {
|
|
|
2013
2038
|
if (chevron) back.append(chevron);
|
|
2014
2039
|
back.append(span('sb-navbar-back-label'));
|
|
2015
2040
|
const inlineTitle = span('sb-navbar-title');
|
|
2016
|
-
|
|
2041
|
+
// The More button of the collapsed `secondary` items (chrome.more).
|
|
2042
|
+
const more = document.createElement('button');
|
|
2043
|
+
more.type = 'button';
|
|
2044
|
+
more.className = 'sb-navbar-more';
|
|
2045
|
+
more.setAttribute('aria-label', 'More');
|
|
2046
|
+
more.setAttribute('aria-haspopup', 'menu');
|
|
2047
|
+
more.hidden = true;
|
|
2048
|
+
const ellipsis = renderSymbol('ellipsis');
|
|
2049
|
+
if (ellipsis) more.append(ellipsis);
|
|
2050
|
+
bar.append(back, inlineTitle, div('sb-navbar-trailing'), more);
|
|
2017
2051
|
const body = div('sb-navscreen-body');
|
|
2018
2052
|
const slot = div('sb-slot sb-navscreen-content');
|
|
2019
2053
|
body.append(div('sb-navbar-large-title'), slot);
|
|
@@ -2768,6 +2802,7 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
|
|
|
2768
2802
|
}
|
|
2769
2803
|
case 'toolbar': {
|
|
2770
2804
|
const placement = (props as ToolbarProps).placement;
|
|
2805
|
+
// `secondary` items shown in the bar look like the trailing ones (glass capsules).
|
|
2771
2806
|
el.dataset['sbPlacement'] =
|
|
2772
2807
|
placement === 'leading' || placement === 'principal' || placement === 'bottom' ? placement : 'trailing';
|
|
2773
2808
|
// A `ToolbarSpacer`: draws nothing.
|
package/src/styles.css
CHANGED
|
@@ -1001,6 +1001,41 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
1001
1001
|
display: none;
|
|
1002
1002
|
}
|
|
1003
1003
|
|
|
1004
|
+
/* The More button of the collapsed `.secondaryAction` items: the ellipsis
|
|
1005
|
+
in a 44pt glass circle at the engine's `more` rect (iOS 26). */
|
|
1006
|
+
.sb-navbar-more {
|
|
1007
|
+
appearance: none;
|
|
1008
|
+
display: inline-flex;
|
|
1009
|
+
align-items: center;
|
|
1010
|
+
justify-content: center;
|
|
1011
|
+
margin: 0;
|
|
1012
|
+
padding: 0;
|
|
1013
|
+
border: 0;
|
|
1014
|
+
border-radius: 22px;
|
|
1015
|
+
background: var(--sb-glass-background);
|
|
1016
|
+
box-shadow: var(--sb-glass-shadow);
|
|
1017
|
+
backdrop-filter: blur(12px) saturate(180%);
|
|
1018
|
+
-webkit-backdrop-filter: blur(12px) saturate(180%);
|
|
1019
|
+
color: var(--sb-color-primary);
|
|
1020
|
+
font-size: var(--sb-font-size-body);
|
|
1021
|
+
cursor: pointer;
|
|
1022
|
+
touch-action: manipulation;
|
|
1023
|
+
-webkit-tap-highlight-color: transparent;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
.sb-navbar-more[hidden] {
|
|
1027
|
+
display: none;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
.sb-navbar-more:active {
|
|
1031
|
+
opacity: 0.3;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
.sb-navbar-more > svg {
|
|
1035
|
+
width: 1.2em;
|
|
1036
|
+
height: 1.2em;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1004
1039
|
.sb-navbar-back:active {
|
|
1005
1040
|
opacity: 0.3;
|
|
1006
1041
|
}
|
|
@@ -2097,6 +2132,13 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
2097
2132
|
display: block;
|
|
2098
2133
|
}
|
|
2099
2134
|
|
|
2135
|
+
/* A toolbar the engine hid (a spacer, `secondary` items collapsed into the
|
|
2136
|
+
More menu), and its items collapsed one by one, draw nothing. */
|
|
2137
|
+
.sb-toolbar[hidden],
|
|
2138
|
+
.sb-toolbar > [data-sb-id][hidden] {
|
|
2139
|
+
display: none;
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2100
2142
|
/* iOS 26: a bar's leading and trailing items share a glass capsule (the
|
|
2101
2143
|
engine sizes the group: 44 tall, each item in a slot at least 44 wide),
|
|
2102
2144
|
drawn in the label color unless tinted. */
|