@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/src/animate.ts CHANGED
@@ -1,36 +1,67 @@
1
- // A small animation toolkit: a cubic-bezier solver, a cancellable tween, and
2
- // the easing curves the iOS transition uses.
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
- export type Easing = (t: number) => number;
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
- const A = (a: number, b: number) => 1 - 3 * b + 3 * a;
8
- const B = (a: number, b: number) => 3 * b - 6 * a;
9
- const C = (a: number) => 3 * a;
10
- const calc = (t: number, a: number, b: number) => ((A(a, b) * t + B(a, b)) * t + C(a)) * t;
11
- const slope = (t: number, a: number, b: number) => 3 * A(a, b) * t * t + 2 * B(a, b) * t + C(a);
12
- return (x) => {
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 s = slope(t, x1, x2);
18
- if (s === 0) break;
19
- t -= (calc(t, x1, x2) - x) / s;
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 calc(t, y1, y2);
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 0 or less jumps straight
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;
@@ -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 (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()]] };
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
- const dx = ev.clientX - drag.x0;
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
- strip.style.display = o.anywhere || stack.entries.length < 2 ? 'none' : '';
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
- listen(strip);
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,
@@ -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
- /** the curve a programmatic push/pop runs on */
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
- const dimOf = (entry: Dimmable): HTMLElement => {
108
- let d = entry[DIM];
109
- if (!d) {
110
- d = document.createElement('div');
111
- d.setAttribute('aria-hidden', 'true');
112
- Object.assign(d.style, { position: 'fixed', inset: '0', pointerEvents: 'none', opacity: '0', zIndex: '2147483647' });
113
- entry[DIM] = d;
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
- d.style.background = r.dimColor;
116
- if (d.parentElement !== entry.el) entry.el.append(d);
117
- return d;
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 = r.shadow;
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
- const w = upper.el.parentElement?.clientWidth ?? 0;
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 = `translate3d(${-p * r.parallax * w}px,0,0)`;
150
- dimOf(lower).style.opacity = String(p * r.dimMax);
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
- lower.el.style.transform = '';
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
  }
@@ -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 tween({ from: p, to: complete ? 0 : 1, duration, ease, onUpdate: (v) => this._apply(lower, upper, v) });
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
- if (lower) lower.el.classList.add('sn-page-visible');
333
- upper.el.classList.add('sn-page-visible');
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
- const duration = animated ? this.transition.duration : 0;
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, resets every transform, renumbers the indexes. */
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 = e === top ? 'translate3d(0,0,0)' : '';
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, not appearance: the
3
- * look lives in the transition and is tuned through the `--sn-*` custom
4
- * properties (see `IOS_TRANSITION_CSS_VARS` and the README). Those properties
5
- * are documented but deliberately not declared, because leaving one unset is
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-busy .sn-page`: no clicks land on a page that is mid-transition.
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-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;will-change:transform}' +
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-busy .sn-page{pointer-events:none}';
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