@stacknav/core 0.2.0 → 0.3.1
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 +72 -17
- package/dist/animate.d.ts +36 -3
- package/dist/animate.d.ts.map +1 -1
- package/dist/animate.js +78 -15
- package/dist/animate.js.map +1 -1
- package/dist/edge-pan-gesture.d.ts +3 -0
- package/dist/edge-pan-gesture.d.ts.map +1 -1
- package/dist/edge-pan-gesture.js +35 -8
- package/dist/edge-pan-gesture.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/ios-transition.d.ts +13 -2
- package/dist/ios-transition.d.ts.map +1 -1
- package/dist/ios-transition.js +35 -22
- package/dist/ios-transition.js.map +1 -1
- package/dist/navigation-stack.d.ts +29 -1
- package/dist/navigation-stack.d.ts.map +1 -1
- package/dist/navigation-stack.js +53 -11
- package/dist/navigation-stack.js.map +1 -1
- package/dist/stacknav.css +38 -1
- package/dist/styles.d.ts +33 -7
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +42 -9
- package/dist/styles.js.map +1 -1
- package/package.json +1 -1
- package/src/animate.ts +85 -16
- package/src/edge-pan-gesture.ts +36 -8
- package/src/index.ts +1 -1
- package/src/ios-transition.ts +42 -25
- package/src/navigation-stack.ts +63 -10
- package/src/styles.ts +42 -9
package/src/animate.ts
CHANGED
|
@@ -1,36 +1,67 @@
|
|
|
1
|
-
// A small animation toolkit
|
|
2
|
-
//
|
|
1
|
+
// A small animation toolkit. The pages themselves are moved by CSS (see
|
|
2
|
+
// styles.ts), so what is here is the arithmetic CSS cannot do for the engine:
|
|
3
|
+
// a curve it can both hand to CSS and sample in JS, the spellings CSS wants,
|
|
4
|
+
// a cancellable tween used only to report progress, and the two helpers that
|
|
5
|
+
// hand a run over to the browser.
|
|
3
6
|
|
|
4
|
-
|
|
7
|
+
/**
|
|
8
|
+
* An easing curve. Callable, so the engine can sample it; `css` is the same
|
|
9
|
+
* curve spelled for `transition-timing-function`, which is what actually
|
|
10
|
+
* drives the pixels. A plain `(t) => number` is still a valid easing, it just
|
|
11
|
+
* falls back to `linear` on the CSS side.
|
|
12
|
+
*/
|
|
13
|
+
export interface Easing {
|
|
14
|
+
(t: number): number;
|
|
15
|
+
readonly css?: string;
|
|
16
|
+
}
|
|
5
17
|
|
|
6
18
|
export function cubicBezier(x1: number, y1: number, x2: number, y2: number): Easing {
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
|
|
19
|
+
// The coefficients are constant for the lifetime of a curve.
|
|
20
|
+
const ax = 1 - 3 * x2 + 3 * x1, bx = 3 * x2 - 6 * x1, cx = 3 * x1;
|
|
21
|
+
const ay = 1 - 3 * y2 + 3 * y1, by = 3 * y2 - 6 * y1, cy = 3 * y1;
|
|
22
|
+
const sampleX = (t: number) => ((ax * t + bx) * t + cx) * t;
|
|
23
|
+
const sampleY = (t: number) => ((ay * t + by) * t + cy) * t;
|
|
24
|
+
const f = (x: number) => {
|
|
13
25
|
if (x <= 0) return 0;
|
|
14
26
|
if (x >= 1) return 1;
|
|
15
27
|
let t = x;
|
|
28
|
+
// Newton converges quickly for ordinary curves. Flat slopes can send it
|
|
29
|
+
// outside [0, 1], so fall back to a bounded search when it fails.
|
|
16
30
|
for (let i = 0; i < 8; i++) {
|
|
17
|
-
const
|
|
18
|
-
if (
|
|
19
|
-
|
|
31
|
+
const error = sampleX(t) - x;
|
|
32
|
+
if (Math.abs(error) < 1e-8) return sampleY(t);
|
|
33
|
+
const slope = (3 * ax * t + 2 * bx) * t + cx;
|
|
34
|
+
if (Math.abs(slope) < 1e-8) break;
|
|
35
|
+
const next = t - error / slope;
|
|
36
|
+
if (next < 0 || next > 1) break;
|
|
37
|
+
t = next;
|
|
38
|
+
}
|
|
39
|
+
let lo = 0, hi = 1;
|
|
40
|
+
for (let i = 0; i < 30; i++) {
|
|
41
|
+
t = (lo + hi) / 2;
|
|
42
|
+
if (sampleX(t) < x) lo = t;
|
|
43
|
+
else hi = t;
|
|
20
44
|
}
|
|
21
|
-
return
|
|
45
|
+
return sampleY(t);
|
|
22
46
|
};
|
|
47
|
+
return Object.assign(f, { css: `cubic-bezier(${x1}, ${y1}, ${x2}, ${y2})` });
|
|
23
48
|
}
|
|
24
49
|
|
|
25
50
|
// `#__PURE__` marks the module-load calls as droppable, so a bundler that does
|
|
26
51
|
// not honour the package's `sideEffects` flag can still leave this module out
|
|
27
52
|
// when nothing here is imported.
|
|
28
53
|
export const easings: { linear: Easing; ios: Easing; easeOut: Easing } = {
|
|
29
|
-
linear: (t) => t,
|
|
54
|
+
linear: /*#__PURE__*/ Object.assign((t: number) => t, { css: 'linear' }),
|
|
30
55
|
ios: /*#__PURE__*/ cubicBezier(0.32, 0.72, 0, 1), // the common approximation of UIKit's navigation curve
|
|
31
56
|
easeOut: /*#__PURE__*/ cubicBezier(0.2, 0.8, 0.2, 1),
|
|
32
57
|
};
|
|
33
58
|
|
|
59
|
+
/** How an easing should be spelled for CSS. A curve with no spelling runs linearly. */
|
|
60
|
+
export const cssEasing = (ease: Easing | undefined): string => ease?.css ?? 'linear';
|
|
61
|
+
|
|
62
|
+
/** How a duration should be spelled for CSS. */
|
|
63
|
+
export const cssDuration = (ms: number): string => (ms > 0 ? `${ms}ms` : '0s');
|
|
64
|
+
|
|
34
65
|
export interface TweenOptions {
|
|
35
66
|
from: number;
|
|
36
67
|
to: number;
|
|
@@ -44,13 +75,18 @@ export type CancellableTween = Promise<void> & { cancel(): void };
|
|
|
44
75
|
/**
|
|
45
76
|
* Animates a number from `from` to `to` over `duration` ms, calling `onUpdate`
|
|
46
77
|
* every frame. Returns a promise that resolves when the tween is done;
|
|
47
|
-
* `promise.cancel()` stops it early. A duration of
|
|
48
|
-
* to `to`.
|
|
78
|
+
* `promise.cancel()` stops it early and resolves the promise. A duration of
|
|
79
|
+
* 0 or less jumps straight to `to`.
|
|
80
|
+
*
|
|
81
|
+
* The engine does not use this to move pages, CSS does that. It uses it to
|
|
82
|
+
* report `progress` to listeners, and only while someone is listening.
|
|
49
83
|
*/
|
|
50
84
|
export function tween({ from, to, duration, ease = easings.linear, onUpdate }: TweenOptions): CancellableTween {
|
|
51
85
|
let raf = 0;
|
|
52
86
|
let done = false;
|
|
87
|
+
let finish: () => void;
|
|
53
88
|
const promise = new Promise<void>((resolve) => {
|
|
89
|
+
finish = resolve;
|
|
54
90
|
if (duration <= 0) {
|
|
55
91
|
onUpdate(to);
|
|
56
92
|
done = true;
|
|
@@ -61,6 +97,7 @@ export function tween({ from, to, duration, ease = easings.linear, onUpdate }: T
|
|
|
61
97
|
if (done) return;
|
|
62
98
|
const k = Math.min(1, (now - t0) / duration);
|
|
63
99
|
onUpdate(from + (to - from) * ease(k));
|
|
100
|
+
if (done) return;
|
|
64
101
|
if (k < 1) raf = requestAnimationFrame(step);
|
|
65
102
|
else {
|
|
66
103
|
done = true;
|
|
@@ -72,9 +109,41 @@ export function tween({ from, to, duration, ease = easings.linear, onUpdate }: T
|
|
|
72
109
|
promise.cancel = () => {
|
|
73
110
|
done = true;
|
|
74
111
|
cancelAnimationFrame(raf);
|
|
112
|
+
finish();
|
|
75
113
|
};
|
|
76
114
|
return promise;
|
|
77
115
|
}
|
|
78
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Commits the styles written so far, so the *next* write is seen as a change
|
|
119
|
+
* and starts a CSS transition from here instead of being collapsed into it.
|
|
120
|
+
* One forced layout per transition, in place of a frame of JavaScript per
|
|
121
|
+
* frame of animation.
|
|
122
|
+
*/
|
|
123
|
+
export function commitStyles(el: HTMLElement): void {
|
|
124
|
+
void el.offsetWidth;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Resolves once the CSS transitions of `properties` on these elements have
|
|
129
|
+
* finished. Nothing running resolves immediately: no transition started, a
|
|
130
|
+
* zero duration, `prefers-reduced-motion`, an element that is not being
|
|
131
|
+
* rendered. Interrupted animations reject, which counts as finished.
|
|
132
|
+
*
|
|
133
|
+
* Only the named properties are waited on, so an app is free to keep its own
|
|
134
|
+
* animation running on a page without stalling the stack.
|
|
135
|
+
*/
|
|
136
|
+
export function animationsFinished(els: Array<HTMLElement | null | undefined>, properties: readonly string[] = ['transform', 'opacity']): Promise<void> {
|
|
137
|
+
const running: Array<Promise<unknown>> = [];
|
|
138
|
+
for (const el of els) {
|
|
139
|
+
if (typeof el?.getAnimations !== 'function') continue;
|
|
140
|
+
for (const animation of el.getAnimations()) {
|
|
141
|
+
const property = (animation as { transitionProperty?: string }).transitionProperty;
|
|
142
|
+
if (property && properties.includes(property)) running.push(animation.finished.catch(() => {}));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return running.length ? Promise.all(running).then(() => {}) : Promise.resolve();
|
|
146
|
+
}
|
|
147
|
+
|
|
79
148
|
export const prefersReducedMotion = (): boolean =>
|
|
80
149
|
typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
package/src/edge-pan-gesture.ts
CHANGED
|
@@ -31,6 +31,8 @@ interface Drag {
|
|
|
31
31
|
target: EventTarget & { setPointerCapture?(id: number): void };
|
|
32
32
|
x0: number;
|
|
33
33
|
y0: number;
|
|
34
|
+
/** +1 when back is a drag to the right, -1 when the container reads right-to-left */
|
|
35
|
+
dir: number;
|
|
34
36
|
handle: InteractivePopHandle | null;
|
|
35
37
|
p: number;
|
|
36
38
|
samples: Array<[number, number]>;
|
|
@@ -41,6 +43,9 @@ interface Drag {
|
|
|
41
43
|
* interactive pop from it. Built on pointer events, so it handles both mouse
|
|
42
44
|
* and touch. Vertical movement early in the gesture hands the touch back to
|
|
43
45
|
* native scrolling.
|
|
46
|
+
*
|
|
47
|
+
* "Leading" is whichever edge the container reads from, so in a right-to-left
|
|
48
|
+
* container the strip sits on the right and back is a drag to the left.
|
|
44
49
|
*/
|
|
45
50
|
export function createEdgePanGesture(options: Partial<EdgePanGestureOptions> = {}): EdgePanGesture {
|
|
46
51
|
const o: EdgePanGestureOptions = {
|
|
@@ -57,6 +62,20 @@ export function createEdgePanGesture(options: Partial<EdgePanGestureOptions> = {
|
|
|
57
62
|
|
|
58
63
|
let stack: NavigationStack;
|
|
59
64
|
let strip: HTMLElement | null = null;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Which way forward is, read the same way the pages read it: `--sn-dir` when
|
|
68
|
+
* the stylesheet sets it, the container's reading direction otherwise. Taking
|
|
69
|
+
* it from one place is what keeps the drag and the transition from disagreeing
|
|
70
|
+
* about which edge is the back edge.
|
|
71
|
+
*/
|
|
72
|
+
const direction = (): number => {
|
|
73
|
+
const style = typeof getComputedStyle === 'function' ? getComputedStyle(stack.container) : null;
|
|
74
|
+
const declared = Number(style?.getPropertyValue('--sn-dir'));
|
|
75
|
+
if (declared < 0) return -1;
|
|
76
|
+
if (declared > 0) return 1;
|
|
77
|
+
return style?.direction === 'rtl' ? -1 : 1;
|
|
78
|
+
};
|
|
60
79
|
let drag: Drag | null = null;
|
|
61
80
|
let suppressClick = false;
|
|
62
81
|
let offs: Array<() => void> = [];
|
|
@@ -64,13 +83,15 @@ export function createEdgePanGesture(options: Partial<EdgePanGestureOptions> = {
|
|
|
64
83
|
const onDown = (ev: PointerEvent) => {
|
|
65
84
|
if (drag || !stack.canPop()) return;
|
|
66
85
|
if (ev.pointerType === 'mouse' && ev.button !== 0) return;
|
|
67
|
-
if (
|
|
68
|
-
drag = { id: ev.pointerId, target: ev.currentTarget as Drag['target'], x0: ev.clientX, y0: ev.clientY, handle: null, p: 1, samples: [[ev.clientX, performance.now()]] };
|
|
86
|
+
if (!o.anywhere && ev.target !== strip) return;
|
|
87
|
+
drag = { id: ev.pointerId, target: ev.currentTarget as Drag['target'], x0: ev.clientX, y0: ev.clientY, dir: direction(), handle: null, p: 1, samples: [[ev.clientX, performance.now()]] };
|
|
69
88
|
};
|
|
70
89
|
|
|
71
90
|
const onMove = (ev: PointerEvent) => {
|
|
72
91
|
if (!drag || ev.pointerId !== drag.id) return;
|
|
73
|
-
|
|
92
|
+
// Signed so that "forward along the drag" is always positive, whichever
|
|
93
|
+
// edge the container calls leading.
|
|
94
|
+
const dx = (ev.clientX - drag.x0) * drag.dir;
|
|
74
95
|
const dy = ev.clientY - drag.y0;
|
|
75
96
|
if (!drag.handle) {
|
|
76
97
|
if (Math.abs(dy) > o.verticalCancelSlop && Math.abs(dy) > Math.abs(dx)) {
|
|
@@ -104,7 +125,7 @@ export function createEdgePanGesture(options: Partial<EdgePanGestureOptions> = {
|
|
|
104
125
|
const s = d.samples;
|
|
105
126
|
const [x1, t1] = s[0];
|
|
106
127
|
const [x2, t2] = s[s.length - 1];
|
|
107
|
-
const velocity = t2 > t1 ? ((x2 - x1) / (t2 - t1)) * 1000 : 0;
|
|
128
|
+
const velocity = (t2 > t1 ? ((x2 - x1) / (t2 - t1)) * 1000 : 0) * d.dir;
|
|
108
129
|
const cancelled = ev.type === 'pointercancel';
|
|
109
130
|
const complete = !cancelled && (velocity > o.completeVelocity || (d.p < 1 - o.completeThreshold && velocity > o.cancelVelocity));
|
|
110
131
|
// The click that follows a drag release must not activate whatever is under the pointer.
|
|
@@ -126,10 +147,16 @@ export function createEdgePanGesture(options: Partial<EdgePanGestureOptions> = {
|
|
|
126
147
|
const listen = (el: HTMLElement) => Object.entries(EVENTS).forEach(([k, f]) => el.addEventListener(k, f as EventListener));
|
|
127
148
|
const unlisten = (el: HTMLElement) => Object.entries(EVENTS).forEach(([k, f]) => el.removeEventListener(k, f as EventListener));
|
|
128
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Whether the strip is shown at all is a CSS question — the stylesheet hides
|
|
152
|
+
* it when there is nothing to go back to, or when the whole page is the
|
|
153
|
+
* target — so this only has to say what is true.
|
|
154
|
+
*/
|
|
129
155
|
const refresh = () => {
|
|
130
156
|
if (!strip) return;
|
|
131
157
|
strip.style.width = o.edgeWidth + 'px';
|
|
132
|
-
|
|
158
|
+
stack.container.classList.toggle('sn-anywhere', o.anywhere);
|
|
159
|
+
stack.container.classList.toggle('sn-can-pop', stack.entries.length > 1);
|
|
133
160
|
};
|
|
134
161
|
|
|
135
162
|
const gesture: EdgePanGesture = {
|
|
@@ -138,10 +165,11 @@ export function createEdgePanGesture(options: Partial<EdgePanGestureOptions> = {
|
|
|
138
165
|
attach(s) {
|
|
139
166
|
stack = s;
|
|
140
167
|
strip = document.createElement('div');
|
|
168
|
+
strip.className = 'sn-edge';
|
|
141
169
|
strip.setAttribute('aria-hidden', 'true');
|
|
142
|
-
Object.assign(strip.style, { position: 'absolute', left: '0', top: '0', bottom: '0', zIndex: '10', touchAction: 'none' });
|
|
143
170
|
stack.container.append(strip);
|
|
144
|
-
|
|
171
|
+
// Pointer events from the strip bubble here; one listener per event
|
|
172
|
+
// avoids applying each move and recording its velocity sample twice.
|
|
145
173
|
listen(stack.container);
|
|
146
174
|
stack.container.addEventListener('click', onClick, true);
|
|
147
175
|
offs = (['push', 'pop', 'replace', 'reset'] as const).map((e) => stack.on(e, refresh));
|
|
@@ -151,9 +179,9 @@ export function createEdgePanGesture(options: Partial<EdgePanGestureOptions> = {
|
|
|
151
179
|
detach() {
|
|
152
180
|
if (!strip) return;
|
|
153
181
|
offs.forEach((f) => f());
|
|
154
|
-
unlisten(strip);
|
|
155
182
|
unlisten(stack.container);
|
|
156
183
|
stack.container.removeEventListener('click', onClick, true);
|
|
184
|
+
stack.container.classList.remove('sn-anywhere', 'sn-can-pop');
|
|
157
185
|
strip.remove();
|
|
158
186
|
strip = null;
|
|
159
187
|
},
|
package/src/index.ts
CHANGED
|
@@ -24,7 +24,7 @@ export { createEdgePanGesture } from './edge-pan-gesture.ts';
|
|
|
24
24
|
export type { EdgePanGesture, EdgePanGestureOptions } from './edge-pan-gesture.ts';
|
|
25
25
|
export { attachBrowserHistory, isIOSBrowser } from './history-adapter.ts';
|
|
26
26
|
export type { BrowserHistoryOptions } from './history-adapter.ts';
|
|
27
|
-
export { cubicBezier, easings, tween, prefersReducedMotion } from './animate.ts';
|
|
27
|
+
export { cubicBezier, easings, cssEasing, cssDuration, tween, commitStyles, animationsFinished, prefersReducedMotion } from './animate.ts';
|
|
28
28
|
export type { Easing, TweenOptions, CancellableTween } from './animate.ts';
|
|
29
29
|
export {
|
|
30
30
|
resolveDirection,
|
package/src/ios-transition.ts
CHANGED
|
@@ -5,7 +5,13 @@ import type { SettleInput, StackEntry, Transition } from './navigation-stack.ts'
|
|
|
5
5
|
export interface IOSTransitionOptions {
|
|
6
6
|
/** ms, programmatic push/pop */
|
|
7
7
|
duration: number;
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* the curve a programmatic push/pop runs on. CSS runs it, so it has to be
|
|
10
|
+
* one CSS can spell: everything `cubicBezier()` and `parseEasing()` build
|
|
11
|
+
* carries a `css` property. A bare `(t) => number` of your own has none, so
|
|
12
|
+
* the pages would run `linear` while `progress` reported your curve; give it
|
|
13
|
+
* a `css` property, or write the whole transition yourself.
|
|
14
|
+
*/
|
|
9
15
|
ease: Easing;
|
|
10
16
|
/** fraction of the width the lower page travels */
|
|
11
17
|
parallax: number;
|
|
@@ -17,7 +23,7 @@ export interface IOSTransitionOptions {
|
|
|
17
23
|
/** ms bounds for finishing an interactive pop */
|
|
18
24
|
settleMin: number;
|
|
19
25
|
settleMax: number;
|
|
20
|
-
/** the curve the remaining distance of an interactive pop runs on */
|
|
26
|
+
/** the curve the remaining distance of an interactive pop runs on; see `ease` */
|
|
21
27
|
settleEase: Easing;
|
|
22
28
|
/** px/s assumed when the pointer was slower than this */
|
|
23
29
|
settleVelocityFloor: number;
|
|
@@ -53,9 +59,6 @@ export interface IOSTransition extends Transition {
|
|
|
53
59
|
refresh(el?: Element | null): void;
|
|
54
60
|
}
|
|
55
61
|
|
|
56
|
-
const DIM = /*#__PURE__*/ Symbol('dim');
|
|
57
|
-
type Dimmable = StackEntry & { [DIM]?: HTMLElement };
|
|
58
|
-
|
|
59
62
|
/**
|
|
60
63
|
* The iOS navigation transition: the upper page slides in from the trailing
|
|
61
64
|
* edge with a shadow on its leading edge, while the lower page parallaxes
|
|
@@ -65,6 +68,11 @@ type Dimmable = StackEntry & { [DIM]?: HTMLElement };
|
|
|
65
68
|
* `IOS_TRANSITION_CSS_VARS`), read when a transition starts. A variable that
|
|
66
69
|
* is set wins over the JS option, so a stylesheet can slow the animation down
|
|
67
70
|
* or restyle it per theme without the app rebuilding the transition.
|
|
71
|
+
*
|
|
72
|
+
* p is only ever written at the ends of a phase. The stack puts that phase's
|
|
73
|
+
* duration and curve in `--sn-t` / `--sn-e` and CSS interpolates between the
|
|
74
|
+
* two writes, so the animation costs a handful of style writes rather than one
|
|
75
|
+
* per page per frame, and runs on the compositor rather than the main thread.
|
|
68
76
|
*/
|
|
69
77
|
export function createIOSTransition(options: Partial<IOSTransitionOptions> = {}): IOSTransition {
|
|
70
78
|
const o: IOSTransitionOptions = {
|
|
@@ -102,21 +110,28 @@ export function createIOSTransition(options: Partial<IOSTransitionOptions> = {})
|
|
|
102
110
|
settleVelocityFloor: parseNumber(read(v.settleVelocityFloor)) ?? o.settleVelocityFloor,
|
|
103
111
|
timeScale: parseNumber(read(v.timeScale)) ?? o.timeScale,
|
|
104
112
|
};
|
|
113
|
+
if (dim?.parentElement) dim.style.setProperty('--sn-dim-fallback', o.dimColor);
|
|
105
114
|
};
|
|
106
115
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
116
|
+
// One overlay, moved to whichever page is underneath. Everything about it
|
|
117
|
+
// except its opacity is a rule in the stylesheet; JS supplies the fallback colour.
|
|
118
|
+
let dim: HTMLElement | null = null;
|
|
119
|
+
const dimOf = (lower: StackEntry): HTMLElement => {
|
|
120
|
+
if (!dim) {
|
|
121
|
+
dim = document.createElement('div');
|
|
122
|
+
dim.className = 'sn-dim';
|
|
123
|
+
dim.setAttribute('aria-hidden', 'true');
|
|
114
124
|
}
|
|
115
|
-
|
|
116
|
-
if (
|
|
117
|
-
return
|
|
125
|
+
dim.style.setProperty('--sn-dim-fallback', o.dimColor);
|
|
126
|
+
if (dim.parentElement !== lower.el) lower.el.append(dim);
|
|
127
|
+
return dim;
|
|
118
128
|
};
|
|
119
129
|
|
|
130
|
+
/** Four decimals is well past a subpixel, and keeps the style strings short. */
|
|
131
|
+
const round = (n: number): number => Math.round(n * 1e4) / 1e4 || 0;
|
|
132
|
+
/** A share of the page's own width, signed by the stylesheet's reading direction. */
|
|
133
|
+
const shift = (fraction: number): string => `translate3d(calc(${round(fraction * 100)}% * var(--sn-dir,1)),0,0)`;
|
|
134
|
+
|
|
120
135
|
return {
|
|
121
136
|
options: o,
|
|
122
137
|
get resolved(): Readonly<IOSTransitionOptions> {
|
|
@@ -139,25 +154,27 @@ export function createIOSTransition(options: Partial<IOSTransitionOptions> = {})
|
|
|
139
154
|
|
|
140
155
|
begin(lower, upper) {
|
|
141
156
|
refresh(upper.el.parentElement);
|
|
142
|
-
upper.el.style.boxShadow =
|
|
157
|
+
upper.el.style.boxShadow = `var(--sn-shadow, ${o.shadow})`;
|
|
143
158
|
if (lower) dimOf(lower);
|
|
144
159
|
},
|
|
160
|
+
/**
|
|
161
|
+
* The state at p, written declaratively. Two calls with `--sn-t` set to a
|
|
162
|
+
* duration make an animation; a call per pointer move with `--sn-t: 0s`
|
|
163
|
+
* makes a drag. Nothing here measures layout: the travel is a share of the
|
|
164
|
+
* page, so a resize mid-transition stays honest.
|
|
165
|
+
*/
|
|
145
166
|
apply(lower, upper, p) {
|
|
146
|
-
|
|
147
|
-
upper.el.style.transform = `translate3d(${(1 - p) * w}px,0,0)`;
|
|
167
|
+
upper.el.style.transform = shift(1 - p);
|
|
148
168
|
if (lower) {
|
|
149
|
-
lower.el.style.transform =
|
|
150
|
-
|
|
169
|
+
lower.el.style.transform = shift(-p * r.parallax);
|
|
170
|
+
dim!.style.opacity = String(p * r.dimMax);
|
|
151
171
|
}
|
|
152
172
|
},
|
|
153
173
|
end(lower, upper) {
|
|
154
174
|
upper.el.style.boxShadow = '';
|
|
155
175
|
upper.el.style.transform = '';
|
|
156
|
-
if (lower)
|
|
157
|
-
|
|
158
|
-
const d = (lower as Dimmable)[DIM];
|
|
159
|
-
if (d) d.remove();
|
|
160
|
-
}
|
|
176
|
+
if (lower) lower.el.style.transform = '';
|
|
177
|
+
dim?.remove();
|
|
161
178
|
},
|
|
162
179
|
};
|
|
163
180
|
}
|
package/src/navigation-stack.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { tween, type Easing } from './animate.ts';
|
|
1
|
+
import { animationsFinished, commitStyles, cssDuration, cssEasing, tween, type CancellableTween, type Easing } from './animate.ts';
|
|
2
2
|
|
|
3
3
|
/** One mounted page. `key` and `data` belong to the caller; the stack only carries them. */
|
|
4
4
|
export interface StackEntry<T = unknown> {
|
|
@@ -17,9 +17,17 @@ export interface SettleInput {
|
|
|
17
17
|
* Describes what the pages look like at any progress p (1 = upper page fully
|
|
18
18
|
* open, 0 = upper page fully off-screen). Push runs p from 0 to 1, pop from
|
|
19
19
|
* 1 to 0.
|
|
20
|
+
*
|
|
21
|
+
* `apply` is a declarative write, not a frame. For a timed phase the stack
|
|
22
|
+
* calls it twice, once at each end, and CSS interpolates between them: the
|
|
23
|
+
* stack puts that phase's duration and curve in `--sn-t` / `--sn-e` on the
|
|
24
|
+
* container, which the stylesheet's `transition` rules read. During a drag
|
|
25
|
+
* `--sn-t` is `0s`, so the same write lands instantly. Keep `apply` to
|
|
26
|
+
* `transform` and `opacity` and the browser keeps it off the main thread.
|
|
20
27
|
*/
|
|
21
28
|
export interface Transition {
|
|
22
29
|
readonly duration: number;
|
|
30
|
+
/** Sampled to report `progress`; its `css` spelling is what drives the pixels. */
|
|
23
31
|
readonly ease: Easing;
|
|
24
32
|
settle(input: SettleInput): { duration: number; ease: Easing };
|
|
25
33
|
begin?(lower: StackEntry | null, upper: StackEntry): void;
|
|
@@ -249,7 +257,7 @@ export class NavigationStack {
|
|
|
249
257
|
finish: async ({ complete, velocity = 0 }) => {
|
|
250
258
|
const remainingPx = (complete ? p : 1 - p) * this.width();
|
|
251
259
|
const { duration, ease } = this.transition.settle({ remainingPx, velocity });
|
|
252
|
-
await
|
|
260
|
+
await this._animate(lower, upper, p, complete ? 0 : 1, duration, ease);
|
|
253
261
|
this._end(lower, upper, 'interactive');
|
|
254
262
|
if (complete) {
|
|
255
263
|
this.entries.pop();
|
|
@@ -265,7 +273,9 @@ export class NavigationStack {
|
|
|
265
273
|
|
|
266
274
|
destroy(): void {
|
|
267
275
|
while (this.entries.length) this._unmount(this.entries.pop()!);
|
|
268
|
-
this.container.classList.remove('sn-container');
|
|
276
|
+
this.container.classList.remove('sn-container', 'sn-busy');
|
|
277
|
+
this.container.style.removeProperty('--sn-t');
|
|
278
|
+
this.container.style.removeProperty('--sn-e');
|
|
269
279
|
}
|
|
270
280
|
|
|
271
281
|
// -------------------------------------------------------------- internals
|
|
@@ -315,7 +325,7 @@ export class NavigationStack {
|
|
|
315
325
|
return { el, index, key, data };
|
|
316
326
|
}
|
|
317
327
|
private _unmount(entry: StackEntry): StackEntry {
|
|
318
|
-
entry.el.classList.remove(this.pageClass, 'sn-page-visible');
|
|
328
|
+
entry.el.classList.remove(this.pageClass, 'sn-page-visible', 'sn-page-upper', 'sn-page-lower');
|
|
319
329
|
entry.el.style.transform = '';
|
|
320
330
|
entry.el.remove();
|
|
321
331
|
return entry;
|
|
@@ -328,9 +338,20 @@ export class NavigationStack {
|
|
|
328
338
|
return this._unmount(entry);
|
|
329
339
|
}
|
|
330
340
|
|
|
341
|
+
/**
|
|
342
|
+
* The duration and curve of the phase in flight, as CSS reads them. `0s`
|
|
343
|
+
* means land where you are told, now, which is what a drag wants and what
|
|
344
|
+
* `prefers-reduced-motion` reduces every phase to.
|
|
345
|
+
*/
|
|
346
|
+
private _timing(duration: number, ease?: Easing): void {
|
|
347
|
+
this.container.style.setProperty('--sn-t', cssDuration(duration));
|
|
348
|
+
this.container.style.setProperty('--sn-e', cssEasing(ease));
|
|
349
|
+
}
|
|
350
|
+
|
|
331
351
|
private _begin(lower: StackEntry | null, upper: StackEntry, kind: TransitionKind): void {
|
|
332
|
-
|
|
333
|
-
|
|
352
|
+
this._timing(0);
|
|
353
|
+
lower?.el.classList.add('sn-page-visible', 'sn-page-lower');
|
|
354
|
+
upper.el.classList.add('sn-page-visible', 'sn-page-upper');
|
|
334
355
|
this.transition.begin?.(lower, upper);
|
|
335
356
|
this._emit('transitionstart', { lower, upper, kind });
|
|
336
357
|
}
|
|
@@ -339,24 +360,56 @@ export class NavigationStack {
|
|
|
339
360
|
this._emit('progress', { lower, upper, p });
|
|
340
361
|
}
|
|
341
362
|
private _end(lower: StackEntry | null, upper: StackEntry, kind: TransitionKind): void {
|
|
363
|
+
this._timing(0);
|
|
364
|
+
upper.el.classList.remove('sn-page-upper');
|
|
365
|
+
lower?.el.classList.remove('sn-page-lower');
|
|
342
366
|
this.transition.end?.(lower, upper);
|
|
343
367
|
this._emit('transitionend', { lower, upper, kind });
|
|
344
368
|
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Hands the run from `from` to `to` over to the browser: commit where the
|
|
372
|
+
* pages are, say how long and on what curve, write where they are going,
|
|
373
|
+
* then wait to be told they arrived. No frame of it is ours.
|
|
374
|
+
*/
|
|
375
|
+
private async _animate(lower: StackEntry | null, upper: StackEntry, from: number, to: number, duration: number, ease: Easing): Promise<void> {
|
|
376
|
+
if (duration <= 0 || from === to) return this._apply(lower, upper, to);
|
|
377
|
+
commitStyles(upper.el);
|
|
378
|
+
this._timing(duration, ease);
|
|
379
|
+
this.transition.apply(lower, upper, to);
|
|
380
|
+
const ticker = this._ticker(lower, upper, from, to, duration, ease);
|
|
381
|
+
await animationsFinished([upper.el, lower?.el]);
|
|
382
|
+
ticker?.cancel();
|
|
383
|
+
this._timing(0);
|
|
384
|
+
this._emit('progress', { lower, upper, p: to });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* `progress` used to be a by-product of animating in JS. Now that CSS
|
|
389
|
+
* animates, reporting it costs a frame loop, so one only runs while someone
|
|
390
|
+
* is subscribed. Chrome that just has to move with the pages is better off
|
|
391
|
+
* reading `--sn-t` / `--sn-e` and the `sn-page-upper` / `sn-page-lower`
|
|
392
|
+
* classes in CSS, which keeps it on the compositor too.
|
|
393
|
+
*/
|
|
394
|
+
private _ticker(lower: StackEntry | null, upper: StackEntry, from: number, to: number, duration: number, ease: Easing): CancellableTween | null {
|
|
395
|
+
if (!this._listeners.get('progress')?.size) return null;
|
|
396
|
+
return tween({ from, to, duration, ease, onUpdate: (p) => this._emit('progress', { lower, upper, p }) });
|
|
397
|
+
}
|
|
398
|
+
|
|
345
399
|
private async _transition(lower: StackEntry | null, upper: StackEntry, from: number, to: number, animated: boolean, kind: TransitionKind): Promise<void> {
|
|
346
400
|
this._begin(lower, upper, kind);
|
|
347
401
|
this._apply(lower, upper, from);
|
|
348
|
-
|
|
349
|
-
await tween({ from, to, duration, ease: this.transition.ease, onUpdate: (p) => this._apply(lower, upper, p) });
|
|
402
|
+
await this._animate(lower, upper, from, to, animated ? this.transition.duration : 0, this.transition.ease);
|
|
350
403
|
this._end(lower, upper, kind);
|
|
351
404
|
}
|
|
352
405
|
|
|
353
|
-
/** Makes only the top page visible,
|
|
406
|
+
/** Makes only the top page visible, returns every page to its resting state, renumbers the indexes. */
|
|
354
407
|
private _settle(): void {
|
|
355
408
|
const top = this.top;
|
|
356
409
|
this.entries.forEach((e, i) => {
|
|
357
410
|
e.index = i;
|
|
358
411
|
e.el.classList.toggle('sn-page-visible', e === top);
|
|
359
|
-
e.el.style.transform =
|
|
412
|
+
e.el.style.transform = '';
|
|
360
413
|
});
|
|
361
414
|
}
|
|
362
415
|
}
|
package/src/styles.ts
CHANGED
|
@@ -1,28 +1,61 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The only styles the engine needs. These cover layout
|
|
3
|
-
* look lives in the transition and is tuned through the
|
|
4
|
-
* properties (see `IOS_TRANSITION_CSS_VARS` and the README).
|
|
5
|
-
* are documented but deliberately not declared, because
|
|
6
|
-
* what makes its JS default apply.
|
|
2
|
+
* The only styles the engine needs. These cover layout and the motion itself,
|
|
3
|
+
* not appearance: the look lives in the transition and is tuned through the
|
|
4
|
+
* `--sn-*` custom properties (see `IOS_TRANSITION_CSS_VARS` and the README).
|
|
5
|
+
* Those properties are documented but deliberately not declared, because
|
|
6
|
+
* leaving one unset is what makes its JS default apply.
|
|
7
|
+
*
|
|
8
|
+
* The engine does not animate the pages; the browser does. Each phase it
|
|
9
|
+
* writes `--sn-t` and `--sn-e` on the container — the duration and curve in
|
|
10
|
+
* force right now — and then writes where the pages should end up. While a
|
|
11
|
+
* pointer is down `--sn-t` is `0s`, so the page lands exactly where the
|
|
12
|
+
* pointer puts it; for a push, a pop or the settle after a release it is that
|
|
13
|
+
* phase's length and `transition` runs it out, on the compositor.
|
|
7
14
|
*
|
|
8
15
|
* The string is kept minified because it ships inside every consumer's JS
|
|
9
16
|
* bundle (`injectStyles()` is the default path); `scripts/write-css.mjs`
|
|
10
17
|
* expands it into the readable `dist/stacknav.css`. What each rule is for:
|
|
11
18
|
*
|
|
12
19
|
* - `.sn-container`: the stack's scroll-clipping frame.
|
|
20
|
+
* - `.sn-container:dir(rtl)`: reading direction is a CSS question, so the
|
|
21
|
+
* transform the engine writes is signed by `--sn-dir` rather than by JS.
|
|
13
22
|
* - `.sn-page`: absolutely fills the container and is its own scroll container.
|
|
14
23
|
* `touch-action: pan-y` keeps vertical scrolling native while horizontal
|
|
15
24
|
* drags reach the gesture; `visibility: hidden` keeps pages beneath the top
|
|
16
25
|
* mounted (scroll position, form state) but out of sight and out of the
|
|
17
|
-
* accessibility tree.
|
|
26
|
+
* accessibility tree. The identity transform is the resting state, and the
|
|
27
|
+
* containing block the dim overlay is positioned against.
|
|
18
28
|
* - `.sn-page-visible`: the top page, and both pages during a transition.
|
|
19
|
-
* - `.sn-
|
|
29
|
+
* - `.sn-page-upper` / `.sn-page-lower`: the two pages taking part in the
|
|
30
|
+
* transition in flight. Only these transition, and only these are promoted,
|
|
31
|
+
* so a deep stack costs nothing at rest. Both `transform` and `opacity` are
|
|
32
|
+
* 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 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.
|
|
37
|
+
* - `.sn-edge`: the strip the swipe-back gesture listens on. `inset-inline-start`
|
|
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.
|
|
41
|
+
* - `.sn-busy`: no clicks land on a page that is mid-transition, and a drag
|
|
42
|
+
* does not select the text under it.
|
|
43
|
+
* - `prefers-reduced-motion`: forces the phase duration to zero in CSS. The
|
|
44
|
+
* `!important` is intentional: it must override the inline `--sn-t` written
|
|
45
|
+
* by the engine, including when the preference changes during a transition.
|
|
20
46
|
*/
|
|
21
47
|
export const STACKNAV_CSS =
|
|
22
48
|
'.sn-container{position:relative;overflow:hidden}' +
|
|
23
|
-
'.sn-
|
|
49
|
+
'.sn-container:dir(rtl){--sn-dir:-1}' +
|
|
50
|
+
'.sn-page{position:absolute;inset:0;overflow-y:auto;overflow-x:hidden;-webkit-overflow-scrolling:touch;overscroll-behavior-y:contain;touch-action:pan-y;visibility:hidden;transform:translate3d(0,0,0)}' +
|
|
24
51
|
'.sn-page-visible{visibility:visible}' +
|
|
25
|
-
'.sn-
|
|
52
|
+
'.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)}' +
|
|
53
|
+
'.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)}' +
|
|
54
|
+
'.sn-edge{position:absolute;inset-block:0;inset-inline-start:0;z-index:10;touch-action:none}' +
|
|
55
|
+
'.sn-container:not(.sn-can-pop) .sn-edge,.sn-container.sn-anywhere .sn-edge{display:none}' +
|
|
56
|
+
'.sn-busy{user-select:none;-webkit-user-select:none}' +
|
|
57
|
+
'.sn-busy .sn-page{pointer-events:none}' +
|
|
58
|
+
'@media(prefers-reduced-motion:reduce){.sn-container{--sn-t:0s!important}}';
|
|
26
59
|
|
|
27
60
|
export const STACKNAV_STYLE_ID = 'stacknav-styles';
|
|
28
61
|
|