@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
@@ -0,0 +1,163 @@
1
+ import { easings, prefersReducedMotion, type Easing } from './animate.ts';
2
+ import { cssVars, parseEasing, parseNumber, parseRatio, parseTime } from './css-vars.ts';
3
+ import type { SettleInput, StackEntry, Transition } from './navigation-stack.ts';
4
+
5
+ export interface IOSTransitionOptions {
6
+ /** ms, programmatic push/pop */
7
+ duration: number;
8
+ /** the curve a programmatic push/pop runs on */
9
+ ease: Easing;
10
+ /** fraction of the width the lower page travels */
11
+ parallax: number;
12
+ dimColor: string;
13
+ /** lower-page overlay opacity at p = 1 (≈0.35 suits dark UIs) */
14
+ dimMax: number;
15
+ /** box-shadow on the incoming page */
16
+ shadow: string;
17
+ /** ms bounds for finishing an interactive pop */
18
+ settleMin: number;
19
+ settleMax: number;
20
+ /** the curve the remaining distance of an interactive pop runs on */
21
+ settleEase: Easing;
22
+ /** px/s assumed when the pointer was slower than this */
23
+ settleVelocityFloor: number;
24
+ /** multiplies every duration, for slow motion and tests */
25
+ timeScale: number;
26
+ }
27
+
28
+ /** The CSS custom property behind each option. */
29
+ export const IOS_TRANSITION_CSS_VARS: Readonly<Record<keyof IOSTransitionOptions, string>> = /*#__PURE__*/ Object.freeze({
30
+ duration: '--sn-duration',
31
+ ease: '--sn-easing',
32
+ parallax: '--sn-parallax',
33
+ dimColor: '--sn-dim-color',
34
+ dimMax: '--sn-dim-max',
35
+ shadow: '--sn-shadow',
36
+ settleMin: '--sn-settle-min',
37
+ settleMax: '--sn-settle-max',
38
+ settleEase: '--sn-settle-easing',
39
+ settleVelocityFloor: '--sn-settle-velocity-floor',
40
+ timeScale: '--sn-time-scale',
41
+ });
42
+
43
+ export interface IOSTransition extends Transition {
44
+ /** The JS options: the defaults with the caller's merged in. Mutable at runtime. */
45
+ readonly options: IOSTransitionOptions;
46
+ /** The values currently in force: `options` with the CSS variables applied over them. */
47
+ readonly resolved: Readonly<IOSTransitionOptions>;
48
+ /**
49
+ * Re-reads the CSS variables, from `el` or from the container of the last
50
+ * transition. Called at the start of every transition. Call it directly
51
+ * after changing `options` or the variables mid-animation.
52
+ */
53
+ refresh(el?: Element | null): void;
54
+ }
55
+
56
+ const DIM = /*#__PURE__*/ Symbol('dim');
57
+ type Dimmable = StackEntry & { [DIM]?: HTMLElement };
58
+
59
+ /**
60
+ * The iOS navigation transition: the upper page slides in from the trailing
61
+ * edge with a shadow on its leading edge, while the lower page parallaxes
62
+ * toward the leading edge and dims. Every value is a function of one number, p.
63
+ *
64
+ * Every option is also a CSS custom property on the container (see
65
+ * `IOS_TRANSITION_CSS_VARS`), read when a transition starts. A variable that
66
+ * is set wins over the JS option, so a stylesheet can slow the animation down
67
+ * or restyle it per theme without the app rebuilding the transition.
68
+ */
69
+ export function createIOSTransition(options: Partial<IOSTransitionOptions> = {}): IOSTransition {
70
+ const o: IOSTransitionOptions = {
71
+ duration: 500,
72
+ ease: easings.ios,
73
+ parallax: 0.3,
74
+ dimColor: '#000',
75
+ dimMax: 0.1,
76
+ shadow: '-3px 0 14px rgba(0,0,0,0.16)',
77
+ settleMin: 120,
78
+ settleMax: 400,
79
+ settleEase: easings.easeOut,
80
+ settleVelocityFloor: 900,
81
+ timeScale: 1,
82
+ ...options,
83
+ };
84
+
85
+ let root: Element | null = null;
86
+ let r: IOSTransitionOptions = { ...o };
87
+
88
+ const refresh = (el?: Element | null): void => {
89
+ if (el !== undefined) root = el;
90
+ const read = cssVars(root);
91
+ const v = IOS_TRANSITION_CSS_VARS;
92
+ r = {
93
+ duration: parseTime(read(v.duration)) ?? o.duration,
94
+ ease: parseEasing(read(v.ease)) ?? o.ease,
95
+ parallax: parseRatio(read(v.parallax)) ?? o.parallax,
96
+ dimColor: read(v.dimColor) ?? o.dimColor,
97
+ dimMax: parseRatio(read(v.dimMax)) ?? o.dimMax,
98
+ shadow: read(v.shadow) ?? o.shadow,
99
+ settleMin: parseTime(read(v.settleMin)) ?? o.settleMin,
100
+ settleMax: parseTime(read(v.settleMax)) ?? o.settleMax,
101
+ settleEase: parseEasing(read(v.settleEase)) ?? o.settleEase,
102
+ settleVelocityFloor: parseNumber(read(v.settleVelocityFloor)) ?? o.settleVelocityFloor,
103
+ timeScale: parseNumber(read(v.timeScale)) ?? o.timeScale,
104
+ };
105
+ };
106
+
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;
114
+ }
115
+ d.style.background = r.dimColor;
116
+ if (d.parentElement !== entry.el) entry.el.append(d);
117
+ return d;
118
+ };
119
+
120
+ return {
121
+ options: o,
122
+ get resolved(): Readonly<IOSTransitionOptions> {
123
+ return r;
124
+ },
125
+ refresh,
126
+ get duration(): number {
127
+ return prefersReducedMotion() ? 0 : r.duration * r.timeScale;
128
+ },
129
+ get ease(): Easing {
130
+ return r.ease;
131
+ },
132
+
133
+ /** How long the remaining distance of an interactive pop should take. */
134
+ settle({ remainingPx, velocity }: SettleInput) {
135
+ if (prefersReducedMotion()) return { duration: 0, ease: r.settleEase };
136
+ const raw = (remainingPx / Math.max(Math.abs(velocity), r.settleVelocityFloor)) * 1000;
137
+ return { duration: Math.min(r.settleMax, Math.max(r.settleMin, raw)) * r.timeScale, ease: r.settleEase };
138
+ },
139
+
140
+ begin(lower, upper) {
141
+ refresh(upper.el.parentElement);
142
+ upper.el.style.boxShadow = r.shadow;
143
+ if (lower) dimOf(lower);
144
+ },
145
+ apply(lower, upper, p) {
146
+ const w = upper.el.parentElement?.clientWidth ?? 0;
147
+ upper.el.style.transform = `translate3d(${(1 - p) * w}px,0,0)`;
148
+ if (lower) {
149
+ lower.el.style.transform = `translate3d(${-p * r.parallax * w}px,0,0)`;
150
+ dimOf(lower).style.opacity = String(p * r.dimMax);
151
+ }
152
+ },
153
+ end(lower, upper) {
154
+ upper.el.style.boxShadow = '';
155
+ 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
+ }
161
+ },
162
+ };
163
+ }
@@ -0,0 +1,362 @@
1
+ import { tween, type Easing } from './animate.ts';
2
+
3
+ /** One mounted page. `key` and `data` belong to the caller; the stack only carries them. */
4
+ export interface StackEntry<T = unknown> {
5
+ el: HTMLElement;
6
+ index: number;
7
+ key: string | null;
8
+ data: T | null;
9
+ }
10
+
11
+ export interface SettleInput {
12
+ remainingPx: number;
13
+ velocity: number;
14
+ }
15
+
16
+ /**
17
+ * Describes what the pages look like at any progress p (1 = upper page fully
18
+ * open, 0 = upper page fully off-screen). Push runs p from 0 to 1, pop from
19
+ * 1 to 0.
20
+ */
21
+ export interface Transition {
22
+ readonly duration: number;
23
+ readonly ease: Easing;
24
+ settle(input: SettleInput): { duration: number; ease: Easing };
25
+ begin?(lower: StackEntry | null, upper: StackEntry): void;
26
+ apply(lower: StackEntry | null, upper: StackEntry, p: number): void;
27
+ end?(lower: StackEntry | null, upper: StackEntry): void;
28
+ }
29
+
30
+ export type TransitionKind = 'push' | 'pop' | 'interactive';
31
+
32
+ /** Where an operation came from: `api`, `gesture`, `history`, or any value a port defines. */
33
+ export type NavigationSource = 'api' | 'gesture' | 'history' | (string & {});
34
+
35
+ export interface MountOptions<T = unknown> {
36
+ animated?: boolean;
37
+ data?: T | null;
38
+ key?: string | null;
39
+ source?: NavigationSource;
40
+ }
41
+
42
+ export interface PushEvent { entry: StackEntry; entries: StackEntry[]; source: NavigationSource }
43
+ export interface PopEvent { entry: StackEntry; removed: StackEntry[]; entries: StackEntry[]; source: NavigationSource }
44
+ export interface ReplaceEvent { entry: StackEntry; removed: StackEntry[]; entries: StackEntry[]; source: NavigationSource }
45
+ export interface ResetEvent { entries: StackEntry[]; removed: StackEntry[]; source: NavigationSource }
46
+ export interface TransitionEvent { lower: StackEntry | null; upper: StackEntry; kind: TransitionKind }
47
+ export interface ProgressEvent { lower: StackEntry | null; upper: StackEntry; p: number }
48
+
49
+ export interface StackEvents {
50
+ push: PushEvent;
51
+ pop: PopEvent;
52
+ replace: ReplaceEvent;
53
+ reset: ResetEvent;
54
+ transitionstart: TransitionEvent;
55
+ progress: ProgressEvent;
56
+ transitionend: TransitionEvent;
57
+ }
58
+
59
+ export interface InteractivePopHandle {
60
+ /** p = 1 fully open … 0 fully popped */
61
+ update(p: number): void;
62
+ /** Resolves when the settle animation ends. */
63
+ finish(input: { complete: boolean; velocity?: number }): Promise<void>;
64
+ }
65
+
66
+ export interface NavigationStackOptions {
67
+ container: HTMLElement;
68
+ transition: Transition;
69
+ pageClass?: string;
70
+ }
71
+
72
+ type Listener<E> = (detail: E) => void;
73
+
74
+ /**
75
+ * A stack of page elements inside one container. The stack owns mounting,
76
+ * ordering, visibility and the transition lifecycle. A `Transition` decides
77
+ * what the pages look like at any progress p.
78
+ *
79
+ * Operations are serialized: one called during a transition waits its turn.
80
+ */
81
+ export class NavigationStack {
82
+ readonly container: HTMLElement;
83
+ transition: Transition;
84
+ readonly pageClass: string;
85
+ entries: StackEntry[] = [];
86
+ busy = false;
87
+ private _queue: Array<() => void> = [];
88
+ private _listeners = new Map<string, Set<Listener<unknown>>>();
89
+
90
+ constructor({ container, transition, pageClass = 'sn-page' }: NavigationStackOptions) {
91
+ if (!container || !transition) throw new Error('NavigationStack needs { container, transition }');
92
+ this.container = container;
93
+ this.transition = transition;
94
+ this.pageClass = pageClass;
95
+ container.classList.add('sn-container');
96
+ }
97
+
98
+ // ---------------------------------------------------------------- state
99
+ get depth(): number {
100
+ return this.entries.length;
101
+ }
102
+ get top(): StackEntry | null {
103
+ return this.entries[this.entries.length - 1] || null;
104
+ }
105
+ width(): number {
106
+ return this.container.clientWidth;
107
+ }
108
+ canPop(): boolean {
109
+ return !this.busy && this.entries.length > 1;
110
+ }
111
+ /** The entry holding `el`, or the entry with `key`, if either is mounted. */
112
+ entryOf(elOrKey: HTMLElement | string): StackEntry | null {
113
+ return this.entries.find((e) => (typeof elOrKey === 'string' ? e.key === elOrKey : e.el === elOrKey)) || null;
114
+ }
115
+
116
+ on<K extends keyof StackEvents>(event: K, fn: Listener<StackEvents[K]>): () => void {
117
+ if (!this._listeners.has(event)) this._listeners.set(event, new Set());
118
+ const set = this._listeners.get(event)!;
119
+ set.add(fn as Listener<unknown>);
120
+ return () => {
121
+ set.delete(fn as Listener<unknown>);
122
+ };
123
+ }
124
+ private _emit<K extends keyof StackEvents>(event: K, detail: StackEvents[K]): void {
125
+ const set = this._listeners.get(event);
126
+ if (set) set.forEach((fn) => fn(detail));
127
+ }
128
+
129
+ // ------------------------------------------------------------ operations
130
+ /**
131
+ * Pushes an element, or a function returning one. Resolves with the entry
132
+ * once the transition has finished. An element already mounted lower in the
133
+ * stack is moved to the top.
134
+ */
135
+ push<T = unknown>(elOrFactory: HTMLElement | (() => HTMLElement), { animated = true, data = null, key = null, source = 'api' }: MountOptions<T> = {}): Promise<StackEntry> {
136
+ return this._run(async () => {
137
+ const el = typeof elOrFactory === 'function' ? elOrFactory() : elOrFactory;
138
+ this._forget(el);
139
+ const lower = this.top;
140
+ const upper = this._mount(el, this.entries.length, data, key);
141
+ this.entries.push(upper);
142
+ await this._transition(lower, upper, 0, 1, animated, 'push');
143
+ this._settle();
144
+ this._emit('push', { entry: upper, entries: this.entries.slice(), source });
145
+ return upper;
146
+ });
147
+ }
148
+
149
+ /** Pops one level. Resolves with the removed entry; its element is detached, not destroyed. */
150
+ pop(opts: { animated?: boolean; source?: NavigationSource } = {}): Promise<StackEntry | null> {
151
+ return this.popTo(this.entries.length - 1, opts);
152
+ }
153
+
154
+ /** Pops until `depth` entries remain (≥ 1). Intermediate pages are removed without animation. */
155
+ popTo(depth: number, { animated = true, source = 'api' }: { animated?: boolean; source?: NavigationSource } = {}): Promise<StackEntry | null> {
156
+ return this._run(async () => {
157
+ if (depth < 1 || this.entries.length <= depth) return null;
158
+ return this._popRevealing(depth, animated, source);
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Pops the top page, revealing `el`. If `el` is already mounted beneath the
164
+ * top, everything above it is removed, as in `popTo`. If it is not mounted,
165
+ * it is placed directly beneath the top first, so a page that no longer
166
+ * exists (for example a fresh instance after a deep link) still arrives with
167
+ * a pop.
168
+ */
169
+ popWith<T = unknown>(el: HTMLElement, { animated = true, data = null, key = null, source = 'api' }: MountOptions<T> = {}): Promise<StackEntry | null> {
170
+ return this._run(async () => {
171
+ if (!this.entries.length) {
172
+ const entry = this._mount(el, 0, data, key);
173
+ this.entries.push(entry);
174
+ this._settle();
175
+ this._emit('push', { entry, entries: this.entries.slice(), source });
176
+ return null;
177
+ }
178
+ const top = this.top!;
179
+ if (top.el === el) return null;
180
+ const existing = this.entries.findIndex((e) => e.el === el);
181
+ if (existing >= 0) return this._popRevealing(existing + 1, animated, source);
182
+ const lower = this._mount(el, this.entries.length - 1, data, key, top.el);
183
+ this.entries.splice(this.entries.length - 1, 0, lower);
184
+ return this._popRevealing(this.entries.length - 1, animated, source);
185
+ });
186
+ }
187
+
188
+ /** Swaps the top page for `el` without animation. Returns the removed entry. */
189
+ replace<T = unknown>(el: HTMLElement, { data = null, key = null, source = 'api' }: MountOptions<T> = {}): Promise<StackEntry | null> {
190
+ return this._run(async () => {
191
+ const old = this.top;
192
+ if (old && old.el === el) return null;
193
+ this._forget(el);
194
+ const removed = old ? [this._unmount(this.entries.pop()!)] : [];
195
+ const entry = this._mount(el, this.entries.length, data, key);
196
+ this.entries.push(entry);
197
+ this._settle();
198
+ this._emit('replace', { entry, removed, entries: this.entries.slice(), source });
199
+ return removed[0] || null;
200
+ });
201
+ }
202
+
203
+ /**
204
+ * Convenience for ports that already resolved the direction: `push`, `pop`
205
+ * (via `popWith`) or `replace`.
206
+ */
207
+ present<T = unknown>(el: HTMLElement, direction: 'push' | 'pop' | 'replace', opts: MountOptions<T> = {}): Promise<StackEntry | null> {
208
+ if (direction === 'pop') return this.popWith(el, opts);
209
+ if (direction === 'replace') return this.replace(el, opts);
210
+ return this.push(el, opts);
211
+ }
212
+
213
+ /** Removes a mounted page without animation, wherever it sits. Returns its entry, or null. */
214
+ remove(el: HTMLElement, { source = 'api' }: { source?: NavigationSource } = {}): Promise<StackEntry | null> {
215
+ return this._run(async () => {
216
+ const entry = this._forget(el);
217
+ if (!entry) return null;
218
+ this._settle();
219
+ this._emit('pop', { entry, removed: [entry], entries: this.entries.slice(), source });
220
+ return entry;
221
+ });
222
+ }
223
+
224
+ /** Replaces the whole stack without animation. Returns the removed entries. */
225
+ reset(elements: HTMLElement[], { source = 'api' }: { source?: NavigationSource } = {}): Promise<StackEntry[]> {
226
+ return this._run(async () => {
227
+ const removed: StackEntry[] = [];
228
+ while (this.entries.length) removed.push(this._unmount(this.entries.pop()!));
229
+ elements.forEach((el, i) => this.entries.push(this._mount(el, i, null, null)));
230
+ this._settle();
231
+ this._emit('reset', { entries: this.entries.slice(), removed, source });
232
+ return removed;
233
+ });
234
+ }
235
+
236
+ /** Starts a pointer-driven pop. Returns null if the stack cannot pop right now. */
237
+ beginInteractivePop(): InteractivePopHandle | null {
238
+ if (!this.canPop()) return null;
239
+ this._setBusy(true);
240
+ const upper = this.top!;
241
+ const lower = this.entries[this.entries.length - 2];
242
+ let p = 1;
243
+ this._begin(lower, upper, 'interactive');
244
+ return {
245
+ update: (v) => {
246
+ p = Math.min(1, Math.max(0, v));
247
+ this._apply(lower, upper, p);
248
+ },
249
+ finish: async ({ complete, velocity = 0 }) => {
250
+ const remainingPx = (complete ? p : 1 - p) * this.width();
251
+ 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) });
253
+ this._end(lower, upper, 'interactive');
254
+ if (complete) {
255
+ this.entries.pop();
256
+ this._unmount(upper);
257
+ }
258
+ this._settle();
259
+ this._setBusy(false);
260
+ if (complete) this._emit('pop', { entry: upper, removed: [upper], entries: this.entries.slice(), source: 'gesture' });
261
+ this._drain();
262
+ },
263
+ };
264
+ }
265
+
266
+ destroy(): void {
267
+ while (this.entries.length) this._unmount(this.entries.pop()!);
268
+ this.container.classList.remove('sn-container');
269
+ }
270
+
271
+ // -------------------------------------------------------------- internals
272
+ private _setBusy(v: boolean): void {
273
+ this.busy = v;
274
+ this.container.classList.toggle('sn-busy', v);
275
+ }
276
+
277
+ /** Serializes operations: while a transition runs, later calls wait their turn. */
278
+ private _run<R>(fn: () => Promise<R>): Promise<R> {
279
+ return new Promise<R>((resolve, reject) => {
280
+ const task = async () => {
281
+ this._setBusy(true);
282
+ try {
283
+ resolve(await fn());
284
+ } catch (e) {
285
+ reject(e);
286
+ } finally {
287
+ this._setBusy(false);
288
+ this._drain();
289
+ }
290
+ };
291
+ if (this.busy) this._queue.push(task);
292
+ else task();
293
+ });
294
+ }
295
+ private _drain(): void {
296
+ if (!this.busy && this._queue.length) this._queue.shift()!();
297
+ }
298
+
299
+ private async _popRevealing(depth: number, animated: boolean, source: NavigationSource): Promise<StackEntry> {
300
+ const upper = this.entries.pop()!; // the visible page: it animates out
301
+ const removed: StackEntry[] = [];
302
+ while (this.entries.length > depth) removed.push(this._unmount(this.entries.pop()!)); // intermediate pages: removed without animation
303
+ const lower = this.top;
304
+ await this._transition(lower, upper, 1, 0, animated, 'pop');
305
+ removed.push(this._unmount(upper));
306
+ this._settle();
307
+ this._emit('pop', { entry: upper, removed, entries: this.entries.slice(), source });
308
+ return upper;
309
+ }
310
+
311
+ private _mount(el: HTMLElement, index: number, data: unknown, key: string | null, before: HTMLElement | null = null): StackEntry {
312
+ el.classList.add(this.pageClass);
313
+ if (before) this.container.insertBefore(el, before);
314
+ else if (el.parentElement !== this.container) this.container.append(el);
315
+ return { el, index, key, data };
316
+ }
317
+ private _unmount(entry: StackEntry): StackEntry {
318
+ entry.el.classList.remove(this.pageClass, 'sn-page-visible');
319
+ entry.el.style.transform = '';
320
+ entry.el.remove();
321
+ return entry;
322
+ }
323
+ /** Drops `el` from the entries if it is mounted. Returns its old entry. */
324
+ private _forget(el: HTMLElement): StackEntry | null {
325
+ const i = this.entries.findIndex((e) => e.el === el);
326
+ if (i < 0) return null;
327
+ const [entry] = this.entries.splice(i, 1);
328
+ return this._unmount(entry);
329
+ }
330
+
331
+ 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');
334
+ this.transition.begin?.(lower, upper);
335
+ this._emit('transitionstart', { lower, upper, kind });
336
+ }
337
+ private _apply(lower: StackEntry | null, upper: StackEntry, p: number): void {
338
+ this.transition.apply(lower, upper, p);
339
+ this._emit('progress', { lower, upper, p });
340
+ }
341
+ private _end(lower: StackEntry | null, upper: StackEntry, kind: TransitionKind): void {
342
+ this.transition.end?.(lower, upper);
343
+ this._emit('transitionend', { lower, upper, kind });
344
+ }
345
+ private async _transition(lower: StackEntry | null, upper: StackEntry, from: number, to: number, animated: boolean, kind: TransitionKind): Promise<void> {
346
+ this._begin(lower, upper, kind);
347
+ 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) });
350
+ this._end(lower, upper, kind);
351
+ }
352
+
353
+ /** Makes only the top page visible, resets every transform, renumbers the indexes. */
354
+ private _settle(): void {
355
+ const top = this.top;
356
+ this.entries.forEach((e, i) => {
357
+ e.index = i;
358
+ e.el.classList.toggle('sn-page-visible', e === top);
359
+ e.el.style.transform = e === top ? 'translate3d(0,0,0)' : '';
360
+ });
361
+ }
362
+ }
package/src/styles.ts ADDED
@@ -0,0 +1,40 @@
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.
7
+ *
8
+ * The string is kept minified because it ships inside every consumer's JS
9
+ * bundle (`injectStyles()` is the default path); `scripts/write-css.mjs`
10
+ * expands it into the readable `dist/stacknav.css`. What each rule is for:
11
+ *
12
+ * - `.sn-container`: the stack's scroll-clipping frame.
13
+ * - `.sn-page`: absolutely fills the container and is its own scroll container.
14
+ * `touch-action: pan-y` keeps vertical scrolling native while horizontal
15
+ * drags reach the gesture; `visibility: hidden` keeps pages beneath the top
16
+ * mounted (scroll position, form state) but out of sight and out of the
17
+ * accessibility tree.
18
+ * - `.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.
20
+ */
21
+ export const STACKNAV_CSS =
22
+ '.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}' +
24
+ '.sn-page-visible{visibility:visible}' +
25
+ '.sn-busy .sn-page{pointer-events:none}';
26
+
27
+ export const STACKNAV_STYLE_ID = 'stacknav-styles';
28
+
29
+ /**
30
+ * Inserts the engine's stylesheet into `doc` once. Framework ports call this so
31
+ * apps need no stylesheet import. Apps that ship `stacknav.css` themselves can
32
+ * skip it.
33
+ */
34
+ export function injectStyles(doc: Document | null = typeof document === 'undefined' ? null : document): void {
35
+ if (!doc || doc.getElementById(STACKNAV_STYLE_ID)) return;
36
+ const style = doc.createElement('style');
37
+ style.id = STACKNAV_STYLE_ID;
38
+ style.textContent = STACKNAV_CSS;
39
+ (doc.head ?? doc.documentElement).append(style);
40
+ }