@swiftbrowser/web 0.10.1 → 0.10.3
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 +68 -13
- package/src/renderer.ts +43 -6
- package/src/styles.css +7 -0
package/package.json
CHANGED
package/src/browserHistory.ts
CHANGED
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
* the entry and, once its animation ends, hides the live page until a new
|
|
35
35
|
* frame is committed, so a pop that finished animating by then would stay
|
|
36
36
|
* under the snapshot for Safari's 3 s watchdog, taking the touches unseen.
|
|
37
|
+
* A push is made at the renderer's `sb:precommit`, before the pushed screen
|
|
38
|
+
* is laid out (see `sync`), and this module never forces a layout.
|
|
37
39
|
*/
|
|
38
40
|
import type { Renderer } from './renderer';
|
|
39
41
|
|
|
@@ -50,10 +52,28 @@ interface HistoryState {
|
|
|
50
52
|
const stateOf = (value: unknown): HistoryState | null => (typeof value === 'object' && value !== null && (value as HistoryState).sbHistory === true ? (value as HistoryState) : null);
|
|
51
53
|
|
|
52
54
|
function shown(win: Window, el: HTMLElement): boolean {
|
|
53
|
-
if (el.
|
|
55
|
+
if (!el.isConnected) return false;
|
|
56
|
+
// Style only, never layout: a layout forced here before a push would hand
|
|
57
|
+
// Safari the pushed screen's size for the entry it covers (see `sync`).
|
|
58
|
+
for (let node: HTMLElement | null = el; node; node = node.parentElement) {
|
|
59
|
+
if (node.hidden || win.getComputedStyle(node).display === 'none') return false;
|
|
60
|
+
}
|
|
54
61
|
return win.getComputedStyle(el).visibility !== 'hidden';
|
|
55
62
|
}
|
|
56
63
|
|
|
64
|
+
/**
|
|
65
|
+
* A back the browser makes after a touch this close to a side of the page is
|
|
66
|
+
* its edge swipe (Safari's, Android's back gesture), which has animated the
|
|
67
|
+
* pop already: the renderer then does not slide the screen out again. The
|
|
68
|
+
* browser's back button sends no touch to the page, so its pop slides.
|
|
69
|
+
*/
|
|
70
|
+
const SWIPE_EDGE_PX = 44;
|
|
71
|
+
/** How long after such a touch a back still counts as the swipe it started. */
|
|
72
|
+
const SWIPE_TOUCH_MS = 2000;
|
|
73
|
+
|
|
74
|
+
/** When `sync` runs: the renderer's `sb:precommit` (the pass's ops applied, not yet laid out), its `sb:commit`, or `enable`. */
|
|
75
|
+
export type SyncStage = 'precommit' | 'commit' | 'enabling';
|
|
76
|
+
|
|
57
77
|
/** How long a back the browser made waits for the app to pop a level before what it shows is recorded as it is. */
|
|
58
78
|
const DISMISS_WAIT_MS = 1000;
|
|
59
79
|
|
|
@@ -66,7 +86,9 @@ export class BrowserHistory {
|
|
|
66
86
|
* split view clears its sidebar selection first, and the detail goes on
|
|
67
87
|
* the next), so a commit that still shows the level is not a new one.
|
|
68
88
|
*/
|
|
69
|
-
private dismissing: { target: number; from: number; until: number } | null = null;
|
|
89
|
+
private dismissing: { target: number; from: number; until: number; instant: boolean } | null = null;
|
|
90
|
+
/** When a touch last began at a side of the page (`SWIPE_EDGE_PX`): the start of the browser's edge swipe. */
|
|
91
|
+
private lastEdgeTouch = Number.NEGATIVE_INFINITY;
|
|
70
92
|
/** `popstate` events to ignore: the ones our own `history.go` causes. */
|
|
71
93
|
private ignorePops = 0;
|
|
72
94
|
/**
|
|
@@ -103,13 +125,15 @@ export class BrowserHistory {
|
|
|
103
125
|
if (!current) this.win.history.replaceState({ ...(this.win.history.state ?? {}), ...this.entry(0, this.selection()) }, '');
|
|
104
126
|
this.watchReplaceState();
|
|
105
127
|
this.renderer.interaction.nativeBackGesture = true;
|
|
128
|
+
this.root.addEventListener('sb:precommit', this.onPreCommit);
|
|
106
129
|
this.root.addEventListener('sb:commit', this.onCommit);
|
|
107
130
|
this.win.addEventListener('popstate', this.onPopState);
|
|
131
|
+
this.win.addEventListener('touchstart', this.onTouchStart, { capture: true, passive: true });
|
|
108
132
|
// Enabled before the first commit (a mount, or a remount such as React's
|
|
109
133
|
// StrictMode makes in development) the screen is not drawn yet: fewer
|
|
110
134
|
// levels than the entry records is not the app popping, so only the
|
|
111
135
|
// levels already shown are pushed here and the first commit settles the rest.
|
|
112
|
-
this.sync(
|
|
136
|
+
this.sync('enabling');
|
|
113
137
|
}
|
|
114
138
|
|
|
115
139
|
dispose(): void {
|
|
@@ -121,8 +145,10 @@ export class BrowserHistory {
|
|
|
121
145
|
this.landingUrl = null;
|
|
122
146
|
this.syncOnArrival = false;
|
|
123
147
|
this.unwatchReplaceState();
|
|
148
|
+
this.root.removeEventListener('sb:precommit', this.onPreCommit);
|
|
124
149
|
this.root.removeEventListener('sb:commit', this.onCommit);
|
|
125
150
|
this.win.removeEventListener('popstate', this.onPopState);
|
|
151
|
+
this.win.removeEventListener('touchstart', this.onTouchStart, { capture: true });
|
|
126
152
|
}
|
|
127
153
|
|
|
128
154
|
/**
|
|
@@ -220,11 +246,15 @@ export class BrowserHistory {
|
|
|
220
246
|
if (!dismissing) return;
|
|
221
247
|
dismissing.from = depth;
|
|
222
248
|
dismissing.until = performance.now() + DISMISS_WAIT_MS;
|
|
223
|
-
if (!this.dismissOne()) this.dismissing = null;
|
|
249
|
+
if (!this.dismissOne(dismissing.instant)) this.dismissing = null;
|
|
224
250
|
}
|
|
225
251
|
|
|
226
|
-
/**
|
|
227
|
-
|
|
252
|
+
/**
|
|
253
|
+
* Asks the app to dismiss one level: the top sheet when nothing is pushed
|
|
254
|
+
* inside it, else the deepest visible stack's top screen. `instant`: the
|
|
255
|
+
* browser has animated the pop (its swipe), so the renderer does not.
|
|
256
|
+
*/
|
|
257
|
+
private dismissOne(instant: boolean): boolean {
|
|
228
258
|
const sheets = this.sheets();
|
|
229
259
|
const top = sheets.at(-1) ?? this.root;
|
|
230
260
|
const stacks = this.stacks(top)
|
|
@@ -236,6 +266,7 @@ export class BrowserHistory {
|
|
|
236
266
|
const screen = this.topScreen(deepest.stack);
|
|
237
267
|
const id = Number(screen?.dataset['sbId']);
|
|
238
268
|
if (!Number.isInteger(id)) return false;
|
|
269
|
+
if (instant) this.renderer.popWithoutAnimation(id);
|
|
239
270
|
this.renderer.tap(id); // what the back button sends
|
|
240
271
|
return true;
|
|
241
272
|
}
|
|
@@ -292,15 +323,31 @@ export class BrowserHistory {
|
|
|
292
323
|
}
|
|
293
324
|
}
|
|
294
325
|
|
|
295
|
-
private readonly
|
|
326
|
+
private readonly onPreCommit = (): void => this.sync('precommit');
|
|
327
|
+
private readonly onCommit = (): void => this.sync('commit');
|
|
296
328
|
|
|
297
|
-
/**
|
|
298
|
-
|
|
329
|
+
/**
|
|
330
|
+
* New levels get entries; levels the app dismissed itself take the history
|
|
331
|
+
* back with them; a moved sidebar selection is an entry too.
|
|
332
|
+
*
|
|
333
|
+
* The entries are pushed at the `precommit`, before the renderer lays the
|
|
334
|
+
* new screen out, and nothing else happens then: Safari sizes the entry
|
|
335
|
+
* being left from the page's last layout at the push (its snapshot's
|
|
336
|
+
* render tree size, more than half of which the page must have again
|
|
337
|
+
* before the snapshot comes down after a swipe back to it), so a push
|
|
338
|
+
* after that layout counted the pushed screen into the entry it covers,
|
|
339
|
+
* and a root lighter than the screen over it stayed under the snapshot
|
|
340
|
+
* until Safari's 3 s watchdog after every swipe back. The rest waits for
|
|
341
|
+
* the `commit`, when the pass is drawn. `enabling`: nothing is drawn yet,
|
|
342
|
+
* so no level is taken as dismissed.
|
|
343
|
+
*/
|
|
344
|
+
sync(stage: SyncStage = 'commit'): void {
|
|
299
345
|
if (!this.enabled) return;
|
|
346
|
+
const dismissing = this.dismissing;
|
|
347
|
+
if (stage === 'precommit' && dismissing) return;
|
|
300
348
|
const depth = this.depth();
|
|
301
349
|
const recorded = this.recorded();
|
|
302
350
|
const selection = this.selection();
|
|
303
|
-
const dismissing = this.dismissing;
|
|
304
351
|
if (dismissing) {
|
|
305
352
|
// Dismissing for a `popstate`: the entry already names the target depth.
|
|
306
353
|
if (depth > dismissing.target && performance.now() < dismissing.until) {
|
|
@@ -320,7 +367,7 @@ export class BrowserHistory {
|
|
|
320
367
|
}
|
|
321
368
|
for (let level = recorded + 1; level <= depth; level++) this.win.history.pushState(this.entry(level, selection), '');
|
|
322
369
|
} else if (depth < recorded) {
|
|
323
|
-
if (
|
|
370
|
+
if (stage !== 'commit') return;
|
|
324
371
|
// The app popped or dismissed on its own (back button, swipe, code): leave those entries.
|
|
325
372
|
this.go(depth - recorded, depth);
|
|
326
373
|
} else if (this.movingTo === null && selection !== this.recordedSelection()) {
|
|
@@ -336,6 +383,12 @@ export class BrowserHistory {
|
|
|
336
383
|
this.restoringSelection = false;
|
|
337
384
|
}
|
|
338
385
|
|
|
386
|
+
private readonly onTouchStart = (event: TouchEvent): void => {
|
|
387
|
+
const touch = event.touches[0];
|
|
388
|
+
if (!touch) return;
|
|
389
|
+
if (touch.clientX <= SWIPE_EDGE_PX || touch.clientX >= this.win.innerWidth - SWIPE_EDGE_PX) this.lastEdgeTouch = performance.now();
|
|
390
|
+
};
|
|
391
|
+
|
|
339
392
|
private readonly onPopState = (event: PopStateEvent): void => {
|
|
340
393
|
if (this.ignorePops > 0) {
|
|
341
394
|
this.ignorePops -= 1;
|
|
@@ -350,8 +403,10 @@ export class BrowserHistory {
|
|
|
350
403
|
// A move the browser makes replaces any back still in progress.
|
|
351
404
|
this.dismissing = null;
|
|
352
405
|
if (target.sbDepth < depth) {
|
|
353
|
-
// Back: dismiss one level now, the rest as each pop lands.
|
|
354
|
-
|
|
406
|
+
// Back: dismiss one level now, the rest as each pop lands. After a
|
|
407
|
+
// touch at a side of the page it is the browser's swipe, animated already.
|
|
408
|
+
const instant = performance.now() - this.lastEdgeTouch < SWIPE_TOUCH_MS;
|
|
409
|
+
this.dismissing = { target: target.sbDepth, from: depth, until: 0, instant };
|
|
355
410
|
this.dismissFrom(depth);
|
|
356
411
|
} else if (target.sbDepth > depth) {
|
|
357
412
|
// Forward: a screen cannot be re-pushed; return to the entry that matches.
|
package/src/renderer.ts
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
* element tree as `LayoutNode`s; id 0 is the root container supplied by the
|
|
6
6
|
* host page. Every op is applied to the DOM immediately; at `commit` the
|
|
7
7
|
* layout engine (src/layout) computes a frame for every element and
|
|
8
|
-
* `layoutDom.ts` positions the elements absolutely from it (a `sb:
|
|
9
|
-
* CustomEvent is dispatched on the root
|
|
10
|
-
*
|
|
8
|
+
* `layoutDom.ts` positions the elements absolutely from it (a `sb:precommit`
|
|
9
|
+
* CustomEvent is dispatched on the root once the pass's ops are applied and
|
|
10
|
+
* before that layout, and a `sb:commit` after it, so the browser history,
|
|
11
|
+
* tests and tooling can observe pass boundaries). Animated passes are handed
|
|
12
|
+
* to the `Animator`.
|
|
11
13
|
*
|
|
12
14
|
* Most kinds are a single DOM element whose children are the element's
|
|
13
15
|
* children. Compound kinds (`navstack`, `navscreen`, `section`, `toggle`,
|
|
@@ -1062,6 +1064,10 @@ export class Renderer {
|
|
|
1062
1064
|
this.passRootRect = null;
|
|
1063
1065
|
this.preSnapshots.clear();
|
|
1064
1066
|
this.preRects.clear();
|
|
1067
|
+
// Before the layout that first holds this pass's new screens: a
|
|
1068
|
+
// history entry pushed here (browserHistory.ts) is sized by Safari
|
|
1069
|
+
// from the page's last layout, without them.
|
|
1070
|
+
this.root.dispatchEvent(new CustomEvent('sb:precommit', { detail: { commits: this.commits + 1 } }));
|
|
1065
1071
|
const changes = this.layout();
|
|
1066
1072
|
this.animator.commitNextFrame(pass, op.animation, changes);
|
|
1067
1073
|
this.commits += 1;
|
|
@@ -1304,6 +1310,24 @@ export class Renderer {
|
|
|
1304
1310
|
this.onEvent({ type: 'tap', id });
|
|
1305
1311
|
}
|
|
1306
1312
|
|
|
1313
|
+
/**
|
|
1314
|
+
* Screens the browser's own history move pops (`popWithoutAnimation`):
|
|
1315
|
+
* the browser has animated the pop already, so the renderer does not.
|
|
1316
|
+
*/
|
|
1317
|
+
private instantPops = new Set<ElementId>();
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* The next removal of screen `id` is a pop the browser has already shown
|
|
1321
|
+
* (its back swipe or button, `browserHistory.ts`): the screen goes without
|
|
1322
|
+
* the slide, and the screen it uncovers takes its place at once rather
|
|
1323
|
+
* than sliding back, so the pop does not play a second time.
|
|
1324
|
+
*/
|
|
1325
|
+
popWithoutAnimation(id: ElementId): void {
|
|
1326
|
+
this.instantPops.add(id);
|
|
1327
|
+
// A pop the app refused leaves nothing to skip for long.
|
|
1328
|
+
window.setTimeout(() => this.instantPops.delete(id), NAV_TRANSITION_MS * 5);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1307
1331
|
/** The repaint keep-alive (`keepRepainting`): its box, timer and deadline while it runs. */
|
|
1308
1332
|
private repaintKeepAlive: { el: HTMLElement; timer: number; until: number; on: boolean } | null = null;
|
|
1309
1333
|
|
|
@@ -2215,7 +2239,8 @@ export class Renderer {
|
|
|
2215
2239
|
// screen (`--sb-nav-from`), and the screen below is handed back to the
|
|
2216
2240
|
// stylesheet so it finishes its slide to 0 as the new top screen.
|
|
2217
2241
|
this.interaction.screenRemoved(el);
|
|
2218
|
-
|
|
2242
|
+
if (this.instantPops.delete(id)) settleWithoutTransition(stack);
|
|
2243
|
+
else animatePop(stack, el);
|
|
2219
2244
|
}
|
|
2220
2245
|
const isSheet = el.classList.contains('sb-sheet');
|
|
2221
2246
|
if (isSheet && parent) animateSheetOut(parent, el);
|
|
@@ -3024,8 +3049,10 @@ function afterAnimation(target: HTMLElement, expectedMs: number, done: () => voi
|
|
|
3024
3049
|
|
|
3025
3050
|
function animatePush(screen: HTMLElement): void {
|
|
3026
3051
|
screen.classList.remove('sb-navscreen--push');
|
|
3027
|
-
//
|
|
3028
|
-
|
|
3052
|
+
// Flush the style (not the layout: a layout forced here, with the screen
|
|
3053
|
+
// just inserted, would size the history entry it covers by it, see
|
|
3054
|
+
// browserHistory.ts) so re-adding the class restarts the animation.
|
|
3055
|
+
void getComputedStyle(screen).animationName;
|
|
3029
3056
|
screen.classList.add('sb-navscreen--push');
|
|
3030
3057
|
afterAnimation(screen, NAV_TRANSITION_MS, () => screen.classList.remove('sb-navscreen--push'));
|
|
3031
3058
|
}
|
|
@@ -3035,6 +3062,16 @@ function animatePush(screen: HTMLElement): void {
|
|
|
3035
3062
|
* visual clone (stripped of element ids so it is invisible to the renderer)
|
|
3036
3063
|
* slides away on top of the stack and is dropped afterwards.
|
|
3037
3064
|
*/
|
|
3065
|
+
/**
|
|
3066
|
+
* A pop the browser has already animated: the stack's screens take their new
|
|
3067
|
+
* places without a transition (styles.css, `data-sb-instant`) for the frames
|
|
3068
|
+
* the change lands in.
|
|
3069
|
+
*/
|
|
3070
|
+
function settleWithoutTransition(stack: HTMLElement): void {
|
|
3071
|
+
stack.dataset['sbInstant'] = '';
|
|
3072
|
+
requestAnimationFrame(() => requestAnimationFrame(() => delete stack.dataset['sbInstant']));
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3038
3075
|
function animatePop(stack: HTMLElement, screen: HTMLElement): void {
|
|
3039
3076
|
const clone = ghostClone(screen);
|
|
3040
3077
|
clone.removeAttribute('inert');
|
package/src/styles.css
CHANGED
|
@@ -1065,6 +1065,13 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
1065
1065
|
animation: sb-nav-push 0.4s var(--sb-nav-easing) both;
|
|
1066
1066
|
}
|
|
1067
1067
|
|
|
1068
|
+
/* A pop the browser has animated already (its back swipe, renderer.ts
|
|
1069
|
+
popWithoutAnimation): the uncovered screen takes its place at once. */
|
|
1070
|
+
.sb-navstack[data-sb-instant] > .sb-slot > .sb-navscreen,
|
|
1071
|
+
.sb-navstack[data-sb-instant] > .sb-slot > .sb-navscreen::after {
|
|
1072
|
+
transition: none !important;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1068
1075
|
.sb-navstack > .sb-navscreen--pop {
|
|
1069
1076
|
z-index: 2;
|
|
1070
1077
|
pointer-events: none;
|