@swiftbrowser/web 0.5.0 → 0.5.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "description": "SwiftBrowser web renderer and dev server: applies render ops from a Swift/Wasm app to the DOM inside an iPhone frame.",
6
6
  "repository": {
@@ -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) => str(t.props['placement']) !== 'bottom' && !isSpacer(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,7 +3260,8 @@ 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 rightUsed = trailingWidth > 0 ? x + size.width - (trailingEdge + NAV_GROUP_SPACING) : 0;
3263
+ const trailingUsed = trailingWidth > 0 || chrome['more'] !== undefined || inlineSecondary.length > 0;
3264
+ const rightUsed = trailingUsed ? x + size.width - (trailingEdge + NAV_GROUP_SPACING) : 0;
3183
3265
  const side = Math.max(leftUsed, rightUsed) + 8;
3184
3266
  const available = Math.max(0, size.width - 2 * side);
3185
3267
  const titleWidth = Math.min(Math.ceil(this.measurer.measureWidth(title, titleFont) - 0.001), Math.floor(size.width * 0.56), Math.floor(available));
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
- bar.append(back, inlineTitle, div('sb-navbar-trailing'));
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. */