@swiftbrowser/web 0.9.12 → 0.9.13
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 +77 -5
- package/src/largeTitle.ts +14 -0
- package/src/layout/engine.ts +23 -1
- package/src/layoutDom.ts +23 -3
- package/src/protocol.ts +28 -2
- package/src/renderer.ts +109 -6
- package/src/scrollVisibility.ts +38 -0
- package/src/styles.css +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
|
|
@@ -60,6 +64,13 @@ export class BrowserHistory {
|
|
|
60
64
|
/** A `popstate` asked for the entry's sidebar selection: the change it brings is recorded, not pushed. */
|
|
61
65
|
private restoringSelection = false;
|
|
62
66
|
private enabled = false;
|
|
67
|
+
/** The page's own `replaceState`, while ours stands in for it. */
|
|
68
|
+
private pageReplaceState: History['replaceState'] | null = null;
|
|
69
|
+
private pageReplaceStateWasOwn = false;
|
|
70
|
+
/** A URL the page set while our own `history.go` was on its way: meant for the entry it lands on. */
|
|
71
|
+
private landingUrl: string | URL | null = null;
|
|
72
|
+
/** A level appeared while our own `history.go` was on its way: its entry is pushed once the move lands. */
|
|
73
|
+
private syncOnArrival = false;
|
|
63
74
|
|
|
64
75
|
constructor(
|
|
65
76
|
private readonly renderer: Renderer,
|
|
@@ -75,10 +86,15 @@ export class BrowserHistory {
|
|
|
75
86
|
this.enabled = true;
|
|
76
87
|
const current = stateOf(this.win.history.state);
|
|
77
88
|
if (!current) this.win.history.replaceState({ ...(this.win.history.state ?? {}), ...this.entry(0, this.selection()) }, '');
|
|
89
|
+
this.watchReplaceState();
|
|
78
90
|
this.renderer.interaction.nativeBackGesture = true;
|
|
79
91
|
this.root.addEventListener('sb:commit', this.onCommit);
|
|
80
92
|
this.win.addEventListener('popstate', this.onPopState);
|
|
81
|
-
|
|
93
|
+
// Enabled before the first commit (a mount, or a remount such as React's
|
|
94
|
+
// StrictMode makes in development) the screen is not drawn yet: fewer
|
|
95
|
+
// levels than the entry records is not the app popping, so only the
|
|
96
|
+
// levels already shown are pushed here and the first commit settles the rest.
|
|
97
|
+
this.sync(true);
|
|
82
98
|
}
|
|
83
99
|
|
|
84
100
|
dispose(): void {
|
|
@@ -87,10 +103,44 @@ export class BrowserHistory {
|
|
|
87
103
|
this.renderer.interaction.nativeBackGesture = false;
|
|
88
104
|
if (this.movingTimer) clearTimeout(this.movingTimer);
|
|
89
105
|
this.movingTo = null;
|
|
106
|
+
this.landingUrl = null;
|
|
107
|
+
this.syncOnArrival = false;
|
|
108
|
+
this.unwatchReplaceState();
|
|
90
109
|
this.root.removeEventListener('sb:commit', this.onCommit);
|
|
91
110
|
this.win.removeEventListener('popstate', this.onPopState);
|
|
92
111
|
}
|
|
93
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Stands in for the page's `replaceState`: it still runs at once, and a
|
|
115
|
+
* URL given while our own `go` is on its way is remembered for the entry
|
|
116
|
+
* the move lands on.
|
|
117
|
+
*/
|
|
118
|
+
private watchReplaceState(): void {
|
|
119
|
+
const history = this.win.history;
|
|
120
|
+
const original = history.replaceState;
|
|
121
|
+
this.pageReplaceState = original;
|
|
122
|
+
this.pageReplaceStateWasOwn = Object.prototype.hasOwnProperty.call(history, 'replaceState');
|
|
123
|
+
history.replaceState = (data: unknown, unused: string, url?: string | URL | null): void => {
|
|
124
|
+
if (this.movingTo !== null && url !== undefined && url !== null) this.landingUrl = url;
|
|
125
|
+
original.call(history, data, unused, url);
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private unwatchReplaceState(): void {
|
|
130
|
+
const original = this.pageReplaceState;
|
|
131
|
+
if (!original) return;
|
|
132
|
+
const history = this.win.history;
|
|
133
|
+
// A browser's is the prototype's: removing ours uncovers it. A stand-in's own property is put back.
|
|
134
|
+
if (this.pageReplaceStateWasOwn) history.replaceState = original;
|
|
135
|
+
else delete (history as Partial<History>).replaceState;
|
|
136
|
+
this.pageReplaceState = null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** `replaceState` as the page had it, for our own entries. */
|
|
140
|
+
private replaceEntry(data: unknown, url?: string | URL | null): void {
|
|
141
|
+
(this.pageReplaceState ?? this.win.history.replaceState).call(this.win.history, data, '', url);
|
|
142
|
+
}
|
|
143
|
+
|
|
94
144
|
/** The levels above the base the app shows now: sheets, plus pushed screens in each visible stack of the top layer. */
|
|
95
145
|
depth(): number {
|
|
96
146
|
const sheets = this.sheets();
|
|
@@ -192,7 +242,13 @@ export class BrowserHistory {
|
|
|
192
242
|
this.movingTimer = setTimeout(() => {
|
|
193
243
|
if (this.movingTo === null) return;
|
|
194
244
|
this.movingTo = null;
|
|
245
|
+
// No move (a go past the ends fires nothing): the URL already sits on the entry at hand.
|
|
246
|
+
this.landingUrl = null;
|
|
195
247
|
this.ignorePops = Math.max(0, this.ignorePops - 1);
|
|
248
|
+
if (this.syncOnArrival) {
|
|
249
|
+
this.syncOnArrival = false;
|
|
250
|
+
this.sync();
|
|
251
|
+
}
|
|
196
252
|
}, 1000);
|
|
197
253
|
this.win.history.go(delta);
|
|
198
254
|
}
|
|
@@ -201,12 +257,21 @@ export class BrowserHistory {
|
|
|
201
257
|
this.movingTo = null;
|
|
202
258
|
if (this.movingTimer) clearTimeout(this.movingTimer);
|
|
203
259
|
this.movingTimer = null;
|
|
260
|
+
// The page's URL for what it shows now, set on the entry we left: it belongs here.
|
|
261
|
+
const url = this.landingUrl;
|
|
262
|
+
this.landingUrl = null;
|
|
263
|
+
if (url !== null) this.replaceEntry(this.win.history.state, url);
|
|
264
|
+
// Levels that appeared during the move get their entries now.
|
|
265
|
+
if (this.syncOnArrival) {
|
|
266
|
+
this.syncOnArrival = false;
|
|
267
|
+
this.sync();
|
|
268
|
+
}
|
|
204
269
|
}
|
|
205
270
|
|
|
206
271
|
private readonly onCommit = (): void => this.sync();
|
|
207
272
|
|
|
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 {
|
|
273
|
+
/** 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. */
|
|
274
|
+
sync(enabling = false): void {
|
|
210
275
|
if (!this.enabled) return;
|
|
211
276
|
const depth = this.depth();
|
|
212
277
|
const recorded = this.recorded();
|
|
@@ -220,8 +285,15 @@ export class BrowserHistory {
|
|
|
220
285
|
return;
|
|
221
286
|
}
|
|
222
287
|
if (depth > recorded) {
|
|
288
|
+
// Under our own pending `go`, a push would land relative to wherever the browser is when it runs (and
|
|
289
|
+
// Chrome moves back past it), leaving the app on an entry below what it shows: push once the move lands.
|
|
290
|
+
if (this.movingTo !== null) {
|
|
291
|
+
this.syncOnArrival = true;
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
223
294
|
for (let level = recorded + 1; level <= depth; level++) this.win.history.pushState(this.entry(level, selection), '');
|
|
224
295
|
} else if (depth < recorded) {
|
|
296
|
+
if (enabling) return;
|
|
225
297
|
// The app popped or dismissed on its own (back button, swipe, code): leave those entries.
|
|
226
298
|
this.go(depth - recorded, depth);
|
|
227
299
|
} else if (this.movingTo === null && selection !== this.recordedSelection()) {
|
|
@@ -231,7 +303,7 @@ export class BrowserHistory {
|
|
|
231
303
|
const history = this.win.history;
|
|
232
304
|
if (this.restoringSelection || !this.recordedSelection()) {
|
|
233
305
|
const { sbSelection: _, ...rest } = (history.state ?? {}) as Partial<HistoryState>;
|
|
234
|
-
|
|
306
|
+
this.replaceEntry({ ...rest, ...this.entry(depth, selection) });
|
|
235
307
|
} else history.pushState(this.entry(depth, selection), '');
|
|
236
308
|
}
|
|
237
309
|
this.restoringSelection = false;
|
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
|
@@ -3429,7 +3429,7 @@ export class LayoutPass {
|
|
|
3429
3429
|
for (const spacer of parts.toolbars.filter(isSpacer)) this.hide(spacer, node);
|
|
3430
3430
|
const barToolbars = parts.toolbars.filter((t) => {
|
|
3431
3431
|
const placement = str(t.props['placement']);
|
|
3432
|
-
return placement !== 'bottom' && placement !== 'secondary' && !isSpacer(t);
|
|
3432
|
+
return placement !== 'bottom' && placement !== 'secondary' && placement !== 'largeTitle' && !isSpacer(t);
|
|
3433
3433
|
});
|
|
3434
3434
|
const secondaryToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'secondary' && !isSpacer(t));
|
|
3435
3435
|
const groups: Group[] = barToolbars.map((toolbar) => {
|
|
@@ -3624,6 +3624,28 @@ export class LayoutPass {
|
|
|
3624
3624
|
if (!inlineSecondary.some((entry) => entry.toolbar === toolbar)) this.hide(toolbar, node);
|
|
3625
3625
|
else for (const item of items) if (collapsedSecondary.some((entry) => entry.item === item)) this.hide(item, node);
|
|
3626
3626
|
}
|
|
3627
|
+
// `.largeTitle` items (iOS 26): in the large title row, in place of the
|
|
3628
|
+
// title's text, from its leading margin and centered in the row; the
|
|
3629
|
+
// renderer moves the toolbar into the title element, so they scroll with
|
|
3630
|
+
// the content as the title does, and the bar shows the title once the row
|
|
3631
|
+
// has gone under it. While the title is inline there is no row: hidden.
|
|
3632
|
+
for (const toolbar of parts.toolbars) {
|
|
3633
|
+
if (str(toolbar.props['placement']) !== 'largeTitle' || isSpacer(toolbar)) continue;
|
|
3634
|
+
const row = chrome['largeTitle'];
|
|
3635
|
+
if (!row) {
|
|
3636
|
+
this.hide(toolbar, node);
|
|
3637
|
+
continue;
|
|
3638
|
+
}
|
|
3639
|
+
const itemCtx = this.childContext(toolbar, cctx);
|
|
3640
|
+
const room: Proposal = { width: Math.max(0, row.width - 2 * NAV_ITEM_MARGIN), height: null };
|
|
3641
|
+
let cursor = row.x + NAV_ITEM_MARGIN;
|
|
3642
|
+
for (const item of toolbar.children) {
|
|
3643
|
+
const itemSize = this.measure(item, itemCtx, room);
|
|
3644
|
+
this.place(item, itemCtx, room, cursor, row.y + (row.height - itemSize.height) / 2);
|
|
3645
|
+
cursor += itemSize.width + NAV_ITEM_SPACING;
|
|
3646
|
+
}
|
|
3647
|
+
this.setFrame(toolbar, row.x, row.y, { width: row.width, height: row.height });
|
|
3648
|
+
}
|
|
3627
3649
|
if (bottomBarHeight > 0) {
|
|
3628
3650
|
this.placeBottomToolbars(bottomToolbars, cctx, chrome, x, y + size.height - bottomBarHeight, size.width);
|
|
3629
3651
|
}
|
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;
|
package/src/protocol.ts
CHANGED
|
@@ -897,7 +897,13 @@ export interface DecoratedProps {
|
|
|
897
897
|
export interface MenuProps {
|
|
898
898
|
deferred?: boolean;
|
|
899
899
|
}
|
|
900
|
-
|
|
900
|
+
/**
|
|
901
|
+
* `largeTitle` (iOS 26 `.largeTitle`): the items take the large title row in
|
|
902
|
+
* place of the title's text, scrolling with the content as the title does;
|
|
903
|
+
* the bar shows the title once the row has gone under it. Hidden while the
|
|
904
|
+
* screen's title is inline.
|
|
905
|
+
*/
|
|
906
|
+
export type ToolbarPlacement = 'trailing' | 'leading' | 'principal' | 'bottom' | 'secondary' | 'largeTitle';
|
|
901
907
|
/**
|
|
902
908
|
* `.toolbar { ToolbarItem(placement:) { ... } }`, an extra child of a
|
|
903
909
|
* `navscreen` after its content. The children are the items, laid out at
|
|
@@ -1884,6 +1890,16 @@ export interface Style {
|
|
|
1884
1890
|
hitTesting?: boolean;
|
|
1885
1891
|
}
|
|
1886
1892
|
|
|
1893
|
+
export interface Style {
|
|
1894
|
+
/**
|
|
1895
|
+
* `.onScrollVisibilityChange(threshold:_:)`: the page sends
|
|
1896
|
+
* `scrollvisibility` to the box when the share of it inside its scroll
|
|
1897
|
+
* view's visible area (below a bar the view scrolls under, above a bar
|
|
1898
|
+
* covering its bottom) crosses `threshold` (src/scrollVisibility.ts).
|
|
1899
|
+
*/
|
|
1900
|
+
scrollVisibility?: { threshold: number };
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1887
1903
|
export interface Style {
|
|
1888
1904
|
/** `.blendMode`: the CSS `mix-blend-mode` the box is composited with. */
|
|
1889
1905
|
blendMode?: string;
|
|
@@ -1957,6 +1973,16 @@ export interface ScrollPositionEvent {
|
|
|
1957
1973
|
type: 'scrollposition';
|
|
1958
1974
|
id: ElementId;
|
|
1959
1975
|
}
|
|
1976
|
+
/**
|
|
1977
|
+
* Sent to a `styled` box with `scrollVisibility`: the share of it inside its
|
|
1978
|
+
* scroll view's visible area came to the threshold or above (`visible`), or
|
|
1979
|
+
* fell below it. The first check after the box appears says where it is.
|
|
1980
|
+
*/
|
|
1981
|
+
export interface ScrollVisibilityEvent {
|
|
1982
|
+
type: 'scrollvisibility';
|
|
1983
|
+
id: ElementId;
|
|
1984
|
+
visible: boolean;
|
|
1985
|
+
}
|
|
1960
1986
|
/**
|
|
1961
1987
|
* A movable `listrow` was dragged to a new place: `to` is the index among
|
|
1962
1988
|
* its container's children (the `list`'s, or its `section`'s) before which
|
|
@@ -2002,7 +2028,7 @@ export interface DropEvent {
|
|
|
2002
2028
|
}
|
|
2003
2029
|
|
|
2004
2030
|
/** The Phase 8 events (merged into `RendererEvent` above). */
|
|
2005
|
-
export type InteractionEvent = DragEvent | PinchEvent | LongPressEvent | DeleteEvent | RefreshEvent | ScrollPositionEvent | MoveEvent | DragStartEvent | DragEndEvent | DropEvent;
|
|
2031
|
+
export type InteractionEvent = DragEvent | PinchEvent | LongPressEvent | DeleteEvent | RefreshEvent | ScrollPositionEvent | ScrollVisibilityEvent | MoveEvent | DragStartEvent | DragEndEvent | DropEvent;
|
|
2006
2032
|
// Phase 8: alerts, controls, effects
|
|
2007
2033
|
// (docs/ops-protocol.md, "Phase 8: interaction"; gestures, swipe actions,
|
|
2008
2034
|
// 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';
|
|
@@ -440,6 +441,10 @@ export class Renderer {
|
|
|
440
441
|
private pendingScrolls: ElementId[] = [];
|
|
441
442
|
/** Lazy rows: the `lazyspacer`s on the page, and those already reported near the visible area. */
|
|
442
443
|
private lazySpacers = new Set<ElementId>();
|
|
444
|
+
/** `styled` boxes with `scrollVisibility`, and what each was last told (src/scrollVisibility.ts). */
|
|
445
|
+
private readonly visibilityTargets = new Map<ElementId, number>();
|
|
446
|
+
private readonly visibilityReported = new Map<ElementId, boolean>();
|
|
447
|
+
private visibilityCheckQueued = false;
|
|
443
448
|
private lazyReported = new Set<ElementId>();
|
|
444
449
|
private lazyCheckQueued = false;
|
|
445
450
|
/** The highest `scrollTo` serial seen: a command keeps its serial until the reader re-runs, and runs once. */
|
|
@@ -864,6 +869,7 @@ export class Renderer {
|
|
|
864
869
|
root.addEventListener('focusout', this.onFocusChange);
|
|
865
870
|
root.addEventListener('scroll', this.onScrollDismissKeyboard, true);
|
|
866
871
|
root.addEventListener('scroll', this.queueLazyCheck, { capture: true, passive: true });
|
|
872
|
+
root.addEventListener('scroll', this.queueVisibilityCheck, { capture: true, passive: true });
|
|
867
873
|
root.addEventListener('scroll', this.onPositionScroll, { capture: true, passive: true });
|
|
868
874
|
root.addEventListener('scrollend', this.onPositionScrollEnd, { capture: true, passive: true });
|
|
869
875
|
root.addEventListener('touchstart', this.onPositionTouchStart, { capture: true, passive: true });
|
|
@@ -1335,6 +1341,7 @@ export class Renderer {
|
|
|
1335
1341
|
this.collectGeometry(result);
|
|
1336
1342
|
this.collectLayouts(result);
|
|
1337
1343
|
if (this.applying === 0) this.flushGeometry();
|
|
1344
|
+
this.queueVisibilityCheck();
|
|
1338
1345
|
this.afterLayout?.();
|
|
1339
1346
|
return changes;
|
|
1340
1347
|
}
|
|
@@ -1585,6 +1592,7 @@ export class Renderer {
|
|
|
1585
1592
|
this.root.removeEventListener('focusin', this.onFocusChange);
|
|
1586
1593
|
this.root.removeEventListener('focusout', this.onFocusChange);
|
|
1587
1594
|
this.root.removeEventListener('scroll', this.onScrollDismissKeyboard, true);
|
|
1595
|
+
this.root.removeEventListener('scroll', this.queueVisibilityCheck, true);
|
|
1588
1596
|
this.root.removeEventListener('scroll', this.onPositionScroll, true);
|
|
1589
1597
|
this.root.removeEventListener('scrollend', this.onPositionScrollEnd, true);
|
|
1590
1598
|
this.root.removeEventListener('touchstart', this.onPositionTouchStart, true);
|
|
@@ -1626,6 +1634,7 @@ export class Renderer {
|
|
|
1626
1634
|
if (kind === 'geometry' || (props as { reportsSize?: unknown }).reportsSize === true) this.geometryReporters.add(id);
|
|
1627
1635
|
applyProps(el, kind, props, this.assets);
|
|
1628
1636
|
this.noteScroll(id, kind, props);
|
|
1637
|
+
this.noteVisibility(id, kind, props);
|
|
1629
1638
|
if (kind === 'list' || kind === 'scrollview') trackScroller(el);
|
|
1630
1639
|
if (kind === 'lazyspacer') this.lazySpacers.add(id);
|
|
1631
1640
|
if (kind === 'host') {
|
|
@@ -1634,6 +1643,18 @@ export class Renderer {
|
|
|
1634
1643
|
}
|
|
1635
1644
|
}
|
|
1636
1645
|
|
|
1646
|
+
/** A toolbar whose placement changed to or from `largeTitle` moves between its screen's content slot and large title. */
|
|
1647
|
+
private rehomeToolbar(id: ElementId, el: HTMLElement, op: Op): void {
|
|
1648
|
+
const parentId = this.parents.get(id);
|
|
1649
|
+
const parent = parentId === undefined ? undefined : this.elements.get(parentId);
|
|
1650
|
+
if (parentId === undefined || !parent || !parent.classList.contains('sb-navscreen') || !el.isConnected) return;
|
|
1651
|
+
const expected = el.dataset['sbPlacement'] === 'largeTitle' ? (largeTitleOf(parent) ?? slotOf(parent)) : slotOf(parent);
|
|
1652
|
+
if (el.parentElement === expected) return;
|
|
1653
|
+
const node = this.nodes.get(id);
|
|
1654
|
+
const index = node ? (this.nodes.get(parentId)?.children.indexOf(node) ?? -1) : -1;
|
|
1655
|
+
if (index >= 0) this.insert(parentId, id, index, op);
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1637
1658
|
/** A `host` element takes the page's content element (src/hostContent.ts) and watches its size. */
|
|
1638
1659
|
private adoptHostContent(id: ElementId, el: HTMLElement, props: HostProps): void {
|
|
1639
1660
|
const content = hostContent(props.content);
|
|
@@ -1680,6 +1701,64 @@ export class Renderer {
|
|
|
1680
1701
|
}
|
|
1681
1702
|
}
|
|
1682
1703
|
|
|
1704
|
+
// -------------------------------------------------------------------------
|
|
1705
|
+
// `.onScrollVisibilityChange` (src/scrollVisibility.ts)
|
|
1706
|
+
// -------------------------------------------------------------------------
|
|
1707
|
+
|
|
1708
|
+
/** A `styled` box with `scrollVisibility` is watched; any other forgets it was. */
|
|
1709
|
+
private noteVisibility(id: ElementId, kind: ElementKind, props: ElementProps): void {
|
|
1710
|
+
const spec = kind === 'styled' ? (props as Partial<StyledProps>)?.style?.scrollVisibility : undefined;
|
|
1711
|
+
if (spec && typeof spec.threshold === 'number') {
|
|
1712
|
+
this.visibilityTargets.set(id, spec.threshold);
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
this.visibilityTargets.delete(id);
|
|
1716
|
+
this.visibilityReported.delete(id);
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
/** After a scroll or a layout, the watched boxes are checked on the next frame (once per frame). */
|
|
1720
|
+
private readonly queueVisibilityCheck = (): void => {
|
|
1721
|
+
if (this.visibilityCheckQueued || this.visibilityTargets.size === 0) return;
|
|
1722
|
+
this.visibilityCheckQueued = true;
|
|
1723
|
+
requestAnimationFrame(() => {
|
|
1724
|
+
this.visibilityCheckQueued = false;
|
|
1725
|
+
this.checkVisibility();
|
|
1726
|
+
});
|
|
1727
|
+
};
|
|
1728
|
+
|
|
1729
|
+
/**
|
|
1730
|
+
* Each watched box against its scroll view's visible area (the view's box
|
|
1731
|
+
* less the bars it runs under), or the root's outside one: told when it
|
|
1732
|
+
* comes into view or leaves it, and once when first checked.
|
|
1733
|
+
*/
|
|
1734
|
+
private checkVisibility(): void {
|
|
1735
|
+
for (const [id, threshold] of this.visibilityTargets) {
|
|
1736
|
+
const el = this.elements.get(id);
|
|
1737
|
+
if (!el || !el.isConnected) {
|
|
1738
|
+
this.visibilityTargets.delete(id);
|
|
1739
|
+
this.visibilityReported.delete(id);
|
|
1740
|
+
continue;
|
|
1741
|
+
}
|
|
1742
|
+
const box = el.getBoundingClientRect();
|
|
1743
|
+
if (box.width === 0 && box.height === 0) continue;
|
|
1744
|
+
const scroller = el.parentElement?.closest<HTMLElement>('.sb-scrollview, .sb-list') ?? null;
|
|
1745
|
+
const area = (scroller ?? this.root).getBoundingClientRect();
|
|
1746
|
+
const viewport = { x: area.left, y: area.top, width: area.width, height: area.height };
|
|
1747
|
+
if (scroller) {
|
|
1748
|
+
const chrome = this.layoutResult?.chrome.get(Number(scroller.dataset[ID_ATTR]));
|
|
1749
|
+
const top = chrome?.['topInset']?.height ?? 0;
|
|
1750
|
+
const covered = chrome?.['bottomInset'];
|
|
1751
|
+
viewport.y += top;
|
|
1752
|
+
viewport.height -= top;
|
|
1753
|
+
if (covered) viewport.height = Math.max(0, viewport.height - covered.height);
|
|
1754
|
+
}
|
|
1755
|
+
const visible = isScrollVisible({ x: box.left, y: box.top, width: box.width, height: box.height }, viewport, threshold);
|
|
1756
|
+
if (this.visibilityReported.get(id) === visible) continue;
|
|
1757
|
+
this.visibilityReported.set(id, visible);
|
|
1758
|
+
this.onEvent({ type: 'scrollvisibility', id, visible });
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1683
1762
|
// -------------------------------------------------------------------------
|
|
1684
1763
|
// `.scrollPosition(id:)` reading back (src/scrollPosition.ts)
|
|
1685
1764
|
// -------------------------------------------------------------------------
|
|
@@ -1910,7 +1989,9 @@ export class Renderer {
|
|
|
1910
1989
|
if (kind === 'text') el.textContent = '';
|
|
1911
1990
|
applyProps(el, kind, props, this.assets);
|
|
1912
1991
|
this.noteScroll(id, kind, props);
|
|
1992
|
+
this.noteVisibility(id, kind, props);
|
|
1913
1993
|
if (kind === 'host') this.adoptHostContent(id, el, props as HostProps);
|
|
1994
|
+
if (kind === 'toolbar') this.rehomeToolbar(id, el, op);
|
|
1914
1995
|
const newToken = el.dataset[ANIMATION_TOKEN];
|
|
1915
1996
|
const tokenChanged = (previous?.tokenChanged ?? false) || (newToken !== undefined && newToken !== oldToken);
|
|
1916
1997
|
const record: UpdatedRecord = { el, before, tokenChanged };
|
|
@@ -1951,7 +2032,9 @@ export class Renderer {
|
|
|
1951
2032
|
// A section's view header / footer lives beside its rows' card, in the
|
|
1952
2033
|
// section element itself; the rows after a header are one DOM slot lower.
|
|
1953
2034
|
const supplement = isSectionSupplement(el);
|
|
1954
|
-
|
|
2035
|
+
// A `.largeTitle` toolbar lives in its screen's large title element, which scrolls with the content.
|
|
2036
|
+
const inLargeTitle = el.dataset['sbPlacement'] === 'largeTitle' && parent.classList.contains('sb-navscreen');
|
|
2037
|
+
const container = supplement && parent.classList.contains('sb-section') ? parent : inLargeTitle ? (largeTitleOf(parent) ?? slotOf(parent)) : slotOf(parent);
|
|
1955
2038
|
// Moves: `index` is the element's position in the parent's child list
|
|
1956
2039
|
// *after* the op is applied. Detaching first makes the index arithmetic
|
|
1957
2040
|
// identical for fresh inserts and for moves within the same parent.
|
|
@@ -1963,9 +2046,21 @@ export class Renderer {
|
|
|
1963
2046
|
const first = this.nodes.get(parentId)?.children.find((child) => child.id !== id);
|
|
1964
2047
|
if (first?.kind === 'sectionheader') slotIndex -= 1;
|
|
1965
2048
|
}
|
|
2049
|
+
// A screen's children may live in two containers (its content slot and
|
|
2050
|
+
// its large title): the next sibling in the same container is the reference.
|
|
2051
|
+
const screenReference = (): Element | null => {
|
|
2052
|
+
const siblings = (this.nodes.get(parentId)?.children ?? []).filter((child) => child.id !== id);
|
|
2053
|
+
for (const sibling of siblings.slice(index)) {
|
|
2054
|
+
const sib = this.elements.get(sibling.id);
|
|
2055
|
+
if (sib && sib.parentElement === container) return sib;
|
|
2056
|
+
}
|
|
2057
|
+
return null;
|
|
2058
|
+
};
|
|
1966
2059
|
const reference = supplement
|
|
1967
2060
|
? el.classList.contains('sb-sectionheader') ? container.firstChild : null
|
|
1968
|
-
: (
|
|
2061
|
+
: parent.classList.contains('sb-navscreen')
|
|
2062
|
+
? screenReference()
|
|
2063
|
+
: (container.children[slotIndex] ?? null);
|
|
1969
2064
|
container.insertBefore(el, reference);
|
|
1970
2065
|
if (!wasAttached) this.pass.inserted.add(el);
|
|
1971
2066
|
// Mirror the move in the layout tree.
|
|
@@ -3234,7 +3329,7 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
|
|
|
3234
3329
|
const placement = (props as ToolbarProps).placement;
|
|
3235
3330
|
// `secondary` items shown in the bar look like the trailing ones (glass capsules).
|
|
3236
3331
|
el.dataset['sbPlacement'] =
|
|
3237
|
-
placement === 'leading' || placement === 'principal' || placement === 'bottom' ? placement : 'trailing';
|
|
3332
|
+
placement === 'leading' || placement === 'principal' || placement === 'bottom' || placement === 'largeTitle' ? placement : 'trailing';
|
|
3238
3333
|
// A `ToolbarSpacer`: draws nothing.
|
|
3239
3334
|
el.toggleAttribute('data-sb-spacer', typeof (props as ToolbarProps).spacer === 'string');
|
|
3240
3335
|
break;
|
|
@@ -3864,9 +3959,17 @@ function applyNavScreen(el: HTMLElement, props: NavScreenProps): void {
|
|
|
3864
3959
|
const largeTitle = largeTitleOf(el);
|
|
3865
3960
|
// A large title that scrolls away collapses into the inline one (shown
|
|
3866
3961
|
// with `data-sb-collapsed`, src/largeTitle.ts).
|
|
3867
|
-
if (inlineTitle)
|
|
3962
|
+
if (inlineTitle) {
|
|
3963
|
+
const appearing = inlineTitle.textContent === '' && title !== '' && inlineTitle.isConnected;
|
|
3964
|
+
inlineTitle.textContent = title;
|
|
3965
|
+
// An inline title given to an empty bar (a header row that has scrolled
|
|
3966
|
+
// under it handing the bar its name) fades in as a collapsing large title does.
|
|
3967
|
+
if (appearing && !large && typeof inlineTitle.animate === 'function') {
|
|
3968
|
+
inlineTitle.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 150, easing: 'ease' });
|
|
3969
|
+
}
|
|
3970
|
+
}
|
|
3868
3971
|
if (largeTitle) {
|
|
3869
|
-
largeTitle.textContent = large ? title : '';
|
|
3972
|
+
largeTitleTextOf(largeTitle).textContent = large ? title : '';
|
|
3870
3973
|
largeTitle.hidden = !large;
|
|
3871
3974
|
}
|
|
3872
3975
|
el.setAttribute('aria-label', title);
|
|
@@ -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
|
@@ -1253,6 +1253,22 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
1253
1253
|
display: none;
|
|
1254
1254
|
}
|
|
1255
1255
|
|
|
1256
|
+
/* A `.largeTitle` toolbar (iOS 26) takes the row in place of the title's
|
|
1257
|
+
text, which the bar still shows once the row has scrolled under it. Its
|
|
1258
|
+
items are content, drawn as anywhere else and answering touches. */
|
|
1259
|
+
.sb-navbar-large-title:has(> .sb-toolbar[data-sb-placement="largeTitle"]:not([hidden])) > .sb-navbar-large-title-text {
|
|
1260
|
+
visibility: hidden;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
.sb-navbar-large-title > .sb-toolbar {
|
|
1264
|
+
font-size: var(--sb-font-size-body);
|
|
1265
|
+
font-weight: 400;
|
|
1266
|
+
line-height: normal;
|
|
1267
|
+
letter-spacing: normal;
|
|
1268
|
+
white-space: normal;
|
|
1269
|
+
pointer-events: auto;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1256
1272
|
/* The large title scrolls with the screen's root list or scroll view
|
|
1257
1273
|
(src/largeTitle.ts moves it into it); drags on it scroll that view. There
|
|
1258
1274
|
it is content, under the bar. */
|