@stacknav/core 0.2.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 (48) hide show
  1. package/README.md +235 -0
  2. package/dist/animate.d.ts +26 -0
  3. package/dist/animate.d.ts.map +1 -0
  4. package/dist/animate.js +69 -0
  5. package/dist/animate.js.map +1 -0
  6. package/dist/css-vars.d.ts +22 -0
  7. package/dist/css-vars.d.ts.map +1 -0
  8. package/dist/css-vars.js +89 -0
  9. package/dist/css-vars.js.map +1 -0
  10. package/dist/direction.d.ts +79 -0
  11. package/dist/direction.d.ts.map +1 -0
  12. package/dist/direction.js +98 -0
  13. package/dist/direction.js.map +1 -0
  14. package/dist/edge-pan-gesture.d.ts +33 -0
  15. package/dist/edge-pan-gesture.d.ts.map +1 -0
  16. package/dist/edge-pan-gesture.js +128 -0
  17. package/dist/edge-pan-gesture.js.map +1 -0
  18. package/dist/history-adapter.d.ts +17 -0
  19. package/dist/history-adapter.d.ts.map +1 -0
  20. package/dist/history-adapter.js +44 -0
  21. package/dist/history-adapter.js.map +1 -0
  22. package/dist/index.d.ts +34 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +30 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/ios-transition.d.ts +50 -0
  27. package/dist/ios-transition.d.ts.map +1 -0
  28. package/dist/ios-transition.js +122 -0
  29. package/dist/ios-transition.js.map +1 -0
  30. package/dist/navigation-stack.d.ts +177 -0
  31. package/dist/navigation-stack.d.ts.map +1 -0
  32. package/dist/navigation-stack.js +296 -0
  33. package/dist/navigation-stack.js.map +1 -0
  34. package/dist/stacknav.css +33 -0
  35. package/dist/styles.d.ts +29 -0
  36. package/dist/styles.d.ts.map +1 -0
  37. package/dist/styles.js +39 -0
  38. package/dist/styles.js.map +1 -0
  39. package/package.json +43 -0
  40. package/src/animate.ts +80 -0
  41. package/src/css-vars.ts +91 -0
  42. package/src/direction.ts +146 -0
  43. package/src/edge-pan-gesture.ts +162 -0
  44. package/src/history-adapter.ts +52 -0
  45. package/src/index.ts +86 -0
  46. package/src/ios-transition.ts +163 -0
  47. package/src/navigation-stack.ts +362 -0
  48. package/src/styles.ts +40 -0
package/src/animate.ts ADDED
@@ -0,0 +1,80 @@
1
+ // A small animation toolkit: a cubic-bezier solver, a cancellable tween, and
2
+ // the easing curves the iOS transition uses.
3
+
4
+ export type Easing = (t: number) => number;
5
+
6
+ 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) => {
13
+ if (x <= 0) return 0;
14
+ if (x >= 1) return 1;
15
+ let t = x;
16
+ 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;
20
+ }
21
+ return calc(t, y1, y2);
22
+ };
23
+ }
24
+
25
+ // `#__PURE__` marks the module-load calls as droppable, so a bundler that does
26
+ // not honour the package's `sideEffects` flag can still leave this module out
27
+ // when nothing here is imported.
28
+ export const easings: { linear: Easing; ios: Easing; easeOut: Easing } = {
29
+ linear: (t) => t,
30
+ ios: /*#__PURE__*/ cubicBezier(0.32, 0.72, 0, 1), // the common approximation of UIKit's navigation curve
31
+ easeOut: /*#__PURE__*/ cubicBezier(0.2, 0.8, 0.2, 1),
32
+ };
33
+
34
+ export interface TweenOptions {
35
+ from: number;
36
+ to: number;
37
+ duration: number;
38
+ ease?: Easing;
39
+ onUpdate: (value: number) => void;
40
+ }
41
+
42
+ export type CancellableTween = Promise<void> & { cancel(): void };
43
+
44
+ /**
45
+ * Animates a number from `from` to `to` over `duration` ms, calling `onUpdate`
46
+ * 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`.
49
+ */
50
+ export function tween({ from, to, duration, ease = easings.linear, onUpdate }: TweenOptions): CancellableTween {
51
+ let raf = 0;
52
+ let done = false;
53
+ const promise = new Promise<void>((resolve) => {
54
+ if (duration <= 0) {
55
+ onUpdate(to);
56
+ done = true;
57
+ return resolve();
58
+ }
59
+ const t0 = performance.now();
60
+ const step = (now: number) => {
61
+ if (done) return;
62
+ const k = Math.min(1, (now - t0) / duration);
63
+ onUpdate(from + (to - from) * ease(k));
64
+ if (k < 1) raf = requestAnimationFrame(step);
65
+ else {
66
+ done = true;
67
+ resolve();
68
+ }
69
+ };
70
+ raf = requestAnimationFrame(step);
71
+ }) as CancellableTween;
72
+ promise.cancel = () => {
73
+ done = true;
74
+ cancelAnimationFrame(raf);
75
+ };
76
+ return promise;
77
+ }
78
+
79
+ export const prefersReducedMotion = (): boolean =>
80
+ typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
@@ -0,0 +1,91 @@
1
+ // Reads the engine's options from CSS custom properties, so the transition can
2
+ // be tuned from a stylesheet (a media query, a theme class, a single container)
3
+ // instead of only from JS. Values are parsed the way CSS reads them: `300ms`,
4
+ // `0.4s`, `30%`, `cubic-bezier(...)`.
5
+ //
6
+ // Every parser returns undefined for anything it does not understand, never
7
+ // NaN, so an unreadable value falls through to its JS option instead of
8
+ // producing an invalid transform.
9
+
10
+ import { cubicBezier, easings, type Easing } from './animate.ts';
11
+
12
+ /** Looks a custom property up on an element; `undefined` when it is not set. */
13
+ export type CSSVarReader = (name: string) => string | undefined;
14
+
15
+ /**
16
+ * Returns a reader over `el`'s computed custom properties. Custom properties
17
+ * inherit, so a variable set on `:root` or any ancestor is visible here. When
18
+ * there is no element or no `getComputedStyle` (SSR, tests), the reader finds
19
+ * nothing and every caller falls back to its JS option.
20
+ */
21
+ export function cssVars(el: Element | null | undefined): CSSVarReader {
22
+ const style = el && typeof getComputedStyle === 'function' ? getComputedStyle(el) : null;
23
+ if (!style) return () => undefined;
24
+ return (name) => {
25
+ const v = style.getPropertyValue(name)?.trim();
26
+ return v ? v : undefined;
27
+ };
28
+ }
29
+
30
+ const NUMBER = /^[-+]?(?:\d+\.?\d*|\.\d+)$/;
31
+
32
+ /** `500ms`, `0.4s`, or a bare number of milliseconds. */
33
+ export function parseTime(v: string | undefined): number | undefined {
34
+ const m = v === undefined ? null : /^([-+]?(?:\d+\.?\d*|\.\d+))(ms|s)?$/i.exec(v.trim());
35
+ if (!m) return undefined;
36
+ return m[2]?.toLowerCase() === 's' ? Number(m[1]) * 1000 : Number(m[1]);
37
+ }
38
+
39
+ export function parseNumber(v: string | undefined): number | undefined {
40
+ const s = v?.trim();
41
+ return s !== undefined && NUMBER.test(s) ? Number(s) : undefined;
42
+ }
43
+
44
+ /** A fraction. `0.3` and `30%` are equivalent. */
45
+ export function parseRatio(v: string | undefined): number | undefined {
46
+ const s = v?.trim();
47
+ if (s === undefined) return undefined;
48
+ if (!s.endsWith('%')) return parseNumber(s);
49
+ const n = parseNumber(s.slice(0, -1));
50
+ return n === undefined ? undefined : n / 100;
51
+ }
52
+
53
+ /**
54
+ * The CSS timing keywords that are cubic curves, plus the two this engine
55
+ * ships. Null-prototype, so `__proto__` and `constructor` miss like any other
56
+ * unknown word instead of returning a truthy non-easing value.
57
+ *
58
+ * Built on first use rather than at module load: solving four curves here
59
+ * would be work a bundler cannot prove pointless, which would keep this
60
+ * module (and `easings`) in bundles that never parse a CSS variable.
61
+ */
62
+ let easingKeywords: Record<string, Easing> | undefined;
63
+ const easingKeywordsOf = (): Record<string, Easing> =>
64
+ (easingKeywords ??= Object.assign(Object.create(null), {
65
+ linear: easings.linear,
66
+ ease: cubicBezier(0.25, 0.1, 0.25, 1),
67
+ 'ease-in': cubicBezier(0.42, 0, 1, 1),
68
+ 'ease-out': cubicBezier(0, 0, 0.58, 1),
69
+ 'ease-in-out': cubicBezier(0.42, 0, 0.58, 1),
70
+ ios: easings.ios,
71
+ 'ios-settle': easings.easeOut,
72
+ }));
73
+
74
+ /**
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.
78
+ */
79
+ export function parseEasing(v: string | undefined): Easing | undefined {
80
+ if (v === undefined) return undefined;
81
+ const s = v.trim().toLowerCase();
82
+ const keyword = easingKeywordsOf()[s];
83
+ if (keyword) return keyword;
84
+ const m = /^cubic-bezier\(([^)]*)\)$/.exec(s);
85
+ if (!m) return undefined;
86
+ const n = m[1].split(',').map((part) => parseNumber(part));
87
+ if (n.length !== 4 || n.some((x) => x === undefined)) return undefined;
88
+ const [x1, y1, x2, y2] = n as number[];
89
+ if (x1 < 0 || x1 > 1 || x2 < 0 || x2 > 1) return undefined;
90
+ return cubicBezier(x1, y1, x2, y2);
91
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Direction resolution: given where the app is and where it is going, decide
3
+ * whether the new page should push over the current one, pop back to it, or
4
+ * replace it. The engine does not decide this on its own. A host supplies an
5
+ * ordered list of strategies, and the first one with an answer wins.
6
+ *
7
+ * Strategies are plain functions, so a host can add its own (a numbering
8
+ * scheme, a route-tree walk, a per-navigation hint) without changing the rest.
9
+ */
10
+
11
+ export type Direction = 'push' | 'pop' | 'replace';
12
+
13
+ /** What a strategy may return: a direction, or no answer (`'auto'`, `undefined`, `null`). */
14
+ export type DirectionOpinion = Direction | 'auto' | undefined | null | void;
15
+
16
+ /** The minimum a strategy needs to know about a page. Hosts may attach more. */
17
+ export interface RouteRef {
18
+ /** stable identity of the page: its URL, a route id, anything unique */
19
+ key: string;
20
+ /** the page's position in the route tree, e.g. URL segments (`['items', '42']`) */
21
+ segments?: readonly string[];
22
+ /** an explicit number when the app numbers its screens: bigger is deeper */
23
+ level?: number | null;
24
+ /** anything the host wants strategies to see (route data, params) */
25
+ data?: Record<string, unknown>;
26
+ }
27
+
28
+ export type NavigationTrigger = 'imperative' | 'history';
29
+
30
+ export interface NavigationContext {
31
+ /** the page on screen, or null when the stack is empty */
32
+ from: RouteRef | null;
33
+ to: RouteRef;
34
+ /** `imperative`: the app navigated. `history`: the browser's back/forward. */
35
+ trigger?: NavigationTrigger;
36
+ /** for history triggers, when known: negative = back, positive = forward */
37
+ historyDelta?: number;
38
+ /** an explicit direction from the caller, if any */
39
+ hint?: DirectionOpinion;
40
+ /** keys of the pages currently kept alive, bottom to top */
41
+ stack?: readonly string[];
42
+ }
43
+
44
+ export type DirectionStrategy = (ctx: NavigationContext) => DirectionOpinion;
45
+
46
+ export interface DirectionResolver {
47
+ (ctx: NavigationContext): Direction;
48
+ readonly strategies: readonly DirectionStrategy[];
49
+ readonly fallback: Direction;
50
+ }
51
+
52
+ const isDirection = (v: DirectionOpinion): v is Direction => v === 'push' || v === 'pop' || v === 'replace';
53
+
54
+ /** Calls each strategy in turn. The first direction returned wins, else `fallback`. */
55
+ export function resolveDirection(strategies: readonly DirectionStrategy[], ctx: NavigationContext, fallback: Direction = 'push'): Direction {
56
+ for (const s of strategies) {
57
+ const d = s(ctx);
58
+ if (isDirection(d)) return d;
59
+ }
60
+ return fallback;
61
+ }
62
+
63
+ /** Bundles strategies and a fallback into a single resolver function. */
64
+ export function createDirectionResolver(strategies: readonly DirectionStrategy[] = defaultStrategies(), fallback: Direction = 'push'): DirectionResolver {
65
+ const resolver = ((ctx: NavigationContext) => resolveDirection(strategies, ctx, fallback)) as DirectionResolver;
66
+ Object.defineProperty(resolver, 'strategies', { value: strategies.slice(), enumerable: true });
67
+ Object.defineProperty(resolver, 'fallback', { value: fallback, enumerable: true });
68
+ return resolver;
69
+ }
70
+
71
+ // ------------------------------------------------------------------ strategies
72
+
73
+ /** Honors an explicit per-navigation hint, e.g. `{ info: { stacknav: 'pop' } }` in Angular's router. */
74
+ export const fromHint = (): DirectionStrategy => (ctx) => ctx.hint;
75
+
76
+ /** Browser back is a pop, browser forward is a push. No answer for imperative navigations. */
77
+ export const fromHistory = (): DirectionStrategy => (ctx) => {
78
+ if (ctx.trigger !== 'history' || !ctx.historyDelta) return undefined;
79
+ return ctx.historyDelta < 0 ? 'pop' : 'push';
80
+ };
81
+
82
+ /** Navigating to a page still kept beneath the current one is a pop back to it. */
83
+ export const fromStack = (): DirectionStrategy => (ctx) => {
84
+ const stack = ctx.stack;
85
+ if (!stack || stack.length < 2) return undefined;
86
+ const i = stack.lastIndexOf(ctx.to.key);
87
+ if (i < 0 || i === stack.length - 1) return undefined;
88
+ return 'pop';
89
+ };
90
+
91
+ export interface LevelOptions {
92
+ /** the direction when both pages carry the same number (default `replace`) */
93
+ sameLevel?: DirectionOpinion;
94
+ }
95
+
96
+ /**
97
+ * For apps that number their screens (`level: 1`, `level: 2`, …): a higher
98
+ * number pushes, a lower one pops. No answer unless both pages carry a number.
99
+ */
100
+ export const fromLevel = ({ sameLevel = 'replace' }: LevelOptions = {}): DirectionStrategy => (ctx) => {
101
+ const a = ctx.from?.level;
102
+ const b = ctx.to.level;
103
+ if (typeof a !== 'number' || typeof b !== 'number') return undefined;
104
+ if (b > a) return 'push';
105
+ if (b < a) return 'pop';
106
+ return sameLevel;
107
+ };
108
+
109
+ export interface TreeOptions {
110
+ /** the direction for two unrelated pages at the same depth, e.g. siblings (default `replace`) */
111
+ sameDepth?: DirectionOpinion;
112
+ }
113
+
114
+ /**
115
+ * Reads the route tree: a descendant of the current page pushes, an ancestor
116
+ * pops. Otherwise a deeper page pushes and a shallower one pops. Requires
117
+ * `segments` on both pages.
118
+ */
119
+ export const fromTree = ({ sameDepth = 'replace' }: TreeOptions = {}): DirectionStrategy => (ctx) => {
120
+ const a = ctx.from?.segments;
121
+ const b = ctx.to.segments;
122
+ if (!a || !b) return undefined;
123
+ if (isPrefix(a, b)) return b.length > a.length ? 'push' : sameDepth;
124
+ if (isPrefix(b, a)) return 'pop';
125
+ if (b.length > a.length) return 'push';
126
+ if (b.length < a.length) return 'pop';
127
+ return sameDepth;
128
+ };
129
+
130
+ /** Always returns the same direction. Useful as the last entry in a list. */
131
+ export const always = (direction: Direction): DirectionStrategy => () => direction;
132
+
133
+ /** The default order: an explicit hint, then browser history, then the kept stack, then numbering, then the tree. */
134
+ export const defaultStrategies = (): DirectionStrategy[] => [fromHint(), fromHistory(), fromStack(), fromLevel(), fromTree()];
135
+
136
+ function isPrefix(prefix: readonly string[], of: readonly string[]): boolean {
137
+ if (prefix.length > of.length) return false;
138
+ for (let i = 0; i < prefix.length; i++) if (prefix[i] !== of[i]) return false;
139
+ return true;
140
+ }
141
+
142
+ /** Split a URL path into segments, ignoring the query, fragment and empty parts. */
143
+ export function segmentsOf(url: string): string[] {
144
+ const path = url.split(/[?#]/, 1)[0];
145
+ return path.split('/').filter(Boolean);
146
+ }
@@ -0,0 +1,162 @@
1
+ import type { InteractivePopHandle, NavigationStack } from './navigation-stack.ts';
2
+
3
+ export interface EdgePanGestureOptions {
4
+ /** px strip on the leading edge that starts the gesture */
5
+ edgeWidth: number;
6
+ /** recognize the drag from anywhere on the page */
7
+ anywhere: boolean;
8
+ /** px of horizontal movement before the drag begins */
9
+ startSlop: number;
10
+ /** px of vertical movement that hands the touch back to scrolling */
11
+ verticalCancelSlop: number;
12
+ /** fraction of the width dragged that completes without velocity */
13
+ completeThreshold: number;
14
+ /** px/s toward the trailing edge that completes the pop regardless of distance */
15
+ completeVelocity: number;
16
+ /** px/s back toward the leading edge that cancels the pop regardless of distance */
17
+ cancelVelocity: number;
18
+ velocitySamples: number;
19
+ }
20
+
21
+ export interface EdgePanGesture {
22
+ readonly options: EdgePanGestureOptions;
23
+ /** Re-reads options changed at runtime, such as `edgeWidth` and `anywhere`. */
24
+ refresh(): void;
25
+ attach(stack: NavigationStack): EdgePanGesture;
26
+ detach(): void;
27
+ }
28
+
29
+ interface Drag {
30
+ id: number;
31
+ target: EventTarget & { setPointerCapture?(id: number): void };
32
+ x0: number;
33
+ y0: number;
34
+ handle: InteractivePopHandle | null;
35
+ p: number;
36
+ samples: Array<[number, number]>;
37
+ }
38
+
39
+ /**
40
+ * Recognizes a horizontal drag from the leading edge and drives the stack's
41
+ * interactive pop from it. Built on pointer events, so it handles both mouse
42
+ * and touch. Vertical movement early in the gesture hands the touch back to
43
+ * native scrolling.
44
+ */
45
+ export function createEdgePanGesture(options: Partial<EdgePanGestureOptions> = {}): EdgePanGesture {
46
+ const o: EdgePanGestureOptions = {
47
+ edgeWidth: 28,
48
+ anywhere: false,
49
+ startSlop: 6,
50
+ verticalCancelSlop: 10,
51
+ completeThreshold: 0.5,
52
+ completeVelocity: 500,
53
+ cancelVelocity: -500,
54
+ velocitySamples: 6,
55
+ ...options,
56
+ };
57
+
58
+ let stack: NavigationStack;
59
+ let strip: HTMLElement | null = null;
60
+ let drag: Drag | null = null;
61
+ let suppressClick = false;
62
+ let offs: Array<() => void> = [];
63
+
64
+ const onDown = (ev: PointerEvent) => {
65
+ if (drag || !stack.canPop()) return;
66
+ 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()]] };
69
+ };
70
+
71
+ const onMove = (ev: PointerEvent) => {
72
+ if (!drag || ev.pointerId !== drag.id) return;
73
+ const dx = ev.clientX - drag.x0;
74
+ const dy = ev.clientY - drag.y0;
75
+ if (!drag.handle) {
76
+ if (Math.abs(dy) > o.verticalCancelSlop && Math.abs(dy) > Math.abs(dx)) {
77
+ drag = null;
78
+ return;
79
+ }
80
+ if (dx < o.startSlop) return;
81
+ const handle = stack.beginInteractivePop();
82
+ if (!handle) {
83
+ drag = null;
84
+ return;
85
+ }
86
+ drag.handle = handle;
87
+ try {
88
+ drag.target.setPointerCapture?.(ev.pointerId);
89
+ } catch {
90
+ /* capture is best-effort */
91
+ }
92
+ }
93
+ drag.samples.push([ev.clientX, performance.now()]);
94
+ if (drag.samples.length > o.velocitySamples) drag.samples.shift();
95
+ drag.p = 1 - Math.min(1, Math.max(0, (dx - o.startSlop) / stack.width()));
96
+ drag.handle.update(drag.p);
97
+ };
98
+
99
+ const onUp = (ev: PointerEvent) => {
100
+ if (!drag || ev.pointerId !== drag.id) return;
101
+ const d = drag;
102
+ drag = null;
103
+ if (!d.handle) return;
104
+ const s = d.samples;
105
+ const [x1, t1] = s[0];
106
+ const [x2, t2] = s[s.length - 1];
107
+ const velocity = t2 > t1 ? ((x2 - x1) / (t2 - t1)) * 1000 : 0;
108
+ const cancelled = ev.type === 'pointercancel';
109
+ const complete = !cancelled && (velocity > o.completeVelocity || (d.p < 1 - o.completeThreshold && velocity > o.cancelVelocity));
110
+ // The click that follows a drag release must not activate whatever is under the pointer.
111
+ suppressClick = true;
112
+ setTimeout(() => {
113
+ suppressClick = false;
114
+ }, 0);
115
+ void d.handle.finish({ complete, velocity });
116
+ };
117
+
118
+ const onClick = (ev: Event) => {
119
+ if (suppressClick) {
120
+ ev.stopPropagation();
121
+ ev.preventDefault();
122
+ }
123
+ };
124
+
125
+ const EVENTS: Record<string, (ev: PointerEvent) => void> = { pointerdown: onDown, pointermove: onMove, pointerup: onUp, pointercancel: onUp };
126
+ const listen = (el: HTMLElement) => Object.entries(EVENTS).forEach(([k, f]) => el.addEventListener(k, f as EventListener));
127
+ const unlisten = (el: HTMLElement) => Object.entries(EVENTS).forEach(([k, f]) => el.removeEventListener(k, f as EventListener));
128
+
129
+ const refresh = () => {
130
+ if (!strip) return;
131
+ strip.style.width = o.edgeWidth + 'px';
132
+ strip.style.display = o.anywhere || stack.entries.length < 2 ? 'none' : '';
133
+ };
134
+
135
+ const gesture: EdgePanGesture = {
136
+ options: o,
137
+ refresh,
138
+ attach(s) {
139
+ stack = s;
140
+ strip = document.createElement('div');
141
+ strip.setAttribute('aria-hidden', 'true');
142
+ Object.assign(strip.style, { position: 'absolute', left: '0', top: '0', bottom: '0', zIndex: '10', touchAction: 'none' });
143
+ stack.container.append(strip);
144
+ listen(strip);
145
+ listen(stack.container);
146
+ stack.container.addEventListener('click', onClick, true);
147
+ offs = (['push', 'pop', 'replace', 'reset'] as const).map((e) => stack.on(e, refresh));
148
+ refresh();
149
+ return gesture;
150
+ },
151
+ detach() {
152
+ if (!strip) return;
153
+ offs.forEach((f) => f());
154
+ unlisten(strip);
155
+ unlisten(stack.container);
156
+ stack.container.removeEventListener('click', onClick, true);
157
+ strip.remove();
158
+ strip = null;
159
+ },
160
+ };
161
+ return gesture;
162
+ }
@@ -0,0 +1,52 @@
1
+ import type { NavigationStack } from './navigation-stack.ts';
2
+
3
+ export interface BrowserHistoryOptions {
4
+ /** the `history.state` property that carries the depth */
5
+ key?: string;
6
+ /** animate pops triggered by the back button. Off on iOS browsers, which animate their own snapshot */
7
+ animateHistoryPop?: boolean;
8
+ /** forward navigation has no page to show. Re-push something here instead of bouncing back */
9
+ onForward?: ((targetDepth: number) => void) | null;
10
+ }
11
+
12
+ /**
13
+ * For apps without a router. Mirrors the stack depth into `history.state`, so
14
+ * the browser or hardware back button pops the stack and stack pops walk
15
+ * history back. Returns a function that detaches everything.
16
+ */
17
+ export function attachBrowserHistory(stack: NavigationStack, { key = 'snDepth', animateHistoryPop = !isIOSBrowser(), onForward = null }: BrowserHistoryOptions = {}): () => void {
18
+ const depthOf = (state: unknown): number => {
19
+ const s = state as Record<string, unknown> | null;
20
+ return s && Number.isInteger(s[key]) ? (s[key] as number) : 0;
21
+ };
22
+ const write = (kind: 'pushState' | 'replaceState') => history[kind]({ ...((history.state as object) || {}), [key]: stack.depth - 1 }, '');
23
+
24
+ write('replaceState');
25
+
26
+ const offPush = stack.on('push', ({ source }) => {
27
+ if (source !== 'history') write('pushState');
28
+ });
29
+ const offPop = stack.on('pop', ({ source, removed }) => {
30
+ if (source !== 'history') history.go(-removed.length);
31
+ });
32
+
33
+ const onPopState = (ev: PopStateEvent) => {
34
+ const target = depthOf(ev.state) + 1;
35
+ if (target === stack.depth) return;
36
+ if (target < stack.depth) void stack.popTo(target, { animated: animateHistoryPop, source: 'history' });
37
+ else if (onForward) onForward(target);
38
+ else history.back();
39
+ };
40
+ window.addEventListener('popstate', onPopState);
41
+
42
+ return () => {
43
+ offPush();
44
+ offPop();
45
+ window.removeEventListener('popstate', onPopState);
46
+ };
47
+ }
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 ADDED
@@ -0,0 +1,86 @@
1
+ export { NavigationStack } from './navigation-stack.ts';
2
+ export type {
3
+ StackEntry,
4
+ Transition,
5
+ TransitionKind,
6
+ NavigationSource,
7
+ MountOptions,
8
+ NavigationStackOptions,
9
+ InteractivePopHandle,
10
+ SettleInput,
11
+ StackEvents,
12
+ PushEvent,
13
+ PopEvent,
14
+ ReplaceEvent,
15
+ ResetEvent,
16
+ TransitionEvent,
17
+ ProgressEvent,
18
+ } from './navigation-stack.ts';
19
+ export { createIOSTransition, IOS_TRANSITION_CSS_VARS } from './ios-transition.ts';
20
+ export type { IOSTransition, IOSTransitionOptions } from './ios-transition.ts';
21
+ export { cssVars, parseTime, parseNumber, parseRatio, parseEasing } from './css-vars.ts';
22
+ 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';
26
+ export type { BrowserHistoryOptions } from './history-adapter.ts';
27
+ export { cubicBezier, easings, tween, prefersReducedMotion } from './animate.ts';
28
+ export type { Easing, TweenOptions, CancellableTween } from './animate.ts';
29
+ export {
30
+ resolveDirection,
31
+ createDirectionResolver,
32
+ defaultStrategies,
33
+ fromHint,
34
+ fromHistory,
35
+ fromStack,
36
+ fromLevel,
37
+ fromTree,
38
+ always,
39
+ segmentsOf,
40
+ } from './direction.ts';
41
+ export type {
42
+ Direction,
43
+ DirectionOpinion,
44
+ DirectionStrategy,
45
+ DirectionResolver,
46
+ NavigationContext,
47
+ NavigationTrigger,
48
+ RouteRef,
49
+ LevelOptions,
50
+ TreeOptions,
51
+ } from './direction.ts';
52
+ export { STACKNAV_CSS, STACKNAV_STYLE_ID, injectStyles } from './styles.ts';
53
+
54
+ 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';
57
+
58
+ export interface IOSStackOptions {
59
+ container: HTMLElement;
60
+ transition?: Partial<IOSTransitionOptions>;
61
+ gesture?: Partial<EdgePanGestureOptions>;
62
+ }
63
+
64
+ export interface IOSStack extends NavigationStack {
65
+ transition: IOSTransition;
66
+ gesture: EdgePanGesture;
67
+ }
68
+
69
+ /**
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.
73
+ */
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;
80
+ const destroy = stack.destroy.bind(stack);
81
+ stack.destroy = () => {
82
+ g.detach();
83
+ destroy();
84
+ };
85
+ return stack;
86
+ }