@stacknav/core 0.3.0 → 0.4.0

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