ngx-stack 22.0.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.
@@ -0,0 +1,2391 @@
1
+ import * as i0 from '@angular/core';
2
+ import { InjectionToken, inject, DOCUMENT, Injectable, makeEnvironmentProviders, provideEnvironmentInitializer, DestroyRef, signal, computed, output, ViewContainerRef, EnvironmentInjector, ChangeDetectorRef, ElementRef, Directive, input, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, Component, Injector } from '@angular/core';
3
+ import { Router, NavigationStart, NavigationEnd, RouteReuseStrategy, ActivatedRoute, ChildrenOutletContexts, PRIMARY_OUTLET, NavigationCancel, NavigationError } from '@angular/router';
4
+ import { Location } from '@angular/common';
5
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
6
+
7
+ const NGX_STACK_DEFAULTS = {
8
+ platform: 'auto',
9
+ direction: 'auto',
10
+ duration: 420,
11
+ deepLinks: false,
12
+ maxDepth: 0,
13
+ manageFocus: true,
14
+ guardPolicy: 'block',
15
+ swipeBack: 'auto',
16
+ swipeEdgeWidth: 50,
17
+ swipeThreshold: 0.5,
18
+ swipeVelocityThreshold: 0.35,
19
+ systemGesture: 'inset',
20
+ systemEdgeInset: 16,
21
+ swipeWithMouse: false,
22
+ animateRoot: false,
23
+ respectReducedMotion: true,
24
+ };
25
+ const NGX_STACK_CONFIG = new InjectionToken('ngx-stack.config', {
26
+ providedIn: 'root',
27
+ factory: () => NGX_STACK_DEFAULTS,
28
+ });
29
+
30
+ function detectPlatform(win) {
31
+ const nav = win.navigator;
32
+ const ua = nav?.userAgent ?? '';
33
+ // iPadOS 13+ reports itself as a Mac, so a Mac with a touchscreen is really an iPad.
34
+ const isIos = /iPad|iPhone|iPod/.test(ua) || (/Macintosh/.test(ua) && (nav?.maxTouchPoints ?? 0) > 1);
35
+ const isAndroid = /Android/.test(ua);
36
+ const shell = win;
37
+ const isCapacitor = !!shell.Capacitor?.isNativePlatform?.();
38
+ const isCordova = !isCapacitor && shell.cordova !== undefined;
39
+ const isNative = isCapacitor || isCordova;
40
+ const isStandalonePwa = nav?.standalone === true ||
41
+ win.matchMedia?.('(display-mode: standalone)').matches === true;
42
+ return {
43
+ kind: isIos ? 'ios' : isAndroid ? 'android' : 'web',
44
+ isIos,
45
+ isAndroid,
46
+ isCapacitor,
47
+ isCordova,
48
+ isNative,
49
+ isStandalonePwa,
50
+ // Note this keys off `isNative`, not `isCapacitor`. Getting that wrong would make an iOS
51
+ // Cordova app needlessly concede the first 16px of the screen edge to a gesture its webview
52
+ // does not have.
53
+ hasSystemBackGesture: isIos && !isNative,
54
+ };
55
+ }
56
+ const NGX_STACK_PLATFORM = new InjectionToken('ngx-stack.platform', {
57
+ providedIn: 'root',
58
+ factory: () => {
59
+ const doc = inject(DOCUMENT);
60
+ const win = doc.defaultView;
61
+ if (!win) {
62
+ // SSR / no DOM: nothing animates anyway, so report the dullest possible platform.
63
+ return {
64
+ kind: 'web',
65
+ isIos: false,
66
+ isAndroid: false,
67
+ isCapacitor: false,
68
+ isCordova: false,
69
+ isNative: false,
70
+ isStandalonePwa: false,
71
+ hasSystemBackGesture: false,
72
+ };
73
+ }
74
+ return detectPlatform(win);
75
+ },
76
+ });
77
+
78
+ /** Split a URL into path segments, dropping the query and fragment. */
79
+ function segmentsOf(url) {
80
+ return url.split(/[?#]/, 1)[0].split('/').filter(Boolean);
81
+ }
82
+ /**
83
+ * Find the route a set of URL segments would land on.
84
+ *
85
+ * A deliberately small re-implementation of route matching, because Angular's recogniser is not
86
+ * public and we need an answer *before* any navigation has happened. It understands static
87
+ * segments, `:params`, and children — which is what a URL hierarchy is made of.
88
+ *
89
+ * What it does not understand is `loadChildren`: the child routes are behind a dynamic import and
90
+ * simply don't exist yet. Rather than guess, it declines to match into a lazy subtree, so pages in
91
+ * one won't have a parent derived for them. Say so explicitly with `data: { parent }`.
92
+ */
93
+ function findRoute(routes, segments) {
94
+ if (!routes)
95
+ return null;
96
+ for (const route of routes) {
97
+ // A redirect isn't a page, and `**` matches everything — it would happily claim to be the
98
+ // parent of anything, which is worse than having no parent at all.
99
+ if (route.redirectTo !== undefined || route.path === '**')
100
+ continue;
101
+ const path = (route.path ?? '').split('/').filter(Boolean);
102
+ if (segments.length < path.length)
103
+ continue;
104
+ const consumed = path.every((part, i) => part.startsWith(':') || part === segments[i]);
105
+ if (!consumed)
106
+ continue;
107
+ const rest = segments.slice(path.length);
108
+ if (rest.length === 0) {
109
+ if (route.component || route.loadComponent)
110
+ return route;
111
+ // A componentless wrapper: the page is its empty-path child.
112
+ const child = findRoute(route.children, []);
113
+ if (child)
114
+ return child;
115
+ continue;
116
+ }
117
+ const child = findRoute(route.children, rest);
118
+ if (child)
119
+ return child;
120
+ }
121
+ return null;
122
+ }
123
+ /**
124
+ * Where a page sits, when it's the first thing the app opened.
125
+ *
126
+ * Two sources, in order:
127
+ *
128
+ * 1. **`data: { parent: '/inbox' }`** on the route. The same declaration the back button uses, so
129
+ * you write it once and it drives both. Necessary whenever the URL doesn't tell the truth about
130
+ * the hierarchy — flat routes like `/item/42`, or a detail page reachable from two places.
131
+ *
132
+ * 2. **The URL's own nesting.** `/inbox/item/12/notes` → `/inbox/item/12` → `/inbox`: drop
133
+ * segments until what's left is a real page. Costs nothing to set up and is right most of the
134
+ * time, because most apps already nest their URLs the way their screens nest.
135
+ */
136
+ function deriveParentUrl(routes, url) {
137
+ const segments = segmentsOf(url);
138
+ if (segments.length === 0)
139
+ return null;
140
+ const declared = findRoute(routes, segments)?.data?.['parent'];
141
+ if (typeof declared === 'string')
142
+ return declared;
143
+ for (let length = segments.length - 1; length > 0; length--) {
144
+ const candidate = segments.slice(0, length);
145
+ const route = findRoute(routes, candidate);
146
+ if (route?.component || route?.loadComponent) {
147
+ return `/${candidate.join('/')}`;
148
+ }
149
+ }
150
+ return null;
151
+ }
152
+
153
+ /**
154
+ * Turns off Angular's own route store/detach machinery, because the stack does that job
155
+ * instead — and better, for this purpose: Angular's version stores a detached view and
156
+ * gives it back on request, while the stack keeps every page mounted and on screen, which
157
+ * is what lets a swipe reveal the page underneath before any navigation has happened.
158
+ *
159
+ * The one thing we do care about is `shouldReuseRoute`. Angular's default reuses a route
160
+ * whose config matches, so `/item/1 → /item/2` would recycle the same component instance.
161
+ * On a stack those are two different pages, and going back from one to the other has to
162
+ * work, so a change of params means a new page.
163
+ */
164
+ class NgxStackRouteReuseStrategy {
165
+ shouldDetach() {
166
+ return false;
167
+ }
168
+ shouldAttach() {
169
+ return false;
170
+ }
171
+ store() {
172
+ // no-op
173
+ }
174
+ retrieve() {
175
+ return null;
176
+ }
177
+ shouldReuseRoute(future, curr) {
178
+ if (future.routeConfig !== curr.routeConfig)
179
+ return false;
180
+ const futureParams = future.params;
181
+ const currParams = curr.params;
182
+ const futureKeys = Object.keys(futureParams);
183
+ if (futureKeys.length !== Object.keys(currParams).length)
184
+ return false;
185
+ return futureKeys.every((key) => futureParams[key] === currParams[key]);
186
+ }
187
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: NgxStackRouteReuseStrategy, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
188
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: NgxStackRouteReuseStrategy });
189
+ }
190
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: NgxStackRouteReuseStrategy, decorators: [{
191
+ type: Injectable
192
+ }] });
193
+
194
+ /**
195
+ * A shadow copy of where we are in the browser's history.
196
+ *
197
+ * The browser will not tell you what `history.back()` would land on, and with tabs we have to
198
+ * know. History is one linear thread; tabs are several independent stacks. Drill into Inbox,
199
+ * switch to Search, drill in there — now the entry behind you is an *Inbox* page, and going
200
+ * "back" in Search must not take you to it. So we keep our own list and cursor, and whoever is
201
+ * popping can ask whether plain `history.back()` happens to do the right thing.
202
+ */
203
+ class NgxStackHistory {
204
+ router = inject(Router);
205
+ entries = [];
206
+ cursor = -1;
207
+ trigger = 'imperative';
208
+ replacing = false;
209
+ constructor() {
210
+ this.router.events.pipe(takeUntilDestroyed()).subscribe((event) => {
211
+ if (event instanceof NavigationStart) {
212
+ this.trigger = event.navigationTrigger ?? 'imperative';
213
+ this.replacing = this.router.getCurrentNavigation()?.extras.replaceUrl === true;
214
+ return;
215
+ }
216
+ if (event instanceof NavigationEnd) {
217
+ this.record(event.urlAfterRedirects);
218
+ }
219
+ });
220
+ }
221
+ /** Where `history.back()` would actually land, or `null` if there's nothing behind us. */
222
+ previousUrl() {
223
+ return this.cursor > 0 ? this.entries[this.cursor - 1] : null;
224
+ }
225
+ record(url) {
226
+ if (this.trigger === 'popstate') {
227
+ // The browser moved the cursor. Work out which way by looking either side of it.
228
+ if (this.cursor > 0 && this.entries[this.cursor - 1] === url) {
229
+ this.cursor--;
230
+ return;
231
+ }
232
+ if (this.cursor + 1 < this.entries.length && this.entries[this.cursor + 1] === url) {
233
+ this.cursor++;
234
+ return;
235
+ }
236
+ // A jump of more than one step, or into history from before the app loaded. We can't map
237
+ // it, so drop what we think we know rather than answer confidently and wrongly.
238
+ this.entries = [url];
239
+ this.cursor = 0;
240
+ return;
241
+ }
242
+ if (this.replacing && this.cursor >= 0) {
243
+ this.entries[this.cursor] = url;
244
+ return;
245
+ }
246
+ // A fresh navigation discards anything ahead of the cursor, exactly as the browser does.
247
+ this.entries = this.entries.slice(0, this.cursor + 1);
248
+ this.entries.push(url);
249
+ this.cursor = this.entries.length - 1;
250
+ }
251
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: NgxStackHistory, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
252
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: NgxStackHistory, providedIn: 'root' });
253
+ }
254
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: NgxStackHistory, decorators: [{
255
+ type: Injectable,
256
+ args: [{ providedIn: 'root' }]
257
+ }], ctorParameters: () => [] });
258
+
259
+ /** Key we tuck the intent under in `NavigationExtras.state`. */
260
+ const NGX_STACK_DIRECTION = '__ngxStackDirection';
261
+ /** Lets a caller force a transition to be instant, e.g. while rebuilding a stack at startup. */
262
+ const NGX_STACK_ANIMATED = '__ngxStackAnimated';
263
+ /**
264
+ * Navigation with an explicit stack intent.
265
+ *
266
+ * You don't have to use this for pushing — plain `router.navigate()` works, and the outlet infers
267
+ * the direction by checking whether the target URL is already on the stack. It's for the cases
268
+ * where that inference is wrong, and for going back, which is subtler than it looks.
269
+ */
270
+ class NgxStackNav {
271
+ router = inject(Router);
272
+ location = inject(Location);
273
+ history = inject(NgxStackHistory);
274
+ target = null;
275
+ /** @internal Called by `NgxStackOutlet`. */
276
+ registerStack(target) {
277
+ this.target = target;
278
+ }
279
+ /** @internal */
280
+ unregisterStack(target) {
281
+ if (this.target === target)
282
+ this.target = null;
283
+ }
284
+ /**
285
+ * Is there anywhere to go back to *within the app*?
286
+ *
287
+ * Ask this, not the shell. Capacitor and Cordova both report whether the *webview* can go back,
288
+ * which is a statement about history — and history is a single linear thread while tabs are
289
+ * several stacks, so it says yes when the only thing behind you is a different tab.
290
+ */
291
+ canGoBack() {
292
+ return (this.target?.backTarget() ?? null) !== null;
293
+ }
294
+ /** Push a page on top, even if its URL is already somewhere on the stack. */
295
+ forward(commands, extras) {
296
+ return this.navigate(commands, extras, 'forward');
297
+ }
298
+ /**
299
+ * Go back one page. This is what the swipe gesture commits to, and what a back button should
300
+ * call. There are three quite different situations behind that one word, and this picks between
301
+ * them:
302
+ *
303
+ * 1. **There's a page below, and it's also the previous history entry.** The easy case. Use a
304
+ * real `history.back()`, so the URL, the browser's back button and the stack all keep telling
305
+ * the same story.
306
+ *
307
+ * 2. **There's a page below, but history disagrees.** This is what tabs do to you: history is a
308
+ * single linear thread, tabs are several stacks, and switching tabs leaves an unrelated page
309
+ * sitting behind you. `history.back()` would jump sideways out of the stack you're in, so
310
+ * navigate to the page we actually mean instead, replacing the current entry so that popping
311
+ * doesn't inflate history.
312
+ *
313
+ * 3. **There's nothing below at all.** A cold deep link — a push notification, a shared URL, a
314
+ * refresh three screens in. `history.back()` here walks out of the app entirely, which is
315
+ * almost never what the user meant by tapping a back arrow inside it. If the route declares
316
+ * where it sits (`data: { parent: '/inbox' }`) we build that page and animate to it as a back,
317
+ * exactly as though it had been there all along.
318
+ */
319
+ back(commands, extras) {
320
+ if (commands !== undefined) {
321
+ return this.navigate(commands, extras, 'back');
322
+ }
323
+ const target = this.target?.backTarget() ?? null;
324
+ if (!target) {
325
+ // Genuinely nowhere to go: no page below, no declared parent. Defer to the browser, which
326
+ // may well leave the app — the honest outcome of there being nothing to go back to.
327
+ this.location.back();
328
+ return Promise.resolve(true);
329
+ }
330
+ if (target.mounted && this.history.previousUrl() === target.url) {
331
+ this.location.back();
332
+ return Promise.resolve(true);
333
+ }
334
+ return this.navigate(target.url, { ...extras, replaceUrl: true }, 'back');
335
+ }
336
+ /** Throw every stack away and start again from this page. */
337
+ root(commands, extras) {
338
+ return this.navigate(commands, { ...extras, replaceUrl: true }, 'root');
339
+ }
340
+ navigate(commands, extras, hint) {
341
+ const merged = {
342
+ ...extras,
343
+ state: { ...(extras?.state ?? {}), [NGX_STACK_DIRECTION]: hint },
344
+ };
345
+ return Array.isArray(commands)
346
+ ? this.router.navigate(commands, merged)
347
+ : this.router.navigateByUrl(commands, merged);
348
+ }
349
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: NgxStackNav, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
350
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: NgxStackNav, providedIn: 'root' });
351
+ }
352
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: NgxStackNav, decorators: [{
353
+ type: Injectable,
354
+ args: [{ providedIn: 'root' }]
355
+ }] });
356
+ /**
357
+ * Read the intent off the navigation currently in flight.
358
+ *
359
+ * Only honoured for imperative navigations. A `popstate` carries the history entry's *stored*
360
+ * state, which still holds whatever hint was set when that entry was first pushed — so trusting
361
+ * it would make pressing Back re-read an old `'forward'` and push a duplicate page. For history
362
+ * traversal the outlet works the direction out from the stack instead, which is unambiguous.
363
+ */
364
+ function readDirectionHint(router) {
365
+ const navigation = router.getCurrentNavigation();
366
+ if (!navigation || navigation.trigger !== 'imperative')
367
+ return null;
368
+ const state = navigation.extras.state;
369
+ const hint = state?.[NGX_STACK_DIRECTION];
370
+ return hint === 'forward' || hint === 'back' || hint === 'root' ? hint : null;
371
+ }
372
+ /** `false` when the caller wants this navigation to land with no transition at all. */
373
+ function readAnimatedHint(router) {
374
+ const navigation = router.getCurrentNavigation();
375
+ if (!navigation || navigation.trigger !== 'imperative')
376
+ return null;
377
+ const state = navigation.extras.state;
378
+ const animated = state?.[NGX_STACK_ANIMATED];
379
+ return typeof animated === 'boolean' ? animated : null;
380
+ }
381
+
382
+ /**
383
+ * Wire up the stack. One call, everything in it.
384
+ *
385
+ * ```ts
386
+ * bootstrapApplication(App, {
387
+ * providers: [
388
+ * provideRouter(routes),
389
+ * provideNgxStack({
390
+ * transitions: { ios: iosTransition, android: androidTransition, web: noneTransition },
391
+ * tabs: ['inbox', 'search'],
392
+ * deepLinks: true, // needs withDisabledInitialNavigation() — see below
393
+ * }),
394
+ * ],
395
+ * });
396
+ * ```
397
+ *
398
+ * Installs {@link NgxStackRouteReuseStrategy}, which is required — Angular's default strategy
399
+ * recycles a component when only the route params change, which would quietly merge `/item/1` and
400
+ * `/item/2` into one page instead of two.
401
+ */
402
+ function provideNgxStack(config = {}) {
403
+ const resolved = { ...NGX_STACK_DEFAULTS, ...config };
404
+ return makeEnvironmentProviders([
405
+ { provide: NGX_STACK_CONFIG, useValue: resolved },
406
+ { provide: RouteReuseStrategy, useClass: NgxStackRouteReuseStrategy },
407
+ ...(resolved.deepLinks ? [rebuildDeepLinks(resolved.deepLinks)] : []),
408
+ ]);
409
+ }
410
+ /** Guards against a `parentOf` that never terminates. Ten screens is already an absurd stack. */
411
+ const MAX_ANCESTORS = 10;
412
+ /**
413
+ * Build the pages that *should* have been underneath, when the app opens partway in.
414
+ *
415
+ * A push notification, a shared link, a refresh three screens deep: the router lands you on the
416
+ * detail page and the stack has exactly one entry. Nothing is beneath it, so there is nothing for a
417
+ * swipe to drag into view.
418
+ *
419
+ * The gesture needs the page below to be mounted and painted *before* the finger touches the
420
+ * screen, and the only honest way to get it there is to have navigated to it. So that's what this
421
+ * does: it rebuilds the ancestor chain at startup, silently, before the app is shown.
422
+ */
423
+ function rebuildDeepLinks(deepLinks) {
424
+ return provideEnvironmentInitializer(() => {
425
+ const router = inject(Router);
426
+ const destroyRef = inject(DestroyRef);
427
+ const findParent = deepLinks === true ? (url) => deriveParentUrl(router.config, url) : deepLinks;
428
+ let handled = false;
429
+ // We intercept the very first navigation rather than letting it land and then correcting
430
+ // ourselves, and the timing is the whole point: NavigationStart fires before anything is
431
+ // recognised or activated, so nothing has been built yet. Asking the router for a different URL
432
+ // here supersedes the one in flight — it is cancelled where it stands and never activates. The
433
+ // deep page therefore gets constructed exactly once, at the end, on top of its ancestors.
434
+ //
435
+ // (Doing this after NavigationEnd would work too, and would build the deep page, throw it away,
436
+ // and build it again — running its resolvers twice for the privilege.)
437
+ const subscription = router.events.subscribe((event) => {
438
+ if (handled || !(event instanceof NavigationStart))
439
+ return;
440
+ handled = true;
441
+ subscription.unsubscribe();
442
+ const target = event.url;
443
+ // Walk up from the target, collecting ancestors outermost-first.
444
+ const chain = [];
445
+ const seen = new Set([target]);
446
+ let current = target;
447
+ while (chain.length < MAX_ANCESTORS) {
448
+ const parent = findParent(current);
449
+ if (!parent || seen.has(parent))
450
+ break;
451
+ seen.add(parent);
452
+ chain.unshift(parent);
453
+ current = parent;
454
+ }
455
+ // Nothing above it — it *is* a root page. Let the navigation Angular already started proceed.
456
+ if (chain.length === 0)
457
+ return;
458
+ void (async () => {
459
+ // Root the stack at the outermost ancestor, replacing the history entry the browser already
460
+ // has for the deep URL…
461
+ await router.navigateByUrl(chain[0], {
462
+ replaceUrl: true,
463
+ state: { [NGX_STACK_DIRECTION]: 'root', [NGX_STACK_ANIMATED]: false },
464
+ });
465
+ // …then walk back down, pushing each page. The stack and the history end up exactly as they
466
+ // would have been if the user had tapped their way here — which is the whole point.
467
+ for (const url of [...chain.slice(1), target]) {
468
+ await router.navigateByUrl(url, {
469
+ state: { [NGX_STACK_DIRECTION]: 'forward', [NGX_STACK_ANIMATED]: false },
470
+ });
471
+ }
472
+ })();
473
+ });
474
+ destroyRef.onDestroy(() => subscription.unsubscribe());
475
+ });
476
+ }
477
+ /**
478
+ * Route the Android hardware back button (and the Android system back gesture, which Capacitor
479
+ * reports through the same event) into the stack.
480
+ *
481
+ * Pass the plugin in rather than having the library import it, so `@capacitor/app` stays out of the
482
+ * dependency graph of anyone shipping only to the web:
483
+ *
484
+ * ```ts
485
+ * import { App } from '@capacitor/app';
486
+ * provideCapacitorBack(App)
487
+ * ```
488
+ *
489
+ * At the root of the stack this calls `exitApp()`, which is what Android users expect — back on the
490
+ * first screen closes the app rather than doing nothing.
491
+ *
492
+ * The event's own `canGoBack` is deliberately ignored. It describes the *webview's* history, and
493
+ * history is a single linear thread while tabs are several stacks — so it says yes when the only
494
+ * thing behind you is a different tab. We ask the stack instead.
495
+ */
496
+ function provideCapacitorBack(app) {
497
+ return makeEnvironmentProviders([
498
+ provideEnvironmentInitializer(() => {
499
+ const platform = inject(NGX_STACK_PLATFORM);
500
+ if (!platform.isCapacitor)
501
+ return;
502
+ const nav = inject(NgxStackNav);
503
+ void app.addListener('backButton', () => {
504
+ if (nav.canGoBack()) {
505
+ void nav.back();
506
+ }
507
+ else {
508
+ void app.exitApp();
509
+ }
510
+ });
511
+ }),
512
+ ]);
513
+ }
514
+ /**
515
+ * The same, for Cordova / PhoneGap.
516
+ *
517
+ * Cordova is a WKWebView (iOS) or WebView (Android) with plugins bolted on, so everything else in
518
+ * this library already works there unchanged — the swipe, the transitions, the stacks, the tabs.
519
+ * The one thing that isn't web is the Android hardware back button, which Cordova delivers as a
520
+ * `backbutton` event on `document` once `deviceready` has fired.
521
+ *
522
+ * ```ts
523
+ * provideCordovaBack()
524
+ * ```
525
+ *
526
+ * Nothing to pass in: unlike Capacitor, Cordova's API is a global. At the root of the stack this
527
+ * calls `navigator.app.exitApp()`.
528
+ */
529
+ function provideCordovaBack() {
530
+ return makeEnvironmentProviders([
531
+ provideEnvironmentInitializer(() => {
532
+ const platform = inject(NGX_STACK_PLATFORM);
533
+ if (!platform.isCordova)
534
+ return;
535
+ const nav = inject(NgxStackNav);
536
+ const doc = inject(DOCUMENT);
537
+ const destroyRef = inject(DestroyRef);
538
+ const onBackButton = (event) => {
539
+ event.preventDefault();
540
+ if (nav.canGoBack()) {
541
+ void nav.back();
542
+ return;
543
+ }
544
+ const exitApp = doc.defaultView?.navigator?.app?.exitApp;
545
+ exitApp?.();
546
+ };
547
+ // Cordova only starts firing `backbutton` after `deviceready`, so subscribing this early is
548
+ // safe — the event simply cannot arrive before then.
549
+ doc.addEventListener('backbutton', onBackButton);
550
+ // An app is normally torn down by the OS, not by us — but a test harness, or a micro-frontend
551
+ // that destroys and recreates the Angular app, would otherwise leave this listener holding a
552
+ // reference to a dead injector and stealing the back button from whatever replaced it.
553
+ destroyRef.onDestroy(() => doc.removeEventListener('backbutton', onBackButton));
554
+ }),
555
+ ]);
556
+ }
557
+
558
+ /** How far back to look. Older samples describe a gesture the finger has already left. */
559
+ const WINDOW_MS = 100;
560
+ /** Horizontal speed of a drag, in px/ms. Positive means moving right. */
561
+ class VelocityTracker {
562
+ samples = [];
563
+ reset(x, t) {
564
+ this.samples = [{ x, t }];
565
+ }
566
+ add(x, t) {
567
+ this.samples.push({ x, t });
568
+ const cutoff = t - WINDOW_MS;
569
+ while (this.samples.length > 2 && this.samples[0].t < cutoff) {
570
+ this.samples.shift();
571
+ }
572
+ }
573
+ velocity() {
574
+ if (this.samples.length < 2)
575
+ return 0;
576
+ const first = this.samples[0];
577
+ const last = this.samples[this.samples.length - 1];
578
+ const dt = last.t - first.t;
579
+ if (dt <= 0)
580
+ return 0;
581
+ return (last.x - first.x) / dt;
582
+ }
583
+ }
584
+
585
+ /** Travel along the axis, in px, before we commit to a swipe rather than a scroll or a tap. */
586
+ const DIRECTION_LOCK_PX = 10;
587
+ const MIN_SETTLE_MS = 90;
588
+ const MAX_SETTLE_MS = 380;
589
+ /** Below this speed the flick is treated as a release, not a throw. */
590
+ const IDLE_SPEED = 0.05;
591
+ /**
592
+ * Edge-drag-to-go-back.
593
+ *
594
+ * Uses touch events rather than pointer events on purpose. On iOS a `touchmove` can be
595
+ * `preventDefault()`-ed to stop the page scrolling out from under the drag, and a `touchstart`
596
+ * can be prevented to stop WebKit starting its own back-navigation gesture. Pointer events give
597
+ * us neither — once WebKit decides the touch is a scroll or a system gesture, we just get a
598
+ * `pointercancel` and the drag dies.
599
+ *
600
+ * Everything horizontal is expressed against a `sign`: +1 in LTR, -1 in RTL. The gesture starts
601
+ * at the *inline start* edge and pulls towards the *inline end*, which in Arabic and Hebrew means
602
+ * starting at the right and dragging left.
603
+ */
604
+ class SwipeBackGesture {
605
+ host;
606
+ config;
607
+ platform;
608
+ state = 'idle';
609
+ startX = 0;
610
+ startY = 0;
611
+ width = 1;
612
+ sign = 1;
613
+ touchId = null;
614
+ back = null;
615
+ velocity = new VelocityTracker();
616
+ teardown = [];
617
+ constructor(host, config, platform) {
618
+ this.host = host;
619
+ this.config = config;
620
+ this.platform = platform;
621
+ const el = host.hostEl;
622
+ // Non-passive: both handlers need to be able to preventDefault.
623
+ this.listen(el, 'touchstart', this.onTouchStart, { passive: false });
624
+ this.listen(el, 'touchmove', this.onTouchMove, { passive: false });
625
+ this.listen(el, 'touchend', this.onTouchEnd);
626
+ this.listen(el, 'touchcancel', this.onTouchEnd);
627
+ if (config.swipeWithMouse) {
628
+ this.listen(el, 'mousedown', this.onMouseDown);
629
+ this.listen(el.ownerDocument, 'mousemove', this.onMouseMove);
630
+ this.listen(el.ownerDocument, 'mouseup', this.onMouseUp);
631
+ }
632
+ }
633
+ listen(target, type, handler, options) {
634
+ target.addEventListener(type, handler, options);
635
+ this.teardown.push(() => target.removeEventListener(type, handler, options));
636
+ }
637
+ // ---------------------------------------------------------------------------
638
+ // Touch
639
+ // ---------------------------------------------------------------------------
640
+ onTouchStart = (event) => {
641
+ if (this.state !== 'idle' || event.touches.length !== 1)
642
+ return;
643
+ const touch = event.touches[0];
644
+ if (!this.arm(touch.clientX, touch.clientY))
645
+ return;
646
+ this.touchId = touch.identifier;
647
+ // Stacks nest: an <ngx-stack> inside a page of an <ngx-stack-outlet> sits inside the outer
648
+ // stack's host element, so this touch is on its way there too. Claim it, or both stacks
649
+ // would go back at once. Listeners fire innermost-first, and arming already required
650
+ // something to go back to — so the innermost stack that *can* go back wins, and one sitting
651
+ // at its own root declines and lets the swipe fall through to its parent.
652
+ event.stopPropagation();
653
+ if (this.suppressesSystemGesture() && event.cancelable) {
654
+ // Stops WebKit from starting its own interactive back navigation on this touch. The cost
655
+ // is that this touch will not produce a synthetic `click`, which is why the default policy
656
+ // is `inset` rather than `suppress`.
657
+ event.preventDefault();
658
+ }
659
+ };
660
+ onTouchMove = (event) => {
661
+ if (this.state === 'idle')
662
+ return;
663
+ const touch = this.trackedTouch(event);
664
+ if (!touch)
665
+ return;
666
+ this.drag(touch.clientX, touch.clientY, event);
667
+ };
668
+ onTouchEnd = () => {
669
+ this.release();
670
+ };
671
+ /** A second finger landing must not hijack a drag the first one started. */
672
+ trackedTouch(event) {
673
+ return Array.from(event.touches).find((touch) => touch.identifier === this.touchId) ?? null;
674
+ }
675
+ // ---------------------------------------------------------------------------
676
+ // Mouse (development convenience only — `swipeWithMouse`)
677
+ // ---------------------------------------------------------------------------
678
+ onMouseDown = (event) => {
679
+ if (this.state !== 'idle' || event.button !== 0)
680
+ return;
681
+ // See onTouchStart: the innermost stack that can go back claims the drag.
682
+ if (this.arm(event.clientX, event.clientY))
683
+ event.stopPropagation();
684
+ };
685
+ onMouseMove = (event) => {
686
+ if (this.state === 'idle')
687
+ return;
688
+ this.drag(event.clientX, event.clientY, event);
689
+ };
690
+ onMouseUp = () => {
691
+ this.release();
692
+ };
693
+ // ---------------------------------------------------------------------------
694
+ arm(x, y) {
695
+ this.sign = this.host.isRtl() ? -1 : 1;
696
+ if (!this.inEdgeZone(x))
697
+ return false;
698
+ if (!this.host.canSwipeBack())
699
+ return false;
700
+ this.state = 'pending';
701
+ this.startX = x;
702
+ this.startY = y;
703
+ this.width = Math.max(this.host.hostEl.clientWidth, 1);
704
+ this.velocity.reset(x, performance.now());
705
+ return true;
706
+ }
707
+ drag(x, y, event) {
708
+ // Travel *away from* the starting edge, so this is positive whichever way the app reads.
709
+ const travel = (x - this.startX) * this.sign;
710
+ if (this.state === 'pending') {
711
+ const dy = y - this.startY;
712
+ // A vertical drag is a scroll, and scrolling wins outright — bail rather than fight it.
713
+ if (Math.abs(dy) > DIRECTION_LOCK_PX && Math.abs(dy) >= Math.abs(travel)) {
714
+ this.reset();
715
+ return;
716
+ }
717
+ if (travel < DIRECTION_LOCK_PX)
718
+ return;
719
+ const back = this.host.beginSwipeBack();
720
+ if (!back) {
721
+ this.reset();
722
+ return;
723
+ }
724
+ this.back = back;
725
+ this.state = 'dragging';
726
+ }
727
+ if (!this.back)
728
+ return;
729
+ // Keep the page underneath from scrolling while the finger is dragging it sideways.
730
+ if (event.cancelable)
731
+ event.preventDefault();
732
+ this.velocity.add(x, performance.now());
733
+ this.back.player.seek(travel / this.width);
734
+ }
735
+ release() {
736
+ const back = this.back;
737
+ const wasDragging = this.state === 'dragging';
738
+ const sign = this.sign;
739
+ this.reset();
740
+ if (!wasDragging || !back)
741
+ return;
742
+ const progress = back.player.progress;
743
+ // Positive means "still moving away from the starting edge", i.e. towards completing.
744
+ const velocity = this.velocity.velocity() * sign;
745
+ const threshold = this.config.swipeVelocityThreshold;
746
+ // A fast flick decides on its own: outwards completes even from barely anywhere, back
747
+ // towards the edge cancels even from past the halfway mark. Otherwise distance decides.
748
+ const complete = velocity > threshold || (velocity > -threshold && progress > this.config.swipeThreshold);
749
+ const remaining = complete ? 1 - progress : progress;
750
+ const ms = this.settleDuration(remaining, Math.abs(velocity));
751
+ if (complete) {
752
+ this.host.commitSwipeBack(back, ms);
753
+ }
754
+ else {
755
+ this.host.abortSwipeBack(back, ms);
756
+ }
757
+ }
758
+ /** Carry the finger's speed into the settle, so the page doesn't change pace on release. */
759
+ settleDuration(remainingProgress, speed) {
760
+ const distance = remainingProgress * this.width;
761
+ const ms = speed > IDLE_SPEED ? distance / speed : this.config.duration * remainingProgress;
762
+ return Math.min(Math.max(ms, MIN_SETTLE_MS), MAX_SETTLE_MS);
763
+ }
764
+ reset() {
765
+ this.state = 'idle';
766
+ this.back = null;
767
+ this.touchId = null;
768
+ }
769
+ /** The zone hugs the *inline start* edge: the left in LTR, the right in RTL. */
770
+ inEdgeZone(x) {
771
+ const rect = this.host.hostEl.getBoundingClientRect();
772
+ const fromEdge = this.sign === 1 ? x - rect.left : rect.right - x;
773
+ const start = this.systemInset();
774
+ return fromEdge >= start && fromEdge <= start + this.config.swipeEdgeWidth;
775
+ }
776
+ /** Pixels at the very edge we concede to the browser's own gesture. */
777
+ systemInset() {
778
+ return this.platform.hasSystemBackGesture && this.config.systemGesture === 'inset'
779
+ ? this.config.systemEdgeInset
780
+ : 0;
781
+ }
782
+ suppressesSystemGesture() {
783
+ return this.platform.hasSystemBackGesture && this.config.systemGesture === 'suppress';
784
+ }
785
+ destroy() {
786
+ if (this.back) {
787
+ this.host.abortSwipeBack(this.back, 0);
788
+ }
789
+ this.reset();
790
+ for (const off of this.teardown)
791
+ off();
792
+ this.teardown.length = 0;
793
+ }
794
+ }
795
+
796
+ /**
797
+ * The master switch for swipe-to-go-back.
798
+ *
799
+ * `provideNgxStack({ swipeBack })` only sets the *starting* value — this is what you reach
800
+ * for when the answer changes while the app is running. A modal is open, a map is eating the
801
+ * drag, a form has unsaved edits, a payment is in flight: turn it off, turn it back on after.
802
+ *
803
+ * ```ts
804
+ * const swipe = inject(NgxStackSwipe);
805
+ * swipe.disable();
806
+ * // …later
807
+ * swipe.reset(); // back to whatever the config said
808
+ * ```
809
+ *
810
+ * This is the global layer. Two narrower ones sit on top of it, and either can veto:
811
+ * `<ngx-stack-outlet [swipeBack]="…">` for one stack, and `ngxCanSwipeBack()` or
812
+ * `data: { swipeBack: false }` for one page. See {@link NgxStackPage}.
813
+ */
814
+ class NgxStackSwipe {
815
+ config = inject(NGX_STACK_CONFIG);
816
+ platform = inject(NGX_STACK_PLATFORM);
817
+ _enabled = signal(this.fromConfig(), /* @ts-ignore */
818
+ ...(ngDevMode ? [{ debugName: "_enabled" }] : /* istanbul ignore next */ []));
819
+ /** Read it in a template or a computed; it's a signal. */
820
+ enabled = this._enabled.asReadonly();
821
+ enable() {
822
+ this._enabled.set(true);
823
+ }
824
+ disable() {
825
+ this._enabled.set(false);
826
+ }
827
+ set(enabled) {
828
+ this._enabled.set(enabled);
829
+ }
830
+ /** Go back to what the config asked for, whatever that resolved to on this device. */
831
+ reset() {
832
+ this._enabled.set(this.fromConfig());
833
+ }
834
+ fromConfig() {
835
+ const setting = this.config.swipeBack;
836
+ if (setting !== 'auto')
837
+ return setting;
838
+ // 'auto' means iOS only. Honour a forced `platform`, so an iOS build under test on a
839
+ // laptop still arms the gesture.
840
+ const kind = this.config.platform === 'auto' ? this.platform.kind : this.config.platform;
841
+ return kind === 'ios';
842
+ }
843
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: NgxStackSwipe, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
844
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: NgxStackSwipe, providedIn: 'root' });
845
+ }
846
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: NgxStackSwipe, decorators: [{
847
+ type: Injectable,
848
+ args: [{ providedIn: 'root' }]
849
+ }] });
850
+
851
+ /**
852
+ * The Material shape: the incoming page rises a short distance and fades in over the outgoing one,
853
+ * which stays exactly where it is and is simply covered.
854
+ *
855
+ * The counterpart to {@link slideTransition}. `androidTransition` and `webTransition` are both
856
+ * instances of this, with the same numbers — but they remain two separate transitions you can point
857
+ * at different things, because "what Android does" and "what a browser does" are two decisions that
858
+ * merely happen to agree today.
859
+ *
860
+ * Note there is no horizontal component, and that has a consequence: an edge swipe would drag
861
+ * sideways while the page moved vertically. That is why `swipeBack: 'auto'` only arms the gesture on
862
+ * iOS. For a swipe on Android or the web, give that platform a {@link slideTransition} instead.
863
+ */
864
+ function riseTransition(options) {
865
+ return (ctx) => {
866
+ const forward = ctx.direction === 'forward';
867
+ // The page riding on top. Going back, that's the one leaving.
868
+ const overEl = forward ? ctx.enteringEl : ctx.leavingEl;
869
+ const animations = [];
870
+ if (overEl) {
871
+ const away = { transform: `translateY(${options.travel}px)`, opacity: '0' };
872
+ const home = { transform: 'translateY(0)', opacity: '1' };
873
+ animations.push({ el: overEl, keyframes: forward ? [away, home] : [home, away] });
874
+ }
875
+ return {
876
+ duration: Math.round(ctx.duration * options.durationScale),
877
+ easing: options.easing,
878
+ animations,
879
+ };
880
+ };
881
+ }
882
+
883
+ /**
884
+ * The Material push: the incoming page rises and fades in over the outgoing one, which stays where
885
+ * it is. Shorter and flatter than iOS — Material moves things a short distance quickly rather than a
886
+ * long distance smoothly.
887
+ *
888
+ * Deliberately has no horizontal component, which is why `swipeBack: 'auto'` doesn't arm the gesture
889
+ * here: a finger dragging sideways would scrub a page moving vertically. On Android the back gesture
890
+ * belongs to the OS anyway. If you do want an edge swipe, give this platform a `slideTransition()`.
891
+ */
892
+ const androidTransition = riseTransition({
893
+ travel: 12,
894
+ easing: 'cubic-bezier(0.05, 0.7, 0.1, 1)',
895
+ durationScale: 0.65,
896
+ });
897
+
898
+ /** The dim overlay that sits on top of a page while it is covered by another. */
899
+ function scrimOf(pageEl) {
900
+ return pageEl.querySelector(':scope > .ngx-stack-scrim');
901
+ }
902
+
903
+ /**
904
+ * The shape both horizontal transitions share: one page rides in over another, which drifts the
905
+ * other way more slowly and dims underneath it.
906
+ *
907
+ * iOS and web are the same animation with different numbers — a full-width slide with a heavy
908
+ * parallax, versus a short drift carried mostly by opacity. Writing that twice invites them to
909
+ * quietly diverge, and the interesting parts here are subtle enough already:
910
+ *
911
+ * - `forward` and `back` are the same keyframes with the roles swapped. That symmetry is what makes
912
+ * a swipe scrubbable at all — the gesture just seeks the `back` spec.
913
+ * - everything horizontal is signed by `rtl`, so a page pushed "forward" in Arabic arrives from the
914
+ * left, which is the direction the language reads towards.
915
+ */
916
+ function slideTransition(options) {
917
+ return (ctx) => {
918
+ const forward = ctx.direction === 'forward';
919
+ const sign = ctx.rtl ? -1 : 1;
920
+ const offMain = `translateX(${options.travel * sign}%)`;
921
+ const offUnder = `translateX(${-options.parallax * sign}%)`;
922
+ const center = 'translateX(0)';
923
+ // The page riding on top, sliding in from (or out to) the edge.
924
+ const overEl = forward ? ctx.enteringEl : ctx.leavingEl;
925
+ // The page underneath, parallaxing and dimming.
926
+ const underEl = forward ? ctx.leavingEl : ctx.enteringEl;
927
+ const animations = [];
928
+ if (overEl) {
929
+ const away = { transform: offMain, ...(options.fade ? { opacity: '0' } : {}) };
930
+ const home = { transform: center, ...(options.fade ? { opacity: '1' } : {}) };
931
+ animations.push({ el: overEl, keyframes: forward ? [away, home] : [home, away] });
932
+ }
933
+ if (underEl) {
934
+ animations.push({
935
+ el: underEl,
936
+ keyframes: forward
937
+ ? [{ transform: center }, { transform: offUnder }]
938
+ : [{ transform: offUnder }, { transform: center }],
939
+ });
940
+ const scrim = options.scrim > 0 ? scrimOf(underEl) : null;
941
+ if (scrim) {
942
+ const clear = { opacity: '0' };
943
+ const dim = { opacity: `${options.scrim}` };
944
+ animations.push({ el: scrim, keyframes: forward ? [clear, dim] : [dim, clear] });
945
+ }
946
+ }
947
+ return {
948
+ duration: Math.round(ctx.duration * options.durationScale),
949
+ easing: options.easing,
950
+ animations,
951
+ };
952
+ };
953
+ }
954
+
955
+ /**
956
+ * The iOS navigation-controller push/pop.
957
+ *
958
+ * The incoming page slides the full width of the screen over the outgoing one, which drifts the
959
+ * other way at a third of the speed and dims underneath it — the parallax that makes a UIKit stack
960
+ * feel like sheets of paper rather than slides.
961
+ *
962
+ * The easing is UIKit's own: almost no acceleration, and a very long tail.
963
+ */
964
+ const iosTransition = slideTransition({
965
+ travel: 100,
966
+ parallax: 33,
967
+ scrim: 0.16,
968
+ fade: false,
969
+ easing: 'cubic-bezier(0.32, 0.72, 0, 1)',
970
+ durationScale: 1,
971
+ });
972
+
973
+ /**
974
+ * The browser default — the same rise-and-fade as Android, and identical to `androidTransition` down
975
+ * to the numbers.
976
+ *
977
+ * They are still two transitions rather than one, and that is the point: "what a phone does" and
978
+ * "what a browser does" are two separate decisions that merely happen to agree today. Retuning the
979
+ * web here changes nothing on Android, and vice versa. If they were the same object, the first person
980
+ * to want a different feel on the desktop would have to change both.
981
+ *
982
+ * Why Material rather than the iOS slide: a full-width slide says *"this screen came from over
983
+ * there"*, which is true of something you swiped into view and not of something you clicked — and on
984
+ * a wide monitor it is a great many pixels moving for no reason.
985
+ */
986
+ const webTransition = riseTransition({
987
+ travel: 12,
988
+ easing: 'cubic-bezier(0.05, 0.7, 0.1, 1)',
989
+ durationScale: 0.65,
990
+ });
991
+
992
+ const clamp01 = (n) => (n < 0 ? 0 : n > 1 ? 1 : n);
993
+ /** Keyframe keys that carry timing rather than style. */
994
+ const META_KEYS = new Set(['offset', 'easing', 'composite']);
995
+ function styleKeysOf(keyframes) {
996
+ const keys = new Set();
997
+ for (const frame of keyframes) {
998
+ for (const key of Object.keys(frame)) {
999
+ if (!META_KEYS.has(key))
1000
+ keys.add(key);
1001
+ }
1002
+ }
1003
+ return [...keys];
1004
+ }
1005
+ /** The element's current rendered value for each animated property. */
1006
+ function snapshotOf(el, keys) {
1007
+ const frame = {};
1008
+ // A page destroyed mid-swipe — a navigation cancelled under the finger — has no defaultView. An
1009
+ // assertion here would throw inside a touch handler, taking the gesture down with it, when the
1010
+ // honest answer is simply that there is nothing left to animate from.
1011
+ const view = el.ownerDocument.defaultView;
1012
+ if (!view)
1013
+ return frame;
1014
+ const computed = view.getComputedStyle(el);
1015
+ for (const key of keys) {
1016
+ frame[key] = computed[key];
1017
+ }
1018
+ return frame;
1019
+ }
1020
+ function styleOnly(frame) {
1021
+ const out = {};
1022
+ for (const [key, value] of Object.entries(frame)) {
1023
+ if (!META_KEYS.has(key))
1024
+ out[key] = value;
1025
+ }
1026
+ return out;
1027
+ }
1028
+ /**
1029
+ * A page transition that can be either played or scrubbed.
1030
+ *
1031
+ * This is the whole reason the library exists. A CSS transition or a View Transition is
1032
+ * fire-and-forget: it runs to completion on its own clock. A swipe-back is the opposite —
1033
+ * the finger owns the clock, the transition has to follow it, and the user may change
1034
+ * their mind halfway and drag right back.
1035
+ *
1036
+ * So a transition is built as a set of paused Web Animations with `fill: 'both'`. Paused at
1037
+ * `currentTime = 0` they hold their first keyframe; the gesture then writes `currentTime`
1038
+ * directly on every touch move.
1039
+ */
1040
+ class TransitionPlayer {
1041
+ spec;
1042
+ animations;
1043
+ duration;
1044
+ disposed = false;
1045
+ /**
1046
+ * @param interactive Build for scrubbing. Timing easing is forced to linear so that
1047
+ * seeking to progress `p` puts the page at exactly `p` of the way across — otherwise
1048
+ * the iOS curve makes the page lead and then lag the finger, which is precisely the
1049
+ * tell that separates a native-feeling swipe from a web one. The real curve is applied
1050
+ * later, by `settle()`.
1051
+ */
1052
+ constructor(spec, interactive = false) {
1053
+ this.spec = spec;
1054
+ this.duration = Math.max(spec.duration, 1);
1055
+ const easing = interactive ? 'linear' : spec.easing;
1056
+ this.animations = spec.animations.map((animation) => {
1057
+ const handle = animation.el.animate(animation.keyframes, {
1058
+ duration: this.duration,
1059
+ easing: interactive ? 'linear' : (animation.easing ?? easing),
1060
+ fill: 'both',
1061
+ });
1062
+ handle.pause();
1063
+ handle.currentTime = 0;
1064
+ return handle;
1065
+ });
1066
+ }
1067
+ /** Where the transition currently sits, in `[0, 1]`. */
1068
+ get progress() {
1069
+ const first = this.animations[0];
1070
+ if (!first)
1071
+ return 0;
1072
+ return clamp01(Number(first.currentTime ?? 0) / this.duration);
1073
+ }
1074
+ /** Jump to a progress in `[0, 1]`. Called on every touch move during a swipe. */
1075
+ seek(progress) {
1076
+ if (this.disposed)
1077
+ return;
1078
+ const time = clamp01(progress) * this.duration;
1079
+ for (const animation of this.animations) {
1080
+ animation.currentTime = time;
1081
+ }
1082
+ }
1083
+ /** Run to progress 1 on the transition's own clock. */
1084
+ play() {
1085
+ return this.run(1);
1086
+ }
1087
+ /**
1088
+ * Hand the clock back to the browser after a scrub, carrying on to `target`.
1089
+ *
1090
+ * Deliberately *not* a continuation of the scrub timeline. Re-applying the easing curve
1091
+ * to a timeline that is already part-way through would snap the page to `easing(t)`, a
1092
+ * visible jump at the exact moment the user lets go. Instead we snapshot what is
1093
+ * currently on screen and animate from there to the target with the real curve, which is
1094
+ * continuous by construction.
1095
+ *
1096
+ * @param ms How long the remaining distance should take. Scale this by the distance left
1097
+ * so a release near the end is quick and one near the start is not.
1098
+ */
1099
+ settle(target, ms, easing = this.spec.easing) {
1100
+ if (this.disposed || this.animations.length === 0)
1101
+ return Promise.resolve();
1102
+ const from = this.spec.animations.map((animation) => ({
1103
+ el: animation.el,
1104
+ frame: snapshotOf(animation.el, styleKeysOf(animation.keyframes)),
1105
+ }));
1106
+ const to = this.spec.animations.map((animation) => styleOnly(target === 1 ? animation.keyframes[animation.keyframes.length - 1] : animation.keyframes[0]));
1107
+ for (const animation of this.animations) {
1108
+ animation.cancel();
1109
+ }
1110
+ this.duration = Math.max(ms, 1);
1111
+ this.animations = from.map((source, index) => source.el.animate([source.frame, to[index]], {
1112
+ duration: this.duration,
1113
+ easing,
1114
+ fill: 'both',
1115
+ }));
1116
+ return this.settled();
1117
+ }
1118
+ run(direction) {
1119
+ if (this.disposed || this.animations.length === 0)
1120
+ return Promise.resolve();
1121
+ // `Animation.play()` auto-rewinds: playing forward from the very end snaps back to the
1122
+ // start. Short-circuit when there is no distance left to cover.
1123
+ if (this.progress >= 0.999) {
1124
+ this.seek(1);
1125
+ return Promise.resolve();
1126
+ }
1127
+ for (const animation of this.animations) {
1128
+ animation.playbackRate = direction;
1129
+ animation.play();
1130
+ }
1131
+ return this.settled();
1132
+ }
1133
+ settled() {
1134
+ return Promise.all(
1135
+ // `finished` rejects when an animation is cancelled mid-flight, which is what happens
1136
+ // whenever a newer navigation interrupts this one. Not an error for us.
1137
+ this.animations.map((animation) => animation.finished.catch(() => undefined))).then(() => undefined);
1138
+ }
1139
+ /** Snap to the end state with no animation. */
1140
+ finish() {
1141
+ this.seek(1);
1142
+ }
1143
+ /** Drop the animations, reverting the elements to the styles they had before. */
1144
+ destroy() {
1145
+ if (this.disposed)
1146
+ return;
1147
+ for (const animation of this.animations) {
1148
+ animation.cancel();
1149
+ }
1150
+ this.disposed = true;
1151
+ }
1152
+ }
1153
+
1154
+ const ANNOUNCER_ID = 'ngx-stack-announcer';
1155
+ /**
1156
+ * Say a page's name out loud, once, to whoever is listening with a screen reader.
1157
+ *
1158
+ * A stack navigation is invisible to assistive tech: no document load, no focus change it can
1159
+ * infer meaning from — the DOM just quietly rearranges. A polite live region is the standard way
1160
+ * to give that back, and it's the same thing Angular's own `RouterOutlet` does not do for you.
1161
+ */
1162
+ function announce(doc, message) {
1163
+ let region = doc.getElementById(ANNOUNCER_ID);
1164
+ if (!region) {
1165
+ region = doc.createElement('div');
1166
+ region.id = ANNOUNCER_ID;
1167
+ region.setAttribute('aria-live', 'polite');
1168
+ region.setAttribute('aria-atomic', 'true');
1169
+ region.className = 'ngx-stack-announcer';
1170
+ doc.body.appendChild(region);
1171
+ }
1172
+ // Re-announcing the same string is a no-op for most screen readers, because the region's
1173
+ // content didn't change. Clearing first forces it to speak.
1174
+ region.textContent = '';
1175
+ region.textContent = message;
1176
+ }
1177
+
1178
+ function callLifecycle(entry, hook) {
1179
+ const instance = entry?.ref.instance;
1180
+ instance?.[hook]?.();
1181
+ }
1182
+ /**
1183
+ * Does the page on top object to being swiped away? Two ways to say so, both vetoes:
1184
+ * `data: { swipeBack: false }` on the route for a page that never allows it, and
1185
+ * `ngxCanSwipeBack()` on the component for one that decides in the moment.
1186
+ *
1187
+ * @param guardPolicy When `'block'`, a route carrying a `canDeactivate` guard refuses the
1188
+ * gesture unless its component implements `ngxCanSwipeBack()`. The guard would otherwise run
1189
+ * only *after* the page had already animated away, and refusing at that point can do nothing
1190
+ * but bounce it back.
1191
+ */
1192
+ function pageAllowsSwipeBack(entry, guardPolicy = 'allow') {
1193
+ if (!entry)
1194
+ return false;
1195
+ const snapshot = entry.route?.snapshot;
1196
+ if (snapshot?.data['swipeBack'] === false)
1197
+ return false;
1198
+ const page = entry.ref.instance;
1199
+ const answer = page?.ngxCanSwipeBack?.();
1200
+ if (answer !== undefined)
1201
+ return answer;
1202
+ if (guardPolicy === 'block' && (snapshot?.routeConfig?.canDeactivate?.length ?? 0) > 0) {
1203
+ return false;
1204
+ }
1205
+ return true;
1206
+ }
1207
+
1208
+ const BUILT_IN = {
1209
+ ios: iosTransition,
1210
+ android: androidTransition,
1211
+ // Its own transition, which today happens to be identical to Android's — see web-transition.ts for
1212
+ // why they stay two things.
1213
+ web: webTransition,
1214
+ };
1215
+ /**
1216
+ * Owns the pages of a stack — their DOM, their order, and the transitions between them.
1217
+ *
1218
+ * Deliberately free of framework plumbing: it knows nothing about the Router or about
1219
+ * imperative pushes. Both `NgxStackOutlet` and `NgxStack` drive this same class, which is why
1220
+ * a swipe-back behaves identically whether the stack is fed by URLs or by `push()`.
1221
+ *
1222
+ * With `tabs` configured it holds one stack *per tab* and shows the active one. Tabs are not a
1223
+ * feature bolted on top — they're the reason the pages live in a map keyed by tab rather than a
1224
+ * flat array. Switching tabs mounts nothing and destroys nothing; it just changes which stack's
1225
+ * top page is on screen, so the tab you left is still exactly where you left it.
1226
+ */
1227
+ class StackController {
1228
+ hostEl;
1229
+ config;
1230
+ platform;
1231
+ stacks = signal({}, /* @ts-ignore */
1232
+ ...(ngDevMode ? [{ debugName: "stacks" }] : /* istanbul ignore next */ []));
1233
+ active = signal('', /* @ts-ignore */
1234
+ ...(ngDevMode ? [{ debugName: "active" }] : /* istanbul ignore next */ []));
1235
+ /** URLs evicted by `maxDepth`, per tab. Remembered so we still know they're *behind* us. */
1236
+ pruned = new Map();
1237
+ /** The active tab's pages, bottom first. */
1238
+ pages = computed(() => this.stacks()[this.active()] ?? [], /* @ts-ignore */
1239
+ ...(ngDevMode ? [{ debugName: "pages" }] : /* istanbul ignore next */ []));
1240
+ depth = computed(() => this.pages().length, /* @ts-ignore */
1241
+ ...(ngDevMode ? [{ debugName: "depth" }] : /* istanbul ignore next */ []));
1242
+ canGoBack = computed(() => this.pages().length > 1 || this.hasPrunedPages(), /* @ts-ignore */
1243
+ ...(ngDevMode ? [{ debugName: "canGoBack" }] : /* istanbul ignore next */ []));
1244
+ activeTab = this.active.asReadonly();
1245
+ _animating = signal(false, /* @ts-ignore */
1246
+ ...(ngDevMode ? [{ debugName: "_animating" }] : /* istanbul ignore next */ []));
1247
+ animating = this._animating.asReadonly();
1248
+ /** Set by the host so it can re-emit these as component outputs. */
1249
+ onTransitionStart;
1250
+ onTransitionEnd;
1251
+ nextId = 0;
1252
+ running = null;
1253
+ doc;
1254
+ constructor(hostEl, config, platform) {
1255
+ this.hostEl = hostEl;
1256
+ this.config = config;
1257
+ this.platform = platform;
1258
+ this.doc = hostEl.ownerDocument;
1259
+ }
1260
+ // ---------------------------------------------------------------------------
1261
+ // Reading the stack
1262
+ // ---------------------------------------------------------------------------
1263
+ stackOf(tab) {
1264
+ return this.stacks()[tab] ?? [];
1265
+ }
1266
+ top(tab = this.active()) {
1267
+ const stack = this.stackOf(tab);
1268
+ return stack.length ? stack[stack.length - 1] : null;
1269
+ }
1270
+ at(index, tab = this.active()) {
1271
+ return this.stackOf(tab)[index] ?? null;
1272
+ }
1273
+ findByUrl(url, tab = this.active()) {
1274
+ return this.stackOf(tab).findIndex((entry) => entry.url === url);
1275
+ }
1276
+ /** Was this URL evicted by `maxDepth`? If so it's behind us, not ahead of us. */
1277
+ wasPruned(url, tab = this.active()) {
1278
+ return this.pruned.get(tab)?.has(url) ?? false;
1279
+ }
1280
+ hasPrunedPages() {
1281
+ return (this.pruned.get(this.active())?.size ?? 0) > 0;
1282
+ }
1283
+ // ---------------------------------------------------------------------------
1284
+ // Building pages
1285
+ // ---------------------------------------------------------------------------
1286
+ /** Wrap a freshly created component in a page element and put it in the host. */
1287
+ adopt(ref, url, route, tab = '') {
1288
+ const element = this.doc.createElement('div');
1289
+ // Starts invisible so it can't paint at its resting position for a frame before the
1290
+ // transition's first keyframe applies.
1291
+ element.className = 'ngx-stack-page ngx-stack-page--invisible';
1292
+ const scrim = this.doc.createElement('div');
1293
+ scrim.className = 'ngx-stack-scrim';
1294
+ // `createComponent` already put the host element in the DOM at the view container's anchor.
1295
+ // Relocating the node is fine — Angular tracks the view, not its position.
1296
+ element.appendChild(ref.location.nativeElement);
1297
+ element.appendChild(scrim);
1298
+ this.hostEl.appendChild(element);
1299
+ return { id: this.nextId++, url, tab, element, scrim, ref, route };
1300
+ }
1301
+ // ---------------------------------------------------------------------------
1302
+ // Transitions
1303
+ // ---------------------------------------------------------------------------
1304
+ /**
1305
+ * Apply a stack operation. The stacks are updated *synchronously* so a navigation arriving
1306
+ * mid-animation still sees the correct top; only the visuals and the destruction of popped
1307
+ * pages wait for the transition.
1308
+ */
1309
+ run(op) {
1310
+ // A new transition while one is still running: snap the old one to its end state rather
1311
+ // than letting two animations fight over the same transforms.
1312
+ if (this.running)
1313
+ this.settle(this.running);
1314
+ // The page currently on screen — which, on a tab switch, belongs to the tab we're leaving.
1315
+ const leaving = this.top(this.active());
1316
+ const stacks = { ...this.stacks() };
1317
+ const before = stacks[op.tab] ?? [];
1318
+ let entering;
1319
+ let removed = [];
1320
+ let direction;
1321
+ switch (op.kind) {
1322
+ case 'push':
1323
+ entering = op.entering;
1324
+ direction = 'forward';
1325
+ stacks[op.tab] = this.prune(op.tab, [...before, entering], (dropped) => removed.push(...dropped));
1326
+ break;
1327
+ case 'replace':
1328
+ entering = op.entering;
1329
+ direction = 'forward';
1330
+ // Only the top goes; everything below it stays, so back still works the same way.
1331
+ removed = before.length ? [before[before.length - 1]] : [];
1332
+ stacks[op.tab] = [...before.slice(0, -1), entering];
1333
+ break;
1334
+ case 'pop': {
1335
+ const target = before[op.toIndex];
1336
+ if (!target)
1337
+ return Promise.resolve();
1338
+ entering = target;
1339
+ direction = 'back';
1340
+ removed = before.slice(op.toIndex + 1);
1341
+ stacks[op.tab] = before.slice(0, op.toIndex + 1);
1342
+ break;
1343
+ }
1344
+ case 'restore':
1345
+ entering = op.entering;
1346
+ direction = 'back';
1347
+ // Everything in this stack sat above the page we're restoring, so all of it goes.
1348
+ removed = [...before];
1349
+ stacks[op.tab] = [entering];
1350
+ this.pruned.get(op.tab)?.delete(entering.url);
1351
+ break;
1352
+ case 'root':
1353
+ entering = op.entering;
1354
+ direction = 'forward';
1355
+ // Root wipes every tab, not just this one — it's "start the app again".
1356
+ removed = Object.values(stacks).flat();
1357
+ for (const key of Object.keys(stacks))
1358
+ delete stacks[key];
1359
+ stacks[op.tab] = [entering];
1360
+ this.pruned.clear();
1361
+ break;
1362
+ }
1363
+ this.stacks.set(stacks);
1364
+ this.active.set(op.tab);
1365
+ if (entering === leaving) {
1366
+ this.applyStates();
1367
+ return Promise.resolve();
1368
+ }
1369
+ return this.transition(entering, leaving, direction, removed, op, this.playerOf(op));
1370
+ }
1371
+ /** Enforce `maxDepth` by dropping pages off the bottom, remembering that they existed. */
1372
+ prune(tab, stack, onDropped) {
1373
+ const max = this.config.maxDepth;
1374
+ if (max <= 0 || stack.length <= max)
1375
+ return stack;
1376
+ const dropped = stack.slice(0, stack.length - max);
1377
+ let urls = this.pruned.get(tab);
1378
+ if (!urls) {
1379
+ urls = new Set();
1380
+ this.pruned.set(tab, urls);
1381
+ }
1382
+ for (const entry of dropped) {
1383
+ if (entry.url)
1384
+ urls.add(entry.url);
1385
+ }
1386
+ onDropped(dropped);
1387
+ return stack.slice(dropped.length);
1388
+ }
1389
+ playerOf(op) {
1390
+ return op.kind === 'pop' ? op.player : undefined;
1391
+ }
1392
+ async transition(entering, leaving, direction, removed, op, existingPlayer) {
1393
+ const isRoot = leaving === null;
1394
+ const shouldAnimate = op.animated && (!isRoot || this.config.animateRoot) && !this.prefersReducedMotion();
1395
+ this.reveal(entering);
1396
+ if (leaving)
1397
+ this.reveal(leaving);
1398
+ callLifecycle(leaving, 'ngxViewWillLeave');
1399
+ callLifecycle(entering, 'ngxViewWillEnter');
1400
+ // A gesture hands us a player it has already scrubbed to the end; otherwise build one.
1401
+ const player = existingPlayer ?? (shouldAnimate ? this.buildPlayer(entering, leaving, direction) : null);
1402
+ // Safe now: with `fill: 'both'` the player is already holding its first keyframe, so
1403
+ // un-hiding cannot show the page at the wrong position.
1404
+ entering.element.classList.remove('ngx-stack-page--invisible');
1405
+ const event = {
1406
+ direction,
1407
+ entering,
1408
+ leaving,
1409
+ tab: op.tab,
1410
+ animated: player !== null,
1411
+ interactive: existingPlayer !== undefined,
1412
+ };
1413
+ const run = { player, entering, leaving, removed, event };
1414
+ this.running = run;
1415
+ this._animating.set(true);
1416
+ this.onTransitionStart?.(event);
1417
+ if (player) {
1418
+ entering.element.classList.add('ngx-stack-page--animating');
1419
+ leaving?.element.classList.add('ngx-stack-page--animating');
1420
+ await player.play();
1421
+ }
1422
+ // A newer navigation may have settled this transition while we were awaiting.
1423
+ if (this.running !== run)
1424
+ return;
1425
+ this.finish(run);
1426
+ }
1427
+ /** Tear down a transition: drop popped pages, fix up classes, fire the `did` hooks. */
1428
+ finish(run) {
1429
+ this.applyStates();
1430
+ // Cancelling reverts elements to their CSS-defined transforms. Do it *after* `applyStates`
1431
+ // has hidden everything below the top, so nothing flashes back to centre.
1432
+ run.player?.destroy();
1433
+ for (const entry of run.removed) {
1434
+ this.destroyEntry(entry);
1435
+ }
1436
+ callLifecycle(run.leaving, 'ngxViewDidLeave');
1437
+ callLifecycle(run.entering, 'ngxViewDidEnter');
1438
+ this.running = null;
1439
+ this._animating.set(false);
1440
+ if (this.config.manageFocus)
1441
+ this.moveFocus(run.entering);
1442
+ this.onTransitionEnd?.(run.event);
1443
+ }
1444
+ /** Jump an in-flight transition straight to its end state. */
1445
+ settle(run) {
1446
+ run.player?.finish();
1447
+ this.finish(run);
1448
+ }
1449
+ // ---------------------------------------------------------------------------
1450
+ // Swipe-back
1451
+ // ---------------------------------------------------------------------------
1452
+ /**
1453
+ * Set up a back transition and hand it to the gesture, paused at progress 0.
1454
+ *
1455
+ * Deliberately fires no lifecycle hooks: a swipe is a *peek* until the user releases, and
1456
+ * pages should not be told they entered somewhere the user may drag right back out of. The
1457
+ * hooks fire when the pop actually commits, through `run()`.
1458
+ */
1459
+ beginInteractiveBack() {
1460
+ if (this.running)
1461
+ return null;
1462
+ const stack = this.pages();
1463
+ if (stack.length < 2)
1464
+ return null;
1465
+ const leaving = stack[stack.length - 1];
1466
+ const entering = stack[stack.length - 2];
1467
+ this.reveal(entering);
1468
+ const player = this.buildPlayer(entering, leaving, 'back', true);
1469
+ entering.element.classList.remove('ngx-stack-page--invisible');
1470
+ entering.element.classList.add('ngx-stack-page--animating');
1471
+ leaving.element.classList.add('ngx-stack-page--animating');
1472
+ return { player, entering, leaving };
1473
+ }
1474
+ /** The user let go without going far enough: run the transition back to where it started. */
1475
+ async abortInteractiveBack(back, ms) {
1476
+ await back.player.settle(0, ms);
1477
+ this.applyStates();
1478
+ back.player.destroy();
1479
+ }
1480
+ buildPlayer(entering, leaving, direction, interactive = false) {
1481
+ const spec = this.transitionFn()({
1482
+ enteringEl: entering.element,
1483
+ leavingEl: leaving?.element ?? null,
1484
+ hostEl: this.hostEl,
1485
+ direction,
1486
+ rtl: this.isRtl(),
1487
+ width: this.hostEl.clientWidth || 1,
1488
+ duration: this.config.duration,
1489
+ });
1490
+ return new TransitionPlayer(spec, interactive);
1491
+ }
1492
+ // ---------------------------------------------------------------------------
1493
+ // Resolution
1494
+ // ---------------------------------------------------------------------------
1495
+ /** Which platform's look we're using, honouring a `platform` override in the config. */
1496
+ platformKind() {
1497
+ return this.config.platform === 'auto' ? this.platform.kind : this.config.platform;
1498
+ }
1499
+ /** One function for every platform, a per-platform override, or the built-in. */
1500
+ transitionFn() {
1501
+ const configured = this.config.transitions;
1502
+ if (typeof configured === 'function')
1503
+ return configured;
1504
+ const kind = this.platformKind();
1505
+ return configured?.[kind] ?? BUILT_IN[kind];
1506
+ }
1507
+ /**
1508
+ * Read from the DOM rather than cached, so an app that flips `dir` at runtime — which is what
1509
+ * a language switcher does — mirrors on the very next transition without a reload.
1510
+ */
1511
+ isRtl() {
1512
+ if (this.config.direction !== 'auto')
1513
+ return this.config.direction === 'rtl';
1514
+ const view = this.doc.defaultView;
1515
+ return view ? view.getComputedStyle(this.hostEl).direction === 'rtl' : false;
1516
+ }
1517
+ /**
1518
+ * Only gates the *automatic* transitions. A swipe-back still animates: the user is dragging it
1519
+ * themselves, and direct manipulation is exempt — a gesture that doesn't visibly follow the
1520
+ * finger isn't reduced motion, it's a broken gesture.
1521
+ */
1522
+ prefersReducedMotion() {
1523
+ if (!this.config.respectReducedMotion)
1524
+ return false;
1525
+ return this.doc.defaultView?.matchMedia('(prefers-reduced-motion: reduce)').matches ?? false;
1526
+ }
1527
+ // ---------------------------------------------------------------------------
1528
+ // DOM state
1529
+ // ---------------------------------------------------------------------------
1530
+ reveal(entry) {
1531
+ entry.element.classList.remove('ngx-stack-page--hidden');
1532
+ }
1533
+ /**
1534
+ * The definitive resting state. Exactly one page is visible: the top of the active tab's
1535
+ * stack. Everything else — buried pages, and every page of every inactive tab — is hidden and
1536
+ * inert but still mounted.
1537
+ */
1538
+ applyStates() {
1539
+ const stacks = this.stacks();
1540
+ const activeTab = this.active();
1541
+ for (const [tab, stack] of Object.entries(stacks)) {
1542
+ const lastIndex = stack.length - 1;
1543
+ stack.forEach((entry, index) => {
1544
+ const isTop = tab === activeTab && index === lastIndex;
1545
+ entry.element.classList.toggle('ngx-stack-page--hidden', !isTop);
1546
+ entry.element.classList.remove('ngx-stack-page--invisible', 'ngx-stack-page--animating');
1547
+ entry.element.inert = !isTop;
1548
+ if (isTop) {
1549
+ entry.element.style.transform = '';
1550
+ entry.scrim.style.opacity = '';
1551
+ }
1552
+ });
1553
+ }
1554
+ }
1555
+ /**
1556
+ * Put focus in the page that just arrived, and say its name out loud.
1557
+ *
1558
+ * Without this a screen-reader user gets no signal that anything happened, and keyboard focus
1559
+ * stays on whatever control they activated — which has now slid off the screen and been marked
1560
+ * `inert`, leaving focus nowhere at all.
1561
+ */
1562
+ moveFocus(entry) {
1563
+ const explicit = entry.element.querySelector('[ngxStackAutofocus]');
1564
+ const target = explicit ?? entry.element;
1565
+ if (!explicit) {
1566
+ // A page container isn't focusable by default; -1 makes it programmatically focusable
1567
+ // without adding it to the tab order.
1568
+ target.setAttribute('tabindex', '-1');
1569
+ }
1570
+ target.focus({ preventScroll: true });
1571
+ const title = entry.route?.snapshot.title ?? this.doc.title;
1572
+ if (title)
1573
+ announce(this.doc, title);
1574
+ }
1575
+ destroyEntry(entry) {
1576
+ entry.ref.destroy();
1577
+ entry.element.remove();
1578
+ }
1579
+ destroy() {
1580
+ if (this.running) {
1581
+ this.running.player?.destroy();
1582
+ this.running = null;
1583
+ }
1584
+ for (const stack of Object.values(this.stacks())) {
1585
+ for (const entry of stack)
1586
+ this.destroyEntry(entry);
1587
+ }
1588
+ this.stacks.set({});
1589
+ this.pruned.clear();
1590
+ }
1591
+ }
1592
+
1593
+ const STYLE_ID = 'ngx-stack-styles';
1594
+ /**
1595
+ * Page wrappers are created imperatively, outside any component template, so Angular's
1596
+ * style encapsulation can't reach them. These rules go in the document once instead.
1597
+ * Everything is driven by CSS custom properties so apps can restyle without `!important`.
1598
+ */
1599
+ const CSS = `
1600
+ .ngx-stack-host {
1601
+ display: block;
1602
+ position: relative;
1603
+ width: 100%;
1604
+ height: 100%;
1605
+ overflow: hidden;
1606
+
1607
+ /* Notches, home indicators, and the Android navigation bar. Declared here so pages can just
1608
+ use var(--ngx-stack-safe-top) without every one of them repeating the env() dance — and so
1609
+ they still resolve to 0px on a browser that has never heard of a notch. */
1610
+ --ngx-stack-safe-top: env(safe-area-inset-top, 0px);
1611
+ --ngx-stack-safe-bottom: env(safe-area-inset-bottom, 0px);
1612
+ --ngx-stack-safe-left: env(safe-area-inset-left, 0px);
1613
+ --ngx-stack-safe-right: env(safe-area-inset-right, 0px);
1614
+ }
1615
+
1616
+ /* Where page titles are announced to screen readers. Visually gone, but not display:none —
1617
+ which would take it out of the accessibility tree along with everything we're trying to say. */
1618
+ .ngx-stack-announcer {
1619
+ position: absolute;
1620
+ width: 1px;
1621
+ height: 1px;
1622
+ margin: -1px;
1623
+ padding: 0;
1624
+ border: 0;
1625
+ overflow: hidden;
1626
+ clip-path: inset(50%);
1627
+ white-space: nowrap;
1628
+ }
1629
+
1630
+ .ngx-stack-page {
1631
+ position: absolute;
1632
+ inset: 0;
1633
+ display: flex;
1634
+ flex-direction: column;
1635
+ overflow: hidden;
1636
+ transform: translateX(0);
1637
+ background: var(--ngx-stack-page-background, #fff);
1638
+ /* Only ever visible while the page is translated, i.e. mid-transition. */
1639
+ box-shadow: var(--ngx-stack-page-shadow, -6px 0 20px rgb(0 0 0 / 12%));
1640
+ }
1641
+
1642
+ /* The routed component fills the wrapper and scrolls; the scrim sits on top of it.
1643
+ Wrapped in :where() so this weighs nothing: a page component only has to say
1644
+ ":host { display: flex }" to take the layout over, with no !important and no fight. */
1645
+ :where(.ngx-stack-page > :not(.ngx-stack-scrim)) {
1646
+ flex: 1 1 auto;
1647
+ min-height: 0;
1648
+ display: block;
1649
+ overflow: auto;
1650
+ -webkit-overflow-scrolling: touch;
1651
+ }
1652
+
1653
+ /* Set while a page is being created, so it can't flash at its final position for a frame
1654
+ before the transition's first keyframe lands. */
1655
+ .ngx-stack-page--invisible {
1656
+ opacity: 0;
1657
+ }
1658
+
1659
+ .ngx-stack-page--animating {
1660
+ will-change: transform;
1661
+ }
1662
+
1663
+ /* Buried pages — and, with tabs, every page of every inactive tab. content-visibility:hidden
1664
+ skips their rendering while preserving internal state (crucially, scroll offsets), so coming
1665
+ back to a page finds it exactly where you left it with no scroll-restoration bookkeeping on
1666
+ our side. They stay in the DOM: that is what makes a tab switch instant and a swipe possible. */
1667
+ .ngx-stack-page--hidden {
1668
+ visibility: hidden;
1669
+ pointer-events: none;
1670
+ content-visibility: hidden;
1671
+ }
1672
+
1673
+ /* Focused programmatically after a transition; never show a ring for it. */
1674
+ .ngx-stack-page:focus {
1675
+ outline: none;
1676
+ }
1677
+
1678
+ .ngx-stack-scrim {
1679
+ position: absolute;
1680
+ inset: 0;
1681
+ z-index: 10;
1682
+ opacity: 0;
1683
+ pointer-events: none;
1684
+ background: var(--ngx-stack-scrim-color, #000);
1685
+ }
1686
+ `;
1687
+ /** Idempotent: safe to call from every outlet on every init. */
1688
+ function ensureStackStyles(doc) {
1689
+ if (doc.getElementById(STYLE_ID))
1690
+ return;
1691
+ const style = doc.createElement('style');
1692
+ style.id = STYLE_ID;
1693
+ style.textContent = CSS;
1694
+ doc.head.appendChild(style);
1695
+ }
1696
+
1697
+ /**
1698
+ * Everything the two kinds of stack have in common — which is almost everything.
1699
+ *
1700
+ * `NgxStackOutlet` is fed by the Router and `NgxStack` by `push()`, and that difference is real but
1701
+ * shallow: it only decides *where the next page comes from*. Once a page exists, holding it,
1702
+ * transitioning to it, dragging it around with a finger and tearing it down are identical jobs, and
1703
+ * a swipe-back must behave the same in both. Keeping that in one place is the only way it stays that
1704
+ * way.
1705
+ *
1706
+ * So a subclass supplies three things — how strict to be about guards, whether it overrides the
1707
+ * app-wide swipe switch, and what a committed swipe should actually *do* — and inherits the rest.
1708
+ */
1709
+ class StackHostBase {
1710
+ /** Fires as a transition begins — including one a finger is about to scrub. */
1711
+ transitionStart = output();
1712
+ /** Fires once the pages have settled and any popped page has been destroyed. */
1713
+ transitionEnd = output();
1714
+ config = inject(NGX_STACK_CONFIG);
1715
+ platform = inject(NGX_STACK_PLATFORM);
1716
+ swipe = inject(NgxStackSwipe);
1717
+ viewContainer = inject(ViewContainerRef);
1718
+ environmentInjector = inject(EnvironmentInjector);
1719
+ changeDetector = inject(ChangeDetectorRef);
1720
+ hostEl = inject((ElementRef)).nativeElement;
1721
+ controller = new StackController(this.hostEl, this.config, this.platform);
1722
+ gesture = null;
1723
+ /** The active stack's pages, bottom first. */
1724
+ pages = this.controller.pages;
1725
+ depth = this.controller.depth;
1726
+ animating = this.controller.animating;
1727
+ constructor() {
1728
+ ensureStackStyles(inject(DOCUMENT));
1729
+ this.controller.onTransitionStart = (event) => this.transitionStart.emit(event);
1730
+ this.controller.onTransitionEnd = (event) => this.transitionEnd.emit(event);
1731
+ }
1732
+ /** Arm the gesture. Subclasses call this once they are ready to be swiped. */
1733
+ startGesture() {
1734
+ this.gesture = new SwipeBackGesture(this, this.config, this.platform);
1735
+ }
1736
+ // ---------------------------------------------------------------------------
1737
+ // Creating pages
1738
+ // ---------------------------------------------------------------------------
1739
+ /**
1740
+ * Instantiate a page component and hand it to the controller to be wrapped and mounted.
1741
+ *
1742
+ * The `markForCheck` matters and is easy to lose: both hosts are OnPush with an empty template of
1743
+ * their own, so nothing would ever dirty the view and the freshly inserted page would sit there
1744
+ * un-checked until something unrelated happened to trigger a pass.
1745
+ */
1746
+ createPage(component, options) {
1747
+ const ref = this.viewContainer.createComponent(component, {
1748
+ index: this.viewContainer.length,
1749
+ injector: options.injector,
1750
+ environmentInjector: options.environmentInjector ?? this.environmentInjector,
1751
+ });
1752
+ this.changeDetector.markForCheck();
1753
+ return ref;
1754
+ }
1755
+ // ---------------------------------------------------------------------------
1756
+ // SwipeBackHost
1757
+ // ---------------------------------------------------------------------------
1758
+ /**
1759
+ * Three layers, narrowest last, each able to veto: the app-wide switch, this stack, and the page
1760
+ * currently on top. Re-evaluated on every touch, so all three can change at runtime.
1761
+ */
1762
+ canSwipeBack() {
1763
+ // An explicit value on the stack overrides the app-wide one, in both directions.
1764
+ if (!(this.swipeBackOverride() ?? this.swipe.enabled()))
1765
+ return false;
1766
+ // Deliberately `depth()`, not `canGoBack()`: the latter counts pages evicted by `maxDepth` and
1767
+ // parents that were never built, which no longer exist and so cannot be dragged into view. A
1768
+ // button can still take you there; a finger can't drag what isn't on screen.
1769
+ if (this.controller.depth() < 2 || this.controller.animating())
1770
+ return false;
1771
+ return pageAllowsSwipeBack(this.controller.top(), this.guardPolicy());
1772
+ }
1773
+ isRtl() {
1774
+ return this.controller.isRtl();
1775
+ }
1776
+ beginSwipeBack() {
1777
+ return this.controller.beginInteractiveBack();
1778
+ }
1779
+ abortSwipeBack(back, ms) {
1780
+ void this.controller.abortInteractiveBack(back, ms);
1781
+ }
1782
+ // ---------------------------------------------------------------------------
1783
+ ngOnDestroy() {
1784
+ this.gesture?.destroy();
1785
+ this.controller.destroy();
1786
+ }
1787
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: StackHostBase, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1788
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.6", type: StackHostBase, isStandalone: true, outputs: { transitionStart: "transitionStart", transitionEnd: "transitionEnd" }, ngImport: i0 });
1789
+ }
1790
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: StackHostBase, decorators: [{
1791
+ type: Directive
1792
+ }], ctorParameters: () => [], propDecorators: { transitionStart: [{ type: i0.Output, args: ["transitionStart"] }], transitionEnd: [{ type: i0.Output, args: ["transitionEnd"] }] } });
1793
+
1794
+ class SystemTransitionWatcher {
1795
+ uaAnimated = false;
1796
+ detach;
1797
+ constructor(win) {
1798
+ const navigation = win.navigation;
1799
+ if (!navigation?.addEventListener) {
1800
+ this.detach = () => undefined;
1801
+ return;
1802
+ }
1803
+ const onNavigate = (event) => {
1804
+ this.uaAnimated = event.hasUAVisualTransition === true;
1805
+ };
1806
+ navigation.addEventListener('navigate', onNavigate);
1807
+ this.detach = () => navigation.removeEventListener('navigate', onNavigate);
1808
+ }
1809
+ /** Did the browser animate the navigation we are about to handle? Reads and clears. */
1810
+ consume() {
1811
+ const value = this.uaAnimated;
1812
+ this.uaAnimated = false;
1813
+ return value;
1814
+ }
1815
+ destroy() {
1816
+ this.detach();
1817
+ }
1818
+ }
1819
+
1820
+ /**
1821
+ * Which tab a URL belongs to, or `''` when the app has no tabs — or when the URL belongs to none of
1822
+ * them, which is what a login screen or a full-screen modal route looks like. Those get their own
1823
+ * stack, outside the tab bar, which is almost always what you want.
1824
+ */
1825
+ function tabOfUrl(url, tabs) {
1826
+ if (!tabs?.length)
1827
+ return '';
1828
+ // Strip the leading slash, query and fragment, then take the first segment.
1829
+ const path = url.replace(/^\/+/, '').split(/[?#]/, 1)[0];
1830
+ const first = path.split('/', 1)[0];
1831
+ return tabs.includes(first) ? first : '';
1832
+ }
1833
+ /**
1834
+ * `data: { tab: 'search' }` — for a page that belongs to a tab its URL doesn't name.
1835
+ *
1836
+ * Most apps nest their URLs by tab and never need this. But a flat route, or a tab whose pages live
1837
+ * under a different prefix, has no way to say where it belongs otherwise, and would silently land in
1838
+ * the no-tab stack: mounted, outside the tab bar, and stranded the moment you switch tabs.
1839
+ */
1840
+ function tabOfRouteData(data, tabs) {
1841
+ const tab = data?.['tab'];
1842
+ if (typeof tab !== 'string')
1843
+ return null;
1844
+ if (tabs?.length && !tabs.includes(tab)) {
1845
+ throw new Error(`[ngx-stack] A route declares data: { tab: '${tab}' }, but '${tab}' is not one of the ` +
1846
+ `configured tabs [${tabs.join(', ')}]. Its pages would go to a stack no tab can reach.`);
1847
+ }
1848
+ return tab;
1849
+ }
1850
+ /**
1851
+ * The tab a route belongs to, asking it and then every route it is nested inside.
1852
+ *
1853
+ * The point of walking the ancestors: with nested routes you declare the tab **once**, on the tab's
1854
+ * root, and every page under it inherits — rather than repeating `data: { tab }` on each one.
1855
+ *
1856
+ * Deliberately reads `routeConfig.data` rather than the snapshot's own `data`. The snapshot's
1857
+ * version already merges in inherited data, but only from ancestors Angular considers inheritable
1858
+ * (componentless ones, unless you change `paramsInheritanceStrategy`). Walking the configs ourselves
1859
+ * means a tab root works whether or not it happens to have a component.
1860
+ */
1861
+ function tabOfRouteTree(pathFromRoot, tabs) {
1862
+ // Innermost first, so a page can override the tab it is nested in.
1863
+ for (let i = pathFromRoot.length - 1; i >= 0; i--) {
1864
+ const tab = tabOfRouteData(pathFromRoot[i].routeConfig?.data, tabs);
1865
+ if (tab)
1866
+ return tab;
1867
+ }
1868
+ return null;
1869
+ }
1870
+ /**
1871
+ * Which stack a page belongs on: what the route tree says, and failing that, what its URL implies.
1872
+ *
1873
+ * The single place that answers this, so the outlet (which files the page) and `NgxStackTabs` (which
1874
+ * lights up the tab bar) can never disagree about where a page went.
1875
+ */
1876
+ function tabOfRoute(snapshot, url, tabs) {
1877
+ return tabOfRouteTree(snapshot.pathFromRoot, tabs) ?? tabOfUrl(url, tabs);
1878
+ }
1879
+
1880
+ /** What Angular's own `OutletInjector` does: hand routed components their route and contexts. */
1881
+ class StackOutletInjector {
1882
+ route;
1883
+ childContexts;
1884
+ parent;
1885
+ constructor(route, childContexts, parent) {
1886
+ this.route = route;
1887
+ this.childContexts = childContexts;
1888
+ this.parent = parent;
1889
+ }
1890
+ get(token, notFoundValue, options) {
1891
+ if (token === ActivatedRoute)
1892
+ return this.route;
1893
+ if (token === ChildrenOutletContexts)
1894
+ return this.childContexts;
1895
+ return this.parent.get(token, notFoundValue, options);
1896
+ }
1897
+ }
1898
+ /**
1899
+ * A `<router-outlet>` that keeps a stack instead of a single page.
1900
+ *
1901
+ * The stock outlet destroys the outgoing component the moment you navigate. That is the right
1902
+ * default and completely incompatible with a swipe-back: the page you are swiping back to has to
1903
+ * already be on screen, mounted and painted, *before* the navigation that reveals it happens —
1904
+ * otherwise there is nothing to drag into view.
1905
+ *
1906
+ * So pages here are mounted once and stay. Which one you are looking at is decided by where you are
1907
+ * in the history, and the direction of each transition is inferred by asking whether the incoming
1908
+ * URL is already on the stack (going back) or not (going forward).
1909
+ *
1910
+ * With `tabs` configured, one outlet holds one stack per tab and shows the active one.
1911
+ */
1912
+ class NgxStackOutlet extends StackHostBase {
1913
+ /** Matches `<router-outlet name>`, for named outlets. */
1914
+ name = input(PRIMARY_OUTLET, /* @ts-ignore */
1915
+ ...(ngDevMode ? [{ debugName: "name" }] : /* istanbul ignore next */ []));
1916
+ /**
1917
+ * Swipe-back for this stack specifically. `null` (the default) defers to `NgxStackSwipe`, the
1918
+ * app-wide switch; `true` or `false` overrides it here.
1919
+ */
1920
+ swipeBack = input(null, /* @ts-ignore */
1921
+ ...(ngDevMode ? [{ debugName: "swipeBack" }] : /* istanbul ignore next */ []));
1922
+ parentContexts = inject(ChildrenOutletContexts);
1923
+ router = inject(Router);
1924
+ nav = inject(NgxStackNav);
1925
+ document = inject(DOCUMENT);
1926
+ systemTransition = null;
1927
+ activatedRouteRef = null;
1928
+ current = null;
1929
+ initialized = false;
1930
+ /** A committed swipe, animated to the end, waiting for the router to catch up. */
1931
+ pendingSwipe = null;
1932
+ // Required by RouterOutletContract, which predates `output()` and types these as EventEmitters.
1933
+ activateEvents = new EventEmitter();
1934
+ deactivateEvents = new EventEmitter();
1935
+ attachEvents = new EventEmitter();
1936
+ detachEvents = new EventEmitter();
1937
+ activeTab = this.controller.activeTab;
1938
+ /**
1939
+ * Is there anywhere to go back to? Show your back button on this.
1940
+ *
1941
+ * True even with a single page on the stack, if that page has a parent it could go up to — which
1942
+ * is exactly the cold-deep-link case, where nothing is beneath but obviously something should be.
1943
+ */
1944
+ canGoBack = computed(() => this.controller.canGoBack() || this.parentOfTop() !== null, /* @ts-ignore */
1945
+ ...(ngDevMode ? [{ debugName: "canGoBack" }] : /* istanbul ignore next */ []));
1946
+ constructor() {
1947
+ super();
1948
+ // If a navigation dies after a swipe has already animated the page away, put it back.
1949
+ this.router.events.pipe(takeUntilDestroyed()).subscribe((event) => {
1950
+ if (event instanceof NavigationCancel || event instanceof NavigationError) {
1951
+ this.rollBackPendingSwipe();
1952
+ }
1953
+ });
1954
+ }
1955
+ ngOnInit() {
1956
+ if (this.initialized)
1957
+ return;
1958
+ this.initialized = true;
1959
+ this.parentContexts.onChildOutletCreated(this.name(), this);
1960
+ // The route can be activated before the outlet exists — e.g. it sits inside an `@if` that only
1961
+ // just became true. Pick up whatever is already waiting for us.
1962
+ const context = this.parentContexts.getContext(this.name());
1963
+ if (context?.route) {
1964
+ this.activateWith(context.route, context.injector);
1965
+ }
1966
+ this.startGesture();
1967
+ this.nav.registerStack(this);
1968
+ const win = this.document.defaultView;
1969
+ if (win)
1970
+ this.systemTransition = new SystemTransitionWatcher(win);
1971
+ }
1972
+ // ---------------------------------------------------------------------------
1973
+ // RouterOutletContract
1974
+ // ---------------------------------------------------------------------------
1975
+ get isActivated() {
1976
+ return this.current !== null;
1977
+ }
1978
+ get component() {
1979
+ return this.current?.ref.instance ?? null;
1980
+ }
1981
+ get activatedRoute() {
1982
+ return this.activatedRouteRef;
1983
+ }
1984
+ get activatedRouteData() {
1985
+ return this.activatedRouteRef?.snapshot.data ?? {};
1986
+ }
1987
+ activateWith(route, environmentInjector) {
1988
+ const url = this.urlOf(route);
1989
+ const tab = tabOfRoute(route.snapshot, url, this.config.tabs);
1990
+ const hint = readDirectionHint(this.router);
1991
+ // If the browser already drew its own back animation, drawing ours would double it up.
1992
+ let animated = !(this.systemTransition?.consume() ?? false);
1993
+ // A caller rebuilding a stack at startup wants the pages to just *be* there, not to watch them
1994
+ // fly in one after another.
1995
+ if (readAnimatedHint(this.router) === false)
1996
+ animated = false;
1997
+ // Changing tabs is not a push and not a pop — it's a cut. Nothing is created, nothing destroyed,
1998
+ // the other tab's stack simply comes back on screen exactly as it was. Sliding it in would imply
1999
+ // a relationship between the two tabs that doesn't exist.
2000
+ if (tab !== this.controller.activeTab() && hint !== 'root')
2001
+ animated = false;
2002
+ const existingIndex = this.controller.findByUrl(url, tab);
2003
+ const swipe = this.pendingSwipe;
2004
+ this.pendingSwipe = null;
2005
+ if (swipe) {
2006
+ // The swipe already ran the whole animation. Hand its player to the controller so it finalises
2007
+ // instead of transitioning a second time — but only if history actually took us where the
2008
+ // swipe aimed. If something else intervened, throw the gesture's work away.
2009
+ const aimedAt = existingIndex >= 0 && this.controller.at(existingIndex, tab) === swipe.entering;
2010
+ if (aimedAt) {
2011
+ this.adopt(swipe.entering, route);
2012
+ void this.controller.run({
2013
+ kind: 'pop',
2014
+ tab,
2015
+ toIndex: existingIndex,
2016
+ animated: true,
2017
+ player: swipe.player,
2018
+ });
2019
+ return;
2020
+ }
2021
+ swipe.player.destroy();
2022
+ }
2023
+ const goingBack = hint !== 'forward' && hint !== 'root';
2024
+ // Already on the stack: unwind to it.
2025
+ if (goingBack && existingIndex >= 0) {
2026
+ this.adopt(this.controller.at(existingIndex, tab), route);
2027
+ void this.controller.run({ kind: 'pop', tab, toIndex: existingIndex, animated });
2028
+ return;
2029
+ }
2030
+ // Going back to a page that isn't mounted. Two ways that happens, wanting the same handling:
2031
+ // `maxDepth` evicted it, or we deep-linked into the middle of the app and are walking up to a
2032
+ // parent that was never built. Either way, rebuild it and animate as a *back*, because that is
2033
+ // what actually happened — whatever the DOM currently remembers about it.
2034
+ if (hint === 'back' || (goingBack && this.controller.wasPruned(url, tab))) {
2035
+ const entry = this.createEntry(route, environmentInjector, url, tab);
2036
+ this.adopt(entry, route);
2037
+ void this.controller.run({ kind: 'restore', tab, entering: entry, animated });
2038
+ this.activateEvents.emit(entry.ref.instance);
2039
+ return;
2040
+ }
2041
+ const entry = this.createEntry(route, environmentInjector, url, tab);
2042
+ this.adopt(entry, route);
2043
+ // `replaceUrl: true` says the history entry was overwritten rather than added. The stack has to
2044
+ // agree: swap the top page rather than stack a second one on it, or the stack grows while
2045
+ // history doesn't and a back button starts skipping pages.
2046
+ const replacing = this.router.getCurrentNavigation()?.extras.replaceUrl === true && this.controller.depth() > 0;
2047
+ void this.controller.run(hint === 'root'
2048
+ ? { kind: 'root', tab, entering: entry, animated }
2049
+ : replacing
2050
+ ? { kind: 'replace', tab, entering: entry, animated }
2051
+ : { kind: 'push', tab, entering: entry, animated });
2052
+ this.activateEvents.emit(entry.ref.instance);
2053
+ }
2054
+ deactivate() {
2055
+ const entry = this.current;
2056
+ if (entry) {
2057
+ const context = this.parentContexts.getContext(this.name());
2058
+ if (context) {
2059
+ // Hold on to this page's nested outlets, so coming back to it doesn't find them empty.
2060
+ entry.savedContexts = context.children.onOutletDeactivated();
2061
+ }
2062
+ this.deactivateEvents.emit(entry.ref.instance);
2063
+ }
2064
+ // Note what we do *not* do here: destroy the component. It stays on the stack. If it is being
2065
+ // popped, the controller destroys it once the transition has played out.
2066
+ this.current = null;
2067
+ this.activatedRouteRef = null;
2068
+ }
2069
+ detach() {
2070
+ throw new Error('[ngx-stack] detach() is not supported. <ngx-stack-outlet> manages page lifetime itself; a ' +
2071
+ 'RouteReuseStrategy that returns shouldDetach: true will fight it. provideNgxStack() ' +
2072
+ 'installs NgxStackRouteReuseStrategy for exactly this reason.');
2073
+ }
2074
+ attach() {
2075
+ throw new Error('[ngx-stack] attach() is not supported. See detach().');
2076
+ }
2077
+ // ---------------------------------------------------------------------------
2078
+ // StackHostBase / BackTarget
2079
+ // ---------------------------------------------------------------------------
2080
+ swipeBackOverride() {
2081
+ return this.swipeBack();
2082
+ }
2083
+ guardPolicy() {
2084
+ return this.config.guardPolicy;
2085
+ }
2086
+ commitSwipeBack(back, ms) {
2087
+ void back.player.settle(1, ms).then(() => {
2088
+ this.pendingSwipe = back;
2089
+ // Go back for real, so the URL and the browser's back button stay honest. The animation is
2090
+ // already finished; `activateWith` will adopt the player and just tidy up.
2091
+ void this.nav.back();
2092
+ });
2093
+ }
2094
+ /**
2095
+ * Where a "back" should actually go.
2096
+ *
2097
+ * Usually the page beneath the top of the *active* stack — which, with tabs, is emphatically not
2098
+ * whatever the browser happens to have behind us in history. When nothing is beneath, we fall back
2099
+ * to the page this one sits under. That page isn't mounted, so it has to be built; `mounted` is
2100
+ * how {@link NgxStackNav.back} tells the two apart, because only one of them can be served by a
2101
+ * plain `history.back()`.
2102
+ */
2103
+ backTarget() {
2104
+ const stack = this.controller.pages();
2105
+ if (stack.length >= 2) {
2106
+ return { url: stack[stack.length - 2].url, mounted: true };
2107
+ }
2108
+ const parent = this.parentOfTop();
2109
+ return parent ? { url: parent, mounted: false } : null;
2110
+ }
2111
+ rollBackPendingSwipe() {
2112
+ const swipe = this.pendingSwipe;
2113
+ if (!swipe)
2114
+ return;
2115
+ this.pendingSwipe = null;
2116
+ void this.controller.abortInteractiveBack(swipe, this.config.duration);
2117
+ }
2118
+ // ---------------------------------------------------------------------------
2119
+ /**
2120
+ * Where the top page sits, when nothing is mounted underneath it.
2121
+ *
2122
+ * Nobody normally has to say: `/inbox/item/12` obviously sits under `/inbox`, and the route config
2123
+ * already knows it. `data: { parent }` is only for URLs that don't tell the truth.
2124
+ */
2125
+ parentOfTop() {
2126
+ const url = this.controller.top()?.url;
2127
+ return url ? deriveParentUrl(this.router.config, url) : null;
2128
+ }
2129
+ createEntry(route, environmentInjector, url, tab) {
2130
+ const component = route.snapshot.routeConfig?.component;
2131
+ if (!component) {
2132
+ throw new Error(`[ngx-stack] Route "${url}" has no component. <ngx-stack-outlet> can only host component ` +
2133
+ 'routes — loadComponent is fine, but redirects and componentless routes are not.');
2134
+ }
2135
+ const childContexts = this.parentContexts.getOrCreateContext(this.name()).children;
2136
+ const injector = new StackOutletInjector(route, childContexts, this.viewContainer.injector);
2137
+ const ref = this.createPage(component, { injector, environmentInjector });
2138
+ return this.controller.adopt(ref, url, route, tab);
2139
+ }
2140
+ /** Make `entry` the outlet's current page and give it back its nested outlets. */
2141
+ adopt(entry, route) {
2142
+ entry.route = route;
2143
+ this.current = entry;
2144
+ this.activatedRouteRef = route;
2145
+ const context = this.parentContexts.getContext(this.name());
2146
+ if (context && entry.savedContexts) {
2147
+ context.children.onOutletReAttached(entry.savedContexts);
2148
+ entry.savedContexts = undefined;
2149
+ }
2150
+ }
2151
+ urlOf(route) {
2152
+ return this.router.serializeUrl(this.router.createUrlTree(['.'], { relativeTo: route }));
2153
+ }
2154
+ ngOnDestroy() {
2155
+ this.systemTransition?.destroy();
2156
+ this.nav.unregisterStack(this);
2157
+ this.parentContexts.onChildOutletDestroyed(this.name());
2158
+ super.ngOnDestroy();
2159
+ }
2160
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: NgxStackOutlet, deps: [], target: i0.ɵɵFactoryTarget.Component });
2161
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.0.6", type: NgxStackOutlet, isStandalone: true, selector: "ngx-stack-outlet", inputs: { name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: false, transformFunction: null }, swipeBack: { classPropertyName: "swipeBack", publicName: "swipeBack", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "ngx-stack-host" }, exportAs: ["ngxStackOutlet"], usesInheritance: true, ngImport: i0, template: '', isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
2162
+ }
2163
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: NgxStackOutlet, decorators: [{
2164
+ type: Component,
2165
+ args: [{
2166
+ selector: 'ngx-stack-outlet',
2167
+ template: '',
2168
+ host: { class: 'ngx-stack-host' },
2169
+ encapsulation: ViewEncapsulation.None,
2170
+ changeDetection: ChangeDetectionStrategy.OnPush,
2171
+ exportAs: 'ngxStackOutlet',
2172
+ }]
2173
+ }], ctorParameters: () => [], propDecorators: { name: [{ type: i0.Input, args: [{ isSignal: true, alias: "name", required: false }] }], swipeBack: [{ type: i0.Input, args: [{ isSignal: true, alias: "swipeBack", required: false }] }] } });
2174
+
2175
+ /** Imperative stacks have no URLs, so all their pages live in the one unnamed stack. */
2176
+ const NO_TAB = '';
2177
+ /**
2178
+ * A self-contained page stack with no URLs — the escape hatch for navigation that has no business
2179
+ * being in the address bar.
2180
+ *
2181
+ * A multi-step filter sheet, a wizard inside a modal, a drill-down in one tab of a tab bar: these
2182
+ * are all stacks, they all want the same push/pop transition and the same swipe-back, and none of
2183
+ * them should push history entries that the browser's back button then has to unwind one at a time.
2184
+ *
2185
+ * ```html
2186
+ * <ngx-stack [root]="FilterStep1" #filters />
2187
+ * ```
2188
+ * ```ts
2189
+ * filters.push(FilterStep2, { category: 'shoes' });
2190
+ * ```
2191
+ *
2192
+ * Pages reach the stack they are standing on with `inject(NgxStack)`. For anything the user should
2193
+ * be able to link to, bookmark or reload, use `<ngx-stack-outlet>` instead.
2194
+ */
2195
+ class NgxStack extends StackHostBase {
2196
+ /** The bottom page. Mounted on init; use `setRoot()` to change it afterwards. */
2197
+ root = input(null, /* @ts-ignore */
2198
+ ...(ngDevMode ? [{ debugName: "root" }] : /* istanbul ignore next */ []));
2199
+ /** Inputs for the root page, applied with `setInput`. */
2200
+ rootInputs = input(/* @ts-ignore */
2201
+ ...(ngDevMode ? [undefined, { debugName: "rootInputs" }] : /* istanbul ignore next */ []));
2202
+ /**
2203
+ * Swipe-back for this stack specifically. `null` (the default) defers to `NgxStackSwipe`, the
2204
+ * app-wide switch; `true` or `false` overrides it here.
2205
+ */
2206
+ swipeBack = input(null, /* @ts-ignore */
2207
+ ...(ngDevMode ? [{ debugName: "swipeBack" }] : /* istanbul ignore next */ []));
2208
+ /** The stack's own node injector, so pages can `inject(NgxStack)` and push further. */
2209
+ pageInjector = inject(Injector);
2210
+ canGoBack = this.controller.canGoBack;
2211
+ ngOnInit() {
2212
+ const root = this.root();
2213
+ if (root) {
2214
+ const entry = this.mount(root, this.rootInputs());
2215
+ void this.controller.run({ kind: 'push', tab: NO_TAB, entering: entry, animated: false });
2216
+ }
2217
+ this.startGesture();
2218
+ }
2219
+ /** Push a page on top. Resolves when the transition has finished. */
2220
+ push(component, inputs) {
2221
+ const entry = this.mount(component, inputs);
2222
+ return this.controller.run({ kind: 'push', tab: NO_TAB, entering: entry, animated: true });
2223
+ }
2224
+ /** Pop the top page. A no-op at the root, so it is safe to call blindly. */
2225
+ pop() {
2226
+ return this.popTo(this.controller.depth() - 2);
2227
+ }
2228
+ /** Unwind to the page at `index` (0 is the root), destroying everything above it. */
2229
+ popTo(index) {
2230
+ if (index < 0 || index >= this.controller.depth() - 1)
2231
+ return Promise.resolve();
2232
+ return this.controller.run({ kind: 'pop', tab: NO_TAB, toIndex: index, animated: true });
2233
+ }
2234
+ popToRoot() {
2235
+ return this.popTo(0);
2236
+ }
2237
+ /** Throw the stack away and start again from `component`. */
2238
+ setRoot(component, inputs) {
2239
+ const entry = this.mount(component, inputs);
2240
+ return this.controller.run({ kind: 'root', tab: NO_TAB, entering: entry, animated: true });
2241
+ }
2242
+ // ---------------------------------------------------------------------------
2243
+ // StackHostBase
2244
+ // ---------------------------------------------------------------------------
2245
+ swipeBackOverride() {
2246
+ return this.swipeBack();
2247
+ }
2248
+ /** No routes here, so no `canDeactivate` guards to be careful about. */
2249
+ guardPolicy() {
2250
+ return 'allow';
2251
+ }
2252
+ commitSwipeBack(back, ms) {
2253
+ void back.player.settle(1, ms).then(() => {
2254
+ const index = this.controller.pages().indexOf(back.entering);
2255
+ if (index < 0)
2256
+ return;
2257
+ // Hand the finished player over so the pop finalises rather than animating a second time.
2258
+ void this.controller.run({
2259
+ kind: 'pop',
2260
+ tab: NO_TAB,
2261
+ toIndex: index,
2262
+ animated: true,
2263
+ player: back.player,
2264
+ });
2265
+ });
2266
+ }
2267
+ // ---------------------------------------------------------------------------
2268
+ mount(component, inputs) {
2269
+ const ref = this.createPage(component, { injector: this.pageInjector });
2270
+ for (const [name, value] of Object.entries(inputs ?? {})) {
2271
+ ref.setInput(name, value);
2272
+ }
2273
+ return this.controller.adopt(ref, '', null, NO_TAB);
2274
+ }
2275
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: NgxStack, deps: null, target: i0.ɵɵFactoryTarget.Component });
2276
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.0.6", type: NgxStack, isStandalone: true, selector: "ngx-stack", inputs: { root: { classPropertyName: "root", publicName: "root", isSignal: true, isRequired: false, transformFunction: null }, rootInputs: { classPropertyName: "rootInputs", publicName: "rootInputs", isSignal: true, isRequired: false, transformFunction: null }, swipeBack: { classPropertyName: "swipeBack", publicName: "swipeBack", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "ngx-stack-host" }, exportAs: ["ngxStack"], usesInheritance: true, ngImport: i0, template: '', isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
2277
+ }
2278
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: NgxStack, decorators: [{
2279
+ type: Component,
2280
+ args: [{
2281
+ selector: 'ngx-stack',
2282
+ template: '',
2283
+ host: { class: 'ngx-stack-host' },
2284
+ encapsulation: ViewEncapsulation.None,
2285
+ changeDetection: ChangeDetectionStrategy.OnPush,
2286
+ exportAs: 'ngxStack',
2287
+ }]
2288
+ }], propDecorators: { root: [{ type: i0.Input, args: [{ isSignal: true, alias: "root", required: false }] }], rootInputs: [{ type: i0.Input, args: [{ isSignal: true, alias: "rootInputs", required: false }] }], swipeBack: [{ type: i0.Input, args: [{ isSignal: true, alias: "swipeBack", required: false }] }] } });
2289
+
2290
+ /**
2291
+ * Remembers where you were in each tab.
2292
+ *
2293
+ * The stacks themselves are `NgxStackOutlet`'s job — configure `tabs` and it keeps one per tab,
2294
+ * mounted and untouched while you're elsewhere. This is the other half: tapping "Search" should
2295
+ * take you back to the search result you were reading three screens deep, not dump you at the
2296
+ * search tab's front page. So we watch navigations, note the current URL of each tab, and
2297
+ * `select()` returns you to it.
2298
+ *
2299
+ * The tab *bar* is yours to draw — the library ships no visual design. All you need from us:
2300
+ *
2301
+ * ```ts
2302
+ * const tabs = inject(NgxStackTabs);
2303
+ * ```
2304
+ * ```html
2305
+ * @for (tab of ['home', 'search', 'profile']; track tab) {
2306
+ * <button [class.active]="tabs.active() === tab" (click)="tabs.select(tab)">{{ tab }}</button>
2307
+ * }
2308
+ * ```
2309
+ */
2310
+ class NgxStackTabs {
2311
+ router = inject(Router);
2312
+ config = inject(NGX_STACK_CONFIG);
2313
+ urls = signal({}, /* @ts-ignore */
2314
+ ...(ngDevMode ? [{ debugName: "urls" }] : /* istanbul ignore next */ []));
2315
+ _active = signal('', /* @ts-ignore */
2316
+ ...(ngDevMode ? [{ debugName: "_active" }] : /* istanbul ignore next */ []));
2317
+ /** The tab currently on screen, or `''` before the first navigation into one. */
2318
+ active = this._active.asReadonly();
2319
+ /** The tabs you configured, in order. */
2320
+ tabs = computed(() => this.config.tabs ?? [], /* @ts-ignore */
2321
+ ...(ngDevMode ? [{ debugName: "tabs" }] : /* istanbul ignore next */ []));
2322
+ constructor() {
2323
+ this.router.events.pipe(takeUntilDestroyed()).subscribe((event) => {
2324
+ if (!(event instanceof NavigationEnd))
2325
+ return;
2326
+ const url = event.urlAfterRedirects;
2327
+ // Exactly the rule the outlet uses to file the page, so the bar and the stacks can't disagree.
2328
+ const tab = tabOfRoute(this.leaf(), url, this.config.tabs);
2329
+ if (!tab)
2330
+ return;
2331
+ this._active.set(tab);
2332
+ this.urls.update((urls) => ({ ...urls, [tab]: url }));
2333
+ });
2334
+ }
2335
+ /** The page that actually landed. */
2336
+ leaf() {
2337
+ let route = this.router.routerState.snapshot.root;
2338
+ while (route.firstChild)
2339
+ route = route.firstChild;
2340
+ return route;
2341
+ }
2342
+ /** Where `tab` was last seen, or its front page if it hasn't been visited yet. */
2343
+ urlOf(tab) {
2344
+ return this.urls()[tab] ?? `/${tab}`;
2345
+ }
2346
+ /**
2347
+ * Switch to `tab`, landing back on whatever page you last had open there.
2348
+ *
2349
+ * Tapping the tab you're already on takes you to its root, which is what every native tab bar
2350
+ * does — it's the standard way back out of a deep drill-down.
2351
+ */
2352
+ select(tab) {
2353
+ if (tab === this._active()) {
2354
+ return this.router.navigateByUrl(`/${tab}`);
2355
+ }
2356
+ return this.router.navigateByUrl(this.urlOf(tab));
2357
+ }
2358
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: NgxStackTabs, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
2359
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: NgxStackTabs, providedIn: 'root' });
2360
+ }
2361
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: NgxStackTabs, decorators: [{
2362
+ type: Injectable,
2363
+ args: [{ providedIn: 'root' }]
2364
+ }], ctorParameters: () => [] });
2365
+
2366
+ /**
2367
+ * Pages swap instantly.
2368
+ *
2369
+ * Give it to a platform you'd rather not animate — `transitions: { web: noneTransition }` is a
2370
+ * common choice, since a page slide on a desktop monitor mostly just moves a lot of pixels.
2371
+ *
2372
+ * Note this also disarms the swipe on that platform in practice: there are no keyframes for a finger
2373
+ * to scrub, so a drag has nothing to follow.
2374
+ */
2375
+ const noneTransition = () => ({
2376
+ duration: 0,
2377
+ easing: 'linear',
2378
+ animations: [],
2379
+ });
2380
+
2381
+ /*
2382
+ * ngx-stack — native-feeling page stacks for Angular.
2383
+ */
2384
+ // Setup
2385
+
2386
+ /**
2387
+ * Generated bundle index. Do not edit.
2388
+ */
2389
+
2390
+ export { NGX_STACK_PLATFORM, NgxStack, NgxStackNav, NgxStackOutlet, NgxStackRouteReuseStrategy, NgxStackSwipe, NgxStackTabs, androidTransition, deriveParentUrl, iosTransition, noneTransition, provideCapacitorBack, provideCordovaBack, provideNgxStack, riseTransition, scrimOf, slideTransition, webTransition };
2391
+ //# sourceMappingURL=ngx-stack.mjs.map