@swiftbrowser/web 0.9.12 → 0.10.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/package.json +1 -1
- package/src/browserHistory.ts +85 -5
- package/src/largeTitle.ts +14 -0
- package/src/layout/engine.ts +104 -10
- package/src/layoutDom.ts +25 -3
- package/src/protocol.ts +51 -2
- package/src/renderer.ts +243 -7
- package/src/scrollTo.ts +11 -1
- package/src/scrollVisibility.ts +38 -0
- package/src/styles.css +51 -1
- package/src/touchPaging.ts +16 -0
package/package.json
CHANGED
package/src/browserHistory.ts
CHANGED
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
* the back button and the scrim send, so the app's own state stays the
|
|
12
12
|
* source of truth; the page's URL is never touched, and an app that maps
|
|
13
13
|
* screens to paths should set them with `replaceState` on the entry the
|
|
14
|
-
* renderer made.
|
|
14
|
+
* renderer made. When the app pops on its own, the renderer's `history.go`
|
|
15
|
+
* back is asynchronous and the app's `replaceState` for the screen it shows
|
|
16
|
+
* now runs before the move lands, on the entry being left; the renderer
|
|
17
|
+
* applies that URL again to the entry it arrives at, so the address bar
|
|
18
|
+
* follows the bar's back button as it follows the browser's.
|
|
15
19
|
*
|
|
16
20
|
* A two-column split view pushes nothing when its sidebar's selection
|
|
17
21
|
* changes, so that is an entry of its own: choosing another sidebar row
|
|
@@ -24,6 +28,12 @@
|
|
|
24
28
|
* pen: the browser's edge swipe (Safari's, Android's back gesture) is the
|
|
25
29
|
* swipe back, and it lands here as a `popstate`. A mouse drag from the edge
|
|
26
30
|
* still pops through the renderer, since no browser turns one into history.
|
|
31
|
+
*
|
|
32
|
+
* Every `popstate` the browser makes also starts the renderer's repaint
|
|
33
|
+
* keep-alive (`Renderer.keepRepainting`): Safari's swipe shows a snapshot of
|
|
34
|
+
* the entry and, once its animation ends, hides the live page until a new
|
|
35
|
+
* frame is committed, so a pop that finished animating by then would stay
|
|
36
|
+
* under the snapshot for Safari's 3 s watchdog, taking the touches unseen.
|
|
27
37
|
*/
|
|
28
38
|
import type { Renderer } from './renderer';
|
|
29
39
|
|
|
@@ -60,6 +70,13 @@ export class BrowserHistory {
|
|
|
60
70
|
/** A `popstate` asked for the entry's sidebar selection: the change it brings is recorded, not pushed. */
|
|
61
71
|
private restoringSelection = false;
|
|
62
72
|
private enabled = false;
|
|
73
|
+
/** The page's own `replaceState`, while ours stands in for it. */
|
|
74
|
+
private pageReplaceState: History['replaceState'] | null = null;
|
|
75
|
+
private pageReplaceStateWasOwn = false;
|
|
76
|
+
/** A URL the page set while our own `history.go` was on its way: meant for the entry it lands on. */
|
|
77
|
+
private landingUrl: string | URL | null = null;
|
|
78
|
+
/** A level appeared while our own `history.go` was on its way: its entry is pushed once the move lands. */
|
|
79
|
+
private syncOnArrival = false;
|
|
63
80
|
|
|
64
81
|
constructor(
|
|
65
82
|
private readonly renderer: Renderer,
|
|
@@ -75,10 +92,15 @@ export class BrowserHistory {
|
|
|
75
92
|
this.enabled = true;
|
|
76
93
|
const current = stateOf(this.win.history.state);
|
|
77
94
|
if (!current) this.win.history.replaceState({ ...(this.win.history.state ?? {}), ...this.entry(0, this.selection()) }, '');
|
|
95
|
+
this.watchReplaceState();
|
|
78
96
|
this.renderer.interaction.nativeBackGesture = true;
|
|
79
97
|
this.root.addEventListener('sb:commit', this.onCommit);
|
|
80
98
|
this.win.addEventListener('popstate', this.onPopState);
|
|
81
|
-
|
|
99
|
+
// Enabled before the first commit (a mount, or a remount such as React's
|
|
100
|
+
// StrictMode makes in development) the screen is not drawn yet: fewer
|
|
101
|
+
// levels than the entry records is not the app popping, so only the
|
|
102
|
+
// levels already shown are pushed here and the first commit settles the rest.
|
|
103
|
+
this.sync(true);
|
|
82
104
|
}
|
|
83
105
|
|
|
84
106
|
dispose(): void {
|
|
@@ -87,10 +109,44 @@ export class BrowserHistory {
|
|
|
87
109
|
this.renderer.interaction.nativeBackGesture = false;
|
|
88
110
|
if (this.movingTimer) clearTimeout(this.movingTimer);
|
|
89
111
|
this.movingTo = null;
|
|
112
|
+
this.landingUrl = null;
|
|
113
|
+
this.syncOnArrival = false;
|
|
114
|
+
this.unwatchReplaceState();
|
|
90
115
|
this.root.removeEventListener('sb:commit', this.onCommit);
|
|
91
116
|
this.win.removeEventListener('popstate', this.onPopState);
|
|
92
117
|
}
|
|
93
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Stands in for the page's `replaceState`: it still runs at once, and a
|
|
121
|
+
* URL given while our own `go` is on its way is remembered for the entry
|
|
122
|
+
* the move lands on.
|
|
123
|
+
*/
|
|
124
|
+
private watchReplaceState(): void {
|
|
125
|
+
const history = this.win.history;
|
|
126
|
+
const original = history.replaceState;
|
|
127
|
+
this.pageReplaceState = original;
|
|
128
|
+
this.pageReplaceStateWasOwn = Object.prototype.hasOwnProperty.call(history, 'replaceState');
|
|
129
|
+
history.replaceState = (data: unknown, unused: string, url?: string | URL | null): void => {
|
|
130
|
+
if (this.movingTo !== null && url !== undefined && url !== null) this.landingUrl = url;
|
|
131
|
+
original.call(history, data, unused, url);
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private unwatchReplaceState(): void {
|
|
136
|
+
const original = this.pageReplaceState;
|
|
137
|
+
if (!original) return;
|
|
138
|
+
const history = this.win.history;
|
|
139
|
+
// A browser's is the prototype's: removing ours uncovers it. A stand-in's own property is put back.
|
|
140
|
+
if (this.pageReplaceStateWasOwn) history.replaceState = original;
|
|
141
|
+
else delete (history as Partial<History>).replaceState;
|
|
142
|
+
this.pageReplaceState = null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** `replaceState` as the page had it, for our own entries. */
|
|
146
|
+
private replaceEntry(data: unknown, url?: string | URL | null): void {
|
|
147
|
+
(this.pageReplaceState ?? this.win.history.replaceState).call(this.win.history, data, '', url);
|
|
148
|
+
}
|
|
149
|
+
|
|
94
150
|
/** The levels above the base the app shows now: sheets, plus pushed screens in each visible stack of the top layer. */
|
|
95
151
|
depth(): number {
|
|
96
152
|
const sheets = this.sheets();
|
|
@@ -192,7 +248,13 @@ export class BrowserHistory {
|
|
|
192
248
|
this.movingTimer = setTimeout(() => {
|
|
193
249
|
if (this.movingTo === null) return;
|
|
194
250
|
this.movingTo = null;
|
|
251
|
+
// No move (a go past the ends fires nothing): the URL already sits on the entry at hand.
|
|
252
|
+
this.landingUrl = null;
|
|
195
253
|
this.ignorePops = Math.max(0, this.ignorePops - 1);
|
|
254
|
+
if (this.syncOnArrival) {
|
|
255
|
+
this.syncOnArrival = false;
|
|
256
|
+
this.sync();
|
|
257
|
+
}
|
|
196
258
|
}, 1000);
|
|
197
259
|
this.win.history.go(delta);
|
|
198
260
|
}
|
|
@@ -201,12 +263,21 @@ export class BrowserHistory {
|
|
|
201
263
|
this.movingTo = null;
|
|
202
264
|
if (this.movingTimer) clearTimeout(this.movingTimer);
|
|
203
265
|
this.movingTimer = null;
|
|
266
|
+
// The page's URL for what it shows now, set on the entry we left: it belongs here.
|
|
267
|
+
const url = this.landingUrl;
|
|
268
|
+
this.landingUrl = null;
|
|
269
|
+
if (url !== null) this.replaceEntry(this.win.history.state, url);
|
|
270
|
+
// Levels that appeared during the move get their entries now.
|
|
271
|
+
if (this.syncOnArrival) {
|
|
272
|
+
this.syncOnArrival = false;
|
|
273
|
+
this.sync();
|
|
274
|
+
}
|
|
204
275
|
}
|
|
205
276
|
|
|
206
277
|
private readonly onCommit = (): void => this.sync();
|
|
207
278
|
|
|
208
|
-
/** After a commit: new levels get entries; levels the app dismissed itself take the history back with them; a moved sidebar selection is an entry too. */
|
|
209
|
-
sync(): void {
|
|
279
|
+
/** After a commit: new levels get entries; levels the app dismissed itself take the history back with them; a moved sidebar selection is an entry too. `enabling`: nothing is drawn yet, so no level is taken as dismissed. */
|
|
280
|
+
sync(enabling = false): void {
|
|
210
281
|
if (!this.enabled) return;
|
|
211
282
|
const depth = this.depth();
|
|
212
283
|
const recorded = this.recorded();
|
|
@@ -220,8 +291,15 @@ export class BrowserHistory {
|
|
|
220
291
|
return;
|
|
221
292
|
}
|
|
222
293
|
if (depth > recorded) {
|
|
294
|
+
// Under our own pending `go`, a push would land relative to wherever the browser is when it runs (and
|
|
295
|
+
// Chrome moves back past it), leaving the app on an entry below what it shows: push once the move lands.
|
|
296
|
+
if (this.movingTo !== null) {
|
|
297
|
+
this.syncOnArrival = true;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
223
300
|
for (let level = recorded + 1; level <= depth; level++) this.win.history.pushState(this.entry(level, selection), '');
|
|
224
301
|
} else if (depth < recorded) {
|
|
302
|
+
if (enabling) return;
|
|
225
303
|
// The app popped or dismissed on its own (back button, swipe, code): leave those entries.
|
|
226
304
|
this.go(depth - recorded, depth);
|
|
227
305
|
} else if (this.movingTo === null && selection !== this.recordedSelection()) {
|
|
@@ -231,7 +309,7 @@ export class BrowserHistory {
|
|
|
231
309
|
const history = this.win.history;
|
|
232
310
|
if (this.restoringSelection || !this.recordedSelection()) {
|
|
233
311
|
const { sbSelection: _, ...rest } = (history.state ?? {}) as Partial<HistoryState>;
|
|
234
|
-
|
|
312
|
+
this.replaceEntry({ ...rest, ...this.entry(depth, selection) });
|
|
235
313
|
} else history.pushState(this.entry(depth, selection), '');
|
|
236
314
|
}
|
|
237
315
|
this.restoringSelection = false;
|
|
@@ -245,6 +323,8 @@ export class BrowserHistory {
|
|
|
245
323
|
}
|
|
246
324
|
const target = stateOf(event.state);
|
|
247
325
|
if (!target) return; // an entry of the page's own (before the base, or the app's router): not ours
|
|
326
|
+
// The browser moved on its own (a swipe, a button): keep painting past its transition.
|
|
327
|
+
this.renderer.keepRepainting();
|
|
248
328
|
const depth = this.depth();
|
|
249
329
|
if (target.sbDepth < depth) {
|
|
250
330
|
// Back: dismiss one level now, the rest as each commit lands.
|
package/src/largeTitle.ts
CHANGED
|
@@ -42,6 +42,20 @@ export function largeTitleOf(screen: HTMLElement): HTMLElement | null {
|
|
|
42
42
|
return title;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The large title's text: a span of its own, as the title element also
|
|
47
|
+
* holds a `.largeTitle` toolbar (which then hides the text, styles.css).
|
|
48
|
+
*/
|
|
49
|
+
export function largeTitleTextOf(title: HTMLElement): HTMLElement {
|
|
50
|
+
let text = title.querySelector<HTMLElement>(':scope > .sb-navbar-large-title-text');
|
|
51
|
+
if (!text) {
|
|
52
|
+
text = document.createElement('span');
|
|
53
|
+
text.className = 'sb-navbar-large-title-text';
|
|
54
|
+
title.prepend(text);
|
|
55
|
+
}
|
|
56
|
+
return text;
|
|
57
|
+
}
|
|
58
|
+
|
|
45
59
|
/** Whether a screen's large title currently scrolls inside one of its scroll views. */
|
|
46
60
|
export function largeTitleScrolls(screen: HTMLElement, title: HTMLElement): boolean {
|
|
47
61
|
return title.parentElement !== null && !title.parentElement.classList.contains('sb-navscreen-body') && screen.contains(title);
|
package/src/layout/engine.ts
CHANGED
|
@@ -85,6 +85,8 @@ export interface Context {
|
|
|
85
85
|
imageScale: number;
|
|
86
86
|
/** Phase 7: true inside the sidebar column of a `navsplit` (a selected `navlink` row drops its chevron there). */
|
|
87
87
|
splitSidebar: boolean;
|
|
88
|
+
/** Inside a leading or trailing item of a navigation bar (a segmented picker there is the bar's own capsule). */
|
|
89
|
+
navBarItem?: boolean;
|
|
88
90
|
/** Rows of a `sidebar`-style list (iPadOS): its links have no chevron. */
|
|
89
91
|
sidebarList?: boolean;
|
|
90
92
|
/**
|
|
@@ -219,6 +221,17 @@ const TAB_BAR_ITEM_INSET = 4;
|
|
|
219
221
|
const PICKER_SEGMENTED_HEIGHT = 32;
|
|
220
222
|
const PICKER_SEGMENT_PAD = 20;
|
|
221
223
|
const PICKER_SEGMENT_FONT_SIZE = 13;
|
|
224
|
+
// iOS 26, a segmented picker in a navigation bar item (measured in the
|
|
225
|
+
// simulator, Examples/Fidelity `topbarsegmented`, with the item's shared
|
|
226
|
+
// glass hidden): the control is a 44-tall glass capsule itself, no track;
|
|
227
|
+
// the selected segment a 36-tall gray capsule inset 4 (no shadow); labels
|
|
228
|
+
// 15 regular; segments of equal width, the widest label and 21 either side.
|
|
229
|
+
// The capsule keeps 4 clear of the bar's other capsules beyond their usual
|
|
230
|
+
// 12 (it sits 16 from the next one).
|
|
231
|
+
const BAR_SEGMENTED_FONT_SIZE = 15;
|
|
232
|
+
const BAR_SEGMENT_PAD = 42;
|
|
233
|
+
const BAR_SEGMENTED_INSET = 4;
|
|
234
|
+
const BAR_SEGMENTED_MARGIN = 4;
|
|
222
235
|
const PICKER_MENU_ROW_HEIGHT = 44;
|
|
223
236
|
const PICKER_MENU_HEIGHT = 34;
|
|
224
237
|
/** `chevron.up.chevron.down` glyph width plus the gap to the value text. */
|
|
@@ -290,6 +303,7 @@ function isSymbolItem(item: LayoutNode): boolean {
|
|
|
290
303
|
|
|
291
304
|
/** A bar item's slot in its glass capsule. */
|
|
292
305
|
function navSlot(item: LayoutNode, width: number): number {
|
|
306
|
+
if (isSegmentedItem(item)) return width + 2 * BAR_SEGMENTED_MARGIN;
|
|
293
307
|
if (isSymbolItem(item) && width <= NAV_GLASS_SIZE) return NAV_GLASS_SIZE;
|
|
294
308
|
return Math.max(NAV_GLASS_SIZE, width + 2 * NAV_GLASS_PAD_H);
|
|
295
309
|
}
|
|
@@ -521,6 +535,12 @@ function ignoresKeyboard(node: LayoutNode): boolean {
|
|
|
521
535
|
}
|
|
522
536
|
|
|
523
537
|
/** The element under a chain of `styled` wrappers. */
|
|
538
|
+
/** A segmented picker as a navigation bar item (under its styled wrappers): it is a capsule of its own. */
|
|
539
|
+
function isSegmentedItem(item: LayoutNode): boolean {
|
|
540
|
+
const inner = unwrapStyled(item);
|
|
541
|
+
return inner.kind === 'picker' && str(inner.props['style']) === 'segmented';
|
|
542
|
+
}
|
|
543
|
+
|
|
524
544
|
function unwrapStyled(node: LayoutNode): LayoutNode {
|
|
525
545
|
let current = node;
|
|
526
546
|
while (current.kind === 'styled' && current.children.length === 1) {
|
|
@@ -1131,6 +1151,8 @@ interface PickerPlan {
|
|
|
1131
1151
|
valueWidth: number;
|
|
1132
1152
|
/** Inline style: whether a label row precedes the options. */
|
|
1133
1153
|
labelRow: boolean;
|
|
1154
|
+
/** Segmented style in a navigation bar item: the bar's glass capsule, its segments inset. */
|
|
1155
|
+
bar?: boolean;
|
|
1134
1156
|
}
|
|
1135
1157
|
|
|
1136
1158
|
// Phase 6 plans.
|
|
@@ -1550,6 +1572,12 @@ export class LayoutPass {
|
|
|
1550
1572
|
if (ctx.barAbove === undefined && ctx.stackAxis === null && !ctx.listRow) return ctx;
|
|
1551
1573
|
return { ...ctx, barAbove: undefined, stackAxis: null, listRow: false };
|
|
1552
1574
|
}
|
|
1575
|
+
case 'toolbar': {
|
|
1576
|
+
const placement = str(node.props['placement']);
|
|
1577
|
+
const navBarItem = placement === 'leading' || placement === 'trailing';
|
|
1578
|
+
if (ctx.stackAxis === null && !ctx.listRow && ctx.navBarItem === navBarItem) return ctx;
|
|
1579
|
+
return { ...ctx, stackAxis: null, listRow: false, navBarItem };
|
|
1580
|
+
}
|
|
1553
1581
|
case 'gesture':
|
|
1554
1582
|
case 'swipeactions':
|
|
1555
1583
|
case 'contextmenu':
|
|
@@ -2014,6 +2042,23 @@ export class LayoutPass {
|
|
|
2014
2042
|
labelRow,
|
|
2015
2043
|
};
|
|
2016
2044
|
}
|
|
2045
|
+
if (style === 'segmented' && ctx.navBarItem) {
|
|
2046
|
+
const font = makeFont('system', BAR_SEGMENTED_FONT_SIZE, 400);
|
|
2047
|
+
let widest = 0;
|
|
2048
|
+
for (const option of options) widest = Math.max(widest, this.measurer.measureWidth(option, font));
|
|
2049
|
+
const natural = options.length * (widest + BAR_SEGMENT_PAD) + 2 * BAR_SEGMENTED_INSET;
|
|
2050
|
+
return {
|
|
2051
|
+
size: { width: finite(p.width) ? p.width : natural, height: NAV_GLASS_SIZE },
|
|
2052
|
+
style: 'segmented',
|
|
2053
|
+
font,
|
|
2054
|
+
options,
|
|
2055
|
+
selected,
|
|
2056
|
+
labelWidth: 0,
|
|
2057
|
+
valueWidth: 0,
|
|
2058
|
+
labelRow: false,
|
|
2059
|
+
bar: true,
|
|
2060
|
+
};
|
|
2061
|
+
}
|
|
2017
2062
|
if (style === 'segmented') {
|
|
2018
2063
|
const font = makeFont('system', PICKER_SEGMENT_FONT_SIZE, 600);
|
|
2019
2064
|
let natural = 0;
|
|
@@ -2057,9 +2102,14 @@ export class LayoutPass {
|
|
|
2057
2102
|
const chrome: Record<string, Rect> = { ...existing };
|
|
2058
2103
|
if (plan.style === 'segmented') {
|
|
2059
2104
|
const n = Math.max(1, plan.options.length);
|
|
2060
|
-
|
|
2105
|
+
// In a bar the segments sit inside the capsule, inset all round; `bar` (the
|
|
2106
|
+
// control's frame) tells the page to draw it as the bar draws it.
|
|
2107
|
+
const inset = plan.bar ? BAR_SEGMENTED_INSET : 0;
|
|
2108
|
+
if (plan.bar) chrome['bar'] = { x, y, width: size.width, height: size.height };
|
|
2109
|
+
else delete chrome['bar'];
|
|
2110
|
+
const segmentWidth = Math.max(0, size.width - 2 * inset) / n;
|
|
2061
2111
|
for (let i = 0; i < plan.options.length; i++) {
|
|
2062
|
-
chrome[`segment${i}`] = { x: x + i * segmentWidth, y, width: segmentWidth, height: size.height };
|
|
2112
|
+
chrome[`segment${i}`] = { x: x + inset + i * segmentWidth, y: y + inset, width: segmentWidth, height: Math.max(0, size.height - 2 * inset) };
|
|
2063
2113
|
}
|
|
2064
2114
|
} else if (plan.style === 'inline') {
|
|
2065
2115
|
// Rows span the list row when there is one (the hit area and the
|
|
@@ -3429,7 +3479,7 @@ export class LayoutPass {
|
|
|
3429
3479
|
for (const spacer of parts.toolbars.filter(isSpacer)) this.hide(spacer, node);
|
|
3430
3480
|
const barToolbars = parts.toolbars.filter((t) => {
|
|
3431
3481
|
const placement = str(t.props['placement']);
|
|
3432
|
-
return placement !== 'bottom' && placement !== 'secondary' && !isSpacer(t);
|
|
3482
|
+
return placement !== 'bottom' && placement !== 'secondary' && placement !== 'largeTitle' && !isSpacer(t);
|
|
3433
3483
|
});
|
|
3434
3484
|
const secondaryToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'secondary' && !isSpacer(t));
|
|
3435
3485
|
const groups: Group[] = barToolbars.map((toolbar) => {
|
|
@@ -3448,6 +3498,10 @@ export class LayoutPass {
|
|
|
3448
3498
|
interface Run {
|
|
3449
3499
|
placement: 'leading' | 'trailing';
|
|
3450
3500
|
groups: Group[];
|
|
3501
|
+
/** A segmented picker's own run: the glass is the control, inside its margin. */
|
|
3502
|
+
segmented?: boolean;
|
|
3503
|
+
/** `.sharedBackgroundVisibility(.hidden)`: a run of its own with no glass. */
|
|
3504
|
+
bare?: boolean;
|
|
3451
3505
|
/** The capsule's width: the slots 12 apart, a point past the end slots (none for a lone item). */
|
|
3452
3506
|
width: number;
|
|
3453
3507
|
}
|
|
@@ -3469,6 +3523,20 @@ export class LayoutPass {
|
|
|
3469
3523
|
}
|
|
3470
3524
|
const group = groupOf.get(toolbar);
|
|
3471
3525
|
if (!group) continue;
|
|
3526
|
+
// A segmented picker is a capsule of its own: it neither joins nor is joined (iOS 26
|
|
3527
|
+
// draws its glass, and an item's shared glass around it would show as a second capsule).
|
|
3528
|
+
if (toolbar.children.some(isSegmentedItem)) {
|
|
3529
|
+
delete open[placement];
|
|
3530
|
+
runs.push({ placement, groups: [group], width: 0, segmented: true });
|
|
3531
|
+
continue;
|
|
3532
|
+
}
|
|
3533
|
+
// An item that hides the bar's shared glass stands alone, at its own width.
|
|
3534
|
+
if (str(toolbar.props['sharedBackground']) === 'hidden') {
|
|
3535
|
+
delete open[placement];
|
|
3536
|
+
group.slots = group.sizes.map((s) => s.width);
|
|
3537
|
+
runs.push({ placement, groups: [group], width: 0, bare: true });
|
|
3538
|
+
continue;
|
|
3539
|
+
}
|
|
3472
3540
|
let run = open[placement];
|
|
3473
3541
|
if (!run) {
|
|
3474
3542
|
run = { placement, groups: [], width: 0 };
|
|
@@ -3564,8 +3632,13 @@ export class LayoutPass {
|
|
|
3564
3632
|
// items, in slots 12 apart from `startX`; each toolbar's frame spans its
|
|
3565
3633
|
// own items' slots. Runs of a placement follow their order in the
|
|
3566
3634
|
// content, leading-to-trailing.
|
|
3567
|
-
|
|
3568
|
-
|
|
3635
|
+
// The capsules are numbered as they are drawn (the page reads them until one is missing).
|
|
3636
|
+
const glassCount = { leading: 0, trailing: 0 };
|
|
3637
|
+
const placeRun = (run: Run, startX: number): void => {
|
|
3638
|
+
const margin = run.segmented ? BAR_SEGMENTED_MARGIN : 0;
|
|
3639
|
+
if (!run.bare) {
|
|
3640
|
+
chrome[`${run.placement}Glass${glassCount[run.placement]++}`] = { x: startX + margin, y: rowY, width: Math.max(0, run.width - 2 * margin), height: NAV_BAR_HEIGHT };
|
|
3641
|
+
}
|
|
3569
3642
|
const count = run.groups.reduce((sum, g) => sum + (g.slots?.length ?? 0), 0);
|
|
3570
3643
|
let cursor = startX + (count > 1 ? NAV_RUN_PAD : 0);
|
|
3571
3644
|
for (const group of run.groups) {
|
|
@@ -3581,19 +3654,18 @@ export class LayoutPass {
|
|
|
3581
3654
|
this.setFrame(group.toolbar, first, rowY, { width: last - first, height: NAV_BAR_HEIGHT });
|
|
3582
3655
|
}
|
|
3583
3656
|
};
|
|
3584
|
-
let leadingIndex = 0;
|
|
3585
3657
|
for (const run of runs.filter((r) => r.placement === 'leading')) {
|
|
3586
3658
|
if (run.width === 0) continue;
|
|
3587
|
-
placeRun(run, leadingCursor
|
|
3659
|
+
placeRun(run, leadingCursor);
|
|
3588
3660
|
leadingCursor += run.width + NAV_GROUP_SPACING;
|
|
3589
3661
|
}
|
|
3590
3662
|
const trailingRuns = runs.filter((r) => r.placement === 'trailing' && r.width > 0);
|
|
3591
3663
|
let trailingCursor = trailingEdge - (trailingWidth > 0 ? trailingWidth - NAV_GROUP_SPACING : 0);
|
|
3592
3664
|
const trailingStart = trailingCursor;
|
|
3593
|
-
|
|
3594
|
-
placeRun(run, trailingCursor
|
|
3665
|
+
for (const run of trailingRuns) {
|
|
3666
|
+
placeRun(run, trailingCursor);
|
|
3595
3667
|
trailingCursor += run.width + NAV_GROUP_SPACING;
|
|
3596
|
-
}
|
|
3668
|
+
}
|
|
3597
3669
|
if (trailingRuns.length > 0) trailingEdge = trailingStart - NAV_GROUP_SPACING;
|
|
3598
3670
|
for (const group of groups) {
|
|
3599
3671
|
if (group.placement !== 'principal') continue;
|
|
@@ -3624,6 +3696,28 @@ export class LayoutPass {
|
|
|
3624
3696
|
if (!inlineSecondary.some((entry) => entry.toolbar === toolbar)) this.hide(toolbar, node);
|
|
3625
3697
|
else for (const item of items) if (collapsedSecondary.some((entry) => entry.item === item)) this.hide(item, node);
|
|
3626
3698
|
}
|
|
3699
|
+
// `.largeTitle` items (iOS 26): in the large title row, in place of the
|
|
3700
|
+
// title's text, from its leading margin and centered in the row; the
|
|
3701
|
+
// renderer moves the toolbar into the title element, so they scroll with
|
|
3702
|
+
// the content as the title does, and the bar shows the title once the row
|
|
3703
|
+
// has gone under it. While the title is inline there is no row: hidden.
|
|
3704
|
+
for (const toolbar of parts.toolbars) {
|
|
3705
|
+
if (str(toolbar.props['placement']) !== 'largeTitle' || isSpacer(toolbar)) continue;
|
|
3706
|
+
const row = chrome['largeTitle'];
|
|
3707
|
+
if (!row) {
|
|
3708
|
+
this.hide(toolbar, node);
|
|
3709
|
+
continue;
|
|
3710
|
+
}
|
|
3711
|
+
const itemCtx = this.childContext(toolbar, cctx);
|
|
3712
|
+
const room: Proposal = { width: Math.max(0, row.width - 2 * NAV_ITEM_MARGIN), height: null };
|
|
3713
|
+
let cursor = row.x + NAV_ITEM_MARGIN;
|
|
3714
|
+
for (const item of toolbar.children) {
|
|
3715
|
+
const itemSize = this.measure(item, itemCtx, room);
|
|
3716
|
+
this.place(item, itemCtx, room, cursor, row.y + (row.height - itemSize.height) / 2);
|
|
3717
|
+
cursor += itemSize.width + NAV_ITEM_SPACING;
|
|
3718
|
+
}
|
|
3719
|
+
this.setFrame(toolbar, row.x, row.y, { width: row.width, height: row.height });
|
|
3720
|
+
}
|
|
3627
3721
|
if (bottomBarHeight > 0) {
|
|
3628
3722
|
this.placeBottomToolbars(bottomToolbars, cctx, chrome, x, y + size.height - bottomBarHeight, size.width);
|
|
3629
3723
|
}
|
package/src/layoutDom.ts
CHANGED
|
@@ -41,7 +41,7 @@ import type { ShapeStyle } from './protocol';
|
|
|
41
41
|
import { applyChartChrome } from './chartDom';
|
|
42
42
|
import { bitmapLaidOut } from './bitmaps';
|
|
43
43
|
import { drawRichText } from './richText';
|
|
44
|
-
import { bindScrollUnderBar, largeTitleOf, largeTitleScrolls } from './largeTitle';
|
|
44
|
+
import { bindScrollUnderBar, largeTitleOf, largeTitleScrolls, largeTitleTextOf } from './largeTitle';
|
|
45
45
|
import { bindScrollEdgeBottom } from './scrollEdge';
|
|
46
46
|
import { beginScrollSyncBatch, flushScrollSyncBatch, type ScrollExtent } from './scrollSync';
|
|
47
47
|
import { BAR_GLASS_KIND, TOOLBAR_ANIMATION, TOOLBAR_TRANSITION, baseTiming, timingFor, transitionStart } from './animation';
|
|
@@ -122,7 +122,14 @@ function applyFrames(ctx: LayoutDomContext, result: LayoutResult, changes: BoxCh
|
|
|
122
122
|
/** The element's box relative to the origin its `left`/`top` use; applied when it differs from the last pass's. */
|
|
123
123
|
const applyBox = (id: number, node: LayoutNode, el: HTMLElement, frame: Rect): { box: Rect; moved: boolean; previous: Rect | null } => {
|
|
124
124
|
const box = drawnBox(node, boxOf(ctx, result, node, frame));
|
|
125
|
-
const origin =
|
|
125
|
+
const origin =
|
|
126
|
+
node.kind === 'sheet'
|
|
127
|
+
? { x: 0, y: 0 }
|
|
128
|
+
: isSectionSupplement(node)
|
|
129
|
+
? supplementOrigin(ctx, result, id)
|
|
130
|
+
: isLargeTitleToolbar(node)
|
|
131
|
+
? largeTitleToolbarOrigin(ctx, result, id)
|
|
132
|
+
: originOf(ctx, result, ctx.parents.get(id) ?? ROOT_ID);
|
|
126
133
|
const relative = { x: box.x - origin.x, y: box.y - origin.y, width: box.width, height: box.height };
|
|
127
134
|
const previous = ctx.boxes.get(id) ?? null;
|
|
128
135
|
boxes.set(id, relative);
|
|
@@ -233,6 +240,18 @@ function supplementOrigin(ctx: LayoutDomContext, result: LayoutResult, id: numbe
|
|
|
233
240
|
return boxOf(ctx, result, parent, frame);
|
|
234
241
|
}
|
|
235
242
|
|
|
243
|
+
/** A `.largeTitle` toolbar: the renderer keeps it in its screen's large title element. */
|
|
244
|
+
export function isLargeTitleToolbar(node: LayoutNode): boolean {
|
|
245
|
+
return node.kind === 'toolbar' && node.props['placement'] === 'largeTitle';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** A `.largeTitle` toolbar sits in the large title element, which is placed at the screen's `largeTitle` row. */
|
|
249
|
+
function largeTitleToolbarOrigin(ctx: LayoutDomContext, result: LayoutResult, id: number): { x: number; y: number } {
|
|
250
|
+
const parentId = ctx.parents.get(id) ?? ROOT_ID;
|
|
251
|
+
const row = result.chrome.get(parentId)?.['largeTitle'];
|
|
252
|
+
return row ?? originOf(ctx, result, parentId);
|
|
253
|
+
}
|
|
254
|
+
|
|
236
255
|
/** Screen-coordinate origin that the children of `parentId` are positioned against. */
|
|
237
256
|
export function originOf(ctx: LayoutDomContext, result: LayoutResult, parentId: number): { x: number; y: number } {
|
|
238
257
|
if (parentId === ROOT_ID) return { x: 0, y: 0 };
|
|
@@ -837,7 +856,8 @@ function applyChrome(
|
|
|
837
856
|
if (texts?.['largeTitle']) {
|
|
838
857
|
setFont(largeTitle, texts['largeTitle'].font);
|
|
839
858
|
const line = texts['largeTitle'].lines.join('\n');
|
|
840
|
-
|
|
859
|
+
const text = largeTitleTextOf(largeTitle);
|
|
860
|
+
if (text.textContent !== line) text.textContent = line;
|
|
841
861
|
}
|
|
842
862
|
} else {
|
|
843
863
|
largeTitle.hidden = true;
|
|
@@ -933,6 +953,8 @@ function applyChrome(
|
|
|
933
953
|
case 'picker': {
|
|
934
954
|
setFont(el, result.fonts.get(node.id));
|
|
935
955
|
if (node.props['style'] === 'segmented') {
|
|
956
|
+
// In a navigation bar item the control is the bar's glass capsule (engine `bar`).
|
|
957
|
+
el.toggleAttribute('data-sb-bar', chrome?.['bar'] !== undefined);
|
|
936
958
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-segment-track'), frame, box);
|
|
937
959
|
const segments = el.querySelectorAll<HTMLElement>(':scope > .sb-segment');
|
|
938
960
|
segments.forEach((segment, i) => placeChrome(segment, chrome?.[`segment${i}`], box));
|
package/src/protocol.ts
CHANGED
|
@@ -489,6 +489,8 @@ export type ButtonRole = 'destructive' | 'cancel' | null;
|
|
|
489
489
|
export interface ButtonProps {
|
|
490
490
|
style?: ButtonStyle;
|
|
491
491
|
role?: ButtonRole;
|
|
492
|
+
/** An `EditButton`: the edit mode it shows ("Done" / "Edit") and flips. A native renderer draws the platform's edit button from it. */
|
|
493
|
+
editMode?: 'active' | 'inactive';
|
|
492
494
|
}
|
|
493
495
|
export interface StyledProps {
|
|
494
496
|
style: Style;
|
|
@@ -539,6 +541,13 @@ export interface UrlImageProps {
|
|
|
539
541
|
resizable: boolean;
|
|
540
542
|
width: number;
|
|
541
543
|
height: number;
|
|
544
|
+
/**
|
|
545
|
+
* React's opt-in (`AsyncImage webInteractions`): the `<img>` keeps the
|
|
546
|
+
* browser's own image interactions (a long press's Save / Share / Copy on
|
|
547
|
+
* iOS, dragging it out, the right-click menu) instead of being inert as a
|
|
548
|
+
* SwiftUI image is. Swift apps never send it.
|
|
549
|
+
*/
|
|
550
|
+
webInteractions?: boolean;
|
|
542
551
|
}
|
|
543
552
|
/**
|
|
544
553
|
* A `UIImage` / `CGImage` bitmap (`Image(uiImage:)`): a recipe the page draws
|
|
@@ -620,6 +629,14 @@ export interface TextFieldProps {
|
|
|
620
629
|
submit?: boolean;
|
|
621
630
|
/** Phase 12 `.submitLabel`: the return key's label (`enterkeyhint`). */
|
|
622
631
|
submitLabel?: string;
|
|
632
|
+
/** `.keyboardType(_:)`: a `UIKeyboardType` case name (`emailAddress`, `URL`, `numberPad`…), the on-screen keyboard (`inputmode`). */
|
|
633
|
+
keyboard?: string;
|
|
634
|
+
/** `.textContentType(_:)`: a `UITextContentType` name (`emailAddress`, `oneTimeCode`…), what autofill offers (`autocomplete`). */
|
|
635
|
+
contentType?: string;
|
|
636
|
+
/** `.textInputAutocapitalization(_:)` (`autocapitalize`). */
|
|
637
|
+
autocapitalization?: 'never' | 'words' | 'sentences' | 'characters';
|
|
638
|
+
/** `.autocorrectionDisabled()` (`autocorrect="off"`). */
|
|
639
|
+
autocorrectionDisabled?: boolean;
|
|
623
640
|
}
|
|
624
641
|
export interface ToggleProps {
|
|
625
642
|
isOn: boolean;
|
|
@@ -897,7 +914,13 @@ export interface DecoratedProps {
|
|
|
897
914
|
export interface MenuProps {
|
|
898
915
|
deferred?: boolean;
|
|
899
916
|
}
|
|
900
|
-
|
|
917
|
+
/**
|
|
918
|
+
* `largeTitle` (iOS 26 `.largeTitle`): the items take the large title row in
|
|
919
|
+
* place of the title's text, scrolling with the content as the title does;
|
|
920
|
+
* the bar shows the title once the row has gone under it. Hidden while the
|
|
921
|
+
* screen's title is inline.
|
|
922
|
+
*/
|
|
923
|
+
export type ToolbarPlacement = 'trailing' | 'leading' | 'principal' | 'bottom' | 'secondary' | 'largeTitle';
|
|
901
924
|
/**
|
|
902
925
|
* `.toolbar { ToolbarItem(placement:) { ... } }`, an extra child of a
|
|
903
926
|
* `navscreen` after its content. The children are the items, laid out at
|
|
@@ -912,6 +935,12 @@ export interface ToolbarProps {
|
|
|
912
935
|
* apart; fixed ones are 8 wide.
|
|
913
936
|
*/
|
|
914
937
|
spacer?: 'flexible' | 'fixed';
|
|
938
|
+
/**
|
|
939
|
+
* `.sharedBackgroundVisibility(.hidden)`: a leading or trailing group of
|
|
940
|
+
* the navigation bar draws no glass of the bar's and is a run of its own
|
|
941
|
+
* (a segmented picker is drawn as its own glass capsule either way).
|
|
942
|
+
*/
|
|
943
|
+
sharedBackground?: 'hidden';
|
|
915
944
|
}
|
|
916
945
|
/**
|
|
917
946
|
* `.searchable(text:)`, an extra child of a `navscreen`: a 36pt rounded
|
|
@@ -1884,6 +1913,16 @@ export interface Style {
|
|
|
1884
1913
|
hitTesting?: boolean;
|
|
1885
1914
|
}
|
|
1886
1915
|
|
|
1916
|
+
export interface Style {
|
|
1917
|
+
/**
|
|
1918
|
+
* `.onScrollVisibilityChange(threshold:_:)`: the page sends
|
|
1919
|
+
* `scrollvisibility` to the box when the share of it inside its scroll
|
|
1920
|
+
* view's visible area (below a bar the view scrolls under, above a bar
|
|
1921
|
+
* covering its bottom) crosses `threshold` (src/scrollVisibility.ts).
|
|
1922
|
+
*/
|
|
1923
|
+
scrollVisibility?: { threshold: number };
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1887
1926
|
export interface Style {
|
|
1888
1927
|
/** `.blendMode`: the CSS `mix-blend-mode` the box is composited with. */
|
|
1889
1928
|
blendMode?: string;
|
|
@@ -1957,6 +1996,16 @@ export interface ScrollPositionEvent {
|
|
|
1957
1996
|
type: 'scrollposition';
|
|
1958
1997
|
id: ElementId;
|
|
1959
1998
|
}
|
|
1999
|
+
/**
|
|
2000
|
+
* Sent to a `styled` box with `scrollVisibility`: the share of it inside its
|
|
2001
|
+
* scroll view's visible area came to the threshold or above (`visible`), or
|
|
2002
|
+
* fell below it. The first check after the box appears says where it is.
|
|
2003
|
+
*/
|
|
2004
|
+
export interface ScrollVisibilityEvent {
|
|
2005
|
+
type: 'scrollvisibility';
|
|
2006
|
+
id: ElementId;
|
|
2007
|
+
visible: boolean;
|
|
2008
|
+
}
|
|
1960
2009
|
/**
|
|
1961
2010
|
* A movable `listrow` was dragged to a new place: `to` is the index among
|
|
1962
2011
|
* its container's children (the `list`'s, or its `section`'s) before which
|
|
@@ -2002,7 +2051,7 @@ export interface DropEvent {
|
|
|
2002
2051
|
}
|
|
2003
2052
|
|
|
2004
2053
|
/** The Phase 8 events (merged into `RendererEvent` above). */
|
|
2005
|
-
export type InteractionEvent = DragEvent | PinchEvent | LongPressEvent | DeleteEvent | RefreshEvent | ScrollPositionEvent | MoveEvent | DragStartEvent | DragEndEvent | DropEvent;
|
|
2054
|
+
export type InteractionEvent = DragEvent | PinchEvent | LongPressEvent | DeleteEvent | RefreshEvent | ScrollPositionEvent | ScrollVisibilityEvent | MoveEvent | DragStartEvent | DragEndEvent | DropEvent;
|
|
2006
2055
|
// Phase 8: alerts, controls, effects
|
|
2007
2056
|
// (docs/ops-protocol.md, "Phase 8: interaction"; gestures, swipe actions,
|
|
2008
2057
|
// context menus, list rows and refresh live in their own block)
|
package/src/renderer.ts
CHANGED
|
@@ -153,13 +153,14 @@ import { applyCanvas, createCanvasElement } from './canvasDrawing';
|
|
|
153
153
|
import { scrollToTarget } from './scrollTo';
|
|
154
154
|
import { POSITION_SETTLE_MS, POSITION_SNAP_WAITS, anchorPoint, positionTarget, restsOnSnapPoint, type PositionCandidate } from './scrollPosition';
|
|
155
155
|
import { TouchPaging } from './touchPaging';
|
|
156
|
+
import { isScrollVisible } from './scrollVisibility';
|
|
156
157
|
import { applyDefaultScrollAnchor, keepScrollAnchors } from './scrollAnchor';
|
|
157
158
|
import { EMPTY_CATALOG, assetColorVar, type AssetCatalog } from './assets';
|
|
158
159
|
import { HIERARCHY_OPACITY, colorToCSS, effectsCSS, styleToCSS, type PaintCSS } from './styles';
|
|
159
160
|
import { hostContent, onHostContentChange } from './hostContent';
|
|
160
161
|
import { HoverTracker, applyPointerAndEffects, safeAreaOverlap } from './pointerEffects';
|
|
161
162
|
import { colorMultiplyFilter } from './colorMultiply';
|
|
162
|
-
import { largeTitleOf } from './largeTitle';
|
|
163
|
+
import { largeTitleOf, largeTitleTextOf } from './largeTitle';
|
|
163
164
|
import { applyEdgeEffect } from './scrollEdge';
|
|
164
165
|
import { discardScrollOffsets, readScrollOffsets, trackScroller } from './scrollSync';
|
|
165
166
|
import { DEFAULT_TYPE_SIZE, TEXT_STYLE_WEIGHT, resolveTextStyle, textStyleVar } from './typography';
|
|
@@ -216,6 +217,16 @@ function dismissMenu(menu: HTMLElement): void {
|
|
|
216
217
|
|
|
217
218
|
/** Duration of the navigation push/pop slide; keep in sync with styles.css (`--sb-nav-easing`). */
|
|
218
219
|
export const NAV_TRANSITION_MS = 400;
|
|
220
|
+
/**
|
|
221
|
+
* How long `keepRepainting` paints after a browser history navigation:
|
|
222
|
+
* through Safari's 3 s snapshot-removal watchdog, with room for a slow
|
|
223
|
+
* device. Measured on an iPhone: painting for 1.5 s (past the swipe
|
|
224
|
+
* animation alone) removed the stall but left a single freeze of the touch
|
|
225
|
+
* in progress at the 3 s mark; 5 s left none.
|
|
226
|
+
*/
|
|
227
|
+
export const REPAINT_KEEPALIVE_MS = 5000;
|
|
228
|
+
/** Between two repaints of the keep-alive: a few frames, so the cost is nothing and the first frame after Safari hides the page is near. */
|
|
229
|
+
const REPAINT_KEEPALIVE_INTERVAL_MS = 50;
|
|
219
230
|
/** Duration of the sheet present/dismiss slide; keep in sync with styles.css. */
|
|
220
231
|
export const SHEET_TRANSITION_MS = 400;
|
|
221
232
|
// Phase 8: the sheet drag (and every other pointer gesture) lives in src/interaction.ts.
|
|
@@ -440,6 +451,10 @@ export class Renderer {
|
|
|
440
451
|
private pendingScrolls: ElementId[] = [];
|
|
441
452
|
/** Lazy rows: the `lazyspacer`s on the page, and those already reported near the visible area. */
|
|
442
453
|
private lazySpacers = new Set<ElementId>();
|
|
454
|
+
/** `styled` boxes with `scrollVisibility`, and what each was last told (src/scrollVisibility.ts). */
|
|
455
|
+
private readonly visibilityTargets = new Map<ElementId, number>();
|
|
456
|
+
private readonly visibilityReported = new Map<ElementId, boolean>();
|
|
457
|
+
private visibilityCheckQueued = false;
|
|
443
458
|
private lazyReported = new Set<ElementId>();
|
|
444
459
|
private lazyCheckQueued = false;
|
|
445
460
|
/** The highest `scrollTo` serial seen: a command keeps its serial until the reader re-runs, and runs once. */
|
|
@@ -864,6 +879,7 @@ export class Renderer {
|
|
|
864
879
|
root.addEventListener('focusout', this.onFocusChange);
|
|
865
880
|
root.addEventListener('scroll', this.onScrollDismissKeyboard, true);
|
|
866
881
|
root.addEventListener('scroll', this.queueLazyCheck, { capture: true, passive: true });
|
|
882
|
+
root.addEventListener('scroll', this.queueVisibilityCheck, { capture: true, passive: true });
|
|
867
883
|
root.addEventListener('scroll', this.onPositionScroll, { capture: true, passive: true });
|
|
868
884
|
root.addEventListener('scrollend', this.onPositionScrollEnd, { capture: true, passive: true });
|
|
869
885
|
root.addEventListener('touchstart', this.onPositionTouchStart, { capture: true, passive: true });
|
|
@@ -1288,6 +1304,52 @@ export class Renderer {
|
|
|
1288
1304
|
this.onEvent({ type: 'tap', id });
|
|
1289
1305
|
}
|
|
1290
1306
|
|
|
1307
|
+
/** The repaint keep-alive (`keepRepainting`): its box, timer and deadline while it runs. */
|
|
1308
|
+
private repaintKeepAlive: { el: HTMLElement; timer: number; until: number; on: boolean } | null = null;
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* Keeps the page painting for `duration` ms: a 1 px box in the root
|
|
1312
|
+
* alternates between two near-identical colors every few frames, so each
|
|
1313
|
+
* frame carries a real repaint. Safari's back and forward swipe shows a
|
|
1314
|
+
* snapshot of the entry it goes to and, once its own animation ends, hides
|
|
1315
|
+
* the live page until the page commits a new frame; a pop that has already
|
|
1316
|
+
* finished animating by then leaves nothing to commit, and Safari keeps the
|
|
1317
|
+
* snapshot (a still image, under which the live page takes the touches)
|
|
1318
|
+
* until a 3 s watchdog; and a page that stops painting before that
|
|
1319
|
+
* watchdog has a touch in progress interrupted when it fires, so the
|
|
1320
|
+
* painting runs through it. Called from browserHistory.ts for every
|
|
1321
|
+
* history navigation the browser makes; a call while one runs extends it.
|
|
1322
|
+
*/
|
|
1323
|
+
keepRepainting(duration = REPAINT_KEEPALIVE_MS): void {
|
|
1324
|
+
const until = performance.now() + duration;
|
|
1325
|
+
if (this.repaintKeepAlive) {
|
|
1326
|
+
this.repaintKeepAlive.until = Math.max(this.repaintKeepAlive.until, until);
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
const el = document.createElement('div');
|
|
1330
|
+
el.setAttribute('aria-hidden', 'true');
|
|
1331
|
+
el.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;pointer-events:none;z-index:2147483647;background:rgba(127,127,127,0.02)';
|
|
1332
|
+
this.root.append(el);
|
|
1333
|
+
const state = { el, timer: 0, until, on: false };
|
|
1334
|
+
this.repaintKeepAlive = state;
|
|
1335
|
+
state.timer = window.setInterval(() => {
|
|
1336
|
+
if (performance.now() >= state.until) {
|
|
1337
|
+
this.stopRepainting();
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
state.on = !state.on;
|
|
1341
|
+
el.style.background = state.on ? 'rgba(127,127,127,0.03)' : 'rgba(127,127,127,0.02)';
|
|
1342
|
+
}, REPAINT_KEEPALIVE_INTERVAL_MS);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
private stopRepainting(): void {
|
|
1346
|
+
const state = this.repaintKeepAlive;
|
|
1347
|
+
if (!state) return;
|
|
1348
|
+
this.repaintKeepAlive = null;
|
|
1349
|
+
window.clearInterval(state.timer);
|
|
1350
|
+
state.el.remove();
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1291
1353
|
/**
|
|
1292
1354
|
* Run the layout engine over the current tree and position every element.
|
|
1293
1355
|
* Returns the elements whose box changed. Runs at every `commit`; call it
|
|
@@ -1335,6 +1397,7 @@ export class Renderer {
|
|
|
1335
1397
|
this.collectGeometry(result);
|
|
1336
1398
|
this.collectLayouts(result);
|
|
1337
1399
|
if (this.applying === 0) this.flushGeometry();
|
|
1400
|
+
this.queueVisibilityCheck();
|
|
1338
1401
|
this.afterLayout?.();
|
|
1339
1402
|
return changes;
|
|
1340
1403
|
}
|
|
@@ -1577,6 +1640,7 @@ export class Renderer {
|
|
|
1577
1640
|
}
|
|
1578
1641
|
|
|
1579
1642
|
dispose(): void {
|
|
1643
|
+
this.stopRepainting();
|
|
1580
1644
|
this.closeMenu();
|
|
1581
1645
|
this.root.removeEventListener('click', this.onClick);
|
|
1582
1646
|
this.root.removeEventListener('pointerdown', this.onRowPress);
|
|
@@ -1585,6 +1649,7 @@ export class Renderer {
|
|
|
1585
1649
|
this.root.removeEventListener('focusin', this.onFocusChange);
|
|
1586
1650
|
this.root.removeEventListener('focusout', this.onFocusChange);
|
|
1587
1651
|
this.root.removeEventListener('scroll', this.onScrollDismissKeyboard, true);
|
|
1652
|
+
this.root.removeEventListener('scroll', this.queueVisibilityCheck, true);
|
|
1588
1653
|
this.root.removeEventListener('scroll', this.onPositionScroll, true);
|
|
1589
1654
|
this.root.removeEventListener('scrollend', this.onPositionScrollEnd, true);
|
|
1590
1655
|
this.root.removeEventListener('touchstart', this.onPositionTouchStart, true);
|
|
@@ -1626,6 +1691,7 @@ export class Renderer {
|
|
|
1626
1691
|
if (kind === 'geometry' || (props as { reportsSize?: unknown }).reportsSize === true) this.geometryReporters.add(id);
|
|
1627
1692
|
applyProps(el, kind, props, this.assets);
|
|
1628
1693
|
this.noteScroll(id, kind, props);
|
|
1694
|
+
this.noteVisibility(id, kind, props);
|
|
1629
1695
|
if (kind === 'list' || kind === 'scrollview') trackScroller(el);
|
|
1630
1696
|
if (kind === 'lazyspacer') this.lazySpacers.add(id);
|
|
1631
1697
|
if (kind === 'host') {
|
|
@@ -1634,6 +1700,18 @@ export class Renderer {
|
|
|
1634
1700
|
}
|
|
1635
1701
|
}
|
|
1636
1702
|
|
|
1703
|
+
/** A toolbar whose placement changed to or from `largeTitle` moves between its screen's content slot and large title. */
|
|
1704
|
+
private rehomeToolbar(id: ElementId, el: HTMLElement, op: Op): void {
|
|
1705
|
+
const parentId = this.parents.get(id);
|
|
1706
|
+
const parent = parentId === undefined ? undefined : this.elements.get(parentId);
|
|
1707
|
+
if (parentId === undefined || !parent || !parent.classList.contains('sb-navscreen') || !el.isConnected) return;
|
|
1708
|
+
const expected = el.dataset['sbPlacement'] === 'largeTitle' ? (largeTitleOf(parent) ?? slotOf(parent)) : slotOf(parent);
|
|
1709
|
+
if (el.parentElement === expected) return;
|
|
1710
|
+
const node = this.nodes.get(id);
|
|
1711
|
+
const index = node ? (this.nodes.get(parentId)?.children.indexOf(node) ?? -1) : -1;
|
|
1712
|
+
if (index >= 0) this.insert(parentId, id, index, op);
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1637
1715
|
/** A `host` element takes the page's content element (src/hostContent.ts) and watches its size. */
|
|
1638
1716
|
private adoptHostContent(id: ElementId, el: HTMLElement, props: HostProps): void {
|
|
1639
1717
|
const content = hostContent(props.content);
|
|
@@ -1680,6 +1758,64 @@ export class Renderer {
|
|
|
1680
1758
|
}
|
|
1681
1759
|
}
|
|
1682
1760
|
|
|
1761
|
+
// -------------------------------------------------------------------------
|
|
1762
|
+
// `.onScrollVisibilityChange` (src/scrollVisibility.ts)
|
|
1763
|
+
// -------------------------------------------------------------------------
|
|
1764
|
+
|
|
1765
|
+
/** A `styled` box with `scrollVisibility` is watched; any other forgets it was. */
|
|
1766
|
+
private noteVisibility(id: ElementId, kind: ElementKind, props: ElementProps): void {
|
|
1767
|
+
const spec = kind === 'styled' ? (props as Partial<StyledProps>)?.style?.scrollVisibility : undefined;
|
|
1768
|
+
if (spec && typeof spec.threshold === 'number') {
|
|
1769
|
+
this.visibilityTargets.set(id, spec.threshold);
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1772
|
+
this.visibilityTargets.delete(id);
|
|
1773
|
+
this.visibilityReported.delete(id);
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
/** After a scroll or a layout, the watched boxes are checked on the next frame (once per frame). */
|
|
1777
|
+
private readonly queueVisibilityCheck = (): void => {
|
|
1778
|
+
if (this.visibilityCheckQueued || this.visibilityTargets.size === 0) return;
|
|
1779
|
+
this.visibilityCheckQueued = true;
|
|
1780
|
+
requestAnimationFrame(() => {
|
|
1781
|
+
this.visibilityCheckQueued = false;
|
|
1782
|
+
this.checkVisibility();
|
|
1783
|
+
});
|
|
1784
|
+
};
|
|
1785
|
+
|
|
1786
|
+
/**
|
|
1787
|
+
* Each watched box against its scroll view's visible area (the view's box
|
|
1788
|
+
* less the bars it runs under), or the root's outside one: told when it
|
|
1789
|
+
* comes into view or leaves it, and once when first checked.
|
|
1790
|
+
*/
|
|
1791
|
+
private checkVisibility(): void {
|
|
1792
|
+
for (const [id, threshold] of this.visibilityTargets) {
|
|
1793
|
+
const el = this.elements.get(id);
|
|
1794
|
+
if (!el || !el.isConnected) {
|
|
1795
|
+
this.visibilityTargets.delete(id);
|
|
1796
|
+
this.visibilityReported.delete(id);
|
|
1797
|
+
continue;
|
|
1798
|
+
}
|
|
1799
|
+
const box = el.getBoundingClientRect();
|
|
1800
|
+
if (box.width === 0 && box.height === 0) continue;
|
|
1801
|
+
const scroller = el.parentElement?.closest<HTMLElement>('.sb-scrollview, .sb-list') ?? null;
|
|
1802
|
+
const area = (scroller ?? this.root).getBoundingClientRect();
|
|
1803
|
+
const viewport = { x: area.left, y: area.top, width: area.width, height: area.height };
|
|
1804
|
+
if (scroller) {
|
|
1805
|
+
const chrome = this.layoutResult?.chrome.get(Number(scroller.dataset[ID_ATTR]));
|
|
1806
|
+
const top = chrome?.['topInset']?.height ?? 0;
|
|
1807
|
+
const covered = chrome?.['bottomInset'];
|
|
1808
|
+
viewport.y += top;
|
|
1809
|
+
viewport.height -= top;
|
|
1810
|
+
if (covered) viewport.height = Math.max(0, viewport.height - covered.height);
|
|
1811
|
+
}
|
|
1812
|
+
const visible = isScrollVisible({ x: box.left, y: box.top, width: box.width, height: box.height }, viewport, threshold);
|
|
1813
|
+
if (this.visibilityReported.get(id) === visible) continue;
|
|
1814
|
+
this.visibilityReported.set(id, visible);
|
|
1815
|
+
this.onEvent({ type: 'scrollvisibility', id, visible });
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1683
1819
|
// -------------------------------------------------------------------------
|
|
1684
1820
|
// `.scrollPosition(id:)` reading back (src/scrollPosition.ts)
|
|
1685
1821
|
// -------------------------------------------------------------------------
|
|
@@ -1796,6 +1932,29 @@ export class Renderer {
|
|
|
1796
1932
|
this.pendingScrolls.push(id);
|
|
1797
1933
|
}
|
|
1798
1934
|
|
|
1935
|
+
/**
|
|
1936
|
+
* `ScrollContext.animateScroll` (src/scrollTo.ts): an animated `scrollTo`
|
|
1937
|
+
* of a snapping scroll view on one axis runs as the touch pager's run (the
|
|
1938
|
+
* snap off until it rests, the last frame writing the offset), held from
|
|
1939
|
+
* reporting its position until then as a finger's drive is, so an app
|
|
1940
|
+
* re-centring its pages on the page it reports does so once, at rest.
|
|
1941
|
+
*/
|
|
1942
|
+
animateScroll(el: HTMLElement, left: number, top: number): boolean {
|
|
1943
|
+
if (el.dataset['sbSnap'] === undefined) return false;
|
|
1944
|
+
const axes = el.dataset['sbAxes'];
|
|
1945
|
+
if (axes !== 'horizontal' && axes !== 'vertical') return false;
|
|
1946
|
+
if (el.dataset['sbReportsPosition'] !== undefined) {
|
|
1947
|
+
this.heldScrolls.add(el);
|
|
1948
|
+
const pending = this.positionTimers.get(el);
|
|
1949
|
+
if (pending !== undefined) {
|
|
1950
|
+
window.clearTimeout(pending);
|
|
1951
|
+
this.positionTimers.delete(el);
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
this.touchPaging.animateTo(el, axes === 'horizontal', axes === 'horizontal' ? left : top);
|
|
1955
|
+
return true;
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1799
1958
|
/** `ScrollViewProxy.scrollTo`: scroll to the targets this pass marked (src/scrollTo.ts). */
|
|
1800
1959
|
private performScrolls(animated: boolean): void {
|
|
1801
1960
|
if (this.pendingScrolls.length === 0) return;
|
|
@@ -1910,7 +2069,9 @@ export class Renderer {
|
|
|
1910
2069
|
if (kind === 'text') el.textContent = '';
|
|
1911
2070
|
applyProps(el, kind, props, this.assets);
|
|
1912
2071
|
this.noteScroll(id, kind, props);
|
|
2072
|
+
this.noteVisibility(id, kind, props);
|
|
1913
2073
|
if (kind === 'host') this.adoptHostContent(id, el, props as HostProps);
|
|
2074
|
+
if (kind === 'toolbar') this.rehomeToolbar(id, el, op);
|
|
1914
2075
|
const newToken = el.dataset[ANIMATION_TOKEN];
|
|
1915
2076
|
const tokenChanged = (previous?.tokenChanged ?? false) || (newToken !== undefined && newToken !== oldToken);
|
|
1916
2077
|
const record: UpdatedRecord = { el, before, tokenChanged };
|
|
@@ -1951,7 +2112,9 @@ export class Renderer {
|
|
|
1951
2112
|
// A section's view header / footer lives beside its rows' card, in the
|
|
1952
2113
|
// section element itself; the rows after a header are one DOM slot lower.
|
|
1953
2114
|
const supplement = isSectionSupplement(el);
|
|
1954
|
-
|
|
2115
|
+
// A `.largeTitle` toolbar lives in its screen's large title element, which scrolls with the content.
|
|
2116
|
+
const inLargeTitle = el.dataset['sbPlacement'] === 'largeTitle' && parent.classList.contains('sb-navscreen');
|
|
2117
|
+
const container = supplement && parent.classList.contains('sb-section') ? parent : inLargeTitle ? (largeTitleOf(parent) ?? slotOf(parent)) : slotOf(parent);
|
|
1955
2118
|
// Moves: `index` is the element's position in the parent's child list
|
|
1956
2119
|
// *after* the op is applied. Detaching first makes the index arithmetic
|
|
1957
2120
|
// identical for fresh inserts and for moves within the same parent.
|
|
@@ -1963,9 +2126,21 @@ export class Renderer {
|
|
|
1963
2126
|
const first = this.nodes.get(parentId)?.children.find((child) => child.id !== id);
|
|
1964
2127
|
if (first?.kind === 'sectionheader') slotIndex -= 1;
|
|
1965
2128
|
}
|
|
2129
|
+
// A screen's children may live in two containers (its content slot and
|
|
2130
|
+
// its large title): the next sibling in the same container is the reference.
|
|
2131
|
+
const screenReference = (): Element | null => {
|
|
2132
|
+
const siblings = (this.nodes.get(parentId)?.children ?? []).filter((child) => child.id !== id);
|
|
2133
|
+
for (const sibling of siblings.slice(index)) {
|
|
2134
|
+
const sib = this.elements.get(sibling.id);
|
|
2135
|
+
if (sib && sib.parentElement === container) return sib;
|
|
2136
|
+
}
|
|
2137
|
+
return null;
|
|
2138
|
+
};
|
|
1966
2139
|
const reference = supplement
|
|
1967
2140
|
? el.classList.contains('sb-sectionheader') ? container.firstChild : null
|
|
1968
|
-
: (
|
|
2141
|
+
: parent.classList.contains('sb-navscreen')
|
|
2142
|
+
? screenReference()
|
|
2143
|
+
: (container.children[slotIndex] ?? null);
|
|
1969
2144
|
container.insertBefore(el, reference);
|
|
1970
2145
|
if (!wasAttached) this.pass.inserted.add(el);
|
|
1971
2146
|
// Mirror the move in the layout tree.
|
|
@@ -3234,7 +3409,7 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
|
|
|
3234
3409
|
const placement = (props as ToolbarProps).placement;
|
|
3235
3410
|
// `secondary` items shown in the bar look like the trailing ones (glass capsules).
|
|
3236
3411
|
el.dataset['sbPlacement'] =
|
|
3237
|
-
placement === 'leading' || placement === 'principal' || placement === 'bottom' ? placement : 'trailing';
|
|
3412
|
+
placement === 'leading' || placement === 'principal' || placement === 'bottom' || placement === 'largeTitle' ? placement : 'trailing';
|
|
3238
3413
|
// A `ToolbarSpacer`: draws nothing.
|
|
3239
3414
|
el.toggleAttribute('data-sb-spacer', typeof (props as ToolbarProps).spacer === 'string');
|
|
3240
3415
|
break;
|
|
@@ -3703,8 +3878,12 @@ function applyImage(el: HTMLElement, props: ImageProps, assets: AssetCatalog): v
|
|
|
3703
3878
|
el.dataset['sbUrl'] = props.url;
|
|
3704
3879
|
if (props.resizable) el.dataset['sbResizable'] = '';
|
|
3705
3880
|
else delete el.dataset['sbResizable'];
|
|
3881
|
+
// React's opt-in to the browser's own image interactions (styles.css).
|
|
3882
|
+
const web = props.webInteractions === true;
|
|
3883
|
+
el.toggleAttribute('data-sb-web', web);
|
|
3706
3884
|
const existing = el.firstElementChild;
|
|
3707
3885
|
if (existing instanceof HTMLImageElement) {
|
|
3886
|
+
existing.draggable = web;
|
|
3708
3887
|
if (existing.getAttribute('src') !== props.url && existing.dataset['sbPending'] !== props.url) {
|
|
3709
3888
|
swapImageSource(existing, props.url);
|
|
3710
3889
|
}
|
|
@@ -3713,12 +3892,13 @@ function applyImage(el: HTMLElement, props: ImageProps, assets: AssetCatalog): v
|
|
|
3713
3892
|
const img = document.createElement('img');
|
|
3714
3893
|
img.src = props.url;
|
|
3715
3894
|
img.alt = '';
|
|
3716
|
-
img.draggable =
|
|
3895
|
+
img.draggable = web;
|
|
3717
3896
|
img.decoding = 'async';
|
|
3718
3897
|
found(props.url, img);
|
|
3719
3898
|
return;
|
|
3720
3899
|
}
|
|
3721
3900
|
delete el.dataset['sbUrl'];
|
|
3901
|
+
el.removeAttribute('data-sb-web');
|
|
3722
3902
|
if (isAssetImage(props)) {
|
|
3723
3903
|
const name = props.name ?? '';
|
|
3724
3904
|
delete el.dataset['sbSymbol'];
|
|
@@ -3827,6 +4007,54 @@ function applyTextField(input: HTMLInputElement | HTMLTextAreaElement, props: Te
|
|
|
3827
4007
|
else delete input.dataset['sbSubmit'];
|
|
3828
4008
|
if (typeof props.submitLabel === 'string' && props.submitLabel.length > 0) input.enterKeyHint = props.submitLabel;
|
|
3829
4009
|
else input.removeAttribute('enterkeyhint');
|
|
4010
|
+
applyTextInputHints(input, props);
|
|
4011
|
+
}
|
|
4012
|
+
|
|
4013
|
+
/** `UIKeyboardType` → `inputmode`: the browser's nearest on-screen keyboard. */
|
|
4014
|
+
const INPUT_MODES: Record<string, string> = {
|
|
4015
|
+
emailAddress: 'email',
|
|
4016
|
+
URL: 'url',
|
|
4017
|
+
numberPad: 'numeric',
|
|
4018
|
+
asciiCapableNumberPad: 'numeric',
|
|
4019
|
+
decimalPad: 'decimal',
|
|
4020
|
+
phonePad: 'tel',
|
|
4021
|
+
namePhonePad: 'tel',
|
|
4022
|
+
webSearch: 'search',
|
|
4023
|
+
};
|
|
4024
|
+
|
|
4025
|
+
/** `UITextContentType` → `autocomplete`: what autofill offers. */
|
|
4026
|
+
const AUTOCOMPLETE: Record<string, string> = {
|
|
4027
|
+
name: 'name',
|
|
4028
|
+
givenName: 'given-name',
|
|
4029
|
+
familyName: 'family-name',
|
|
4030
|
+
emailAddress: 'email',
|
|
4031
|
+
telephoneNumber: 'tel',
|
|
4032
|
+
username: 'username',
|
|
4033
|
+
password: 'current-password',
|
|
4034
|
+
newPassword: 'new-password',
|
|
4035
|
+
oneTimeCode: 'one-time-code',
|
|
4036
|
+
URL: 'url',
|
|
4037
|
+
fullStreetAddress: 'street-address',
|
|
4038
|
+
postalCode: 'postal-code',
|
|
4039
|
+
creditCardNumber: 'cc-number',
|
|
4040
|
+
};
|
|
4041
|
+
|
|
4042
|
+
/**
|
|
4043
|
+
* `.keyboardType`, `.textContentType`, `.textInputAutocapitalization` and
|
|
4044
|
+
* `.autocorrectionDisabled` as the input's attributes (docs/ops-protocol.md,
|
|
4045
|
+
* "Text input hints"). Without a content type autofill stays off, as a field
|
|
4046
|
+
* in an app does not offer the page's saved values.
|
|
4047
|
+
*/
|
|
4048
|
+
function applyTextInputHints(input: HTMLInputElement | HTMLTextAreaElement, props: TextFieldProps): void {
|
|
4049
|
+
const mode = props.keyboard ? INPUT_MODES[props.keyboard] : undefined;
|
|
4050
|
+
if (mode) input.inputMode = mode;
|
|
4051
|
+
else input.removeAttribute('inputmode');
|
|
4052
|
+
input.autocomplete = ((props.contentType ? AUTOCOMPLETE[props.contentType] : undefined) ?? 'off') as AutoFill;
|
|
4053
|
+
const capitalize = props.autocapitalization === 'never' ? 'none' : props.autocapitalization;
|
|
4054
|
+
if (capitalize) input.setAttribute('autocapitalize', capitalize);
|
|
4055
|
+
else input.removeAttribute('autocapitalize');
|
|
4056
|
+
if (props.autocorrectionDisabled === true) input.setAttribute('autocorrect', 'off');
|
|
4057
|
+
else input.removeAttribute('autocorrect');
|
|
3830
4058
|
}
|
|
3831
4059
|
|
|
3832
4060
|
/** Phase 12 `.scrollTargetLayout()`: the stack's children snap (styles.css). */
|
|
@@ -3864,9 +4092,17 @@ function applyNavScreen(el: HTMLElement, props: NavScreenProps): void {
|
|
|
3864
4092
|
const largeTitle = largeTitleOf(el);
|
|
3865
4093
|
// A large title that scrolls away collapses into the inline one (shown
|
|
3866
4094
|
// with `data-sb-collapsed`, src/largeTitle.ts).
|
|
3867
|
-
if (inlineTitle)
|
|
4095
|
+
if (inlineTitle) {
|
|
4096
|
+
const appearing = inlineTitle.textContent === '' && title !== '' && inlineTitle.isConnected;
|
|
4097
|
+
inlineTitle.textContent = title;
|
|
4098
|
+
// An inline title given to an empty bar (a header row that has scrolled
|
|
4099
|
+
// under it handing the bar its name) fades in as a collapsing large title does.
|
|
4100
|
+
if (appearing && !large && typeof inlineTitle.animate === 'function') {
|
|
4101
|
+
inlineTitle.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 150, easing: 'ease' });
|
|
4102
|
+
}
|
|
4103
|
+
}
|
|
3868
4104
|
if (largeTitle) {
|
|
3869
|
-
largeTitle.textContent = large ? title : '';
|
|
4105
|
+
largeTitleTextOf(largeTitle).textContent = large ? title : '';
|
|
3870
4106
|
largeTitle.hidden = !large;
|
|
3871
4107
|
}
|
|
3872
4108
|
el.setAttribute('aria-label', title);
|
package/src/scrollTo.ts
CHANGED
|
@@ -49,6 +49,14 @@ export interface ScrollContext {
|
|
|
49
49
|
nodes: Map<number, LayoutNode>;
|
|
50
50
|
parents: Map<number, number>;
|
|
51
51
|
elements: Map<number, HTMLElement>;
|
|
52
|
+
/**
|
|
53
|
+
* Runs an animated scroll of `el` to the offsets itself, returning false
|
|
54
|
+
* to leave it to the browser's smooth scrolling. The renderer takes the
|
|
55
|
+
* snapping scroll views (`scrollTargetBehavior`): a browser animating a
|
|
56
|
+
* scroll-snapped container may snap it again once the animation ends or
|
|
57
|
+
* the content moves (Safari does), so they run as a finger's page does.
|
|
58
|
+
*/
|
|
59
|
+
animateScroll?: (el: HTMLElement, left: number, top: number) => boolean;
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
function anchorOf(command: ScrollToCommand | undefined): UnitPoint | null {
|
|
@@ -116,7 +124,9 @@ export function scrollToTarget(
|
|
|
116
124
|
changed = true;
|
|
117
125
|
}
|
|
118
126
|
if (changed) {
|
|
119
|
-
if (smooth &&
|
|
127
|
+
if (smooth && ctx.animateScroll?.(el, left, top)) {
|
|
128
|
+
// Run by the context (a snapping scroll view).
|
|
129
|
+
} else if (smooth && typeof el.scrollTo === 'function') {
|
|
120
130
|
el.scrollTo({ left, top, behavior: 'smooth' });
|
|
121
131
|
} else {
|
|
122
132
|
el.scrollLeft = left;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.onScrollVisibilityChange(threshold:_:)` (iOS 18; docs/ops-protocol.md,
|
|
3
|
+
* "Scroll visibility"): a `styled` box with `scrollVisibility` is told when
|
|
4
|
+
* the share of it inside its scroll view's visible area crosses its
|
|
5
|
+
* threshold. The visible area is the scroll view's box less a bar it scrolls
|
|
6
|
+
* under (its `topInset`) and a bar covering its bottom (`bottomInset`), as
|
|
7
|
+
* content under a bar is not in view; outside a scroll view it is the root.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface Span {
|
|
11
|
+
start: number;
|
|
12
|
+
length: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The share (0...1) of `target` along an axis inside `viewport`; 0 for a target of no extent. */
|
|
16
|
+
export function visibleShare(target: Span, viewport: Span): number {
|
|
17
|
+
if (target.length <= 0) return 0;
|
|
18
|
+
const start = Math.max(target.start, viewport.start);
|
|
19
|
+
const end = Math.min(target.start + target.length, viewport.start + viewport.length);
|
|
20
|
+
return Math.max(0, end - start) / target.length;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether a box is visible: some of it is in view, and at least `threshold`
|
|
25
|
+
* of it along the scroll axes (both shares multiplied for a view scrolling
|
|
26
|
+
* both ways, the other axis counted too, as a box off to the side is not in
|
|
27
|
+
* view).
|
|
28
|
+
*/
|
|
29
|
+
export function isScrollVisible(
|
|
30
|
+
target: { x: number; y: number; width: number; height: number },
|
|
31
|
+
viewport: { x: number; y: number; width: number; height: number },
|
|
32
|
+
threshold: number,
|
|
33
|
+
): boolean {
|
|
34
|
+
const horizontal = visibleShare({ start: target.x, length: target.width }, { start: viewport.x, length: viewport.width });
|
|
35
|
+
const vertical = visibleShare({ start: target.y, length: target.height }, { start: viewport.y, length: viewport.height });
|
|
36
|
+
const share = horizontal * vertical;
|
|
37
|
+
return share > 0 && share >= Math.min(1, Math.max(0, threshold));
|
|
38
|
+
}
|
package/src/styles.css
CHANGED
|
@@ -666,6 +666,17 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
666
666
|
-webkit-user-select: none;
|
|
667
667
|
}
|
|
668
668
|
|
|
669
|
+
/* React's `AsyncImage webInteractions`: the picture is a web page's image
|
|
670
|
+
again, with the browser's long press (Save / Share / Copy on iOS), drag
|
|
671
|
+
out and right-click menu. */
|
|
672
|
+
.sb-image[data-sb-web] > img {
|
|
673
|
+
pointer-events: auto;
|
|
674
|
+
user-select: auto;
|
|
675
|
+
-webkit-user-select: auto;
|
|
676
|
+
-webkit-touch-callout: default;
|
|
677
|
+
-webkit-user-drag: auto;
|
|
678
|
+
}
|
|
679
|
+
|
|
669
680
|
/* `.interpolation(.none)`: a QR code's modules stay sharp when scaled up. */
|
|
670
681
|
.sb-image[data-sb-interpolation='none'] > img,
|
|
671
682
|
.sb-image[data-sb-interpolation='none'] > canvas {
|
|
@@ -1253,6 +1264,22 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
1253
1264
|
display: none;
|
|
1254
1265
|
}
|
|
1255
1266
|
|
|
1267
|
+
/* A `.largeTitle` toolbar (iOS 26) takes the row in place of the title's
|
|
1268
|
+
text, which the bar still shows once the row has scrolled under it. Its
|
|
1269
|
+
items are content, drawn as anywhere else and answering touches. */
|
|
1270
|
+
.sb-navbar-large-title:has(> .sb-toolbar[data-sb-placement="largeTitle"]:not([hidden])) > .sb-navbar-large-title-text {
|
|
1271
|
+
visibility: hidden;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
.sb-navbar-large-title > .sb-toolbar {
|
|
1275
|
+
font-size: var(--sb-font-size-body);
|
|
1276
|
+
font-weight: 400;
|
|
1277
|
+
line-height: normal;
|
|
1278
|
+
letter-spacing: normal;
|
|
1279
|
+
white-space: normal;
|
|
1280
|
+
pointer-events: auto;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1256
1283
|
/* The large title scrolls with the screen's root list or scroll view
|
|
1257
1284
|
(src/largeTitle.ts moves it into it); drags on it scroll that view. There
|
|
1258
1285
|
it is content, under the bar. */
|
|
@@ -1898,6 +1925,28 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
1898
1925
|
max-width: calc(100% - 8px);
|
|
1899
1926
|
}
|
|
1900
1927
|
|
|
1928
|
+
/* iOS 26: a segmented picker in a navigation bar item is the bar's glass
|
|
1929
|
+
capsule itself (the run's glass behind it, engine `bar`): no track, the
|
|
1930
|
+
labels in the engine's font (15 regular), the selected segment a gray
|
|
1931
|
+
capsule the glass tints, with no shadow, 2 in from its neighbours. */
|
|
1932
|
+
.sb-picker[data-sb-style="segmented"][data-sb-bar] > .sb-segment-track {
|
|
1933
|
+
display: none;
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
.sb-picker[data-sb-style="segmented"][data-sb-bar] > .sb-segment {
|
|
1937
|
+
font: inherit;
|
|
1938
|
+
letter-spacing: normal;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
.sb-picker[data-sb-style="segmented"][data-sb-bar] > .sb-segment::before {
|
|
1942
|
+
inset: 0 2px;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
.sb-picker[data-sb-style="segmented"][data-sb-bar] > .sb-segment[aria-checked="true"]::before {
|
|
1946
|
+
background: var(--sb-glass-selected);
|
|
1947
|
+
box-shadow: none;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1901
1950
|
.sb-segment:focus-visible {
|
|
1902
1951
|
outline: 2px solid var(--sb-color-accent);
|
|
1903
1952
|
outline-offset: -2px;
|
|
@@ -2982,7 +3031,8 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
2982
3031
|
}
|
|
2983
3032
|
|
|
2984
3033
|
: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],
|
|
2985
|
-
: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 >
|
|
3034
|
+
: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 > *,
|
|
3035
|
+
: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 {
|
|
2986
3036
|
pointer-events: none !important;
|
|
2987
3037
|
}
|
|
2988
3038
|
|
package/src/touchPaging.ts
CHANGED
|
@@ -334,6 +334,22 @@ export class TouchPaging {
|
|
|
334
334
|
this.runTo(el, g.horizontal, offset, destination);
|
|
335
335
|
};
|
|
336
336
|
|
|
337
|
+
/**
|
|
338
|
+
* The app scrolls `el` to `to` with an animation (`withAnimation` around
|
|
339
|
+
* `ScrollViewProxy.scrollTo`): the same eased run a finger's page ends
|
|
340
|
+
* with, the snap off until it rests, the last frame writing `to` itself
|
|
341
|
+
* and `onRest` reporting the page. A browser's own smooth scroll of a
|
|
342
|
+
* snapping container is left to snap it again when it likes; Safari does,
|
|
343
|
+
* after the animation or when the app re-centres its pages.
|
|
344
|
+
*/
|
|
345
|
+
animateTo(el: HTMLElement, horizontal: boolean, to: number): void {
|
|
346
|
+
this.stopRun();
|
|
347
|
+
const max = horizontal ? el.scrollWidth - el.clientWidth : el.scrollHeight - el.clientHeight;
|
|
348
|
+
const target = Math.min(Math.max(0, max), Math.max(0, to));
|
|
349
|
+
el.style.scrollSnapType = 'none';
|
|
350
|
+
this.runTo(el, horizontal, horizontal ? el.scrollLeft : el.scrollTop, target);
|
|
351
|
+
}
|
|
352
|
+
|
|
337
353
|
/** Runs the offset to `to` with an ease-out, then lets the container snap again. */
|
|
338
354
|
private runTo(el: HTMLElement, horizontal: boolean, from: number, to: number): void {
|
|
339
355
|
this.stopRun();
|