@swiftbrowser/web 0.17.2 → 0.17.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/README.md +1 -0
- package/package.json +1 -1
- package/src/barMorph.ts +411 -0
- package/src/interaction.ts +11 -0
- package/src/layout/engine.ts +26 -5
- package/src/layoutDom.ts +3 -0
- package/src/renderer.ts +86 -4
- package/src/styles.css +33 -6
package/README.md
CHANGED
|
@@ -36,6 +36,7 @@ src/imageOps.ts Core Image's steps (sepia, crystallize, edges, blur, pixella
|
|
|
36
36
|
src/hostContent.ts `host` elements: the registry of the page's own DOM elements the renderer adopts (docs/ops-protocol.md, "Host elements")
|
|
37
37
|
src/pointerEffects.ts Hover events, tooltips, cursors and symbol effects for page-side producers; frame reports' safe-area overlap (docs/ops-protocol.md, "Pointer, effects, matched geometry and frames on screen")
|
|
38
38
|
src/press.ts iOS 26 press feedback: the Liquid Glass under a pressed bar button or glass button swells and springs back
|
|
39
|
+
src/barMorph.ts iOS 26 navigation: one bar through a push, pop or swipe back; the capsules change shape and the items come and go on an overlay while the screens slide
|
|
39
40
|
src/styles.css iOS tokens, device frame, device mode, element styles, host boxes and islands
|
|
40
41
|
src/page.css The app page around the frame: its toolbar, stage, error banner, device-mode chrome
|
|
41
42
|
node/ Node side: apps.ts (the AppSpec registry type), config.ts (Vite config from specs),
|
package/package.json
CHANGED
package/src/barMorph.ts
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iOS 26: one navigation bar through a push or pop.
|
|
3
|
+
*
|
|
4
|
+
* Each screen draws its own bar (the back and More circles in `.sb-navbar`,
|
|
5
|
+
* the glass capsules `.sb-bar-glass` and the `leading` / `trailing` toolbar
|
|
6
|
+
* items laid out on them, src/layoutDom.ts), and a screen slides as a whole,
|
|
7
|
+
* so the bar's items slid with it. On iOS 26 the navigation controller draws
|
|
8
|
+
* one bar: through a push or pop the items stay in it, a capsule both screens
|
|
9
|
+
* have changes shape from the one's to the other's, the old screen's items
|
|
10
|
+
* scale and fade out as the new screen's scale and fade in (the bar's own
|
|
11
|
+
* transition for an item that comes or goes, src/animation.ts), an item both
|
|
12
|
+
* screens draw the same at the same place stays as it is, and the back button
|
|
13
|
+
* appears or goes with the depth. The titles still travel with their screens
|
|
14
|
+
* (the large title with the content it heads), as the content does.
|
|
15
|
+
*
|
|
16
|
+
* The morph is an overlay on the stack (`.sb-navbar-morph`, styles.css: above
|
|
17
|
+
* the screens and the pop clone, taking no input) holding visual clones of
|
|
18
|
+
* both screens' bar parts, while the parts themselves are hidden
|
|
19
|
+
* (`data-sb-bar-morph`) for the slide. The renderer builds it at the commit
|
|
20
|
+
* that holds the push or pop, in the frame the slide starts (the layout has
|
|
21
|
+
* placed the new screen's parts by then), and it drops itself when the slide
|
|
22
|
+
* ends. A swipe back builds it when the drag claims the pointer and scrubs it
|
|
23
|
+
* with the drag; the release plays the rest forward (the pop, from the pop
|
|
24
|
+
* clone the screen leaves behind) or back (the snap back) over the slide's
|
|
25
|
+
* duration, as the screens move.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { TOOLBAR_TRANSITION, transitionStart } from './animation';
|
|
29
|
+
import { ghostClone } from './ghost';
|
|
30
|
+
import type { Rect } from './layout';
|
|
31
|
+
|
|
32
|
+
/** On a screen's bar part the morph stands in for: hidden while it runs (styles.css). */
|
|
33
|
+
export const BAR_MORPH_ATTR = 'data-sb-bar-morph';
|
|
34
|
+
/** The overlay's class (styles.css). */
|
|
35
|
+
export const BAR_MORPH_CLASS = 'sb-navbar-morph';
|
|
36
|
+
/** The slide's duration under `prefers-reduced-motion` (styles.css: 0.01s). */
|
|
37
|
+
const REDUCED_MOTION_MS = 10;
|
|
38
|
+
/** The slide's easing (styles.css `--sb-nav-easing`), read from the stack; this when it cannot be. */
|
|
39
|
+
const NAV_EASING = 'cubic-bezier(0.32, 0.72, 0, 1)';
|
|
40
|
+
/** Two boxes this close are one place. */
|
|
41
|
+
const RECT_EPSILON = 0.5;
|
|
42
|
+
/** A drag this close to an end has nothing left to play. */
|
|
43
|
+
const PROGRESS_EPSILON = 0.01;
|
|
44
|
+
|
|
45
|
+
export type BarPlacement = 'leading' | 'trailing';
|
|
46
|
+
export type BarPartKind = 'glass' | 'back' | 'more' | 'item';
|
|
47
|
+
|
|
48
|
+
/** A part of a screen's bar, as the plan sees it. */
|
|
49
|
+
export interface BarPart {
|
|
50
|
+
kind: BarPartKind;
|
|
51
|
+
/** The run an item or capsule belongs to; the back and More circles have none. */
|
|
52
|
+
placement: BarPlacement | null;
|
|
53
|
+
/** Its box, relative to the screen. */
|
|
54
|
+
rect: Rect;
|
|
55
|
+
/** What it draws, without element ids (two items that draw the same at the same place are one): `signatureOf`. */
|
|
56
|
+
signature: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type MorphPhase = 'keep' | 'move' | 'exit' | 'enter';
|
|
60
|
+
|
|
61
|
+
/** One clone of the morph: the part drawn (the arriving screen's, or the leaving screen's for `exit`) and how it goes from `from` to `to`. */
|
|
62
|
+
export interface MorphStep<T extends BarPart> {
|
|
63
|
+
part: T;
|
|
64
|
+
from: Rect;
|
|
65
|
+
to: Rect;
|
|
66
|
+
phase: MorphPhase;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sameRect(a: Rect, b: Rect): boolean {
|
|
70
|
+
return (
|
|
71
|
+
Math.abs(a.x - b.x) < RECT_EPSILON &&
|
|
72
|
+
Math.abs(a.y - b.y) < RECT_EPSILON &&
|
|
73
|
+
Math.abs(a.width - b.width) < RECT_EPSILON &&
|
|
74
|
+
Math.abs(a.height - b.height) < RECT_EPSILON
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* How the leaving screen's bar parts (`from`) become the arriving screen's
|
|
80
|
+
* (`to`): the capsules first (drawn under the rest), each run of a placement
|
|
81
|
+
* meeting the run in its place on the other screen, leading runs counted
|
|
82
|
+
* from the leading edge and trailing runs from the trailing edge, where the
|
|
83
|
+
* bar anchors them (a capsule with none opposite scales and fades as the
|
|
84
|
+
* items do); then the back and More circles, one each; then the items, an
|
|
85
|
+
* item the other screen draws the same at the same place staying as it is
|
|
86
|
+
* and every other leaving or arriving.
|
|
87
|
+
*/
|
|
88
|
+
export function planBarMorph<T extends BarPart>(from: readonly T[], to: readonly T[]): MorphStep<T>[] {
|
|
89
|
+
const steps: MorphStep<T>[] = [];
|
|
90
|
+
const pair = (old: T | undefined, arriving: T | undefined): void => {
|
|
91
|
+
if (old && arriving) {
|
|
92
|
+
steps.push({ part: arriving, from: old.rect, to: arriving.rect, phase: sameRect(old.rect, arriving.rect) ? 'keep' : 'move' });
|
|
93
|
+
} else if (old) {
|
|
94
|
+
steps.push({ part: old, from: old.rect, to: old.rect, phase: 'exit' });
|
|
95
|
+
} else if (arriving) {
|
|
96
|
+
steps.push({ part: arriving, from: arriving.rect, to: arriving.rect, phase: 'enter' });
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
for (const placement of ['leading', 'trailing'] as const) {
|
|
100
|
+
const olds = from.filter((part) => part.kind === 'glass' && part.placement === placement);
|
|
101
|
+
const news = to.filter((part) => part.kind === 'glass' && part.placement === placement);
|
|
102
|
+
const count = Math.max(olds.length, news.length);
|
|
103
|
+
for (let i = 0; i < count; i++) {
|
|
104
|
+
const at = (list: readonly T[]): T | undefined => (placement === 'leading' ? list[i] : list[list.length - 1 - i]);
|
|
105
|
+
pair(at(olds), at(news));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for (const kind of ['back', 'more'] as const) {
|
|
109
|
+
pair(
|
|
110
|
+
from.find((part) => part.kind === kind),
|
|
111
|
+
to.find((part) => part.kind === kind),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const arriving = to.filter((part) => part.kind === 'item');
|
|
115
|
+
const kept = new Set<T>();
|
|
116
|
+
for (const old of from) {
|
|
117
|
+
if (old.kind !== 'item') continue;
|
|
118
|
+
const twin = arriving.find(
|
|
119
|
+
(item) => !kept.has(item) && item.placement === old.placement && sameRect(old.rect, item.rect) && item.signature === old.signature,
|
|
120
|
+
);
|
|
121
|
+
if (twin) {
|
|
122
|
+
kept.add(twin);
|
|
123
|
+
steps.push({ part: twin, from: twin.rect, to: twin.rect, phase: 'keep' });
|
|
124
|
+
} else {
|
|
125
|
+
steps.push({ part: old, from: old.rect, to: old.rect, phase: 'exit' });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const item of arriving) {
|
|
129
|
+
if (!kept.has(item)) steps.push({ part: item, from: item.rect, to: item.rect, phase: 'enter' });
|
|
130
|
+
}
|
|
131
|
+
return steps;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// The screens' parts
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
interface ScreenPart extends BarPart {
|
|
139
|
+
el: HTMLElement;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function placementOf(el: HTMLElement): BarPlacement | null {
|
|
143
|
+
const placement = el.dataset['sbPlacement'];
|
|
144
|
+
return placement === 'leading' || placement === 'trailing' ? placement : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A screen's (or pop clone's) glass capsules in the navigation bar, in the bar's order. */
|
|
148
|
+
function capsulesOf(screen: HTMLElement): HTMLElement[] {
|
|
149
|
+
return Array.from(screen.querySelectorAll<HTMLElement>(':scope > .sb-bar-glass:not([data-sb-exiting])')).filter((el) => placementOf(el) !== null);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Its `leading` and `trailing` toolbar groups the bar draws. */
|
|
153
|
+
function groupsOf(screen: HTMLElement): HTMLElement[] {
|
|
154
|
+
return Array.from(screen.querySelectorAll<HTMLElement>(':scope > .sb-navscreen-body > .sb-navscreen-content > .sb-toolbar')).filter(
|
|
155
|
+
(el) => placementOf(el) !== null && !el.hidden && !el.hasAttribute('data-sb-spacer'),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Its back and More circles, those shown. */
|
|
160
|
+
function circlesOf(screen: HTMLElement): { kind: 'back' | 'more'; el: HTMLElement }[] {
|
|
161
|
+
const out: { kind: 'back' | 'more'; el: HTMLElement }[] = [];
|
|
162
|
+
for (const kind of ['back', 'more'] as const) {
|
|
163
|
+
const el = screen.querySelector<HTMLElement>(`:scope > .sb-navbar > .sb-navbar-${kind}`);
|
|
164
|
+
if (el && !el.hidden) out.push({ kind, el });
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The elements of a screen's bar the morph stands in for (hidden while it runs): the capsules, the circles and the groups with their items. */
|
|
170
|
+
function barElementsOf(screen: HTMLElement): HTMLElement[] {
|
|
171
|
+
return [...capsulesOf(screen), ...circlesOf(screen).map((circle) => circle.el), ...groupsOf(screen)];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** An element's markup as the plan compares it: without element ids, the morph's own mark, or where its toolbar put it. */
|
|
175
|
+
function signatureOf(el: HTMLElement): string {
|
|
176
|
+
const copy = el.cloneNode(true) as HTMLElement;
|
|
177
|
+
for (const node of [copy, ...copy.querySelectorAll('*')]) {
|
|
178
|
+
node.removeAttribute('data-sb-id');
|
|
179
|
+
node.removeAttribute('data-sb-ghost');
|
|
180
|
+
node.removeAttribute(BAR_MORPH_ATTR);
|
|
181
|
+
}
|
|
182
|
+
copy.style.left = '';
|
|
183
|
+
copy.style.top = '';
|
|
184
|
+
return copy.outerHTML;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The parts of a screen's bar with their boxes relative to the screen (wherever its slide has it). */
|
|
188
|
+
function partsOf(screen: HTMLElement): ScreenPart[] {
|
|
189
|
+
const base = screen.getBoundingClientRect();
|
|
190
|
+
// The frame may be scaled to fit the page: boxes in the screen's own pixels.
|
|
191
|
+
const scale = screen.offsetWidth > 0 && base.width > 0 ? base.width / screen.offsetWidth : 1;
|
|
192
|
+
const rectOf = (el: HTMLElement): Rect => {
|
|
193
|
+
const r = el.getBoundingClientRect();
|
|
194
|
+
return { x: (r.left - base.left) / scale, y: (r.top - base.top) / scale, width: r.width / scale, height: r.height / scale };
|
|
195
|
+
};
|
|
196
|
+
const out: ScreenPart[] = [];
|
|
197
|
+
const add = (kind: BarPartKind, placement: BarPlacement | null, el: HTMLElement, signature = ''): void => {
|
|
198
|
+
const rect = rectOf(el);
|
|
199
|
+
if (rect.width === 0 && rect.height === 0) return;
|
|
200
|
+
out.push({ kind, placement, rect, signature, el });
|
|
201
|
+
};
|
|
202
|
+
for (const capsule of capsulesOf(screen)) add('glass', placementOf(capsule), capsule);
|
|
203
|
+
for (const circle of circlesOf(screen)) add(circle.kind, null, circle.el);
|
|
204
|
+
for (const group of groupsOf(screen)) {
|
|
205
|
+
const placement = placementOf(group);
|
|
206
|
+
for (const item of group.querySelectorAll<HTMLElement>(':scope > [data-sb-id], :scope > [data-sb-ghost]')) {
|
|
207
|
+
if (!item.hidden) add('item', placement, item, signatureOf(item));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function reducedMotion(): boolean {
|
|
214
|
+
return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function navEasing(stack: HTMLElement): string {
|
|
218
|
+
const easing = getComputedStyle(stack).getPropertyValue('--sb-nav-easing').trim();
|
|
219
|
+
return easing.length > 0 ? easing : NAV_EASING;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Put a clone at `rect` in the overlay (the screen's coordinates), free of what its own layout gave it. */
|
|
223
|
+
function place(clone: HTMLElement, rect: Rect): void {
|
|
224
|
+
const s = clone.style;
|
|
225
|
+
s.position = 'absolute';
|
|
226
|
+
s.left = `${rect.x}px`;
|
|
227
|
+
s.top = `${rect.y}px`;
|
|
228
|
+
s.width = `${rect.width}px`;
|
|
229
|
+
s.height = `${rect.height}px`;
|
|
230
|
+
s.margin = '0';
|
|
231
|
+
s.maxWidth = 'none';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const px = (value: number): string => `${value}px`;
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// The morph
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
export interface BarMorphOptions {
|
|
241
|
+
/** The slide's duration in ms (reduced motion shortens it as the stylesheet does). */
|
|
242
|
+
duration: number;
|
|
243
|
+
/** A swipe back: built paused at its start, moved by `scrub`, then played on (`finish`) or back (`cancel`). */
|
|
244
|
+
interactive: boolean;
|
|
245
|
+
/** Called once the morph has dropped itself. */
|
|
246
|
+
onDone?: () => void;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export class BarMorph {
|
|
250
|
+
/** The screen (or its pop clone) whose bar leaves. */
|
|
251
|
+
from: HTMLElement;
|
|
252
|
+
/** The screen whose bar arrives. */
|
|
253
|
+
readonly to: HTMLElement;
|
|
254
|
+
private readonly overlay: HTMLElement;
|
|
255
|
+
private readonly animations: globalThis.Animation[] = [];
|
|
256
|
+
/** The screens' own parts, hidden until the morph is done. */
|
|
257
|
+
private readonly held = new Set<HTMLElement>();
|
|
258
|
+
private readonly duration: number;
|
|
259
|
+
private readonly onDone: (() => void) | undefined;
|
|
260
|
+
/** Paused and scrubbed by a drag (until the release plays it). */
|
|
261
|
+
private interactive: boolean;
|
|
262
|
+
/** Where a drag has it, 0 to 1. */
|
|
263
|
+
private progress = 0;
|
|
264
|
+
private disposed = false;
|
|
265
|
+
private timer: number | null = null;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Builds the morph of the bar of `from` into the bar of `to`, both screens
|
|
269
|
+
* of `stack`, from where their parts are now, and starts it (or holds it
|
|
270
|
+
* for a drag); null when neither screen has a bar part.
|
|
271
|
+
*/
|
|
272
|
+
static begin(stack: HTMLElement, from: HTMLElement, to: HTMLElement, options: BarMorphOptions): BarMorph | null {
|
|
273
|
+
const steps = planBarMorph(partsOf(from), partsOf(to));
|
|
274
|
+
if (steps.length === 0) return null;
|
|
275
|
+
return new BarMorph(stack, from, to, steps, options);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private constructor(stack: HTMLElement, from: HTMLElement, to: HTMLElement, steps: readonly MorphStep<ScreenPart>[], options: BarMorphOptions) {
|
|
279
|
+
this.from = from;
|
|
280
|
+
this.to = to;
|
|
281
|
+
this.onDone = options.onDone;
|
|
282
|
+
this.interactive = options.interactive;
|
|
283
|
+
this.duration = reducedMotion() ? REDUCED_MOTION_MS : options.duration;
|
|
284
|
+
const easing = options.interactive ? 'linear' : navEasing(stack);
|
|
285
|
+
const overlay = document.createElement('div');
|
|
286
|
+
overlay.className = BAR_MORPH_CLASS;
|
|
287
|
+
overlay.setAttribute('aria-hidden', 'true');
|
|
288
|
+
overlay.inert = true;
|
|
289
|
+
// Over the arriving screen's box in the stack (its slide aside).
|
|
290
|
+
place(overlay, { x: to.offsetLeft, y: to.offsetTop, width: to.offsetWidth, height: to.offsetHeight });
|
|
291
|
+
for (const step of steps) {
|
|
292
|
+
const clone = ghostClone(step.part.el);
|
|
293
|
+
clone.removeAttribute(BAR_MORPH_ATTR);
|
|
294
|
+
if (step.part.kind === 'item') clone.dataset['sbBarItem'] = step.part.placement ?? '';
|
|
295
|
+
place(clone, step.to);
|
|
296
|
+
const animation = this.animate(clone, step, easing);
|
|
297
|
+
if (animation) this.animations.push(animation);
|
|
298
|
+
overlay.append(clone);
|
|
299
|
+
}
|
|
300
|
+
this.overlay = overlay;
|
|
301
|
+
for (const el of [...barElementsOf(from), ...barElementsOf(to)]) this.hold(el);
|
|
302
|
+
stack.append(overlay);
|
|
303
|
+
if (!options.interactive) this.settle();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** The clone's tween: its box from the leaving part's to the arriving part's, and the bar's item transition in or out. */
|
|
307
|
+
private animate(clone: HTMLElement, step: MorphStep<ScreenPart>, easing: string): globalThis.Animation | null {
|
|
308
|
+
const start: Keyframe = {};
|
|
309
|
+
const end: Keyframe = {};
|
|
310
|
+
if (step.phase === 'move') {
|
|
311
|
+
start['left'] = px(step.from.x);
|
|
312
|
+
start['top'] = px(step.from.y);
|
|
313
|
+
start['width'] = px(step.from.width);
|
|
314
|
+
start['height'] = px(step.from.height);
|
|
315
|
+
end['left'] = px(step.to.x);
|
|
316
|
+
end['top'] = px(step.to.y);
|
|
317
|
+
end['width'] = px(step.to.width);
|
|
318
|
+
end['height'] = px(step.to.height);
|
|
319
|
+
}
|
|
320
|
+
if (step.phase === 'enter' || step.phase === 'exit') {
|
|
321
|
+
const gone = transitionStart(TOOLBAR_TRANSITION, step.phase === 'exit');
|
|
322
|
+
const own = clone.style.transform;
|
|
323
|
+
const goneTransform = gone.transform === undefined ? own || 'none' : own ? `${own} ${gone.transform}` : gone.transform;
|
|
324
|
+
const [away, here] = step.phase === 'enter' ? [start, end] : [end, start];
|
|
325
|
+
away['opacity'] = gone.opacity ?? 0;
|
|
326
|
+
away['transform'] = goneTransform;
|
|
327
|
+
here['opacity'] = 1;
|
|
328
|
+
here['transform'] = own || 'none';
|
|
329
|
+
}
|
|
330
|
+
if (Object.keys(start).length === 0) return null;
|
|
331
|
+
const animation = clone.animate([start, end], { duration: this.duration, easing, fill: 'both' });
|
|
332
|
+
if (this.interactive) {
|
|
333
|
+
animation.pause();
|
|
334
|
+
animation.currentTime = 0;
|
|
335
|
+
}
|
|
336
|
+
return animation;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private hold(el: HTMLElement): void {
|
|
340
|
+
el.setAttribute(BAR_MORPH_ATTR, '');
|
|
341
|
+
this.held.add(el);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** A drag has the slide at `progress` (0: as before, 1: done): the morph follows. */
|
|
345
|
+
scrub(progress: number): void {
|
|
346
|
+
if (this.disposed || !this.interactive) return;
|
|
347
|
+
this.progress = Math.min(1, Math.max(0, progress));
|
|
348
|
+
const time = this.progress * this.duration;
|
|
349
|
+
for (const animation of this.animations) animation.currentTime = time;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The screen the drag was popping has left, `clone` sliding out in its
|
|
354
|
+
* place (renderer.ts `animatePop`): the clone's bar parts hide too, and the
|
|
355
|
+
* rest of the morph plays over the slide's duration, as the screens move.
|
|
356
|
+
*/
|
|
357
|
+
popped(clone: HTMLElement): void {
|
|
358
|
+
if (this.disposed) return;
|
|
359
|
+
for (const el of barElementsOf(clone)) this.hold(el);
|
|
360
|
+
this.from = clone;
|
|
361
|
+
this.finish();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Plays the rest of a scrubbed morph forward over the slide's duration. */
|
|
365
|
+
finish(): void {
|
|
366
|
+
if (this.disposed || !this.interactive) return;
|
|
367
|
+
this.play(1 - this.progress);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Plays a scrubbed morph back to its start over the slide's duration (the snap back), then drops it. */
|
|
371
|
+
cancel(): void {
|
|
372
|
+
if (this.disposed || !this.interactive) return;
|
|
373
|
+
this.play(-this.progress);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** The rest of the morph at `rate` (negative: back to the start), taking the slide's duration. */
|
|
377
|
+
private play(rate: number): void {
|
|
378
|
+
this.interactive = false;
|
|
379
|
+
if (Math.abs(rate) <= PROGRESS_EPSILON) {
|
|
380
|
+
this.dispose();
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
for (const animation of this.animations) {
|
|
384
|
+
animation.playbackRate = rate;
|
|
385
|
+
animation.play();
|
|
386
|
+
}
|
|
387
|
+
this.settle();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Drops the morph once its tweens have ended (or after the slide's duration, with none, or should they never end). */
|
|
391
|
+
private settle(): void {
|
|
392
|
+
const done = (): void => this.dispose();
|
|
393
|
+
if (this.animations.length > 0) Promise.allSettled(this.animations.map((animation) => animation.finished)).then(done, done);
|
|
394
|
+
this.timer = window.setTimeout(done, this.duration + 100);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Removes the overlay and shows the screens' own parts again. */
|
|
398
|
+
dispose(): void {
|
|
399
|
+
if (this.disposed) return;
|
|
400
|
+
this.disposed = true;
|
|
401
|
+
if (this.timer !== null) {
|
|
402
|
+
window.clearTimeout(this.timer);
|
|
403
|
+
this.timer = null;
|
|
404
|
+
}
|
|
405
|
+
for (const animation of this.animations) animation.cancel();
|
|
406
|
+
this.overlay.remove();
|
|
407
|
+
for (const el of this.held) el.removeAttribute(BAR_MORPH_ATTR);
|
|
408
|
+
this.held.clear();
|
|
409
|
+
this.onDone?.();
|
|
410
|
+
}
|
|
411
|
+
}
|
package/src/interaction.ts
CHANGED
|
@@ -102,6 +102,14 @@ export interface InteractionHost {
|
|
|
102
102
|
/** Whether the popover of element `id` is open. */
|
|
103
103
|
isMenuOpen(id: ElementId): boolean;
|
|
104
104
|
closeMenu(): void;
|
|
105
|
+
/**
|
|
106
|
+
* iOS 26 (src/barMorph.ts): a swipe back of `screen` has started, with
|
|
107
|
+
* `below` about to show: its bar morphs into the screen below's with the
|
|
108
|
+
* drag (`barMorphScrub`, 0 to 1), and back should the screen snap back.
|
|
109
|
+
*/
|
|
110
|
+
barMorphBegin(screen: HTMLElement, below: HTMLElement): void;
|
|
111
|
+
barMorphScrub(screen: HTMLElement, progress: number): void;
|
|
112
|
+
barMorphSnapBack(screen: HTMLElement): void;
|
|
105
113
|
}
|
|
106
114
|
|
|
107
115
|
interface Sample {
|
|
@@ -564,6 +572,7 @@ export class InteractionController {
|
|
|
564
572
|
this.host.revealScreen(c.below);
|
|
565
573
|
c.below.style.transition = 'none';
|
|
566
574
|
c.below.style.visibility = 'visible';
|
|
575
|
+
this.host.barMorphBegin(c.screen, c.below);
|
|
567
576
|
}
|
|
568
577
|
this.swipeBack = { screen: c.screen, below: c.below, completing: false, timer: null };
|
|
569
578
|
break;
|
|
@@ -626,6 +635,7 @@ export class InteractionController {
|
|
|
626
635
|
if (c.below) {
|
|
627
636
|
c.below.style.transform = `translateX(${-30 * (1 - progress)}%)`;
|
|
628
637
|
c.below.style.setProperty('--sb-nav-dim', String(round2(0.12 * (1 - progress))));
|
|
638
|
+
this.host.barMorphScrub(c.screen, progress);
|
|
629
639
|
}
|
|
630
640
|
return;
|
|
631
641
|
}
|
|
@@ -1187,6 +1197,7 @@ export class InteractionController {
|
|
|
1187
1197
|
delete screen.dataset['sbSwipeDragging'];
|
|
1188
1198
|
delete screen.dataset['sbSwipePop'];
|
|
1189
1199
|
screen.style.removeProperty('--sb-nav-from');
|
|
1200
|
+
this.host.barMorphSnapBack(screen);
|
|
1190
1201
|
// Dropping the inline transforms lets the stylesheet's 0.4 s transition
|
|
1191
1202
|
// carry each screen from where it is to its resting transform.
|
|
1192
1203
|
void screen.offsetWidth;
|
package/src/layout/engine.ts
CHANGED
|
@@ -162,6 +162,10 @@ const NAV_BAR_HEIGHT = 44;
|
|
|
162
162
|
const NAV_BAR_INLINE_BOTTOM = 10;
|
|
163
163
|
// iOS 26: the large title's row is 62 (52 before).
|
|
164
164
|
const LARGE_TITLE_HEIGHT = 62;
|
|
165
|
+
// iOS 26: `.largeTitle` toolbar items take the title's place 10 below the
|
|
166
|
+
// bar's 44 row, in a row exactly their height: the content starts right
|
|
167
|
+
// under them (measured in the iOS 26 simulator, Native's LargeTitleTests).
|
|
168
|
+
const LARGE_TITLE_ITEM_TOP = 10;
|
|
165
169
|
const SHEET_GRABBER_ROW = 20;
|
|
166
170
|
const SHEET_GRABBER: Size = { width: 36, height: 5 };
|
|
167
171
|
const SHEET_GRABBER_TOP = 8;
|
|
@@ -3390,8 +3394,9 @@ export class LayoutPass {
|
|
|
3390
3394
|
|
|
3391
3395
|
/**
|
|
3392
3396
|
* The navigation bar is the status bar plus a 44 row (plus 62 for a large
|
|
3393
|
-
* title
|
|
3394
|
-
*
|
|
3397
|
+
* title, or 10 and the height of its `.largeTitle` items); a Phase 6
|
|
3398
|
+
* `searchfield` is a capsule floating above the bottom (iOS 26); `bottom`
|
|
3399
|
+
* toolbars float their items on glass in a 48 row 28
|
|
3395
3400
|
* above the bottom (`placeBottomToolbars`); the content is proposed the
|
|
3396
3401
|
* rest. Toolbar items are measured at their ideal size and laid out in iOS
|
|
3397
3402
|
* 26 glass capsules in the 44 row: adjacent `leading` (or `trailing`)
|
|
@@ -3403,6 +3408,18 @@ export class LayoutPass {
|
|
|
3403
3408
|
* inline title (`chrome.title`) is centered and truncated to the room the
|
|
3404
3409
|
* bar items leave.
|
|
3405
3410
|
*/
|
|
3411
|
+
/** The tallest `.largeTitle` toolbar item at its ideal size, or null when the screen has none (the text title shows). */
|
|
3412
|
+
private largeTitleItemsHeight(toolbars: readonly LayoutNode[], cctx: Context, width: number): number | null {
|
|
3413
|
+
let height: number | null = null;
|
|
3414
|
+
for (const toolbar of toolbars) {
|
|
3415
|
+
if (str(toolbar.props['placement']) !== 'largeTitle' || str(toolbar.props['spacer']) !== null) continue;
|
|
3416
|
+
const itemCtx = this.childContext(toolbar, cctx);
|
|
3417
|
+
const room: Proposal = { width: Math.max(0, width - 2 * NAV_ITEM_MARGIN), height: null };
|
|
3418
|
+
for (const item of toolbar.children) height = Math.max(height ?? 0, this.measure(item, itemCtx, room).height);
|
|
3419
|
+
}
|
|
3420
|
+
return height;
|
|
3421
|
+
}
|
|
3422
|
+
|
|
3406
3423
|
private placeNavScreen(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
3407
3424
|
const { safeArea, screen, dynamicTypeSize } = this.env;
|
|
3408
3425
|
const statusBar = y <= 0 ? safeArea.top : (cctx.barInset ?? 0);
|
|
@@ -3410,10 +3427,12 @@ export class LayoutPass {
|
|
|
3410
3427
|
const depth = num(node.props['depth']) ?? 0;
|
|
3411
3428
|
const large = displayMode === 'large' || (displayMode === 'automatic' && depth === 0);
|
|
3412
3429
|
const barHeight = statusBar + NAV_BAR_HEIGHT + (!large && y <= 0 ? NAV_BAR_INLINE_BOTTOM : 0);
|
|
3413
|
-
const largeHeight = large ? LARGE_TITLE_HEIGHT : 0;
|
|
3414
3430
|
const parts = this.navScreenParts(node);
|
|
3415
3431
|
const reachesBottom = y + size.height >= screen.height - EPSILON;
|
|
3416
3432
|
const isSpacer = (t: LayoutNode): boolean => str(t.props['spacer']) !== null;
|
|
3433
|
+
// `.largeTitle` items size the large title row; the text title is 62.
|
|
3434
|
+
const titleItemsHeight = large ? this.largeTitleItemsHeight(parts.toolbars, cctx, size.width) : null;
|
|
3435
|
+
const largeHeight = !large ? 0 : titleItemsHeight !== null ? LARGE_TITLE_ITEM_TOP + titleItemsHeight : LARGE_TITLE_HEIGHT;
|
|
3417
3436
|
const bottomToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'bottom');
|
|
3418
3437
|
const searchMargin = reachesBottom ? SEARCH_BOTTOM_MARGIN : SEARCH_BOTTOM_MARGIN_INSET;
|
|
3419
3438
|
const bottomBarHeight = bottomToolbars.some((t) => !isSpacer(t))
|
|
@@ -3443,7 +3462,7 @@ export class LayoutPass {
|
|
|
3443
3462
|
barRow: { x, y: y + statusBar, width: size.width, height: NAV_BAR_HEIGHT },
|
|
3444
3463
|
content,
|
|
3445
3464
|
};
|
|
3446
|
-
if (large) chrome['largeTitle'] = { x, y: y + barHeight, width: size.width, height:
|
|
3465
|
+
if (large) chrome['largeTitle'] = { x, y: y + barHeight, width: size.width, height: largeHeight };
|
|
3447
3466
|
if (parts.search) {
|
|
3448
3467
|
const marginH = searchOnTop ? SEARCH_FIELD_MARGIN_H : SEARCH_BOTTOM_MARGIN_H;
|
|
3449
3468
|
chrome['searchField'] = {
|
|
@@ -3711,9 +3730,11 @@ export class LayoutPass {
|
|
|
3711
3730
|
const itemCtx = this.childContext(toolbar, cctx);
|
|
3712
3731
|
const room: Proposal = { width: Math.max(0, row.width - 2 * NAV_ITEM_MARGIN), height: null };
|
|
3713
3732
|
let cursor = row.x + NAV_ITEM_MARGIN;
|
|
3733
|
+
// The items' band: the row below its 10 top, as tall as the tallest item.
|
|
3734
|
+
const band = row.height - LARGE_TITLE_ITEM_TOP;
|
|
3714
3735
|
for (const item of toolbar.children) {
|
|
3715
3736
|
const itemSize = this.measure(item, itemCtx, room);
|
|
3716
|
-
this.place(item, itemCtx, room, cursor, row.y + (
|
|
3737
|
+
this.place(item, itemCtx, room, cursor, row.y + LARGE_TITLE_ITEM_TOP + (band - itemSize.height) / 2);
|
|
3717
3738
|
cursor += itemSize.width + NAV_ITEM_SPACING;
|
|
3718
3739
|
}
|
|
3719
3740
|
this.setFrame(toolbar, row.x, row.y, { width: row.width, height: row.height });
|
package/src/layoutDom.ts
CHANGED
|
@@ -944,6 +944,9 @@ function applyChrome(
|
|
|
944
944
|
applyChartChrome(el, node, result, box);
|
|
945
945
|
return;
|
|
946
946
|
case 'listrow':
|
|
947
|
+
// A selectable or editable row is the list's row: the engine reads a
|
|
948
|
+
// badge through it and puts the badge's rect on it, so it draws it.
|
|
949
|
+
applyBadge(result, node, el, box);
|
|
947
950
|
// Phase 8: the edit-mode control (selection circle / minus) at the
|
|
948
951
|
// engine's `editControl` rect and the reorder grip at `reorderControl`;
|
|
949
952
|
// hidden when the list is not editing.
|
package/src/renderer.ts
CHANGED
|
@@ -59,6 +59,7 @@ import { createGaugeRing, isCircularGauge, updateGaugeRing } from './gaugeRing';
|
|
|
59
59
|
import { bitmapStore, showBitmap } from './bitmaps';
|
|
60
60
|
import { clearRichText, runTemplates } from './richText';
|
|
61
61
|
import { applyLayout, setTabHidden, type BoxChange } from './layoutDom';
|
|
62
|
+
import { BarMorph } from './barMorph';
|
|
62
63
|
import { ghostClone } from './ghost';
|
|
63
64
|
import { ExternalDrops } from './dragDrop';
|
|
64
65
|
import { InteractionController, type InteractionHost } from './interaction';
|
|
@@ -897,6 +898,13 @@ export class Renderer {
|
|
|
897
898
|
revealScreen: (screen) => this.revealScreen(screen),
|
|
898
899
|
isMenuOpen: (id) => this.menu?.anchorId === id,
|
|
899
900
|
closeMenu: () => this.closeMenu(),
|
|
901
|
+
// iOS 26: the bar morphs with a swipe back (src/barMorph.ts).
|
|
902
|
+
barMorphBegin: (screen, below) => {
|
|
903
|
+
const stack = screen.closest<HTMLElement>('.sb-navstack');
|
|
904
|
+
if (stack) this.beginBarMorph(stack, screen, below, true);
|
|
905
|
+
},
|
|
906
|
+
barMorphScrub: (screen, progress) => this.swipeBackMorphOf(screen)?.scrub(progress),
|
|
907
|
+
barMorphSnapBack: (screen) => this.swipeBackMorphOf(screen)?.cancel(),
|
|
900
908
|
};
|
|
901
909
|
this.interaction = new InteractionController(host);
|
|
902
910
|
// iOS 26: the glass under a pressed bar button swells and springs back (src/press.ts).
|
|
@@ -1070,6 +1078,7 @@ export class Renderer {
|
|
|
1070
1078
|
this.root.dispatchEvent(new CustomEvent('sb:precommit', { detail: { commits: this.commits + 1 } }));
|
|
1071
1079
|
const changes = this.layout();
|
|
1072
1080
|
this.animator.commitNextFrame(pass, op.animation, changes);
|
|
1081
|
+
this.beginBarMorphsNextFrame();
|
|
1073
1082
|
this.commits += 1;
|
|
1074
1083
|
this.syncFocus();
|
|
1075
1084
|
this.performScrolls(op.animation != null);
|
|
@@ -1153,6 +1162,8 @@ export class Renderer {
|
|
|
1153
1162
|
/** Detach everything under the root and forget all non-root elements. */
|
|
1154
1163
|
reset(): void {
|
|
1155
1164
|
stopVideos(this.root);
|
|
1165
|
+
for (const morph of Array.from(this.barMorphs.values())) morph.dispose();
|
|
1166
|
+
this.pendingBarMorphs = [];
|
|
1156
1167
|
this.root.replaceChildren();
|
|
1157
1168
|
this.elements.clear();
|
|
1158
1169
|
this.elements.set(ROOT_ID, this.root);
|
|
@@ -1316,6 +1327,55 @@ export class Renderer {
|
|
|
1316
1327
|
*/
|
|
1317
1328
|
private instantPops = new Set<ElementId>();
|
|
1318
1329
|
|
|
1330
|
+
/**
|
|
1331
|
+
* iOS 26: one bar through a push or pop (src/barMorph.ts). The morph in
|
|
1332
|
+
* flight on each stack, and the pushes and pops this pass started, whose
|
|
1333
|
+
* morphs begin once the commit's layout has placed the screens.
|
|
1334
|
+
*/
|
|
1335
|
+
private readonly barMorphs = new Map<HTMLElement, BarMorph>();
|
|
1336
|
+
private pendingBarMorphs: { stack: HTMLElement; from: HTMLElement; to: HTMLElement }[] = [];
|
|
1337
|
+
|
|
1338
|
+
/** Begins the morph of the bar of `from` into the bar of `to` on `stack`, in place of any in flight there. */
|
|
1339
|
+
private beginBarMorph(stack: HTMLElement, from: HTMLElement, to: HTMLElement, interactive: boolean): BarMorph | null {
|
|
1340
|
+
this.barMorphs.get(stack)?.dispose();
|
|
1341
|
+
if (!from.isConnected || !to.isConnected) return null;
|
|
1342
|
+
// (a push or pop that another in the same pass overtook: the later one's morph)
|
|
1343
|
+
if (!interactive && to.dataset['sbNavPosition'] !== 'top') return null;
|
|
1344
|
+
const morph = BarMorph.begin(stack, from, to, {
|
|
1345
|
+
duration: NAV_TRANSITION_MS,
|
|
1346
|
+
interactive,
|
|
1347
|
+
onDone: () => {
|
|
1348
|
+
if (this.barMorphs.get(stack) === morph) this.barMorphs.delete(stack);
|
|
1349
|
+
},
|
|
1350
|
+
});
|
|
1351
|
+
if (morph) this.barMorphs.set(stack, morph);
|
|
1352
|
+
return morph;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/** The morph a swipe back of `screen` drives, if one is in flight. */
|
|
1356
|
+
private swipeBackMorphOf(screen: HTMLElement): BarMorph | null {
|
|
1357
|
+
const stack = screen.closest<HTMLElement>('.sb-navstack');
|
|
1358
|
+
const morph = stack ? this.barMorphs.get(stack) : undefined;
|
|
1359
|
+
return morph && morph.from === screen ? morph : null;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* Begins the morphs of this pass's pushes and pops in the frame their
|
|
1364
|
+
* slides start, after the Animator's commit (`commitNextFrame`), from where
|
|
1365
|
+
* the layout has put the bars' parts; the last push or pop of a stack in
|
|
1366
|
+
* the pass is the one shown.
|
|
1367
|
+
*/
|
|
1368
|
+
private beginBarMorphsNextFrame(): void {
|
|
1369
|
+
if (this.pendingBarMorphs.length === 0) return;
|
|
1370
|
+
const pending = new Map(this.pendingBarMorphs.map((entry) => [entry.stack, entry]));
|
|
1371
|
+
this.pendingBarMorphs = [];
|
|
1372
|
+
const begin = (): void => {
|
|
1373
|
+
for (const { stack, from, to } of pending.values()) this.beginBarMorph(stack, from, to, false);
|
|
1374
|
+
};
|
|
1375
|
+
if (typeof requestAnimationFrame !== 'function' || (typeof document !== 'undefined' && document.hidden)) begin();
|
|
1376
|
+
else requestAnimationFrame(begin);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1319
1379
|
/**
|
|
1320
1380
|
* The next removal of screen `id` is a pop the browser has already shown
|
|
1321
1381
|
* (its back swipe or button, `browserHistory.ts`): the screen goes without
|
|
@@ -2186,7 +2246,14 @@ export class Renderer {
|
|
|
2186
2246
|
this.engine?.invalidateDown(node);
|
|
2187
2247
|
}
|
|
2188
2248
|
if (el.classList.contains('sb-navscreen') && parent.classList.contains('sb-navstack')) {
|
|
2189
|
-
if (container.children.length > 1 && previousParent !== container)
|
|
2249
|
+
if (container.children.length > 1 && previousParent !== container) {
|
|
2250
|
+
animatePush(el);
|
|
2251
|
+
// iOS 26: the bar of the screen it covers morphs into its bar (at commit).
|
|
2252
|
+
const covered = el.previousElementSibling;
|
|
2253
|
+
if (covered instanceof HTMLElement && covered.classList.contains('sb-navscreen')) {
|
|
2254
|
+
this.pendingBarMorphs.push({ stack: parent, from: covered, to: el });
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2190
2257
|
refreshNavStack(parent);
|
|
2191
2258
|
}
|
|
2192
2259
|
if (previousParent) {
|
|
@@ -2233,6 +2300,10 @@ export class Renderer {
|
|
|
2233
2300
|
const parent = el.parentElement;
|
|
2234
2301
|
// Clones of what left this subtree earlier (a bar group's, src/animation.ts) go with it.
|
|
2235
2302
|
this.animator.dropExitClonesIn(el);
|
|
2303
|
+
// So does the bar morph of a stack in it (its overlay is in the stack).
|
|
2304
|
+
for (const [stack, morph] of this.barMorphs) {
|
|
2305
|
+
if (stack === el || el.contains(stack)) morph.dispose();
|
|
2306
|
+
}
|
|
2236
2307
|
const stack = parent?.classList.contains('sb-navstack-screens')
|
|
2237
2308
|
? parent.parentElement
|
|
2238
2309
|
: null;
|
|
@@ -2240,9 +2311,19 @@ export class Renderer {
|
|
|
2240
2311
|
// Phase 8: after a swipe back the clone starts where the drag left the
|
|
2241
2312
|
// screen (`--sb-nav-from`), and the screen below is handed back to the
|
|
2242
2313
|
// stylesheet so it finishes its slide to 0 as the new top screen.
|
|
2314
|
+
const uncovered = el.previousElementSibling as HTMLElement;
|
|
2243
2315
|
this.interaction.screenRemoved(el);
|
|
2244
|
-
|
|
2245
|
-
|
|
2316
|
+
const morph = this.barMorphs.get(stack);
|
|
2317
|
+
if (this.instantPops.delete(id)) {
|
|
2318
|
+
settleWithoutTransition(stack);
|
|
2319
|
+
morph?.dispose();
|
|
2320
|
+
} else {
|
|
2321
|
+
const clone = animatePop(stack, el);
|
|
2322
|
+
// iOS 26: its bar morphs into the uncovered screen's: on from where a
|
|
2323
|
+
// swipe back has it, else from the start, at commit.
|
|
2324
|
+
if (morph && morph.from === el) morph.popped(clone);
|
|
2325
|
+
else this.pendingBarMorphs.push({ stack, from: clone, to: uncovered });
|
|
2326
|
+
}
|
|
2246
2327
|
}
|
|
2247
2328
|
const isSheet = el.classList.contains('sb-sheet');
|
|
2248
2329
|
if (isSheet && parent) animateSheetOut(parent, el);
|
|
@@ -3074,13 +3155,14 @@ function settleWithoutTransition(stack: HTMLElement): void {
|
|
|
3074
3155
|
requestAnimationFrame(() => requestAnimationFrame(() => delete stack.dataset['sbInstant']));
|
|
3075
3156
|
}
|
|
3076
3157
|
|
|
3077
|
-
function animatePop(stack: HTMLElement, screen: HTMLElement):
|
|
3158
|
+
function animatePop(stack: HTMLElement, screen: HTMLElement): HTMLElement {
|
|
3078
3159
|
const clone = ghostClone(screen);
|
|
3079
3160
|
clone.removeAttribute('inert');
|
|
3080
3161
|
clone.setAttribute('aria-hidden', 'true');
|
|
3081
3162
|
clone.classList.add('sb-navscreen--pop');
|
|
3082
3163
|
stack.append(clone);
|
|
3083
3164
|
afterAnimation(clone, NAV_TRANSITION_MS, () => clone.remove());
|
|
3165
|
+
return clone;
|
|
3084
3166
|
}
|
|
3085
3167
|
|
|
3086
3168
|
// ---------------------------------------------------------------------------
|
package/src/styles.css
CHANGED
|
@@ -1100,6 +1100,30 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
1100
1100
|
}
|
|
1101
1101
|
}
|
|
1102
1102
|
|
|
1103
|
+
/* iOS 26: one bar through a push or pop (src/barMorph.ts). The morph is an
|
|
1104
|
+
overlay on the stack, above the screens and the pop clone, holding clones
|
|
1105
|
+
of both screens' bar parts (the capsules under the circles and the items)
|
|
1106
|
+
while the screens slide under it; the parts themselves are hidden for the
|
|
1107
|
+
slide. It takes no input. */
|
|
1108
|
+
.sb-navbar-morph {
|
|
1109
|
+
position: absolute;
|
|
1110
|
+
z-index: 3;
|
|
1111
|
+
pointer-events: none;
|
|
1112
|
+
user-select: none;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/* Its clones of the circles and the items go over the capsule clones (z 2,
|
|
1116
|
+
as in a screen), as their toolbars (z 3) put them there. */
|
|
1117
|
+
.sb-navbar-morph > .sb-navbar-back,
|
|
1118
|
+
.sb-navbar-morph > .sb-navbar-more,
|
|
1119
|
+
.sb-navbar-morph > [data-sb-bar-item] {
|
|
1120
|
+
z-index: 3;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
[data-sb-bar-morph] {
|
|
1124
|
+
visibility: hidden;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1103
1127
|
/* Navigation bar: the engine's `bar` rect (status bar + 44); the renderer
|
|
1104
1128
|
pads the status bar height so the 44px row sits at the bottom. */
|
|
1105
1129
|
.sb-navbar {
|
|
@@ -2597,13 +2621,16 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
2597
2621
|
}
|
|
2598
2622
|
}
|
|
2599
2623
|
|
|
2600
|
-
/* The exit clone of a bar item (src/animation.ts)
|
|
2624
|
+
/* The exit clone of a bar item (src/animation.ts), and an item's clone in
|
|
2625
|
+
the bar morph of a push or pop (src/barMorph.ts), is drawn as its toolbar
|
|
2601
2626
|
drew it: the bar's symbol size and weight, the label color. */
|
|
2602
|
-
.sb-exit-clone[data-sb-bar-item]
|
|
2627
|
+
.sb-exit-clone[data-sb-bar-item],
|
|
2628
|
+
.sb-navbar-morph [data-sb-bar-item] {
|
|
2603
2629
|
--sb-color-accent: var(--sb-color-primary);
|
|
2604
2630
|
}
|
|
2605
2631
|
|
|
2606
|
-
.sb-exit-clone[data-sb-bar-item] .sb-image > svg
|
|
2632
|
+
.sb-exit-clone[data-sb-bar-item] .sb-image > svg,
|
|
2633
|
+
.sb-navbar-morph [data-sb-bar-item] .sb-image > svg {
|
|
2607
2634
|
width: 1.57em;
|
|
2608
2635
|
height: 1.57em;
|
|
2609
2636
|
stroke-width: 1.85;
|
|
@@ -3037,9 +3064,9 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
3037
3064
|
pointer-events: auto;
|
|
3038
3065
|
}
|
|
3039
3066
|
|
|
3040
|
-
:is([data-sb-hidden], [data-sb-disabled], [data-sb-hit-testing="false"], .sb-navscreen--pop, .sb-exit-clone, .sb-sheet--out, .sb-alert--out, .sb-tab[data-sb-tab-hidden]) [data-sb-kind],
|
|
3041
|
-
:is([data-sb-hidden], [data-sb-disabled], [data-sb-hit-testing="false"], .sb-navscreen--pop, .sb-exit-clone, .sb-sheet--out, .sb-alert--out, .sb-tab[data-sb-tab-hidden]) .sb-navbar > *,
|
|
3042
|
-
:is([data-sb-hidden], [data-sb-disabled], [data-sb-hit-testing="false"], .sb-navscreen--pop, .sb-exit-clone, .sb-sheet--out, .sb-alert--out, .sb-tab[data-sb-tab-hidden]) .sb-image[data-sb-web] > img {
|
|
3067
|
+
:is([data-sb-hidden], [data-sb-disabled], [data-sb-hit-testing="false"], .sb-navscreen--pop, .sb-exit-clone, .sb-navbar-morph, .sb-sheet--out, .sb-alert--out, .sb-tab[data-sb-tab-hidden]) [data-sb-kind],
|
|
3068
|
+
:is([data-sb-hidden], [data-sb-disabled], [data-sb-hit-testing="false"], .sb-navscreen--pop, .sb-exit-clone, .sb-navbar-morph, .sb-sheet--out, .sb-alert--out, .sb-tab[data-sb-tab-hidden]) .sb-navbar > *,
|
|
3069
|
+
:is([data-sb-hidden], [data-sb-disabled], [data-sb-hit-testing="false"], .sb-navscreen--pop, .sb-exit-clone, .sb-navbar-morph, .sb-sheet--out, .sb-alert--out, .sb-tab[data-sb-tab-hidden]) .sb-image[data-sb-web] > img {
|
|
3043
3070
|
pointer-events: none !important;
|
|
3044
3071
|
}
|
|
3045
3072
|
|