@swiftbrowser/web 0.5.6 → 0.6.0
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 +39 -7
- package/app/index.html +25 -13
- package/node/dist/node/config.d.ts +24 -3
- package/node/dist/node/config.d.ts.map +1 -1
- package/node/dist/node/config.js +41 -10
- package/node/dist/node/config.js.map +1 -1
- package/node/dist/node/index.d.ts +9 -4
- package/node/dist/node/index.d.ts.map +1 -1
- package/node/dist/node/index.js +14 -5
- package/node/dist/node/index.js.map +1 -1
- package/node/dist/plugins/app-manifests.d.ts +12 -4
- package/node/dist/plugins/app-manifests.d.ts.map +1 -1
- package/node/dist/plugins/app-manifests.js +14 -9
- package/node/dist/plugins/app-manifests.js.map +1 -1
- package/node/dist/plugins/site-layout.d.ts +41 -0
- package/node/dist/plugins/site-layout.d.ts.map +1 -0
- package/node/dist/plugins/site-layout.js +77 -0
- package/node/dist/plugins/site-layout.js.map +1 -0
- package/package.json +1 -1
- package/plugins/app-manifests.ts +28 -9
- package/plugins/site-layout.ts +96 -0
- package/src/animation.ts +86 -3
- package/src/deviceMode.ts +9 -2
- package/src/gaugeRing.ts +2 -2
- package/src/layout/engine.ts +228 -51
- package/src/layout/index.ts +1 -1
- package/src/layoutDom.ts +96 -12
- package/src/main.ts +6 -4
- package/src/press.ts +130 -0
- package/src/protocol.ts +26 -0
- package/src/renderer.ts +57 -14
- package/src/styles.css +184 -29
- package/src/styles.ts +3 -0
- package/src/symbols.ts +3 -1
- package/src/vite-env.d.ts +18 -0
package/src/layoutDom.ts
CHANGED
|
@@ -34,11 +34,15 @@
|
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
36
|
import type { LayoutEnvironment, LayoutNode, LayoutResult, Rect, ResolvedFont, TextLayout } from './layout';
|
|
37
|
+
import { listRowBackgroundOf } from './layout';
|
|
38
|
+
import { styleToCSS } from './styles';
|
|
39
|
+
import type { ShapeStyle } from './protocol';
|
|
37
40
|
import { applyChartChrome } from './chartDom';
|
|
38
41
|
import { bitmapLaidOut } from './bitmaps';
|
|
39
42
|
import { drawRichText } from './richText';
|
|
40
43
|
import { bindScrollUnderBar, largeTitleOf, largeTitleScrolls } from './largeTitle';
|
|
41
44
|
import { bindScrollEdgeBottom } from './scrollEdge';
|
|
45
|
+
import { BOTTOM_GLASS_KIND, TOOLBAR_ANIMATION, TOOLBAR_TRANSITION, baseTiming, timingFor, transitionStart } from './animation';
|
|
42
46
|
|
|
43
47
|
export interface LayoutDomContext {
|
|
44
48
|
root: HTMLElement;
|
|
@@ -113,7 +117,7 @@ export function applyLayout(ctx: LayoutDomContext, result: LayoutResult): BoxCha
|
|
|
113
117
|
// A former menu item that is laid out again (moved out of the menu), or a
|
|
114
118
|
// `viewthatfits` candidate chosen again, shows again.
|
|
115
119
|
if (el.hidden) el.hidden = false;
|
|
116
|
-
applyChrome(result, node, el, box, frame, scrollBottomInset(ctx, result, node, frame));
|
|
120
|
+
applyChrome(result, node, el, box, frame, scrollBottomInset(ctx, result, node, frame), previous === null, changes);
|
|
117
121
|
if (node.kind === 'tab') setTabHidden(el, result.hidden.has(id));
|
|
118
122
|
}
|
|
119
123
|
|
|
@@ -260,22 +264,79 @@ function placeEditControl(node: HTMLElement | null, rect: Rect | undefined, orig
|
|
|
260
264
|
|
|
261
265
|
/**
|
|
262
266
|
* iOS 26: the glass a screen's bottom toolbar items float on, one element
|
|
263
|
-
* per capsule (engine `bottomGlass0` …), under the items (styles.css).
|
|
267
|
+
* per capsule (engine `bottomGlass0` …), under the items (styles.css). A
|
|
268
|
+
* capsule is known by the bottom toolbars drawn on it (`data-sb-toolbars`:
|
|
269
|
+
* the ids of those whose frames lie in it), not by its index, so a group
|
|
270
|
+
* that goes takes its own capsule with it and the groups after it keep
|
|
271
|
+
* theirs. A capsule that moves or resizes is reported in `changes` (kind
|
|
272
|
+
* `bottomglass`, no id) so the Animator tweens it with the toolbar's items;
|
|
273
|
+
* one that appears on a screen already there, or goes, scales and fades as
|
|
274
|
+
* the items do (`TOOLBAR_TRANSITION`), in the toolbar's own spring.
|
|
264
275
|
*/
|
|
265
|
-
function syncBottomGlass(
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
276
|
+
function syncBottomGlass(
|
|
277
|
+
result: LayoutResult,
|
|
278
|
+
node: LayoutNode,
|
|
279
|
+
screen: HTMLElement,
|
|
280
|
+
chrome: Record<string, Rect> | undefined,
|
|
281
|
+
frame: Rect,
|
|
282
|
+
screenIsNew: boolean,
|
|
283
|
+
changes: BoxChange[],
|
|
284
|
+
): void {
|
|
285
|
+
const unmatched = new Set(screen.querySelectorAll<HTMLElement>(':scope > .sb-bottom-glass:not([data-sb-exiting])'));
|
|
286
|
+
const toolbars = node.children.filter((child) => child.kind === 'toolbar' && child.props['placement'] === 'bottom');
|
|
287
|
+
for (let count = 0, rect = chrome?.['bottomGlass0']; rect; rect = chrome?.[`bottomGlass${++count}`]) {
|
|
288
|
+
const capsule = rect;
|
|
289
|
+
const ids = toolbars.filter((toolbar) => centeredIn(result.frames.get(toolbar.id), capsule)).map((toolbar) => String(toolbar.id));
|
|
290
|
+
const relative = { x: rect.x - frame.x, y: rect.y - frame.y, width: rect.width, height: rect.height };
|
|
291
|
+
let glass = Array.from(unmatched).find((candidate) => (candidate.dataset['sbToolbars'] ?? '').split(' ').some((id) => ids.includes(id)));
|
|
292
|
+
if (glass) {
|
|
293
|
+
unmatched.delete(glass);
|
|
294
|
+
const from = glassBox(glass);
|
|
295
|
+
if (!sameRect(from, relative)) changes.push({ id: -1, el: glass, kind: BOTTOM_GLASS_KIND, from, to: relative });
|
|
296
|
+
} else {
|
|
271
297
|
glass = document.createElement('div');
|
|
272
298
|
glass.className = 'sb-bottom-glass';
|
|
273
299
|
screen.append(glass);
|
|
300
|
+
if (!screenIsNew) transitionGlass(glass, false);
|
|
301
|
+
changes.push({ id: -1, el: glass, kind: BOTTOM_GLASS_KIND, from: null, to: relative });
|
|
274
302
|
}
|
|
303
|
+
glass.dataset['sbToolbars'] = ids.join(' ');
|
|
275
304
|
placeChrome(glass, rect, frame);
|
|
276
|
-
count += 1;
|
|
277
305
|
}
|
|
278
|
-
for (const extra of
|
|
306
|
+
for (const extra of unmatched) transitionGlass(extra, true);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Whether `rect`'s center lies in `area` (a toolbar's frame spans its items, all on one capsule). */
|
|
310
|
+
function centeredIn(rect: Rect | undefined, area: Rect): boolean {
|
|
311
|
+
if (!rect) return false;
|
|
312
|
+
const cx = rect.x + rect.width / 2;
|
|
313
|
+
const cy = rect.y + rect.height / 2;
|
|
314
|
+
return cx >= area.x && cx <= area.x + area.width && cy >= area.y && cy <= area.y + area.height;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** The box a glass capsule was placed at (`placeChrome`), relative to its screen. */
|
|
318
|
+
function glassBox(glass: HTMLElement): Rect {
|
|
319
|
+
const s = glass.style;
|
|
320
|
+
return { x: parseFloat(s.left) || 0, y: parseFloat(s.top) || 0, width: parseFloat(s.width) || 0, height: parseFloat(s.height) || 0 };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Scales and fades a bottom bar capsule in, or out (then removes it), as its toolbar's items do. */
|
|
324
|
+
function transitionGlass(glass: HTMLElement, exit: boolean): void {
|
|
325
|
+
const timing = baseTiming(timingFor(TOOLBAR_ANIMATION));
|
|
326
|
+
const gone = transitionStart(TOOLBAR_TRANSITION, exit);
|
|
327
|
+
const frames: Keyframe[] = [
|
|
328
|
+
{ opacity: gone.opacity ?? 0, transform: gone.transform ?? 'none' },
|
|
329
|
+
{ opacity: 1, transform: 'none' },
|
|
330
|
+
];
|
|
331
|
+
if (!exit) {
|
|
332
|
+
glass.animate(frames, { ...timing, fill: 'backwards' });
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
glass.toggleAttribute('data-sb-exiting', true);
|
|
336
|
+
const out = glass.animate(frames.slice().reverse(), { ...timing, fill: 'forwards' });
|
|
337
|
+
const done = (): void => glass.remove();
|
|
338
|
+
out.finished.then(done, done);
|
|
339
|
+
window.setTimeout(done, timing.duration + timing.delay + 100);
|
|
279
340
|
}
|
|
280
341
|
|
|
281
342
|
function placeChrome(node: HTMLElement | null, rect: Rect | undefined, origin: Rect): void {
|
|
@@ -434,12 +495,23 @@ function applyChrome(
|
|
|
434
495
|
box: Rect,
|
|
435
496
|
frame: Rect,
|
|
436
497
|
bottomInset: number,
|
|
498
|
+
isNew: boolean,
|
|
499
|
+
changes: BoxChange[],
|
|
437
500
|
): void {
|
|
438
501
|
const chrome = result.chrome.get(node.id);
|
|
439
502
|
// `.listRowSeparator(.hidden)`: a list row leaves out the separator below
|
|
440
503
|
// it, or above it (the previous row's line; styles.css).
|
|
441
504
|
setFlag(el, 'sbSeparatorBottom', chrome?.['separatorBottomHidden'] ? 'hidden' : null);
|
|
442
505
|
setFlag(el, 'sbSeparatorTop', chrome?.['separatorTopHidden'] ? 'hidden' : null);
|
|
506
|
+
// `.listRowBackground(_:)`: the row's cell paint (styles.css reads it, and
|
|
507
|
+
// a card with such a row leaves its own background to its rows).
|
|
508
|
+
const rowBackground = chrome?.['row'] ? listRowBackgroundOf(node) : undefined;
|
|
509
|
+
const rowPaint = rowBackground !== undefined ? styleToCSS(rowBackground as ShapeStyle, 'paint').value : '';
|
|
510
|
+
if (el.style.getPropertyValue('--sb-row-background') !== rowPaint) {
|
|
511
|
+
if (rowPaint) el.style.setProperty('--sb-row-background', rowPaint);
|
|
512
|
+
else el.style.removeProperty('--sb-row-background');
|
|
513
|
+
}
|
|
514
|
+
setFlag(el, 'sbRowBackground', rowPaint ? '' : null);
|
|
443
515
|
// iOS 26: the separator under a list row starts at its first text (styles.css).
|
|
444
516
|
const separator = chrome?.['separator'];
|
|
445
517
|
const leading = separator ? `${separator.x}px` : '';
|
|
@@ -598,10 +670,20 @@ function applyChrome(
|
|
|
598
670
|
if (content && size) {
|
|
599
671
|
const s = content.style;
|
|
600
672
|
const axes = String(node.props['axes'] ?? 'vertical');
|
|
601
|
-
|
|
673
|
+
// `.scrollClipDisabled()`: the frame reaches past the viewport the
|
|
674
|
+
// scroll view laid out in (chrome `viewport`), by `before` and
|
|
675
|
+
// `after`. The content keeps its place (its margin is `before`) and
|
|
676
|
+
// runs `after` further, so it scrolls as far as it did and snaps
|
|
677
|
+
// against the viewport's edges.
|
|
678
|
+
const viewport = chrome?.['viewport'];
|
|
679
|
+
const before = viewport ? viewport.x - frame.x : 0;
|
|
680
|
+
const after = viewport ? frame.x + frame.width - (viewport.x + viewport.width) : 0;
|
|
681
|
+
s.width = `${size.width + after}px`;
|
|
602
682
|
s.height = `${size.height + (axes === 'horizontal' ? 0 : bottomInset)}px`;
|
|
603
683
|
s.marginLeft = `${rect ? rect.x - frame.x : 0}px`;
|
|
604
684
|
s.marginTop = `${rect ? rect.y - frame.y : 0}px`;
|
|
685
|
+
el.style.scrollPaddingLeft = before ? `${before}px` : '';
|
|
686
|
+
el.style.scrollPaddingRight = after ? `${after}px` : '';
|
|
605
687
|
}
|
|
606
688
|
bindScrollUnderBar(el, frame, chrome?.['topInset'], chrome?.['largeTitle']);
|
|
607
689
|
bindScrollEdgeBottom(el, String(node.props['axes'] ?? 'vertical') === 'horizontal' ? undefined : chrome?.['bottomInset']);
|
|
@@ -623,7 +705,9 @@ function applyChrome(
|
|
|
623
705
|
bar.style.paddingTop = `${top}px`;
|
|
624
706
|
bar.style.paddingBottom = `${bottom}px`;
|
|
625
707
|
}
|
|
626
|
-
syncBottomGlass(el, chrome, frame);
|
|
708
|
+
syncBottomGlass(result, node, el, chrome, frame, isNew, changes);
|
|
709
|
+
// iOS 26 in a tab view: the search field under the title (styles.css).
|
|
710
|
+
setFlag(el, 'sbSearchTop', chrome?.['searchTop'] ? '' : null);
|
|
627
711
|
const largeRect = chrome?.['largeTitle'];
|
|
628
712
|
if (largeTitle) {
|
|
629
713
|
if (largeRect) {
|
package/src/main.ts
CHANGED
|
@@ -47,12 +47,14 @@ const buildStatus = $<HTMLElement>('#build-status');
|
|
|
47
47
|
|
|
48
48
|
const params = new URLSearchParams(location.search);
|
|
49
49
|
const useMock = params.get('mock') === '1';
|
|
50
|
-
|
|
50
|
+
// The app: `?app=`, else the site's default (`define`, node/config.ts).
|
|
51
|
+
const requestedApp = (params.get('app') ?? __SB_DEFAULT_APP__).replace(/[^A-Za-z0-9_-]/g, '') || __SB_DEFAULT_APP__;
|
|
51
52
|
|
|
52
53
|
// Frame mode (the fake iPhone bezel, desktop and tests) or device mode (a
|
|
53
|
-
// real phone: full screen, real safe areas, OS status bar
|
|
54
|
-
//
|
|
55
|
-
|
|
54
|
+
// real phone: full screen, real safe areas, OS status bar; every screen of a
|
|
55
|
+
// site built for a deployment). See deviceMode.ts; the inline script in
|
|
56
|
+
// app/index.html pre-set the attribute with the same rule.
|
|
57
|
+
const pageMode: PageMode = decidePageMode(location.search, window, __SB_PAGE_MODE__);
|
|
56
58
|
applyPageMode(pageMode);
|
|
57
59
|
const deviceMode = pageMode === 'device';
|
|
58
60
|
|
package/src/press.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iOS 26's press feedback on Liquid Glass: while a finger is on a bar button
|
|
3
|
+
* or a glass button, the glass under it swells (scales up a little) and
|
|
4
|
+
* brightens; when the finger lifts it springs back. The stylesheet draws it
|
|
5
|
+
* (`[data-sb-pressed]`, `[data-sb-pressed-item]` in styles.css); this
|
|
6
|
+
* controller marks the glass under the pressed control from the pointer
|
|
7
|
+
* down to the pointer up, as UIKit highlights a button while it tracks a
|
|
8
|
+
* touch: lifting the highlight when the pointer leaves the control (plus a
|
|
9
|
+
* margin) and restoring it when it comes back.
|
|
10
|
+
*
|
|
11
|
+
* The glass under a control: a bar item's shared capsule (a `leading` or
|
|
12
|
+
* `trailing` toolbar), a bottom bar item's `.sb-bottom-glass` (the one
|
|
13
|
+
* under it: the glass is beside the toolbar, not around it), the back and
|
|
14
|
+
* More circles, the sidebar toggle, a `.glass` button, or the `.glassEffect`
|
|
15
|
+
* box around a button. A control on no glass (a plain or bordered button)
|
|
16
|
+
* keeps the stylesheet's `:active` dimming.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The controls a press can highlight the glass under. */
|
|
20
|
+
const PRESSABLE = '.sb-button, .sb-menu, .sb-navbar-back, .sb-navbar-more, .sb-navsplit-toggle, .sb-picker[data-sb-style="menu"]';
|
|
21
|
+
|
|
22
|
+
/** Controls that are their own glass. */
|
|
23
|
+
const OWN_GLASS = '.sb-navbar-back, .sb-navbar-more, .sb-navsplit-toggle, .sb-button[data-sb-style="glass"]';
|
|
24
|
+
|
|
25
|
+
/** UIKit keeps a button highlighted while the touch stays within this far outside it. */
|
|
26
|
+
export const PRESS_SLOP = 24;
|
|
27
|
+
|
|
28
|
+
interface Press {
|
|
29
|
+
pointerId: number;
|
|
30
|
+
control: HTMLElement;
|
|
31
|
+
surface: HTMLElement;
|
|
32
|
+
/** Where the control was on the pointer down (its bounds plus the slop hold the press). */
|
|
33
|
+
bounds: DOMRect;
|
|
34
|
+
held: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class PressEffects {
|
|
38
|
+
private press: Press | null = null;
|
|
39
|
+
|
|
40
|
+
constructor(private readonly root: HTMLElement) {
|
|
41
|
+
root.addEventListener('pointerdown', this.onPointerDown, true);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
dispose(): void {
|
|
45
|
+
this.root.removeEventListener('pointerdown', this.onPointerDown, true);
|
|
46
|
+
this.release();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private readonly onPointerDown = (event: PointerEvent): void => {
|
|
50
|
+
if (this.press || !event.isPrimary || (event.pointerType === 'mouse' && event.button !== 0)) return;
|
|
51
|
+
const control = (event.target as Element | null)?.closest<HTMLElement>(PRESSABLE);
|
|
52
|
+
if (!control || !this.root.contains(control) || isDisabled(control)) return;
|
|
53
|
+
const surface = glassSurfaceOf(control, this.root);
|
|
54
|
+
if (!surface) return;
|
|
55
|
+
this.press = { pointerId: event.pointerId, control, surface, bounds: control.getBoundingClientRect(), held: false };
|
|
56
|
+
this.hold(true);
|
|
57
|
+
window.addEventListener('pointermove', this.onPointerMove);
|
|
58
|
+
window.addEventListener('pointerup', this.onPointerUp);
|
|
59
|
+
window.addEventListener('pointercancel', this.onPointerUp);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
private readonly onPointerMove = (event: PointerEvent): void => {
|
|
63
|
+
const press = this.press;
|
|
64
|
+
if (!press || event.pointerId !== press.pointerId) return;
|
|
65
|
+
const b = press.bounds;
|
|
66
|
+
const inside =
|
|
67
|
+
event.clientX >= b.left - PRESS_SLOP &&
|
|
68
|
+
event.clientX <= b.right + PRESS_SLOP &&
|
|
69
|
+
event.clientY >= b.top - PRESS_SLOP &&
|
|
70
|
+
event.clientY <= b.bottom + PRESS_SLOP;
|
|
71
|
+
this.hold(inside);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
private readonly onPointerUp = (event: PointerEvent): void => {
|
|
75
|
+
if (this.press && event.pointerId === this.press.pointerId) this.release();
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Marks (or unmarks) the pressed glass and the control on it. */
|
|
79
|
+
private hold(held: boolean): void {
|
|
80
|
+
const press = this.press;
|
|
81
|
+
if (!press || press.held === held) return;
|
|
82
|
+
press.held = held;
|
|
83
|
+
press.surface.toggleAttribute('data-sb-pressed', held);
|
|
84
|
+
if (press.control !== press.surface) press.control.toggleAttribute('data-sb-pressed-item', held);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private release(): void {
|
|
88
|
+
this.hold(false);
|
|
89
|
+
this.press = null;
|
|
90
|
+
window.removeEventListener('pointermove', this.onPointerMove);
|
|
91
|
+
window.removeEventListener('pointerup', this.onPointerUp);
|
|
92
|
+
window.removeEventListener('pointercancel', this.onPointerUp);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A control the renderer disabled (`.disabled(true)` marks the element around it). */
|
|
97
|
+
function isDisabled(control: HTMLElement): boolean {
|
|
98
|
+
return control.closest('[data-sb-disabled]') !== null || control.getAttribute('aria-disabled') === 'true';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The Liquid Glass under `control`, or null when it sits on none (see the
|
|
103
|
+
* top of the file).
|
|
104
|
+
*/
|
|
105
|
+
export function glassSurfaceOf(control: HTMLElement, root: HTMLElement): HTMLElement | null {
|
|
106
|
+
if (control.matches(OWN_GLASS)) return control;
|
|
107
|
+
const toolbar = control.closest<HTMLElement>('.sb-toolbar');
|
|
108
|
+
if (toolbar && root.contains(toolbar)) {
|
|
109
|
+
const placement = toolbar.dataset['sbPlacement'];
|
|
110
|
+
if (placement === 'leading' || placement === 'trailing') return toolbar;
|
|
111
|
+
if (placement === 'bottom') return bottomGlassUnder(control, toolbar);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const glass = control.closest<HTMLElement>('[data-sb-glass]');
|
|
115
|
+
return glass && root.contains(glass) ? glass : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The bottom bar capsule a bottom toolbar item is drawn on: the screen's `.sb-bottom-glass` under its center. */
|
|
119
|
+
function bottomGlassUnder(control: HTMLElement, toolbar: HTMLElement): HTMLElement | null {
|
|
120
|
+
const screen = toolbar.closest<HTMLElement>('.sb-navscreen');
|
|
121
|
+
if (!screen) return null;
|
|
122
|
+
const rect = control.getBoundingClientRect();
|
|
123
|
+
const cx = rect.left + rect.width / 2;
|
|
124
|
+
const cy = rect.top + rect.height / 2;
|
|
125
|
+
for (const glass of screen.querySelectorAll<HTMLElement>(':scope > .sb-bottom-glass')) {
|
|
126
|
+
const g = glass.getBoundingClientRect();
|
|
127
|
+
if (cx >= g.left && cx <= g.right && cy >= g.top && cy <= g.bottom) return glass;
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -22,6 +22,8 @@ export interface RGBAColor {
|
|
|
22
22
|
|
|
23
23
|
export type SemanticColorName =
|
|
24
24
|
| 'primary'
|
|
25
|
+
/** `.foreground` (ForegroundStyle): the color where it is drawn (CSS `currentColor`). */
|
|
26
|
+
| 'foreground'
|
|
25
27
|
| 'secondary'
|
|
26
28
|
| 'accent'
|
|
27
29
|
| 'systemBackground'
|
|
@@ -396,6 +398,12 @@ export interface Style {
|
|
|
396
398
|
* 11 vertical) for the list row this styled element is.
|
|
397
399
|
*/
|
|
398
400
|
listRowInsets?: EdgeInsets;
|
|
401
|
+
/**
|
|
402
|
+
* `.listRowBackground(_:)` with a `Color` (or ShapeStyle): the list row this
|
|
403
|
+
* styled element is (or is in) has its cell painted with it instead of the
|
|
404
|
+
* card's; `clear` shows the list's background.
|
|
405
|
+
*/
|
|
406
|
+
listRowBackground?: ShapeStyle;
|
|
399
407
|
/**
|
|
400
408
|
* `.imageScale(_:)`: symbol images in the subtree (`image` with
|
|
401
409
|
* `systemName`, and catalog `symbol` images) measure 0.8 / 1 / 1.25 of the
|
|
@@ -700,6 +708,8 @@ export interface TabProps {
|
|
|
700
708
|
badge?: string;
|
|
701
709
|
/** iOS 18 `Tab(role: .search)` (absent otherwise); Swift already put it last. */
|
|
702
710
|
role?: 'search';
|
|
711
|
+
/** A catalog image (a custom symbol) for the bar item when there is no `systemImage` (absent otherwise). */
|
|
712
|
+
image?: string;
|
|
703
713
|
}
|
|
704
714
|
export type PickerStyle = 'segmented' | 'menu' | 'inline';
|
|
705
715
|
/**
|
|
@@ -728,6 +738,14 @@ export interface GeometryProps {
|
|
|
728
738
|
* only, as before.
|
|
729
739
|
*/
|
|
730
740
|
reportsFrame?: boolean;
|
|
741
|
+
/**
|
|
742
|
+
* The Swift view the size is reported to. Swift keeps the element when
|
|
743
|
+
* another view takes the reader's place, and changes this: the next layout
|
|
744
|
+
* pass then reports the size again, unchanged or not, since the new view
|
|
745
|
+
* has not heard it. Also on every other element that reports its size
|
|
746
|
+
* (`reportsSize`).
|
|
747
|
+
*/
|
|
748
|
+
reader?: number;
|
|
731
749
|
}
|
|
732
750
|
|
|
733
751
|
// Phase 6 kinds (docs/phase6/layout-containers.md).
|
|
@@ -842,6 +860,8 @@ export type DecorationRole = 'overlay' | 'background';
|
|
|
842
860
|
export interface DecoratedProps {
|
|
843
861
|
role: DecorationRole;
|
|
844
862
|
alignment: FrameAlignment;
|
|
863
|
+
/** The decoration is in an `if`: aligned by its edges, its `.alignmentGuide`s unused (absent otherwise). */
|
|
864
|
+
conditional?: boolean;
|
|
845
865
|
}
|
|
846
866
|
// Phase 6 kinds (docs/phase6/chrome.md).
|
|
847
867
|
|
|
@@ -1814,6 +1834,12 @@ export interface ScrollViewProps {
|
|
|
1814
1834
|
refreshing?: boolean;
|
|
1815
1835
|
/** Phase 12 `.scrollTargetBehavior`: CSS scroll snapping on the axis. */
|
|
1816
1836
|
snap?: 'viewAligned' | 'paging';
|
|
1837
|
+
/**
|
|
1838
|
+
* `.scrollClipDisabled()`: a horizontal scroll view's frame reaches the
|
|
1839
|
+
* screen's sides (chrome `viewport` is its own rect), so its content draws
|
|
1840
|
+
* past it while scrolling and snapping as before.
|
|
1841
|
+
*/
|
|
1842
|
+
clipDisabled?: boolean;
|
|
1817
1843
|
}
|
|
1818
1844
|
|
|
1819
1845
|
export interface Style {
|
package/src/renderer.ts
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
|
|
36
36
|
import {
|
|
37
37
|
Animator,
|
|
38
|
+
barToolbarOf,
|
|
38
39
|
EXIT_CLONE_CLASS,
|
|
39
40
|
emptyPass,
|
|
40
41
|
setTransition,
|
|
@@ -58,6 +59,7 @@ import { applyLayout, setTabHidden, type BoxChange } from './layoutDom';
|
|
|
58
59
|
import { ghostClone } from './ghost';
|
|
59
60
|
import { ExternalDrops } from './dragDrop';
|
|
60
61
|
import { InteractionController, type InteractionHost } from './interaction';
|
|
62
|
+
import { PressEffects } from './press';
|
|
61
63
|
import { symbolOf, textOf, unwrapStyledNode } from './nodeQueries';
|
|
62
64
|
import {
|
|
63
65
|
INTERACTION_KINDS,
|
|
@@ -371,6 +373,7 @@ export class Renderer {
|
|
|
371
373
|
* swipe back, the sheet drag, pull to refresh) and arbitrates between them.
|
|
372
374
|
*/
|
|
373
375
|
readonly interaction: InteractionController;
|
|
376
|
+
private readonly pressEffects: PressEffects;
|
|
374
377
|
/** Text, links and files dragged in from outside the page (src/dragDrop.ts). */
|
|
375
378
|
private readonly externalDrops: ExternalDrops;
|
|
376
379
|
/** `video` elements' media events, as `video` events (src/video.ts). */
|
|
@@ -820,6 +823,8 @@ export class Renderer {
|
|
|
820
823
|
closeMenu: () => this.closeMenu(),
|
|
821
824
|
};
|
|
822
825
|
this.interaction = new InteractionController(host);
|
|
826
|
+
// iOS 26: the glass under a pressed bar button swells and springs back (src/press.ts).
|
|
827
|
+
this.pressEffects = new PressEffects(root);
|
|
823
828
|
this.externalDrops = new ExternalDrops(host);
|
|
824
829
|
root.addEventListener('change', this.onChange);
|
|
825
830
|
root.addEventListener('pointerdown', this.onSliderPointerDown);
|
|
@@ -1430,6 +1435,7 @@ export class Renderer {
|
|
|
1430
1435
|
this.root.removeEventListener('focusout', this.onFocusChange);
|
|
1431
1436
|
this.root.removeEventListener('scroll', this.onScrollDismissKeyboard, true);
|
|
1432
1437
|
this.interaction.dispose();
|
|
1438
|
+
this.pressEffects.dispose();
|
|
1433
1439
|
this.externalDrops.dispose();
|
|
1434
1440
|
this.videoEvents.dispose();
|
|
1435
1441
|
this.resizeObserver?.disconnect();
|
|
@@ -1573,7 +1579,14 @@ export class Renderer {
|
|
|
1573
1579
|
}
|
|
1574
1580
|
}
|
|
1575
1581
|
if (node) {
|
|
1576
|
-
|
|
1582
|
+
const next = asRecord(props);
|
|
1583
|
+
// Another Swift view now reads this element's size (a `geometry`
|
|
1584
|
+
// element kept for a new page's reader): it knows no size yet, so the
|
|
1585
|
+
// next layout pass reports the size even when it is unchanged.
|
|
1586
|
+
if ((kind === 'geometry' || next['reportsSize'] === true) && next['reader'] !== node.props['reader']) {
|
|
1587
|
+
this.reportedGeometry.delete(id);
|
|
1588
|
+
}
|
|
1589
|
+
node.props = next;
|
|
1577
1590
|
// Phase 9: the next layout re-measures this subtree and its ancestors;
|
|
1578
1591
|
// a screen's title also feeds the next screen's back label.
|
|
1579
1592
|
if (node.kind === 'navscreen') {
|
|
@@ -1619,10 +1632,10 @@ export class Renderer {
|
|
|
1619
1632
|
if (stack) refreshNavStack(stack);
|
|
1620
1633
|
}
|
|
1621
1634
|
if (kind === 'sheet' && el.parentElement) refreshSheets(el.parentElement);
|
|
1622
|
-
if (kind === 'tabview') refreshTabBar(el);
|
|
1635
|
+
if (kind === 'tabview') refreshTabBar(el, this.assets);
|
|
1623
1636
|
if (kind === 'tab') {
|
|
1624
1637
|
const tabview = tabViewOf(el);
|
|
1625
|
-
if (tabview) refreshTabBar(tabview);
|
|
1638
|
+
if (tabview) refreshTabBar(tabview, this.assets);
|
|
1626
1639
|
}
|
|
1627
1640
|
// The popover shows what its control had when it opened; drop it when
|
|
1628
1641
|
// the control (or, for a `menu`, anything inside it) changes.
|
|
@@ -1697,9 +1710,9 @@ export class Renderer {
|
|
|
1697
1710
|
// Phase 8: an alert fades in (and scales from 1.1) on its own.
|
|
1698
1711
|
if (el.classList.contains('sb-alert') && !wasAttached) animateAlertIn(el);
|
|
1699
1712
|
if (el.classList.contains('sb-tab')) {
|
|
1700
|
-
if (parent.classList.contains('sb-tabview')) refreshTabBar(parent);
|
|
1713
|
+
if (parent.classList.contains('sb-tabview')) refreshTabBar(parent, this.assets);
|
|
1701
1714
|
const oldTabView = previousParent && previousParent !== container ? tabViewOf(previousParent) : null;
|
|
1702
|
-
if (oldTabView && oldTabView !== parent) refreshTabBar(oldTabView);
|
|
1715
|
+
if (oldTabView && oldTabView !== parent) refreshTabBar(oldTabView, this.assets);
|
|
1703
1716
|
}
|
|
1704
1717
|
}
|
|
1705
1718
|
|
|
@@ -1734,7 +1747,9 @@ export class Renderer {
|
|
|
1734
1747
|
for (let a = parent; a && a !== this.root; a = a.parentElement!) ancestors.push(a);
|
|
1735
1748
|
let rect: DOMRect | null = null;
|
|
1736
1749
|
const matched: { id: string; el: HTMLElement; rect: DOMRect }[] = [];
|
|
1737
|
-
|
|
1750
|
+
// A bar toolbar's items (and the toolbar) leave with a transition of
|
|
1751
|
+
// their own in any pass (src/animation.ts, `implicitToolbars`).
|
|
1752
|
+
if (this.passAnimates !== false || barToolbarOf(el, parent)) {
|
|
1738
1753
|
this.passRootRect ??= this.animator.rootRect();
|
|
1739
1754
|
rect = this.animator.rectOf(el, this.passRootRect);
|
|
1740
1755
|
// `.matchedGeometryEffect`: where the matched boxes in the subtree were.
|
|
@@ -1766,7 +1781,7 @@ export class Renderer {
|
|
|
1766
1781
|
}
|
|
1767
1782
|
if (stack) refreshNavStack(stack);
|
|
1768
1783
|
if (isSheet && parent) refreshSheets(parent);
|
|
1769
|
-
if (tabview) refreshTabBar(tabview);
|
|
1784
|
+
if (tabview) refreshTabBar(tabview, this.assets);
|
|
1770
1785
|
}
|
|
1771
1786
|
|
|
1772
1787
|
/** Forget an element in every map; detaches its node from the tree. */
|
|
@@ -2528,7 +2543,7 @@ function clampIndex(value: unknown, count: number): number {
|
|
|
2528
2543
|
* the selected tab: only it is visible and interactive. The layout engine
|
|
2529
2544
|
* confirms the hidden set at `commit`; this keeps the DOM right in between.
|
|
2530
2545
|
*/
|
|
2531
|
-
function refreshTabBar(tabview: HTMLElement): void {
|
|
2546
|
+
function refreshTabBar(tabview: HTMLElement, assets: AssetCatalog): void {
|
|
2532
2547
|
const bar = tabview.querySelector<HTMLElement>(':scope > .sb-tabbar');
|
|
2533
2548
|
if (!bar) return;
|
|
2534
2549
|
const tabs = Array.from(slotOf(tabview).children).filter((c) => c.classList.contains('sb-tab')) as HTMLElement[];
|
|
@@ -2552,14 +2567,17 @@ function refreshTabBar(tabview: HTMLElement): void {
|
|
|
2552
2567
|
item.setAttribute('aria-label', title);
|
|
2553
2568
|
item.toggleAttribute('data-sb-selected', isSelected);
|
|
2554
2569
|
const icon = item.querySelector<HTMLElement>(':scope > .sb-tabbar-icon');
|
|
2555
|
-
|
|
2570
|
+
const asset = symbol ? '' : (tab.dataset['sbAsset'] ?? '');
|
|
2571
|
+
if (icon && (icon.dataset['sbSymbol'] !== symbol || (icon.dataset['sbAsset'] ?? '') !== asset)) {
|
|
2556
2572
|
icon.dataset['sbSymbol'] = symbol;
|
|
2573
|
+
if (asset) icon.dataset['sbAsset'] = asset;
|
|
2574
|
+
else delete icon.dataset['sbAsset'];
|
|
2557
2575
|
// iOS draws tab bar symbols in their filled form.
|
|
2558
|
-
const
|
|
2559
|
-
if (
|
|
2576
|
+
const glyph = symbol ? renderSymbol(filledSymbolName(symbol)) : asset ? tabAssetIcon(asset, assets) : null;
|
|
2577
|
+
if (glyph) icon.replaceChildren(glyph);
|
|
2560
2578
|
else icon.replaceChildren();
|
|
2561
|
-
icon.toggleAttribute('data-sb-missing', Boolean(symbol) && !
|
|
2562
|
-
icon.hidden = !symbol;
|
|
2579
|
+
icon.toggleAttribute('data-sb-missing', Boolean(symbol || asset) && !glyph);
|
|
2580
|
+
icon.hidden = !symbol && !asset;
|
|
2563
2581
|
}
|
|
2564
2582
|
const label = item.querySelector<HTMLElement>(':scope > .sb-tabbar-title');
|
|
2565
2583
|
if (label && label.textContent !== title) label.textContent = title;
|
|
@@ -2575,6 +2593,29 @@ function refreshTabBar(tabview: HTMLElement): void {
|
|
|
2575
2593
|
});
|
|
2576
2594
|
}
|
|
2577
2595
|
|
|
2596
|
+
/**
|
|
2597
|
+
* A tab bar item's catalog icon: a custom symbol masks the item's color, in
|
|
2598
|
+
* its `.fill` variant when the catalog has one (as iOS draws SF Symbols in a
|
|
2599
|
+
* tab bar); any other image shows as it is. Null when the catalog has neither.
|
|
2600
|
+
*/
|
|
2601
|
+
function tabAssetIcon(name: string, assets: AssetCatalog): Element | null {
|
|
2602
|
+
const asset = assets.images[`${name}.fill`]?.kind === 'symbol' ? assets.images[`${name}.fill`] : assets.images[name];
|
|
2603
|
+
if (!asset) return null;
|
|
2604
|
+
if (asset.kind === 'symbol') {
|
|
2605
|
+
const mask = document.createElement('span');
|
|
2606
|
+
mask.className = 'sb-tabbar-mask';
|
|
2607
|
+
const url = `url("${asset.src}")`;
|
|
2608
|
+
mask.style.setProperty('mask-image', url);
|
|
2609
|
+
mask.style.setProperty('-webkit-mask-image', url);
|
|
2610
|
+
return mask;
|
|
2611
|
+
}
|
|
2612
|
+
const img = document.createElement('img');
|
|
2613
|
+
img.src = asset.src;
|
|
2614
|
+
img.alt = '';
|
|
2615
|
+
img.draggable = false;
|
|
2616
|
+
return img;
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2578
2619
|
// ---------------------------------------------------------------------------
|
|
2579
2620
|
// Sheets
|
|
2580
2621
|
// ---------------------------------------------------------------------------
|
|
@@ -2867,9 +2908,11 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
|
|
|
2867
2908
|
break;
|
|
2868
2909
|
}
|
|
2869
2910
|
case 'tab': {
|
|
2870
|
-
const { title, systemImage, badge, role } = props as TabProps;
|
|
2911
|
+
const { title, systemImage, badge, role, image } = props as TabProps;
|
|
2871
2912
|
el.dataset['sbTitle'] = title ?? '';
|
|
2872
2913
|
el.dataset['sbSymbol'] = systemImage ?? '';
|
|
2914
|
+
if (image) el.dataset['sbAsset'] = image;
|
|
2915
|
+
else delete el.dataset['sbAsset'];
|
|
2873
2916
|
if (badge) el.dataset['sbBadge'] = badge;
|
|
2874
2917
|
else delete el.dataset['sbBadge'];
|
|
2875
2918
|
if (role) el.dataset['sbRole'] = role;
|