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.
- package/LICENSE +21 -0
- package/README.md +760 -0
- package/fesm2022/ngx-stack.mjs +2391 -0
- package/fesm2022/ngx-stack.mjs.map +1 -0
- package/package.json +56 -0
- package/schematics/migration.json +10 -0
- package/schematics/migrations/v23/index.js +62 -0
- package/schematics/package.json +3 -0
- package/types/ngx-stack.d.ts +1075 -0
|
@@ -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
|