@stacknav/core 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -60
- package/dist/animate.d.ts +20 -2
- package/dist/animate.d.ts.map +1 -1
- package/dist/animate.js +76 -13
- package/dist/animate.js.map +1 -1
- package/dist/css-vars.d.ts +3 -3
- package/dist/css-vars.d.ts.map +1 -1
- package/dist/css-vars.js +47 -4
- package/dist/css-vars.js.map +1 -1
- package/dist/history-adapter.d.ts +0 -1
- package/dist/history-adapter.d.ts.map +1 -1
- package/dist/history-adapter.js +1 -5
- package/dist/history-adapter.js.map +1 -1
- package/dist/index.d.ts +27 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +38 -15
- package/dist/index.js.map +1 -1
- package/dist/native-transition.d.ts +93 -0
- package/dist/native-transition.d.ts.map +1 -0
- package/dist/{ios-transition.js → native-transition.js} +48 -29
- package/dist/native-transition.js.map +1 -0
- package/dist/navigation-stack.d.ts +3 -2
- package/dist/navigation-stack.d.ts.map +1 -1
- package/dist/navigation-stack.js +45 -10
- package/dist/navigation-stack.js.map +1 -1
- package/dist/platform.d.ts +6 -0
- package/dist/platform.d.ts.map +1 -0
- package/dist/platform.js +22 -0
- package/dist/platform.js.map +1 -0
- package/dist/stacknav.css +10 -13
- package/dist/styles.d.ts +10 -11
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +14 -16
- package/dist/styles.js.map +1 -1
- package/dist/swipe-back.d.ts +4 -0
- package/dist/swipe-back.d.ts.map +1 -0
- package/dist/swipe-back.js +31 -0
- package/dist/swipe-back.js.map +1 -0
- package/package.json +3 -2
- package/src/animate.ts +74 -15
- package/src/css-vars.ts +43 -4
- package/src/history-adapter.ts +1 -5
- package/src/index.ts +49 -24
- package/src/{ios-transition.ts → native-transition.ts} +72 -39
- package/src/navigation-stack.ts +33 -10
- package/src/platform.ts +23 -0
- package/src/styles.ts +14 -16
- package/src/swipe-back.ts +35 -0
- package/dist/edge-pan-gesture.d.ts +0 -36
- package/dist/edge-pan-gesture.d.ts.map +0 -1
- package/dist/edge-pan-gesture.js +0 -155
- package/dist/edge-pan-gesture.js.map +0 -1
- package/dist/ios-transition.d.ts +0 -61
- package/dist/ios-transition.d.ts.map +0 -1
- package/dist/ios-transition.js.map +0 -1
- package/src/edge-pan-gesture.ts +0 -190
package/src/css-vars.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// NaN, so an unreadable value falls through to its JS option instead of
|
|
8
8
|
// producing an invalid transform.
|
|
9
9
|
|
|
10
|
-
import { cubicBezier, easings, type Easing } from './animate.ts';
|
|
10
|
+
import { cubicBezier, easings, linearEasing, type Easing } from './animate.ts';
|
|
11
11
|
|
|
12
12
|
/** Looks a custom property up on an element; `undefined` when it is not set. */
|
|
13
13
|
export type CSSVarReader = (name: string) => string | undefined;
|
|
@@ -69,18 +69,57 @@ const easingKeywordsOf = (): Record<string, Easing> =>
|
|
|
69
69
|
'ease-in-out': cubicBezier(0.42, 0, 0.58, 1),
|
|
70
70
|
ios: easings.ios,
|
|
71
71
|
'ios-settle': easings.easeOut,
|
|
72
|
+
android: easings.android,
|
|
73
|
+
'android-settle': easings.androidSettle,
|
|
72
74
|
}));
|
|
73
75
|
|
|
74
76
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
77
|
+
* `linear(y [x%] [x%], …)` as CSS defines it: a stop without a position sits
|
|
78
|
+
* evenly between its positioned neighbours, the first and last default to 0%
|
|
79
|
+
* and 100%, positions never go backwards, and two positions make two stops.
|
|
80
|
+
*/
|
|
81
|
+
function parseLinear(body: string): Easing | undefined {
|
|
82
|
+
const xs: Array<number | undefined> = [], ys: number[] = [];
|
|
83
|
+
for (const stop of body.split(',')) {
|
|
84
|
+
const [y, ...positions] = stop.trim().split(/\s+/);
|
|
85
|
+
const yn = parseNumber(y);
|
|
86
|
+
if (yn === undefined || positions.length > 2) return undefined;
|
|
87
|
+
for (const pos of positions.length ? positions : [undefined]) {
|
|
88
|
+
const xn = pos === undefined ? undefined : pos.endsWith('%') ? parseNumber(pos.slice(0, -1)) : NaN;
|
|
89
|
+
if (Number.isNaN(xn)) return undefined;
|
|
90
|
+
xs.push(xn === undefined ? undefined : xn / 100);
|
|
91
|
+
ys.push(yn);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (ys.length < 2) return undefined;
|
|
95
|
+
xs[0] ??= 0;
|
|
96
|
+
xs[xs.length - 1] ??= 1;
|
|
97
|
+
for (let i = 1; i < xs.length; i++) {
|
|
98
|
+
if (xs[i] !== undefined) continue;
|
|
99
|
+
let j = i;
|
|
100
|
+
while (xs[j] === undefined) j++;
|
|
101
|
+
for (let k = i; k < j; k++) xs[k] = xs[i - 1]! + ((xs[j]! - xs[i - 1]!) * (k - i + 1)) / (j - i + 1);
|
|
102
|
+
}
|
|
103
|
+
const x = xs as number[];
|
|
104
|
+
for (let i = 1; i < x.length; i++) x[i] = Math.max(x[i], x[i - 1]);
|
|
105
|
+
return linearEasing(
|
|
106
|
+
x.map((xi, i) => [xi, ys[i]] as const),
|
|
107
|
+
`linear(${body.trim()})`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A timing keyword, `cubic-bezier(x1, y1, x2, y2)` or `linear(…)`. The bezier's
|
|
113
|
+
* x coordinates must be within [0, 1], as CSS requires. Outside that range the
|
|
114
|
+
* curve is not a function of time and the solver would not converge.
|
|
78
115
|
*/
|
|
79
116
|
export function parseEasing(v: string | undefined): Easing | undefined {
|
|
80
117
|
if (v === undefined) return undefined;
|
|
81
118
|
const s = v.trim().toLowerCase();
|
|
82
119
|
const keyword = easingKeywordsOf()[s];
|
|
83
120
|
if (keyword) return keyword;
|
|
121
|
+
const l = /^linear\(([^)]*)\)$/.exec(s);
|
|
122
|
+
if (l) return parseLinear(l[1]);
|
|
84
123
|
const m = /^cubic-bezier\(([^)]*)\)$/.exec(s);
|
|
85
124
|
if (!m) return undefined;
|
|
86
125
|
const n = m[1].split(',').map((part) => parseNumber(part));
|
package/src/history-adapter.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { NavigationStack } from './navigation-stack.ts';
|
|
2
|
+
import { isIOSBrowser } from './platform.ts';
|
|
2
3
|
|
|
3
4
|
export interface BrowserHistoryOptions {
|
|
4
5
|
/** the `history.state` property that carries the depth */
|
|
@@ -45,8 +46,3 @@ export function attachBrowserHistory(stack: NavigationStack, { key = 'snDepth',
|
|
|
45
46
|
window.removeEventListener('popstate', onPopState);
|
|
46
47
|
};
|
|
47
48
|
}
|
|
48
|
-
|
|
49
|
-
export function isIOSBrowser(): boolean {
|
|
50
|
-
if (typeof navigator === 'undefined') return false;
|
|
51
|
-
return /iP(hone|ad|od)/.test(navigator.platform) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
|
52
|
-
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export type { SwipeBackMode } from './swipe-back.ts';
|
|
1
2
|
export { NavigationStack } from './navigation-stack.ts';
|
|
2
3
|
export type {
|
|
3
4
|
StackEntry,
|
|
@@ -16,15 +17,15 @@ export type {
|
|
|
16
17
|
TransitionEvent,
|
|
17
18
|
ProgressEvent,
|
|
18
19
|
} from './navigation-stack.ts';
|
|
19
|
-
export {
|
|
20
|
-
export type {
|
|
20
|
+
export { createNativeTransition, nativeTransitionPreset, NATIVE_TRANSITION_CSS_VARS } from './native-transition.ts';
|
|
21
|
+
export type { NativeTransition, NativeTransitionOptions, NativeTransitionPreset } from './native-transition.ts';
|
|
21
22
|
export { cssVars, parseTime, parseNumber, parseRatio, parseEasing } from './css-vars.ts';
|
|
22
23
|
export type { CSSVarReader } from './css-vars.ts';
|
|
23
|
-
export {
|
|
24
|
-
export type { EdgePanGesture, EdgePanGestureOptions } from './edge-pan-gesture.ts';
|
|
25
|
-
export { attachBrowserHistory, isIOSBrowser } from './history-adapter.ts';
|
|
24
|
+
export { attachBrowserHistory } from './history-adapter.ts';
|
|
26
25
|
export type { BrowserHistoryOptions } from './history-adapter.ts';
|
|
27
|
-
export {
|
|
26
|
+
export { detectPlatform, isIOSBrowser, isAndroidBrowser } from './platform.ts';
|
|
27
|
+
export type { Platform } from './platform.ts';
|
|
28
|
+
export { cubicBezier, linearEasing, easings, cssEasing, cssDuration, tween, commitStyles, animationsFinished, prefersReducedMotion, matchesMedia, isTouchPrimary } from './animate.ts';
|
|
28
29
|
export type { Easing, TweenOptions, CancellableTween } from './animate.ts';
|
|
29
30
|
export {
|
|
30
31
|
resolveDirection,
|
|
@@ -52,34 +53,58 @@ export type {
|
|
|
52
53
|
export { STACKNAV_CSS, STACKNAV_STYLE_ID, injectStyles } from './styles.ts';
|
|
53
54
|
|
|
54
55
|
import { NavigationStack } from './navigation-stack.ts';
|
|
55
|
-
import {
|
|
56
|
-
import { createEdgePanGesture, type EdgePanGesture, type EdgePanGestureOptions } from './edge-pan-gesture.ts';
|
|
56
|
+
import { createNativeTransition, type NativeTransition, type NativeTransitionOptions } from './native-transition.ts';
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
import { suppressBrowserSwipe, type SwipeBackMode } from './swipe-back.ts';
|
|
59
|
+
|
|
60
|
+
export interface NativeStackOptions {
|
|
59
61
|
container: HTMLElement;
|
|
60
|
-
transition?: Partial<
|
|
61
|
-
|
|
62
|
+
transition?: Partial<NativeTransitionOptions>;
|
|
63
|
+
/** Default browser. Disabled requests document-wide browser swipe suppression. */
|
|
64
|
+
swipeBack?: SwipeBackMode;
|
|
62
65
|
}
|
|
63
66
|
|
|
64
|
-
export interface
|
|
65
|
-
transition:
|
|
66
|
-
|
|
67
|
+
export interface NativeStack extends NavigationStack {
|
|
68
|
+
transition: NativeTransition;
|
|
69
|
+
readonly swipeBack: SwipeBackMode;
|
|
70
|
+
/** Changes the policy without replacing pages or changing browser history. */
|
|
71
|
+
setSwipeBack(mode: SwipeBackMode): void;
|
|
67
72
|
}
|
|
68
73
|
|
|
69
74
|
/**
|
|
70
|
-
* Wires the
|
|
71
|
-
* transition
|
|
72
|
-
* `
|
|
75
|
+
* Wires the two pieces together in one call: a stack in `container` and the
|
|
76
|
+
* platform's native transition. The browser keeps the back gesture unless
|
|
77
|
+
* `disabled` asks for suppression; destroying releases that request.
|
|
78
|
+
*
|
|
79
|
+
* There is no gesture recognizer here on purpose. In a browser tab the browser
|
|
80
|
+
* already owns the edge and will not give it up, so a second recognizer reads
|
|
81
|
+
* as two backs at once. Apps that own the edge -- an installed PWA, a native
|
|
82
|
+
* webview -- can drive `beginInteractivePop()` from their own pointer handling.
|
|
73
83
|
*/
|
|
74
|
-
export function
|
|
75
|
-
const t =
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
84
|
+
export function createNativeStack({ container, transition = {}, swipeBack = 'browser' }: NativeStackOptions): NativeStack {
|
|
85
|
+
const t = createNativeTransition(transition);
|
|
86
|
+
const stack = new NavigationStack({ container, transition: t }) as NativeStack;
|
|
87
|
+
let mode: SwipeBackMode | undefined;
|
|
88
|
+
let release: (() => void) | undefined;
|
|
89
|
+
let destroyed = false;
|
|
90
|
+
Object.defineProperty(stack, 'swipeBack', { get: () => mode });
|
|
91
|
+
stack.setSwipeBack = (next) => {
|
|
92
|
+
if (destroyed || next === mode) return;
|
|
93
|
+
if (next !== 'browser' && next !== 'disabled') throw new TypeError('Invalid swipeBack mode');
|
|
94
|
+
if (next === 'browser') {
|
|
95
|
+
release?.();
|
|
96
|
+
release = undefined;
|
|
97
|
+
} else {
|
|
98
|
+
release ??= suppressBrowserSwipe(container);
|
|
99
|
+
}
|
|
100
|
+
mode = next;
|
|
101
|
+
};
|
|
102
|
+
stack.setSwipeBack(swipeBack);
|
|
80
103
|
const destroy = stack.destroy.bind(stack);
|
|
81
104
|
stack.destroy = () => {
|
|
82
|
-
|
|
105
|
+
destroyed = true;
|
|
106
|
+
release?.();
|
|
107
|
+
release = undefined;
|
|
83
108
|
destroy();
|
|
84
109
|
};
|
|
85
110
|
return stack;
|
|
@@ -1,20 +1,33 @@
|
|
|
1
1
|
import { easings, prefersReducedMotion, type Easing } from './animate.ts';
|
|
2
2
|
import { cssVars, parseEasing, parseNumber, parseRatio, parseTime } from './css-vars.ts';
|
|
3
3
|
import type { SettleInput, StackEntry, Transition } from './navigation-stack.ts';
|
|
4
|
+
import { detectPlatform, type Platform } from './platform.ts';
|
|
4
5
|
|
|
5
|
-
export interface
|
|
6
|
+
export interface NativeTransitionOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Whose push/pop to imitate. `auto` (the default) asks the browser and
|
|
9
|
+
* falls back to `ios`. It picks the defaults for everything below; an
|
|
10
|
+
* option given explicitly wins over the platform's, as a CSS variable wins
|
|
11
|
+
* over both.
|
|
12
|
+
*/
|
|
13
|
+
platform: Platform | 'auto';
|
|
6
14
|
/** ms, programmatic push/pop */
|
|
7
15
|
duration: number;
|
|
8
16
|
/**
|
|
9
17
|
* the curve a programmatic push/pop runs on. CSS runs it, so it has to be
|
|
10
|
-
* one CSS can spell: everything `cubicBezier()
|
|
11
|
-
* carries a `css` property. A bare `(t) => number` of
|
|
12
|
-
* the pages would run `linear` while `progress`
|
|
13
|
-
* a `css` property, or write the whole
|
|
18
|
+
* one CSS can spell: everything `cubicBezier()`, `bezierPath()` and
|
|
19
|
+
* `parseEasing()` build carries a `css` property. A bare `(t) => number` of
|
|
20
|
+
* your own has none, so the pages would run `linear` while `progress`
|
|
21
|
+
* reported your curve; give it a `css` property, or write the whole
|
|
22
|
+
* transition yourself.
|
|
14
23
|
*/
|
|
15
24
|
ease: Easing;
|
|
25
|
+
/** fraction of the width the upper page travels (1 = from off-screen; Android slides a short way and fades) */
|
|
26
|
+
travel: number;
|
|
16
27
|
/** fraction of the width the lower page travels */
|
|
17
28
|
parallax: number;
|
|
29
|
+
/** opacity of the upper page when fully closed (1 = no fade) */
|
|
30
|
+
fade: number;
|
|
18
31
|
dimColor: string;
|
|
19
32
|
/** lower-page overlay opacity at p = 1 (≈0.35 suits dark UIs) */
|
|
20
33
|
dimMax: number;
|
|
@@ -31,11 +44,33 @@ export interface IOSTransitionOptions {
|
|
|
31
44
|
timeScale: number;
|
|
32
45
|
}
|
|
33
46
|
|
|
47
|
+
/** Every option except the platform, which is decided once and has no CSS variable. */
|
|
48
|
+
export type NativeTransitionPreset = Readonly<Omit<NativeTransitionOptions, 'platform'>>;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Each platform's own push/pop, as its system animates it.
|
|
52
|
+
*
|
|
53
|
+
* - `ios`: UIKit's navigation push. The upper page slides the full width with
|
|
54
|
+
* a shadow on its leading edge, the lower page parallaxes 30% and dims.
|
|
55
|
+
* - `android`: the framework's activity open/close since Android 13
|
|
56
|
+
* (`activity_open_enter.xml` and friends): both pages slide 96 dp, about a
|
|
57
|
+
* quarter of a phone, over 450 ms on `fast_out_extra_slow_in`, and the
|
|
58
|
+
* upper page fades through the first part of it. No shadow, no dim.
|
|
59
|
+
*/
|
|
60
|
+
export function nativeTransitionPreset(platform: Platform): NativeTransitionPreset {
|
|
61
|
+
const shared = { settleMin: 120, settleMax: 400, settleVelocityFloor: 900, timeScale: 1, dimColor: '#000' };
|
|
62
|
+
return platform === 'android'
|
|
63
|
+
? { ...shared, duration: 450, ease: easings.android, travel: 0.25, parallax: 0.25, fade: 0, dimMax: 0, shadow: 'none', settleEase: easings.androidSettle }
|
|
64
|
+
: { ...shared, duration: 500, ease: easings.ios, travel: 1, parallax: 0.3, fade: 1, dimMax: 0.1, shadow: '-3px 0 14px rgba(0,0,0,0.16)', settleEase: easings.easeOut };
|
|
65
|
+
}
|
|
66
|
+
|
|
34
67
|
/** The CSS custom property behind each option. */
|
|
35
|
-
export const
|
|
68
|
+
export const NATIVE_TRANSITION_CSS_VARS: Readonly<Record<keyof NativeTransitionPreset, string>> = /*#__PURE__*/ Object.freeze({
|
|
36
69
|
duration: '--sn-duration',
|
|
37
70
|
ease: '--sn-easing',
|
|
71
|
+
travel: '--sn-travel',
|
|
38
72
|
parallax: '--sn-parallax',
|
|
73
|
+
fade: '--sn-fade',
|
|
39
74
|
dimColor: '--sn-dim-color',
|
|
40
75
|
dimMax: '--sn-dim-max',
|
|
41
76
|
shadow: '--sn-shadow',
|
|
@@ -46,11 +81,11 @@ export const IOS_TRANSITION_CSS_VARS: Readonly<Record<keyof IOSTransitionOptions
|
|
|
46
81
|
timeScale: '--sn-time-scale',
|
|
47
82
|
});
|
|
48
83
|
|
|
49
|
-
export interface
|
|
50
|
-
/** The JS options: the
|
|
51
|
-
readonly options:
|
|
84
|
+
export interface NativeTransition extends Transition {
|
|
85
|
+
/** The JS options: the platform's preset with the caller's merged in. `platform` is the one chosen. Mutable at runtime. */
|
|
86
|
+
readonly options: NativeTransitionOptions & { platform: Platform };
|
|
52
87
|
/** The values currently in force: `options` with the CSS variables applied over them. */
|
|
53
|
-
readonly resolved: Readonly<
|
|
88
|
+
readonly resolved: Readonly<NativeTransitionOptions & { platform: Platform }>;
|
|
54
89
|
/**
|
|
55
90
|
* Re-reads the CSS variables, from `el` or from the container of the last
|
|
56
91
|
* transition. Called at the start of every transition. Call it directly
|
|
@@ -60,47 +95,40 @@ export interface IOSTransition extends Transition {
|
|
|
60
95
|
}
|
|
61
96
|
|
|
62
97
|
/**
|
|
63
|
-
* The
|
|
64
|
-
* edge
|
|
65
|
-
*
|
|
98
|
+
* The platform's navigation transition: the upper page slides in from the
|
|
99
|
+
* trailing edge while the lower page parallaxes toward the leading edge. On
|
|
100
|
+
* iOS the upper page travels the full width under a shadow and the lower page
|
|
101
|
+
* dims; on Android both travel a short way and the upper page fades. Every
|
|
102
|
+
* value is a function of one number, p.
|
|
66
103
|
*
|
|
67
104
|
* Every option is also a CSS custom property on the container (see
|
|
68
|
-
* `
|
|
69
|
-
* is set wins over the JS option, so a stylesheet can slow the animation
|
|
70
|
-
* or restyle it per theme without the app rebuilding the transition.
|
|
105
|
+
* `NATIVE_TRANSITION_CSS_VARS`), read when a transition starts. A variable
|
|
106
|
+
* that is set wins over the JS option, so a stylesheet can slow the animation
|
|
107
|
+
* down or restyle it per theme without the app rebuilding the transition.
|
|
71
108
|
*
|
|
72
109
|
* p is only ever written at the ends of a phase. The stack puts that phase's
|
|
73
110
|
* duration and curve in `--sn-t` / `--sn-e` and CSS interpolates between the
|
|
74
111
|
* two writes, so the animation costs a handful of style writes rather than one
|
|
75
112
|
* per page per frame, and runs on the compositor rather than the main thread.
|
|
76
113
|
*/
|
|
77
|
-
export function
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
ease: easings.ios,
|
|
81
|
-
parallax: 0.3,
|
|
82
|
-
dimColor: '#000',
|
|
83
|
-
dimMax: 0.1,
|
|
84
|
-
shadow: '-3px 0 14px rgba(0,0,0,0.16)',
|
|
85
|
-
settleMin: 120,
|
|
86
|
-
settleMax: 400,
|
|
87
|
-
settleEase: easings.easeOut,
|
|
88
|
-
settleVelocityFloor: 900,
|
|
89
|
-
timeScale: 1,
|
|
90
|
-
...options,
|
|
91
|
-
};
|
|
114
|
+
export function createNativeTransition(options: Partial<NativeTransitionOptions> = {}): NativeTransition {
|
|
115
|
+
const platform = !options.platform || options.platform === 'auto' ? detectPlatform() : options.platform;
|
|
116
|
+
const o: NativeTransitionOptions & { platform: Platform } = { ...nativeTransitionPreset(platform), ...options, platform };
|
|
92
117
|
|
|
93
118
|
let root: Element | null = null;
|
|
94
|
-
let r:
|
|
119
|
+
let r: NativeTransitionOptions & { platform: Platform } = { ...o };
|
|
95
120
|
|
|
96
121
|
const refresh = (el?: Element | null): void => {
|
|
97
122
|
if (el !== undefined) root = el;
|
|
98
123
|
const read = cssVars(root);
|
|
99
|
-
const v =
|
|
124
|
+
const v = NATIVE_TRANSITION_CSS_VARS;
|
|
100
125
|
r = {
|
|
126
|
+
platform: o.platform,
|
|
101
127
|
duration: parseTime(read(v.duration)) ?? o.duration,
|
|
102
128
|
ease: parseEasing(read(v.ease)) ?? o.ease,
|
|
129
|
+
travel: parseRatio(read(v.travel)) ?? o.travel,
|
|
103
130
|
parallax: parseRatio(read(v.parallax)) ?? o.parallax,
|
|
131
|
+
fade: parseRatio(read(v.fade)) ?? o.fade,
|
|
104
132
|
dimColor: read(v.dimColor) ?? o.dimColor,
|
|
105
133
|
dimMax: parseRatio(read(v.dimMax)) ?? o.dimMax,
|
|
106
134
|
shadow: read(v.shadow) ?? o.shadow,
|
|
@@ -110,10 +138,11 @@ export function createIOSTransition(options: Partial<IOSTransitionOptions> = {})
|
|
|
110
138
|
settleVelocityFloor: parseNumber(read(v.settleVelocityFloor)) ?? o.settleVelocityFloor,
|
|
111
139
|
timeScale: parseNumber(read(v.timeScale)) ?? o.timeScale,
|
|
112
140
|
};
|
|
141
|
+
if (dim?.parentElement) dim.style.setProperty('--sn-dim-fallback', o.dimColor);
|
|
113
142
|
};
|
|
114
143
|
|
|
115
144
|
// One overlay, moved to whichever page is underneath. Everything about it
|
|
116
|
-
// except its
|
|
145
|
+
// except its opacity is a rule in the stylesheet; JS supplies the fallback colour.
|
|
117
146
|
let dim: HTMLElement | null = null;
|
|
118
147
|
const dimOf = (lower: StackEntry): HTMLElement => {
|
|
119
148
|
if (!dim) {
|
|
@@ -121,7 +150,7 @@ export function createIOSTransition(options: Partial<IOSTransitionOptions> = {})
|
|
|
121
150
|
dim.className = 'sn-dim';
|
|
122
151
|
dim.setAttribute('aria-hidden', 'true');
|
|
123
152
|
}
|
|
124
|
-
dim.style.
|
|
153
|
+
dim.style.setProperty('--sn-dim-fallback', o.dimColor);
|
|
125
154
|
if (dim.parentElement !== lower.el) lower.el.append(dim);
|
|
126
155
|
return dim;
|
|
127
156
|
};
|
|
@@ -133,7 +162,7 @@ export function createIOSTransition(options: Partial<IOSTransitionOptions> = {})
|
|
|
133
162
|
|
|
134
163
|
return {
|
|
135
164
|
options: o,
|
|
136
|
-
get resolved(): Readonly<
|
|
165
|
+
get resolved(): Readonly<NativeTransitionOptions & { platform: Platform }> {
|
|
137
166
|
return r;
|
|
138
167
|
},
|
|
139
168
|
refresh,
|
|
@@ -153,7 +182,7 @@ export function createIOSTransition(options: Partial<IOSTransitionOptions> = {})
|
|
|
153
182
|
|
|
154
183
|
begin(lower, upper) {
|
|
155
184
|
refresh(upper.el.parentElement);
|
|
156
|
-
upper.el.style.boxShadow =
|
|
185
|
+
upper.el.style.boxShadow = `var(--sn-shadow, ${o.shadow})`;
|
|
157
186
|
if (lower) dimOf(lower);
|
|
158
187
|
},
|
|
159
188
|
/**
|
|
@@ -163,15 +192,19 @@ export function createIOSTransition(options: Partial<IOSTransitionOptions> = {})
|
|
|
163
192
|
* page, so a resize mid-transition stays honest.
|
|
164
193
|
*/
|
|
165
194
|
apply(lower, upper, p) {
|
|
166
|
-
upper.el.style.transform = shift(1 - p);
|
|
195
|
+
upper.el.style.transform = shift((1 - p) * r.travel);
|
|
196
|
+
// Only a look that fades writes opacity: a property that is not written
|
|
197
|
+
// starts no transition, and a page's own opacity is left alone.
|
|
198
|
+
if (r.fade < 1) upper.el.style.opacity = String(round(r.fade + (1 - r.fade) * p));
|
|
167
199
|
if (lower) {
|
|
168
200
|
lower.el.style.transform = shift(-p * r.parallax);
|
|
169
|
-
|
|
201
|
+
dim!.style.opacity = String(p * r.dimMax);
|
|
170
202
|
}
|
|
171
203
|
},
|
|
172
204
|
end(lower, upper) {
|
|
173
205
|
upper.el.style.boxShadow = '';
|
|
174
206
|
upper.el.style.transform = '';
|
|
207
|
+
upper.el.style.opacity = '';
|
|
175
208
|
if (lower) lower.el.style.transform = '';
|
|
176
209
|
dim?.remove();
|
|
177
210
|
},
|
package/src/navigation-stack.ts
CHANGED
|
@@ -92,7 +92,9 @@ export class NavigationStack {
|
|
|
92
92
|
readonly pageClass: string;
|
|
93
93
|
entries: StackEntry[] = [];
|
|
94
94
|
busy = false;
|
|
95
|
-
private
|
|
95
|
+
private _destroyed = false;
|
|
96
|
+
private _activeTransition: { lower: StackEntry | null; upper: StackEntry } | null = null;
|
|
97
|
+
private _queue: Array<{ run: () => void; cancel: () => void }> = [];
|
|
96
98
|
private _listeners = new Map<string, Set<Listener<unknown>>>();
|
|
97
99
|
|
|
98
100
|
constructor({ container, transition, pageClass = 'sn-page' }: NavigationStackOptions) {
|
|
@@ -130,6 +132,7 @@ export class NavigationStack {
|
|
|
130
132
|
};
|
|
131
133
|
}
|
|
132
134
|
private _emit<K extends keyof StackEvents>(event: K, detail: StackEvents[K]): void {
|
|
135
|
+
if (this._destroyed) return;
|
|
133
136
|
const set = this._listeners.get(event);
|
|
134
137
|
if (set) set.forEach((fn) => fn(detail));
|
|
135
138
|
}
|
|
@@ -251,13 +254,16 @@ export class NavigationStack {
|
|
|
251
254
|
this._begin(lower, upper, 'interactive');
|
|
252
255
|
return {
|
|
253
256
|
update: (v) => {
|
|
257
|
+
if (this._destroyed) return;
|
|
254
258
|
p = Math.min(1, Math.max(0, v));
|
|
255
259
|
this._apply(lower, upper, p);
|
|
256
260
|
},
|
|
257
261
|
finish: async ({ complete, velocity = 0 }) => {
|
|
262
|
+
if (this._destroyed) return;
|
|
258
263
|
const remainingPx = (complete ? p : 1 - p) * this.width();
|
|
259
264
|
const { duration, ease } = this.transition.settle({ remainingPx, velocity });
|
|
260
265
|
await this._animate(lower, upper, p, complete ? 0 : 1, duration, ease);
|
|
266
|
+
if (this._destroyed) return;
|
|
261
267
|
this._end(lower, upper, 'interactive');
|
|
262
268
|
if (complete) {
|
|
263
269
|
this.entries.pop();
|
|
@@ -271,7 +277,20 @@ export class NavigationStack {
|
|
|
271
277
|
};
|
|
272
278
|
}
|
|
273
279
|
|
|
280
|
+
/** Terminal: queued and subsequent navigation reject with AbortError. */
|
|
274
281
|
destroy(): void {
|
|
282
|
+
if (this._destroyed) return;
|
|
283
|
+
this._destroyed = true;
|
|
284
|
+
this._listeners.clear();
|
|
285
|
+
for (const task of this._queue.splice(0)) task.cancel();
|
|
286
|
+
this.busy = false;
|
|
287
|
+
const active = this._activeTransition;
|
|
288
|
+
this._activeTransition = null;
|
|
289
|
+
if (active) {
|
|
290
|
+
this.transition.end?.(active.lower, active.upper);
|
|
291
|
+
// During a pop the outgoing page has already left entries.
|
|
292
|
+
if (!this.entries.includes(active.upper)) this._unmount(active.upper);
|
|
293
|
+
}
|
|
275
294
|
while (this.entries.length) this._unmount(this.entries.pop()!);
|
|
276
295
|
this.container.classList.remove('sn-container', 'sn-busy');
|
|
277
296
|
this.container.style.removeProperty('--sn-t');
|
|
@@ -280,6 +299,7 @@ export class NavigationStack {
|
|
|
280
299
|
|
|
281
300
|
// -------------------------------------------------------------- internals
|
|
282
301
|
private _setBusy(v: boolean): void {
|
|
302
|
+
if (this._destroyed) return;
|
|
283
303
|
this.busy = v;
|
|
284
304
|
this.container.classList.toggle('sn-busy', v);
|
|
285
305
|
}
|
|
@@ -287,6 +307,8 @@ export class NavigationStack {
|
|
|
287
307
|
/** Serializes operations: while a transition runs, later calls wait their turn. */
|
|
288
308
|
private _run<R>(fn: () => Promise<R>): Promise<R> {
|
|
289
309
|
return new Promise<R>((resolve, reject) => {
|
|
310
|
+
const cancel = () => reject(new DOMException('NavigationStack has been destroyed', 'AbortError'));
|
|
311
|
+
if (this._destroyed) return cancel();
|
|
290
312
|
const task = async () => {
|
|
291
313
|
this._setBusy(true);
|
|
292
314
|
try {
|
|
@@ -298,12 +320,12 @@ export class NavigationStack {
|
|
|
298
320
|
this._drain();
|
|
299
321
|
}
|
|
300
322
|
};
|
|
301
|
-
if (this.busy) this._queue.push(task);
|
|
323
|
+
if (this.busy) this._queue.push({ run: task, cancel });
|
|
302
324
|
else task();
|
|
303
325
|
});
|
|
304
326
|
}
|
|
305
327
|
private _drain(): void {
|
|
306
|
-
if (!this.busy && this._queue.length) this._queue.shift()
|
|
328
|
+
if (!this._destroyed && !this.busy && this._queue.length) this._queue.shift()!.run();
|
|
307
329
|
}
|
|
308
330
|
|
|
309
331
|
private async _popRevealing(depth: number, animated: boolean, source: NavigationSource): Promise<StackEntry> {
|
|
@@ -312,6 +334,7 @@ export class NavigationStack {
|
|
|
312
334
|
while (this.entries.length > depth) removed.push(this._unmount(this.entries.pop()!)); // intermediate pages: removed without animation
|
|
313
335
|
const lower = this.top;
|
|
314
336
|
await this._transition(lower, upper, 1, 0, animated, 'pop');
|
|
337
|
+
if (this._destroyed) return upper;
|
|
315
338
|
removed.push(this._unmount(upper));
|
|
316
339
|
this._settle();
|
|
317
340
|
this._emit('pop', { entry: upper, removed, entries: this.entries.slice(), source });
|
|
@@ -344,26 +367,26 @@ export class NavigationStack {
|
|
|
344
367
|
* `prefers-reduced-motion` reduces every phase to.
|
|
345
368
|
*/
|
|
346
369
|
private _timing(duration: number, ease?: Easing): void {
|
|
370
|
+
if (this._destroyed) return;
|
|
347
371
|
this.container.style.setProperty('--sn-t', cssDuration(duration));
|
|
348
372
|
this.container.style.setProperty('--sn-e', cssEasing(ease));
|
|
349
373
|
}
|
|
350
374
|
|
|
351
375
|
private _begin(lower: StackEntry | null, upper: StackEntry, kind: TransitionKind): void {
|
|
376
|
+
this._activeTransition = { lower, upper };
|
|
352
377
|
this._timing(0);
|
|
353
378
|
lower?.el.classList.add('sn-page-visible', 'sn-page-lower');
|
|
354
379
|
upper.el.classList.add('sn-page-visible', 'sn-page-upper');
|
|
355
380
|
this.transition.begin?.(lower, upper);
|
|
356
381
|
this._emit('transitionstart', { lower, upper, kind });
|
|
357
382
|
}
|
|
358
|
-
/** Writes the state at p without announcing it: the ticker reports the way there. */
|
|
359
|
-
private _write(lower: StackEntry | null, upper: StackEntry, p: number): void {
|
|
360
|
-
this.transition.apply(lower, upper, p);
|
|
361
|
-
}
|
|
362
383
|
private _apply(lower: StackEntry | null, upper: StackEntry, p: number): void {
|
|
363
|
-
this.
|
|
384
|
+
this.transition.apply(lower, upper, p);
|
|
364
385
|
this._emit('progress', { lower, upper, p });
|
|
365
386
|
}
|
|
366
387
|
private _end(lower: StackEntry | null, upper: StackEntry, kind: TransitionKind): void {
|
|
388
|
+
if (this._destroyed) return;
|
|
389
|
+
this._activeTransition = null;
|
|
367
390
|
this._timing(0);
|
|
368
391
|
upper.el.classList.remove('sn-page-upper');
|
|
369
392
|
lower?.el.classList.remove('sn-page-lower');
|
|
@@ -377,10 +400,10 @@ export class NavigationStack {
|
|
|
377
400
|
* then wait to be told they arrived. No frame of it is ours.
|
|
378
401
|
*/
|
|
379
402
|
private async _animate(lower: StackEntry | null, upper: StackEntry, from: number, to: number, duration: number, ease: Easing): Promise<void> {
|
|
380
|
-
if (duration <= 0) return this._apply(lower, upper, to);
|
|
403
|
+
if (duration <= 0 || from === to) return this._apply(lower, upper, to);
|
|
381
404
|
commitStyles(upper.el);
|
|
382
405
|
this._timing(duration, ease);
|
|
383
|
-
this.
|
|
406
|
+
this.transition.apply(lower, upper, to);
|
|
384
407
|
const ticker = this._ticker(lower, upper, from, to, duration, ease);
|
|
385
408
|
await animationsFinished([upper.el, lower?.el]);
|
|
386
409
|
ticker?.cancel();
|
package/src/platform.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Which native platform the page is running on, so the transition can follow
|
|
2
|
+
// that platform's own push/pop. Nothing here is certain: user agents lie, and
|
|
3
|
+
// desktop browsers are neither. iOS is the default when nothing is recognized,
|
|
4
|
+
// because that is the look most web apps expect from a stack transition.
|
|
5
|
+
|
|
6
|
+
export type Platform = 'ios' | 'android';
|
|
7
|
+
|
|
8
|
+
export function isIOSBrowser(): boolean {
|
|
9
|
+
if (typeof navigator === 'undefined') return false;
|
|
10
|
+
return /iP(hone|ad|od)/.test(navigator.platform) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isAndroidBrowser(): boolean {
|
|
14
|
+
if (typeof navigator === 'undefined') return false;
|
|
15
|
+
const hints = (navigator as { userAgentData?: { platform?: string } }).userAgentData;
|
|
16
|
+
if (hints?.platform) return hints.platform === 'Android';
|
|
17
|
+
return /\bAndroid\b/.test(navigator.userAgent ?? '');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `android` on an Android browser, otherwise `ios`. */
|
|
21
|
+
export function detectPlatform(): Platform {
|
|
22
|
+
return !isIOSBrowser() && isAndroidBrowser() ? 'android' : 'ios';
|
|
23
|
+
}
|
package/src/styles.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The only styles the engine needs. These cover layout and the motion itself,
|
|
3
3
|
* not appearance: the look lives in the transition and is tuned through the
|
|
4
|
-
* `--sn-*` custom properties (see `
|
|
4
|
+
* `--sn-*` custom properties (see `NATIVE_TRANSITION_CSS_VARS` and the README).
|
|
5
5
|
* Those properties are documented but deliberately not declared, because
|
|
6
6
|
* leaving one unset is what makes its JS default apply.
|
|
7
7
|
*
|
|
@@ -20,8 +20,7 @@
|
|
|
20
20
|
* - `.sn-container:dir(rtl)`: reading direction is a CSS question, so the
|
|
21
21
|
* transform the engine writes is signed by `--sn-dir` rather than by JS.
|
|
22
22
|
* - `.sn-page`: absolutely fills the container and is its own scroll container.
|
|
23
|
-
* `
|
|
24
|
-
* drags reach the gesture; `visibility: hidden` keeps pages beneath the top
|
|
23
|
+
* `visibility: hidden` keeps pages beneath the top
|
|
25
24
|
* mounted (scroll position, form state) but out of sight and out of the
|
|
26
25
|
* accessibility tree. The identity transform is the resting state, and the
|
|
27
26
|
* containing block the dim overlay is positioned against.
|
|
@@ -30,28 +29,27 @@
|
|
|
30
29
|
* transition in flight. Only these transition, and only these are promoted,
|
|
31
30
|
* so a deep stack costs nothing at rest. Both `transform` and `opacity` are
|
|
32
31
|
* listed, so a transition of your own can fade a page as well as move it and
|
|
33
|
-
* still be run by the browser; the iOS look only ever changes `transform
|
|
34
|
-
* and a property that does not change
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* puts it on the leading edge in either reading direction; its width is the
|
|
39
|
-
* gesture's `edgeWidth` option, and whether it is shown at all follows from
|
|
40
|
-
* the container's classes.
|
|
32
|
+
* still be run by the browser; the iOS look only ever changes `transform`
|
|
33
|
+
* (the Android look fades as well), and a property that does not change
|
|
34
|
+
* starts no transition.
|
|
35
|
+
* - `.sn-dim`: the overlay the lower page dims behind. CSS resolves its colour;
|
|
36
|
+
* the transition supplies a fallback colour and writes its opacity.
|
|
41
37
|
* - `.sn-busy`: no clicks land on a page that is mid-transition, and a drag
|
|
42
38
|
* does not select the text under it.
|
|
39
|
+
* - `prefers-reduced-motion`: forces the phase duration to zero in CSS. The
|
|
40
|
+
* `!important` is intentional: it must override the inline `--sn-t` written
|
|
41
|
+
* by the engine, including when the preference changes during a transition.
|
|
43
42
|
*/
|
|
44
43
|
export const STACKNAV_CSS =
|
|
45
44
|
'.sn-container{position:relative;overflow:hidden}' +
|
|
46
45
|
'.sn-container:dir(rtl){--sn-dir:-1}' +
|
|
47
|
-
'.sn-page{position:absolute;inset:0;overflow-y:auto;overflow-x:hidden;-webkit-overflow-scrolling:touch;overscroll-behavior-y:contain;
|
|
46
|
+
'.sn-page{position:absolute;inset:0;overflow-y:auto;overflow-x:hidden;-webkit-overflow-scrolling:touch;overscroll-behavior-y:contain;visibility:hidden;transform:translate3d(0,0,0)}' +
|
|
48
47
|
'.sn-page-visible{visibility:visible}' +
|
|
49
48
|
'.sn-page-upper,.sn-page-lower{will-change:transform;transition-property:transform,opacity;transition-duration:var(--sn-t,0s);transition-timing-function:var(--sn-e,linear)}' +
|
|
50
|
-
'.sn-dim{position:fixed;inset:0;z-index:2147483647;pointer-events:none;opacity:0;transition:opacity var(--sn-t,0s) var(--sn-e,linear)}' +
|
|
51
|
-
'.sn-edge{position:absolute;inset-block:0;inset-inline-start:0;z-index:10;touch-action:none}' +
|
|
52
|
-
'.sn-container:not(.sn-can-pop) .sn-edge,.sn-container.sn-anywhere .sn-edge{display:none}' +
|
|
49
|
+
'.sn-dim{position:fixed;inset:0;z-index:2147483647;pointer-events:none;background:var(--sn-dim-color,var(--sn-dim-fallback,#000));opacity:0;transition:opacity var(--sn-t,0s) var(--sn-e,linear)}' +
|
|
53
50
|
'.sn-busy{user-select:none;-webkit-user-select:none}' +
|
|
54
|
-
'.sn-busy .sn-page{pointer-events:none}'
|
|
51
|
+
'.sn-busy .sn-page{pointer-events:none}' +
|
|
52
|
+
'@media(prefers-reduced-motion:reduce){.sn-container{--sn-t:0s!important}}';
|
|
55
53
|
|
|
56
54
|
export const STACKNAV_STYLE_ID = 'stacknav-styles';
|
|
57
55
|
|