@stacknav/core 0.2.0 → 0.3.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 +58 -16
- package/dist/animate.d.ts +34 -1
- package/dist/animate.d.ts.map +1 -1
- package/dist/animate.js +46 -4
- 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 +32 -5
- 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 +31 -20
- package/dist/ios-transition.js.map +1 -1
- package/dist/navigation-stack.d.ts +31 -1
- package/dist/navigation-stack.d.ts.map +1 -1
- package/dist/navigation-stack.js +58 -12
- package/dist/navigation-stack.js.map +1 -1
- package/dist/stacknav.css +32 -1
- package/dist/styles.d.ts +30 -7
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +37 -8
- package/dist/styles.js.map +1 -1
- package/package.json +1 -1
- package/src/animate.ts +58 -5
- package/src/edge-pan-gesture.ts +33 -5
- package/src/index.ts +1 -1
- package/src/ios-transition.ts +39 -23
- package/src/navigation-stack.ts +68 -11
- package/src/styles.ts +37 -8
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> = [];
|
|
@@ -65,12 +84,14 @@ export function createEdgePanGesture(options: Partial<EdgePanGestureOptions> = {
|
|
|
65
84
|
if (drag || !stack.canPop()) return;
|
|
66
85
|
if (ev.pointerType === 'mouse' && ev.button !== 0) return;
|
|
67
86
|
if (ev.currentTarget === stack.container && !o.anywhere) return;
|
|
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()]] };
|
|
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,8 +165,8 @@ 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
|
listen(strip);
|
|
145
172
|
listen(stack.container);
|
|
@@ -154,6 +181,7 @@ export function createEdgePanGesture(options: Partial<EdgePanGestureOptions> = {
|
|
|
154
181
|
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 = {
|
|
@@ -104,19 +112,25 @@ export function createIOSTransition(options: Partial<IOSTransitionOptions> = {})
|
|
|
104
112
|
};
|
|
105
113
|
};
|
|
106
114
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
115
|
+
// One overlay, moved to whichever page is underneath. Everything about it
|
|
116
|
+
// except its colour and its opacity is a rule in the stylesheet.
|
|
117
|
+
let dim: HTMLElement | null = null;
|
|
118
|
+
const dimOf = (lower: StackEntry): HTMLElement => {
|
|
119
|
+
if (!dim) {
|
|
120
|
+
dim = document.createElement('div');
|
|
121
|
+
dim.className = 'sn-dim';
|
|
122
|
+
dim.setAttribute('aria-hidden', 'true');
|
|
114
123
|
}
|
|
115
|
-
|
|
116
|
-
if (
|
|
117
|
-
return
|
|
124
|
+
dim.style.background = r.dimColor;
|
|
125
|
+
if (dim.parentElement !== lower.el) lower.el.append(dim);
|
|
126
|
+
return dim;
|
|
118
127
|
};
|
|
119
128
|
|
|
129
|
+
/** Four decimals is well past a subpixel, and keeps the style strings short. */
|
|
130
|
+
const round = (n: number): number => Math.round(n * 1e4) / 1e4 || 0;
|
|
131
|
+
/** A share of the page's own width, signed by the stylesheet's reading direction. */
|
|
132
|
+
const shift = (fraction: number): string => `translate3d(calc(${round(fraction * 100)}% * var(--sn-dir,1)),0,0)`;
|
|
133
|
+
|
|
120
134
|
return {
|
|
121
135
|
options: o,
|
|
122
136
|
get resolved(): Readonly<IOSTransitionOptions> {
|
|
@@ -142,22 +156,24 @@ export function createIOSTransition(options: Partial<IOSTransitionOptions> = {})
|
|
|
142
156
|
upper.el.style.boxShadow = r.shadow;
|
|
143
157
|
if (lower) dimOf(lower);
|
|
144
158
|
},
|
|
159
|
+
/**
|
|
160
|
+
* The state at p, written declaratively. Two calls with `--sn-t` set to a
|
|
161
|
+
* duration make an animation; a call per pointer move with `--sn-t: 0s`
|
|
162
|
+
* makes a drag. Nothing here measures layout: the travel is a share of the
|
|
163
|
+
* page, so a resize mid-transition stays honest.
|
|
164
|
+
*/
|
|
145
165
|
apply(lower, upper, p) {
|
|
146
|
-
|
|
147
|
-
upper.el.style.transform = `translate3d(${(1 - p) * w}px,0,0)`;
|
|
166
|
+
upper.el.style.transform = shift(1 - p);
|
|
148
167
|
if (lower) {
|
|
149
|
-
lower.el.style.transform =
|
|
168
|
+
lower.el.style.transform = shift(-p * r.parallax);
|
|
150
169
|
dimOf(lower).style.opacity = String(p * r.dimMax);
|
|
151
170
|
}
|
|
152
171
|
},
|
|
153
172
|
end(lower, upper) {
|
|
154
173
|
upper.el.style.boxShadow = '';
|
|
155
174
|
upper.el.style.transform = '';
|
|
156
|
-
if (lower)
|
|
157
|
-
|
|
158
|
-
const d = (lower as Dimmable)[DIM];
|
|
159
|
-
if (d) d.remove();
|
|
160
|
-
}
|
|
175
|
+
if (lower) lower.el.style.transform = '';
|
|
176
|
+
dim?.remove();
|
|
161
177
|
},
|
|
162
178
|
};
|
|
163
179
|
}
|
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,35 +338,82 @@ 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
|
}
|
|
337
|
-
|
|
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 {
|
|
338
360
|
this.transition.apply(lower, upper, p);
|
|
361
|
+
}
|
|
362
|
+
private _apply(lower: StackEntry | null, upper: StackEntry, p: number): void {
|
|
363
|
+
this._write(lower, upper, p);
|
|
339
364
|
this._emit('progress', { lower, upper, p });
|
|
340
365
|
}
|
|
341
366
|
private _end(lower: StackEntry | null, upper: StackEntry, kind: TransitionKind): void {
|
|
367
|
+
this._timing(0);
|
|
368
|
+
upper.el.classList.remove('sn-page-upper');
|
|
369
|
+
lower?.el.classList.remove('sn-page-lower');
|
|
342
370
|
this.transition.end?.(lower, upper);
|
|
343
371
|
this._emit('transitionend', { lower, upper, kind });
|
|
344
372
|
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Hands the run from `from` to `to` over to the browser: commit where the
|
|
376
|
+
* pages are, say how long and on what curve, write where they are going,
|
|
377
|
+
* then wait to be told they arrived. No frame of it is ours.
|
|
378
|
+
*/
|
|
379
|
+
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);
|
|
381
|
+
commitStyles(upper.el);
|
|
382
|
+
this._timing(duration, ease);
|
|
383
|
+
this._write(lower, upper, to);
|
|
384
|
+
const ticker = this._ticker(lower, upper, from, to, duration, ease);
|
|
385
|
+
await animationsFinished([upper.el, lower?.el]);
|
|
386
|
+
ticker?.cancel();
|
|
387
|
+
this._timing(0);
|
|
388
|
+
this._emit('progress', { lower, upper, p: to });
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* `progress` used to be a by-product of animating in JS. Now that CSS
|
|
393
|
+
* animates, reporting it costs a frame loop, so one only runs while someone
|
|
394
|
+
* is subscribed. Chrome that just has to move with the pages is better off
|
|
395
|
+
* reading `--sn-t` / `--sn-e` and the `sn-page-upper` / `sn-page-lower`
|
|
396
|
+
* classes in CSS, which keeps it on the compositor too.
|
|
397
|
+
*/
|
|
398
|
+
private _ticker(lower: StackEntry | null, upper: StackEntry, from: number, to: number, duration: number, ease: Easing): CancellableTween | null {
|
|
399
|
+
if (!this._listeners.get('progress')?.size) return null;
|
|
400
|
+
return tween({ from, to, duration, ease, onUpdate: (p) => this._emit('progress', { lower, upper, p }) });
|
|
401
|
+
}
|
|
402
|
+
|
|
345
403
|
private async _transition(lower: StackEntry | null, upper: StackEntry, from: number, to: number, animated: boolean, kind: TransitionKind): Promise<void> {
|
|
346
404
|
this._begin(lower, upper, kind);
|
|
347
405
|
this._apply(lower, upper, from);
|
|
348
|
-
|
|
349
|
-
await tween({ from, to, duration, ease: this.transition.ease, onUpdate: (p) => this._apply(lower, upper, p) });
|
|
406
|
+
await this._animate(lower, upper, from, to, animated ? this.transition.duration : 0, this.transition.ease);
|
|
350
407
|
this._end(lower, upper, kind);
|
|
351
408
|
}
|
|
352
409
|
|
|
353
|
-
/** Makes only the top page visible,
|
|
410
|
+
/** Makes only the top page visible, returns every page to its resting state, renumbers the indexes. */
|
|
354
411
|
private _settle(): void {
|
|
355
412
|
const top = this.top;
|
|
356
413
|
this.entries.forEach((e, i) => {
|
|
357
414
|
e.index = i;
|
|
358
415
|
e.el.classList.toggle('sn-page-visible', e === top);
|
|
359
|
-
e.el.style.transform =
|
|
416
|
+
e.el.style.transform = '';
|
|
360
417
|
});
|
|
361
418
|
}
|
|
362
419
|
}
|
package/src/styles.ts
CHANGED
|
@@ -1,27 +1,56 @@
|
|
|
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. Its colour and its
|
|
36
|
+
* opacity are written by the transition; everything else is here.
|
|
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.
|
|
20
43
|
*/
|
|
21
44
|
export const STACKNAV_CSS =
|
|
22
45
|
'.sn-container{position:relative;overflow:hidden}' +
|
|
23
|
-
'.sn-
|
|
46
|
+
'.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;touch-action:pan-y;visibility:hidden;transform:translate3d(0,0,0)}' +
|
|
24
48
|
'.sn-page-visible{visibility:visible}' +
|
|
49
|
+
'.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}' +
|
|
53
|
+
'.sn-busy{user-select:none;-webkit-user-select:none}' +
|
|
25
54
|
'.sn-busy .sn-page{pointer-events:none}';
|
|
26
55
|
|
|
27
56
|
export const STACKNAV_STYLE_ID = 'stacknav-styles';
|