@swiftbrowser/web 0.6.0 → 0.6.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/animation.ts +44 -12
- package/src/chartDom.ts +6 -2
- package/src/layout/engine.ts +164 -50
- package/src/layout/index.ts +1 -1
- package/src/layoutDom.ts +40 -33
- package/src/press.ts +51 -13
- package/src/protocol.ts +16 -0
- package/src/renderer.ts +48 -1
- package/src/styles.css +109 -58
- package/src/styles.ts +18 -5
- package/src/symbols.ts +15 -7
package/package.json
CHANGED
package/src/animation.ts
CHANGED
|
@@ -287,8 +287,8 @@ export const DEFAULT_TRANSITION: Transition = { kind: 'opacity' };
|
|
|
287
287
|
/** The spring a bar toolbar's groups change with (iOS 26), and how its items come and go. */
|
|
288
288
|
export const TOOLBAR_ANIMATION: Animation = { type: 'spring', duration: 0.5, bounce: 0.2 };
|
|
289
289
|
export const TOOLBAR_TRANSITION: Transition = { kind: 'scale', scale: 0.6 };
|
|
290
|
-
/** The `kind` of a
|
|
291
|
-
export const
|
|
290
|
+
/** The `kind` of a bar glass capsule's frame change (src/layoutDom.ts): chrome, not an element. */
|
|
291
|
+
export const BAR_GLASS_KIND = 'barglass';
|
|
292
292
|
|
|
293
293
|
const BAR_PLACEMENTS: ReadonlySet<string> = new Set(['leading', 'trailing', 'principal', 'bottom']);
|
|
294
294
|
|
|
@@ -316,18 +316,24 @@ function implicitTransitionOf(el: HTMLElement, parent: Element | null = el.paren
|
|
|
316
316
|
/**
|
|
317
317
|
* The bar toolbars whose groups this pass changed, each with its screen: one
|
|
318
318
|
* that gained or lost an item (or came or went itself, on a screen already
|
|
319
|
-
* there), or whose capsule or an item of it resized
|
|
320
|
-
* moved
|
|
321
|
-
* its
|
|
319
|
+
* there), or whose capsule or an item of it resized; and a group that only
|
|
320
|
+
* moved because another of its screen's changed, which slides over with
|
|
321
|
+
* it, as its capsule does. A group that only moved on its own (the screen
|
|
322
|
+
* resized, a More button appeared beside it) is not one: its items are as
|
|
323
|
+
* they were.
|
|
322
324
|
*/
|
|
323
325
|
export function implicitToolbars(pass: PassRecord, frames: readonly FrameChange[]): Map<HTMLElement, Element | null> {
|
|
324
326
|
const out = new Map<HTMLElement, Element | null>();
|
|
327
|
+
const moved = new Map<HTMLElement, Element | null>();
|
|
325
328
|
const isNew = (toolbar: HTMLElement): boolean => pass.inserted.has(toolbar) || hasAncestorIn(toolbar, pass.inserted);
|
|
326
329
|
for (const change of frames) {
|
|
327
330
|
const toolbar = barToolbarOf(change.el);
|
|
328
331
|
if (!toolbar || !change.from || !change.el.isConnected || isNew(toolbar)) continue;
|
|
329
332
|
const { from, to } = change;
|
|
330
|
-
if (change.el === toolbar && from.width === to.width && from.height === to.height)
|
|
333
|
+
if (change.el === toolbar && from.width === to.width && from.height === to.height) {
|
|
334
|
+
moved.set(toolbar, toolbar.closest('.sb-navscreen'));
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
331
337
|
out.set(toolbar, toolbar.closest('.sb-navscreen'));
|
|
332
338
|
}
|
|
333
339
|
for (const el of pass.inserted) {
|
|
@@ -343,6 +349,11 @@ export function implicitToolbars(pass: PassRecord, frames: readonly FrameChange[
|
|
|
343
349
|
// A removed group is detached: its screen is among the ancestors it had.
|
|
344
350
|
out.set(toolbar, toolbar.isConnected ? toolbar.closest('.sb-navscreen') : (rec.ancestors.find((a) => a.classList.contains('sb-navscreen')) ?? null));
|
|
345
351
|
}
|
|
352
|
+
// (a group the bar does not draw, `secondary` items collapsed into the More button, moves nothing beside it)
|
|
353
|
+
const changedScreens = new Set(Array.from(out).filter(([toolbar]) => !toolbar.hidden).map(([, screen]) => screen));
|
|
354
|
+
for (const [toolbar, screen] of moved) {
|
|
355
|
+
if (!out.has(toolbar) && changedScreens.has(screen)) out.set(toolbar, screen);
|
|
356
|
+
}
|
|
346
357
|
return out;
|
|
347
358
|
}
|
|
348
359
|
|
|
@@ -495,12 +506,19 @@ export class Animator {
|
|
|
495
506
|
if (rec.tokenChanged) tokenRoots.set(el, rec.implicit ? commitAnimation : rec.animation);
|
|
496
507
|
}
|
|
497
508
|
// iOS 26: a bar toolbar's groups change with a spring of their own (see
|
|
498
|
-
// the top of the file); the
|
|
499
|
-
//
|
|
509
|
+
// the top of the file); the bar's glass capsules, beside their toolbars
|
|
510
|
+
// rather than around them, follow the screen's bar toolbars.
|
|
511
|
+
// The spring moves the groups and their items, not what is inside an
|
|
512
|
+
// item: a button whose label changes (a tool selected, its symbol now
|
|
513
|
+
// on a tinted disc) changes in place, as UIKit redraws a bar item.
|
|
500
514
|
const glassScreens = new Set<Element | null>();
|
|
515
|
+
const groupsOnly = new Set<HTMLElement>();
|
|
501
516
|
for (const [toolbar, screen] of implicitToolbars(pass, frames)) {
|
|
502
|
-
if (!tokenRoots.has(toolbar))
|
|
503
|
-
|
|
517
|
+
if (!tokenRoots.has(toolbar)) {
|
|
518
|
+
tokenRoots.set(toolbar, commitAnimation ?? TOOLBAR_ANIMATION);
|
|
519
|
+
if (!commitAnimation) groupsOnly.add(toolbar);
|
|
520
|
+
}
|
|
521
|
+
glassScreens.add(screen);
|
|
504
522
|
}
|
|
505
523
|
if (!commitAnimation && tokenRoots.size === 0) {
|
|
506
524
|
// Not animated: frames still tweening toward a box this pass replaced
|
|
@@ -517,8 +535,14 @@ export class Animator {
|
|
|
517
535
|
const matchedTo = new Map<RemovedRecord, { from: DOMRect; to: DOMRect }>();
|
|
518
536
|
|
|
519
537
|
const animationFor = (chain: Iterable<HTMLElement>): Animation | undefined | false => {
|
|
538
|
+
let depth = 0;
|
|
520
539
|
for (const el of chain) {
|
|
521
|
-
if (tokenRoots.has(el))
|
|
540
|
+
if (tokenRoots.has(el)) {
|
|
541
|
+
// (below an item of a group the bar's own spring moves: not animated)
|
|
542
|
+
if (groupsOnly.has(el) && depth > 1) return false;
|
|
543
|
+
return tokenRoots.get(el) ?? { type: 'default' };
|
|
544
|
+
}
|
|
545
|
+
depth++;
|
|
522
546
|
}
|
|
523
547
|
return commitAnimation ?? false;
|
|
524
548
|
};
|
|
@@ -549,9 +573,12 @@ export class Animator {
|
|
|
549
573
|
if (!from || !el.isConnected || pass.inserted.has(el)) continue;
|
|
550
574
|
if (this.selfAnimating.has(change.kind)) continue;
|
|
551
575
|
let animation = animationFor(ancestorsAndSelf(el));
|
|
552
|
-
if (animation === false && change.kind ===
|
|
576
|
+
if (animation === false && change.kind === BAR_GLASS_KIND && glassScreens.has(el.parentElement)) {
|
|
553
577
|
animation = TOOLBAR_ANIMATION;
|
|
554
578
|
}
|
|
579
|
+
// An item of such a group that only resized about its own center (its
|
|
580
|
+
// label changed, its slot stayed) is redrawn in place, not tweened.
|
|
581
|
+
if (animation !== false && groupsOnly.has(el.parentElement as HTMLElement) && sameCenter(from, to)) animation = false;
|
|
555
582
|
if (animation === false) {
|
|
556
583
|
// Moved in place, unless it carries on with a tween of its own.
|
|
557
584
|
if (!this.retargetFrame(change, null)) jumped.set(el, { x: to.x - from.x, y: to.y - from.y });
|
|
@@ -975,6 +1002,11 @@ function jumpAbove(
|
|
|
975
1002
|
}
|
|
976
1003
|
|
|
977
1004
|
/** The element's ancestors, nearest first. */
|
|
1005
|
+
/** Whether two boxes share their center (to within a point: each edge is snapped to a whole point). */
|
|
1006
|
+
function sameCenter(a: FrameRect, b: FrameRect): boolean {
|
|
1007
|
+
return Math.abs(a.x + a.width / 2 - (b.x + b.width / 2)) <= 1 && Math.abs(a.y + a.height / 2 - (b.y + b.height / 2)) <= 1;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
978
1010
|
function ancestorsOf(el: HTMLElement): HTMLElement[] {
|
|
979
1011
|
const out: HTMLElement[] = [];
|
|
980
1012
|
for (let a = el.parentElement; a; a = a.parentElement) out.push(a);
|
package/src/chartDom.ts
CHANGED
|
@@ -135,11 +135,15 @@ function buildPlot(doc: Document, props: Partial<ChartProps>, plot: Rect, box: R
|
|
|
135
135
|
const grid = svg(doc, 'g', { class: 'sb-chart-grid' });
|
|
136
136
|
for (const u of gridLines(props.xAxis)) {
|
|
137
137
|
const x = r2(Math.floor(mapX(local, u)) + 0.5);
|
|
138
|
-
|
|
138
|
+
const line = svg(doc, 'line', { class: 'sb-chart-gridline', 'data-sb-axis': 'x', x1: x, y1: 0, x2: x, y2: h });
|
|
139
|
+
if (props.xAxis?.dashed) line.dataset['sbDashed'] = '';
|
|
140
|
+
grid.append(line);
|
|
139
141
|
}
|
|
140
142
|
for (const u of gridLines(props.yAxis)) {
|
|
141
143
|
const y = r2(Math.floor(mapY(local, u)) + 0.5);
|
|
142
|
-
|
|
144
|
+
const line = svg(doc, 'line', { class: 'sb-chart-gridline', 'data-sb-axis': 'y', x1: 0, y1: y, x2: w, y2: y });
|
|
145
|
+
if (props.yAxis?.dashed) line.dataset['sbDashed'] = '';
|
|
146
|
+
grid.append(line);
|
|
143
147
|
}
|
|
144
148
|
root.append(grid);
|
|
145
149
|
|
package/src/layout/engine.ts
CHANGED
|
@@ -252,21 +252,38 @@ const NAV_ITEM_MARGIN = 16;
|
|
|
252
252
|
// iOS 26: the back button is a 44pt glass circle 16 from the leading edge,
|
|
253
253
|
// and the bar's leading and trailing items sit in glass capsules 44 tall,
|
|
254
254
|
// each item in a slot: a symbol the 44 circle, anything else (text, a
|
|
255
|
-
// label) its width and 12 either side, at least 44
|
|
255
|
+
// label) its width and 12 either side, at least 44. Adjacent items of one
|
|
256
|
+
// placement share a capsule (a run; a `ToolbarSpacer` of that placement ends
|
|
257
|
+
// one), their slots 12 apart and the capsule a point past the end slots; the
|
|
258
|
+
// capsules, the back button and the More button sit 12 apart (measured in
|
|
259
|
+
// the iOS 26 simulator, Examples/Fidelity `topbar*`).
|
|
256
260
|
const NAV_GLASS_SIZE = 44;
|
|
257
261
|
const NAV_GLASS_PAD_H = 12;
|
|
258
|
-
const NAV_GROUP_SPACING =
|
|
262
|
+
const NAV_GROUP_SPACING = 12;
|
|
263
|
+
const NAV_RUN_GAP = 12;
|
|
264
|
+
const NAV_RUN_PAD = 1;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The children an item draws in the bar: a menu or context menu draws its
|
|
268
|
+
* label (the first child) and lists the rest in its popover, so its items
|
|
269
|
+
* (which a lazy menu only builds when it opens) do not make it wider or a
|
|
270
|
+
* text item.
|
|
271
|
+
*/
|
|
272
|
+
function drawnChildren(node: LayoutNode): LayoutNode[] {
|
|
273
|
+
return node.kind === 'menu' || node.kind === 'contextmenu' ? node.children.slice(0, 1) : node.children;
|
|
274
|
+
}
|
|
259
275
|
|
|
260
276
|
/** Whether any text is laid out in an item (a bottom bar pads text items as text). */
|
|
261
277
|
function hasText(item: LayoutNode): boolean {
|
|
262
|
-
return item.kind === 'text' || item.
|
|
278
|
+
return item.kind === 'text' || drawnChildren(item).some(hasText);
|
|
263
279
|
}
|
|
264
280
|
|
|
265
281
|
/** A bar item that is only a symbol (an `image`, or a button, menu or link around one). */
|
|
266
282
|
function isSymbolItem(item: LayoutNode): boolean {
|
|
267
283
|
const inner = unwrapStyled(item);
|
|
268
284
|
if (inner.kind === 'image') return true;
|
|
269
|
-
|
|
285
|
+
const drawn = drawnChildren(inner);
|
|
286
|
+
return drawn.length === 1 && isSymbolItem(drawn[0] as LayoutNode);
|
|
270
287
|
}
|
|
271
288
|
|
|
272
289
|
/** A bar item's slot in its glass capsule. */
|
|
@@ -294,9 +311,11 @@ const BOTTOM_FIXED_SPACER = 8;
|
|
|
294
311
|
const SEARCH_FIELD_HEIGHT = 36;
|
|
295
312
|
const SEARCH_FIELD_MARGIN_H = 16;
|
|
296
313
|
// iOS 26: a navigation stack's search field is a 48-tall glass capsule
|
|
297
|
-
// floating
|
|
298
|
-
//
|
|
314
|
+
// floating 28 from the sides (measured in the simulator: x 28.67…364.33 on the
|
|
315
|
+
// Fidelity `search` screen and Food Truck's Donuts) and 28 above the screen
|
|
316
|
+
// bottom (8 above a view that ends higher); the content stops above it.
|
|
299
317
|
const SEARCH_BOTTOM_HEIGHT = 48;
|
|
318
|
+
const SEARCH_BOTTOM_MARGIN_H = 28;
|
|
300
319
|
const SEARCH_BOTTOM_MARGIN = 28;
|
|
301
320
|
const SEARCH_BOTTOM_MARGIN_INSET = 8;
|
|
302
321
|
const SEARCH_FIELD_DEFAULT_WIDTH = 200;
|
|
@@ -2094,6 +2113,17 @@ export class LayoutPass {
|
|
|
2094
2113
|
const child = edge === 'top' ? node.children[0] : node.children[node.children.length - 1];
|
|
2095
2114
|
return child && !isSpacer(child) ? this.textEdge(child, this.childContext(node, ctx), edge, depth + 1) : null;
|
|
2096
2115
|
}
|
|
2116
|
+
case 'hstack': {
|
|
2117
|
+
// A row with text in it (Food Truck's flavor line: a symbol and its
|
|
2118
|
+
// name) is spaced as its largest text, as iOS 26 spaces it.
|
|
2119
|
+
const cctx = this.childContext(node, ctx);
|
|
2120
|
+
let font: ResolvedFont | null = null;
|
|
2121
|
+
for (const child of node.children) {
|
|
2122
|
+
const found = this.textEdge(child, cctx, edge, depth + 1);
|
|
2123
|
+
if (found && (!font || found.size > font.size)) font = found;
|
|
2124
|
+
}
|
|
2125
|
+
return font;
|
|
2126
|
+
}
|
|
2097
2127
|
default:
|
|
2098
2128
|
return null;
|
|
2099
2129
|
}
|
|
@@ -3242,11 +3272,14 @@ export class LayoutPass {
|
|
|
3242
3272
|
* (iOS 26); `bottom` toolbars float their items on glass in a 48 row 28
|
|
3243
3273
|
* above the bottom (`placeBottomToolbars`); the content is proposed the
|
|
3244
3274
|
* rest. Toolbar items are measured at their ideal size and laid out in iOS
|
|
3245
|
-
* 26 glass capsules
|
|
3246
|
-
*
|
|
3247
|
-
* `
|
|
3248
|
-
*
|
|
3249
|
-
*
|
|
3275
|
+
* 26 glass capsules in the 44 row: adjacent `leading` (or `trailing`)
|
|
3276
|
+
* toolbars share one (a run, chrome `leadingGlass0` …, `trailingGlass0` …;
|
|
3277
|
+
* a `ToolbarSpacer` of the placement ends one), their items in 44-tall
|
|
3278
|
+
* slots 12 apart, the capsules 12 apart (`leading` after the back button,
|
|
3279
|
+
* `trailing` ending 16 from the trailing edge, in their order in the
|
|
3280
|
+
* content); a `principal` group is centered in place of the title. The
|
|
3281
|
+
* inline title (`chrome.title`) is centered and truncated to the room the
|
|
3282
|
+
* bar items leave.
|
|
3250
3283
|
*/
|
|
3251
3284
|
private placeNavScreen(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
3252
3285
|
const { safeArea, screen, dynamicTypeSize } = this.env;
|
|
@@ -3284,10 +3317,11 @@ export class LayoutPass {
|
|
|
3284
3317
|
};
|
|
3285
3318
|
if (large) chrome['largeTitle'] = { x, y: y + barHeight, width: size.width, height: LARGE_TITLE_HEIGHT };
|
|
3286
3319
|
if (parts.search) {
|
|
3320
|
+
const marginH = searchOnTop ? SEARCH_FIELD_MARGIN_H : SEARCH_BOTTOM_MARGIN_H;
|
|
3287
3321
|
chrome['searchField'] = {
|
|
3288
|
-
x: x +
|
|
3322
|
+
x: x + marginH,
|
|
3289
3323
|
y: searchOnTop ? y + barHeight + largeHeight + SEARCH_TOP_ABOVE : y + size.height - searchBottom,
|
|
3290
|
-
width: Math.max(0, size.width - 2 *
|
|
3324
|
+
width: Math.max(0, size.width - 2 * marginH),
|
|
3291
3325
|
height: searchOnTop ? SEARCH_TOP_HEIGHT : SEARCH_BOTTOM_HEIGHT,
|
|
3292
3326
|
};
|
|
3293
3327
|
// The page draws a field under the title as the bar's (a gray capsule)
|
|
@@ -3331,10 +3365,45 @@ export class LayoutPass {
|
|
|
3331
3365
|
const total = sizes.reduce((sum, s) => sum + s.width, 0) + Math.max(0, sizes.length - 1) * NAV_ITEM_SPACING;
|
|
3332
3366
|
return { toolbar, placement, sizes, slots: null, total };
|
|
3333
3367
|
});
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3368
|
+
// The leading and trailing runs: consecutive toolbars of one placement
|
|
3369
|
+
// share a capsule, a spacer of that placement ends one.
|
|
3370
|
+
interface Run {
|
|
3371
|
+
placement: 'leading' | 'trailing';
|
|
3372
|
+
groups: Group[];
|
|
3373
|
+
/** The capsule's width: the slots 12 apart, a point past the end slots (none for a lone item). */
|
|
3374
|
+
width: number;
|
|
3375
|
+
}
|
|
3376
|
+
const groupOf = new Map(groups.map((g) => [g.toolbar, g]));
|
|
3377
|
+
const runs: Run[] = [];
|
|
3378
|
+
const open: Partial<Record<'leading' | 'trailing', Run>> = {};
|
|
3379
|
+
const runWidth = (run: Run): number => {
|
|
3380
|
+
const slots = run.groups.flatMap((g) => g.slots ?? []);
|
|
3381
|
+
if (slots.length === 0) return 0;
|
|
3382
|
+
const inner = slots.reduce((sum, w) => sum + w, 0) + (slots.length - 1) * NAV_RUN_GAP;
|
|
3383
|
+
return slots.length > 1 ? inner + 2 * NAV_RUN_PAD : inner;
|
|
3384
|
+
};
|
|
3385
|
+
for (const toolbar of parts.toolbars) {
|
|
3386
|
+
const placement = str(toolbar.props['placement']);
|
|
3387
|
+
if (placement !== 'leading' && placement !== 'trailing') continue;
|
|
3388
|
+
if (isSpacer(toolbar)) {
|
|
3389
|
+
delete open[placement];
|
|
3390
|
+
continue;
|
|
3391
|
+
}
|
|
3392
|
+
const group = groupOf.get(toolbar);
|
|
3393
|
+
if (!group) continue;
|
|
3394
|
+
let run = open[placement];
|
|
3395
|
+
if (!run) {
|
|
3396
|
+
run = { placement, groups: [], width: 0 };
|
|
3397
|
+
open[placement] = run;
|
|
3398
|
+
runs.push(run);
|
|
3399
|
+
}
|
|
3400
|
+
run.groups.push(group);
|
|
3401
|
+
}
|
|
3402
|
+
for (const run of runs) run.width = runWidth(run);
|
|
3403
|
+
const runsWidth = (placement: string): number =>
|
|
3404
|
+
runs.filter((r) => r.placement === placement && r.width > 0).reduce((sum, r) => sum + r.width + NAV_GROUP_SPACING, 0);
|
|
3405
|
+
const leadingWidth = runsWidth('leading');
|
|
3406
|
+
const trailingWidth = runsWidth('trailing');
|
|
3338
3407
|
const principal = groups.some((g) => g.placement === 'principal');
|
|
3339
3408
|
|
|
3340
3409
|
// `.secondaryAction` items (placement `secondary`), as iOS treats them:
|
|
@@ -3356,7 +3425,7 @@ export class LayoutPass {
|
|
|
3356
3425
|
return { toolbar, item, size: itemSize, slot: navSlot(item, itemSize.width) };
|
|
3357
3426
|
});
|
|
3358
3427
|
});
|
|
3359
|
-
// Items of one toolbar share its glass capsule; separate toolbars sit
|
|
3428
|
+
// Items of one toolbar share its glass capsule; separate toolbars sit 12 apart, as the trailing runs do.
|
|
3360
3429
|
const secondaryGap = (previous: SecondaryItem | undefined, entry: SecondaryItem): number =>
|
|
3361
3430
|
previous && previous.toolbar !== entry.toolbar ? NAV_GROUP_SPACING : 0;
|
|
3362
3431
|
const secondaryWidth = (entries: readonly SecondaryItem[]): number =>
|
|
@@ -3413,28 +3482,46 @@ export class LayoutPass {
|
|
|
3413
3482
|
cursor += (slot ?? itemSize.width) + spacing;
|
|
3414
3483
|
});
|
|
3415
3484
|
};
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
break;
|
|
3436
|
-
}
|
|
3485
|
+
// A run's capsule (chrome `leadingGlass0` …, `trailingGlass0` …) and its
|
|
3486
|
+
// items, in slots 12 apart from `startX`; each toolbar's frame spans its
|
|
3487
|
+
// own items' slots. Runs of a placement follow their order in the
|
|
3488
|
+
// content, leading-to-trailing.
|
|
3489
|
+
const placeRun = (run: Run, startX: number, index: number): void => {
|
|
3490
|
+
chrome[`${run.placement}Glass${index}`] = { x: startX, y: rowY, width: run.width, height: NAV_BAR_HEIGHT };
|
|
3491
|
+
const count = run.groups.reduce((sum, g) => sum + (g.slots?.length ?? 0), 0);
|
|
3492
|
+
let cursor = startX + (count > 1 ? NAV_RUN_PAD : 0);
|
|
3493
|
+
for (const group of run.groups) {
|
|
3494
|
+
const itemCtx = this.childContext(group.toolbar, cctx);
|
|
3495
|
+
const first = cursor;
|
|
3496
|
+
group.toolbar.children.forEach((item, i) => {
|
|
3497
|
+
const itemSize = group.sizes[i] as Size;
|
|
3498
|
+
const slot = group.slots?.[i] ?? itemSize.width;
|
|
3499
|
+
this.place(item, itemCtx, { width: null, height: null }, cursor + (slot - itemSize.width) / 2, rowY + (NAV_BAR_HEIGHT - itemSize.height) / 2);
|
|
3500
|
+
cursor += slot + NAV_RUN_GAP;
|
|
3501
|
+
});
|
|
3502
|
+
const last = group.toolbar.children.length > 0 ? cursor - NAV_RUN_GAP : first;
|
|
3503
|
+
this.setFrame(group.toolbar, first, rowY, { width: last - first, height: NAV_BAR_HEIGHT });
|
|
3437
3504
|
}
|
|
3505
|
+
};
|
|
3506
|
+
let leadingIndex = 0;
|
|
3507
|
+
for (const run of runs.filter((r) => r.placement === 'leading')) {
|
|
3508
|
+
if (run.width === 0) continue;
|
|
3509
|
+
placeRun(run, leadingCursor, leadingIndex++);
|
|
3510
|
+
leadingCursor += run.width + NAV_GROUP_SPACING;
|
|
3511
|
+
}
|
|
3512
|
+
const trailingRuns = runs.filter((r) => r.placement === 'trailing' && r.width > 0);
|
|
3513
|
+
let trailingCursor = trailingEdge - (trailingWidth > 0 ? trailingWidth - NAV_GROUP_SPACING : 0);
|
|
3514
|
+
const trailingStart = trailingCursor;
|
|
3515
|
+
trailingRuns.forEach((run, index) => {
|
|
3516
|
+
placeRun(run, trailingCursor, index);
|
|
3517
|
+
trailingCursor += run.width + NAV_GROUP_SPACING;
|
|
3518
|
+
});
|
|
3519
|
+
if (trailingRuns.length > 0) trailingEdge = trailingStart - NAV_GROUP_SPACING;
|
|
3520
|
+
for (const group of groups) {
|
|
3521
|
+
if (group.placement !== 'principal') continue;
|
|
3522
|
+
const startX = x + (size.width - group.total) / 2;
|
|
3523
|
+
this.setFrame(group.toolbar, startX, rowY, { width: group.total, height: NAV_BAR_HEIGHT });
|
|
3524
|
+
placeGroup(group, startX, rowY, NAV_BAR_HEIGHT, NAV_ITEM_SPACING);
|
|
3438
3525
|
}
|
|
3439
3526
|
// The inline secondary items, right-aligned before the trailing groups,
|
|
3440
3527
|
// each toolbar framed around its own; the collapsed ones are hidden (the
|
|
@@ -3475,11 +3562,9 @@ export class LayoutPass {
|
|
|
3475
3562
|
// takes up to a pixel off a fractional width at a fractional origin, and
|
|
3476
3563
|
// the title truncates with an ellipsis ("Animati…") when its box is any
|
|
3477
3564
|
// narrower than its text.
|
|
3478
|
-
const
|
|
3479
|
-
const
|
|
3480
|
-
|
|
3481
|
-
scroller !== null &&
|
|
3482
|
-
(scroller.kind === 'list' || (scroller.kind === 'scrollview' && this.scrollAxes(scroller).vertical));
|
|
3565
|
+
const direct = parts.content ? unwrapStyled(parts.content) : null;
|
|
3566
|
+
const scroller = parts.content ? this.screenScroller(parts.content) : null;
|
|
3567
|
+
const titleScrolls = large && scroller !== null;
|
|
3483
3568
|
// A large title that scrolls away collapses into the inline title, which
|
|
3484
3569
|
// the renderer shows once it has (`data-sb-collapsed`).
|
|
3485
3570
|
if ((!large || titleScrolls) && !principal) {
|
|
@@ -3543,9 +3628,7 @@ export class LayoutPass {
|
|
|
3543
3628
|
barAbove: undefined,
|
|
3544
3629
|
...(bottomCover > 0 ? { bottomBar: bottomCover, bottomEdge: y + size.height } : {}),
|
|
3545
3630
|
};
|
|
3546
|
-
|
|
3547
|
-
scroller !== null && (scroller.kind === 'list' || (scroller.kind === 'scrollview' && this.scrollAxes(scroller).vertical));
|
|
3548
|
-
if (scroller && scrollsUnderBar) {
|
|
3631
|
+
if (scroller) {
|
|
3549
3632
|
// iOS 26: the scroll view starts at the top of the screen and its
|
|
3550
3633
|
// content scrolls under the bar (and the large title moves with it);
|
|
3551
3634
|
// the content starts below them. It runs to the bottom of the screen,
|
|
@@ -3559,14 +3642,14 @@ export class LayoutPass {
|
|
|
3559
3642
|
this.place(child, contentCtx, { width: content.width, height: content.height + top + bottomCover }, content.x, y);
|
|
3560
3643
|
return;
|
|
3561
3644
|
}
|
|
3562
|
-
if (
|
|
3645
|
+
if (direct) this.scrollInsets.delete(direct);
|
|
3563
3646
|
// A scroll view deeper in the content that touches the bar runs under it
|
|
3564
3647
|
// (`scrollEdgeGrowth`); not under a large title, which stays put then.
|
|
3565
3648
|
// One that reaches the bottom of the content runs down under all the
|
|
3566
3649
|
// content clears there.
|
|
3567
3650
|
if (!large) contentCtx.barAbove = { top: content.y, height: content.y - y, x, width: size.width };
|
|
3568
3651
|
if (bottomCover > 0) contentCtx.bottomBar = bottomCover + bottomInset;
|
|
3569
|
-
if (
|
|
3652
|
+
if (direct && direct.kind === 'scrollview') {
|
|
3570
3653
|
this.place(child, contentCtx, { width: content.width, height: content.height }, content.x, content.y);
|
|
3571
3654
|
return;
|
|
3572
3655
|
}
|
|
@@ -3577,6 +3660,30 @@ export class LayoutPass {
|
|
|
3577
3660
|
this.place(child, contentCtx, proposal, content.x + dx, content.y + dy);
|
|
3578
3661
|
}
|
|
3579
3662
|
|
|
3663
|
+
/**
|
|
3664
|
+
* The list or vertical scroll view that is a screen's content: through
|
|
3665
|
+
* modifiers and wrappers that put their child at their own origin and size
|
|
3666
|
+
* (a `GeometryReader`'s or a one-child `ZStack`'s child, a decoration's
|
|
3667
|
+
* content), as UIKit finds a screen's
|
|
3668
|
+
* scroll view. Food Truck's Truck page is a `GeometryReader` around its
|
|
3669
|
+
* `ScrollView`, its Donuts page a `ZStack` with a background around one:
|
|
3670
|
+
* their large titles scroll and collapse as on iOS. Null when there is none.
|
|
3671
|
+
*/
|
|
3672
|
+
private screenScroller(node: LayoutNode): LayoutNode | null {
|
|
3673
|
+
let current: LayoutNode | undefined = node;
|
|
3674
|
+
for (let depth = 0; current && depth < 32; depth++) {
|
|
3675
|
+
if (current.kind === 'list' || (current.kind === 'scrollview' && this.scrollAxes(current).vertical)) return current;
|
|
3676
|
+
if ((current.kind === 'styled' || current.kind === 'geometry' || current.kind === 'zstack') && current.children.length === 1) {
|
|
3677
|
+
current = current.children[0];
|
|
3678
|
+
} else if (current.kind === 'decorated') {
|
|
3679
|
+
current = current.children[0];
|
|
3680
|
+
} else {
|
|
3681
|
+
return null;
|
|
3682
|
+
}
|
|
3683
|
+
}
|
|
3684
|
+
return null;
|
|
3685
|
+
}
|
|
3686
|
+
|
|
3580
3687
|
/**
|
|
3581
3688
|
* iOS 26's bottom toolbar: the items of the `bottom` toolbars, in order,
|
|
3582
3689
|
* float on Liquid Glass in a 48 row at the top of the bottom bar's band
|
|
@@ -3843,7 +3950,10 @@ export class LayoutPass {
|
|
|
3843
3950
|
/**
|
|
3844
3951
|
* The inspector column: its content below the status bar (at the top of
|
|
3845
3952
|
* the screen) and clear of the trailing inset, top-aligned and centered
|
|
3846
|
-
* horizontally (a list or form fills it).
|
|
3953
|
+
* horizontally (a list or form fills it). A navigation stack fills the
|
|
3954
|
+
* column from its top instead, as the split view's columns do, so its bar
|
|
3955
|
+
* takes the status bar's room and is as tall as theirs (with the 10 under
|
|
3956
|
+
* an inline title) and its content starts level with theirs.
|
|
3847
3957
|
*/
|
|
3848
3958
|
private placeInspectorPanel(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
3849
3959
|
const { screen, safeArea } = this.env;
|
|
@@ -3851,6 +3961,10 @@ export class LayoutPass {
|
|
|
3851
3961
|
const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
|
|
3852
3962
|
const area: Size = { width: Math.max(0, size.width - trailing), height: Math.max(0, size.height - top) };
|
|
3853
3963
|
for (const child of node.children) {
|
|
3964
|
+
if (unwrapStyled(child).kind === 'navstack') {
|
|
3965
|
+
this.place(child, cctx, { width: area.width, height: size.height }, x, y);
|
|
3966
|
+
continue;
|
|
3967
|
+
}
|
|
3854
3968
|
const childSize = this.measure(child, cctx, area);
|
|
3855
3969
|
const { dx } = alignIn(area, childSize, 'center');
|
|
3856
3970
|
this.place(child, cctx, area, x + dx, y + top);
|
package/src/layout/index.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* | kind | keys |
|
|
11
11
|
* |---------------------|------|
|
|
12
|
-
* | `navscreen` | `bar` (status bar + 44), `largeTitle` (only when the title is large), `back` (only when depth > 0), `title` (Phase 6: the centered inline title's rect, truncated to the room the bar items leave; absent for a large title or a `principal` toolbar), `searchField` (Phase 6: the 36px field of a `searchfield` child), `bottomBar` (iOS 26: the band from a `bottom` toolbar's 48 glass row to the screen bottom, when one is present), `bottomGlass0` … (its glass capsules, in order), `content` |
|
|
12
|
+
* | `navscreen` | `bar` (status bar + 44), `largeTitle` (only when the title is large), `back` (only when depth > 0), `title` (Phase 6: the centered inline title's rect, truncated to the room the bar items leave; absent for a large title or a `principal` toolbar), `searchField` (Phase 6: the 36px field of a `searchfield` child), `leadingGlass0` …, `trailingGlass0` … (iOS 26: the bar's glass capsules, one per run of adjacent `leading` / `trailing` toolbars, in order), `bottomBar` (iOS 26: the band from a `bottom` toolbar's 48 glass row to the screen bottom, when one is present), `bottomGlass0` … (its glass capsules, in order), `content` |
|
|
13
13
|
* | `sheet` | `grabber` (36×5), `content` (below the 20px grabber row) |
|
|
14
14
|
* | `toggle` | `label`, `switch` (51×31 at the trailing edge) |
|
|
15
15
|
* | `button` (bordered) | `label` |
|
package/src/layoutDom.ts
CHANGED
|
@@ -42,7 +42,7 @@ import { bitmapLaidOut } from './bitmaps';
|
|
|
42
42
|
import { drawRichText } from './richText';
|
|
43
43
|
import { bindScrollUnderBar, largeTitleOf, largeTitleScrolls } from './largeTitle';
|
|
44
44
|
import { bindScrollEdgeBottom } from './scrollEdge';
|
|
45
|
-
import {
|
|
45
|
+
import { BAR_GLASS_KIND, TOOLBAR_ANIMATION, TOOLBAR_TRANSITION, baseTiming, timingFor, transitionStart } from './animation';
|
|
46
46
|
|
|
47
47
|
export interface LayoutDomContext {
|
|
48
48
|
root: HTMLElement;
|
|
@@ -263,17 +263,21 @@ function placeEditControl(node: HTMLElement | null, rect: Rect | undefined, orig
|
|
|
263
263
|
}
|
|
264
264
|
|
|
265
265
|
/**
|
|
266
|
-
* iOS 26: the glass a screen's
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
266
|
+
* iOS 26: the glass a screen's bar items float on, one element per capsule
|
|
267
|
+
* (`.sb-bar-glass`, styles.css): the navigation bar's runs of leading and
|
|
268
|
+
* trailing items (engine `leadingGlass0` …, `trailingGlass0` …) and the
|
|
269
|
+
* bottom bar's (`bottomGlass0` …), under the items. A capsule is known by
|
|
270
|
+
* the toolbars drawn on it (`data-sb-toolbars`: the ids of those of its
|
|
271
|
+
* placement whose frames lie in it), not by its index, so a group that goes
|
|
272
|
+
* takes its own capsule with it and the groups after it keep theirs, and an
|
|
273
|
+
* item swapped for another (its toolbar's element kept, Toolbar.swift)
|
|
274
|
+
* changes on a capsule that stays. A capsule that moves or resizes is
|
|
275
|
+
* reported in `changes` (kind `barglass`, no id) so the Animator tweens it
|
|
276
|
+
* with the toolbar's items; one that appears on a screen already there, or
|
|
277
|
+
* goes, scales and fades as the items do (`TOOLBAR_TRANSITION`), in the
|
|
278
|
+
* toolbar's own spring.
|
|
275
279
|
*/
|
|
276
|
-
function
|
|
280
|
+
function syncBarGlass(
|
|
277
281
|
result: LayoutResult,
|
|
278
282
|
node: LayoutNode,
|
|
279
283
|
screen: HTMLElement,
|
|
@@ -282,28 +286,31 @@ function syncBottomGlass(
|
|
|
282
286
|
screenIsNew: boolean,
|
|
283
287
|
changes: BoxChange[],
|
|
284
288
|
): void {
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
289
|
+
for (const placement of ['leading', 'trailing', 'bottom'] as const) {
|
|
290
|
+
const unmatched = new Set(screen.querySelectorAll<HTMLElement>(`:scope > .sb-bar-glass[data-sb-placement="${placement}"]:not([data-sb-exiting])`));
|
|
291
|
+
const toolbars = node.children.filter((child) => child.kind === 'toolbar' && child.props['placement'] === placement);
|
|
292
|
+
for (let count = 0, rect = chrome?.[`${placement}Glass0`]; rect; rect = chrome?.[`${placement}Glass${++count}`]) {
|
|
293
|
+
const capsule = rect;
|
|
294
|
+
const ids = toolbars.filter((toolbar) => centeredIn(result.frames.get(toolbar.id), capsule)).map((toolbar) => String(toolbar.id));
|
|
295
|
+
const relative = { x: rect.x - frame.x, y: rect.y - frame.y, width: rect.width, height: rect.height };
|
|
296
|
+
let glass = Array.from(unmatched).find((candidate) => (candidate.dataset['sbToolbars'] ?? '').split(' ').some((id) => ids.includes(id)));
|
|
297
|
+
if (glass) {
|
|
298
|
+
unmatched.delete(glass);
|
|
299
|
+
const from = glassBox(glass);
|
|
300
|
+
if (!sameRect(from, relative)) changes.push({ id: -1, el: glass, kind: BAR_GLASS_KIND, from, to: relative });
|
|
301
|
+
} else {
|
|
302
|
+
glass = document.createElement('div');
|
|
303
|
+
glass.className = 'sb-bar-glass';
|
|
304
|
+
glass.dataset['sbPlacement'] = placement;
|
|
305
|
+
screen.append(glass);
|
|
306
|
+
if (!screenIsNew) transitionGlass(glass, false);
|
|
307
|
+
changes.push({ id: -1, el: glass, kind: BAR_GLASS_KIND, from: null, to: relative });
|
|
308
|
+
}
|
|
309
|
+
glass.dataset['sbToolbars'] = ids.join(' ');
|
|
310
|
+
placeChrome(glass, rect, frame);
|
|
302
311
|
}
|
|
303
|
-
|
|
304
|
-
placeChrome(glass, rect, frame);
|
|
312
|
+
for (const extra of unmatched) transitionGlass(extra, true);
|
|
305
313
|
}
|
|
306
|
-
for (const extra of unmatched) transitionGlass(extra, true);
|
|
307
314
|
}
|
|
308
315
|
|
|
309
316
|
/** Whether `rect`'s center lies in `area` (a toolbar's frame spans its items, all on one capsule). */
|
|
@@ -320,7 +327,7 @@ function glassBox(glass: HTMLElement): Rect {
|
|
|
320
327
|
return { x: parseFloat(s.left) || 0, y: parseFloat(s.top) || 0, width: parseFloat(s.width) || 0, height: parseFloat(s.height) || 0 };
|
|
321
328
|
}
|
|
322
329
|
|
|
323
|
-
/** Scales and fades a
|
|
330
|
+
/** Scales and fades a bar capsule in, or out (then removes it), as its toolbar's items do. */
|
|
324
331
|
function transitionGlass(glass: HTMLElement, exit: boolean): void {
|
|
325
332
|
const timing = baseTiming(timingFor(TOOLBAR_ANIMATION));
|
|
326
333
|
const gone = transitionStart(TOOLBAR_TRANSITION, exit);
|
|
@@ -705,7 +712,7 @@ function applyChrome(
|
|
|
705
712
|
bar.style.paddingTop = `${top}px`;
|
|
706
713
|
bar.style.paddingBottom = `${bottom}px`;
|
|
707
714
|
}
|
|
708
|
-
|
|
715
|
+
syncBarGlass(result, node, el, chrome, frame, isNew, changes);
|
|
709
716
|
// iOS 26 in a tab view: the search field under the title (styles.css).
|
|
710
717
|
setFlag(el, 'sbSearchTop', chrome?.['searchTop'] ? '' : null);
|
|
711
718
|
const largeRect = chrome?.['largeTitle'];
|