@stacknav/angular 0.1.1
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/README.md +198 -0
- package/fesm2022/stacknav-angular.mjs +724 -0
- package/fesm2022/stacknav-angular.mjs.map +1 -0
- package/package.json +43 -0
- package/types/stacknav-angular.d.ts +260 -0
- package/types/stacknav-angular.d.ts.map +1 -0
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
import { Location, DOCUMENT } from '@angular/common';
|
|
2
|
+
import * as i0 from '@angular/core';
|
|
3
|
+
import { InjectionToken, makeEnvironmentProviders, inject, Injectable, input, EventEmitter, ViewContainerRef, ChangeDetectorRef, ElementRef, Injector, reflectComponentType, Output, Input, Directive } from '@angular/core';
|
|
4
|
+
import { Router, ROUTER_CONFIGURATION, NavigationStart, NavigationEnd, NavigationCancel, NavigationError, NavigationSkipped, PRIMARY_OUTLET, ChildrenOutletContexts, ActivatedRoute, ROUTER_OUTLET_DATA, BaseRouteReuseStrategy } from '@angular/router';
|
|
5
|
+
import { createDirectionResolver, defaultStrategies, createIOSStack, injectStyles, segmentsOf } from '@stacknav/core';
|
|
6
|
+
import { BehaviorSubject, switchMap, Subscription, combineLatest, of, from } from 'rxjs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The `ActivatedRoute` a page component injects. When a page kept alive beneath
|
|
10
|
+
* the stack is reached again, the router hands it a new route object. This
|
|
11
|
+
* proxy keeps the component's subscriptions valid by switching them to whichever
|
|
12
|
+
* route is current.
|
|
13
|
+
*/
|
|
14
|
+
class StackNavActivatedRoute {
|
|
15
|
+
current$;
|
|
16
|
+
url;
|
|
17
|
+
params;
|
|
18
|
+
queryParams;
|
|
19
|
+
fragment;
|
|
20
|
+
data;
|
|
21
|
+
title;
|
|
22
|
+
paramMap;
|
|
23
|
+
queryParamMap;
|
|
24
|
+
constructor(route) {
|
|
25
|
+
this.current$ = new BehaviorSubject(route);
|
|
26
|
+
const of = (pick) => this.current$.pipe(switchMap(pick));
|
|
27
|
+
this.url = of((r) => r.url);
|
|
28
|
+
this.params = of((r) => r.params);
|
|
29
|
+
this.queryParams = of((r) => r.queryParams);
|
|
30
|
+
this.fragment = of((r) => r.fragment);
|
|
31
|
+
this.data = of((r) => r.data);
|
|
32
|
+
this.title = of((r) => r.title);
|
|
33
|
+
this.paramMap = of((r) => r.paramMap);
|
|
34
|
+
this.queryParamMap = of((r) => r.queryParamMap);
|
|
35
|
+
}
|
|
36
|
+
/** The router's route object behind the proxy right now. */
|
|
37
|
+
get actual() {
|
|
38
|
+
return this.current$.value;
|
|
39
|
+
}
|
|
40
|
+
/** @internal */
|
|
41
|
+
swap(route) {
|
|
42
|
+
if (route !== this.current$.value)
|
|
43
|
+
this.current$.next(route);
|
|
44
|
+
}
|
|
45
|
+
get snapshot() {
|
|
46
|
+
return this.actual.snapshot;
|
|
47
|
+
}
|
|
48
|
+
get outlet() {
|
|
49
|
+
return this.actual.outlet;
|
|
50
|
+
}
|
|
51
|
+
get component() {
|
|
52
|
+
return this.actual.component;
|
|
53
|
+
}
|
|
54
|
+
get routeConfig() {
|
|
55
|
+
return this.actual.routeConfig;
|
|
56
|
+
}
|
|
57
|
+
get root() {
|
|
58
|
+
return this.actual.root;
|
|
59
|
+
}
|
|
60
|
+
get parent() {
|
|
61
|
+
return this.actual.parent;
|
|
62
|
+
}
|
|
63
|
+
get firstChild() {
|
|
64
|
+
return this.actual.firstChild;
|
|
65
|
+
}
|
|
66
|
+
get children() {
|
|
67
|
+
return this.actual.children;
|
|
68
|
+
}
|
|
69
|
+
get pathFromRoot() {
|
|
70
|
+
return this.actual.pathFromRoot;
|
|
71
|
+
}
|
|
72
|
+
toString() {
|
|
73
|
+
return this.actual.toString();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const STACKNAV_CONFIG = /*#__PURE__*/ new InjectionToken('STACKNAV_CONFIG', {
|
|
78
|
+
providedIn: 'root',
|
|
79
|
+
factory: () => resolveConfig({}),
|
|
80
|
+
});
|
|
81
|
+
function defaultLevelOf(snapshot) {
|
|
82
|
+
const v = snapshot.data?.['stackLevel'];
|
|
83
|
+
return typeof v === 'number' ? v : undefined;
|
|
84
|
+
}
|
|
85
|
+
/** The route's URL path from the root down to and including this route, e.g. `items/42;view=full`. */
|
|
86
|
+
function defaultKeyOf(snapshot) {
|
|
87
|
+
return snapshot.pathFromRoot
|
|
88
|
+
.flatMap((s) => s.url.map((u) => u.toString()))
|
|
89
|
+
.join('/');
|
|
90
|
+
}
|
|
91
|
+
function resolveConfig(c) {
|
|
92
|
+
const resolve = typeof c.direction === 'function'
|
|
93
|
+
? c.direction
|
|
94
|
+
: createDirectionResolver(c.direction ?? defaultStrategies(), c.fallbackDirection ?? 'push');
|
|
95
|
+
return {
|
|
96
|
+
resolve,
|
|
97
|
+
levelOf: c.levelOf ?? defaultLevelOf,
|
|
98
|
+
keyOf: c.keyOf ?? defaultKeyOf,
|
|
99
|
+
infoKey: c.infoKey ?? 'stacknav',
|
|
100
|
+
transition: c.transition ?? {},
|
|
101
|
+
gesture: c.gesture ?? {},
|
|
102
|
+
detachInactiveViews: c.detachInactiveViews ?? false,
|
|
103
|
+
injectStyles: c.injectStyles ?? true,
|
|
104
|
+
animated: c.animated ?? true,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Configures the outlets. Add it next to `provideRouter()`. It changes no
|
|
109
|
+
* router configuration.
|
|
110
|
+
*
|
|
111
|
+
* ```ts
|
|
112
|
+
* bootstrapApplication(App, { providers: [provideRouter(routes), provideStackNav()] });
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
function provideStackNav(config = {}) {
|
|
116
|
+
return makeEnvironmentProviders([{ provide: STACKNAV_CONFIG, useValue: resolveConfig(config) }]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A model of the browser's history as the router walks it: which entry is
|
|
121
|
+
* current, and which came before. It answers two questions for the outlet: is
|
|
122
|
+
* this navigation going back or forward, and is the previous history entry the
|
|
123
|
+
* page beneath the top? Everything comes from public router events, so it needs
|
|
124
|
+
* no router configuration. Internal to the outlet.
|
|
125
|
+
*/
|
|
126
|
+
class StackNavHistory {
|
|
127
|
+
router = inject(Router);
|
|
128
|
+
config = inject(STACKNAV_CONFIG);
|
|
129
|
+
cancelResolution = inject(ROUTER_CONFIGURATION, { optional: true })?.canceledNavigationResolution ?? 'replace';
|
|
130
|
+
entries = [];
|
|
131
|
+
cursor = -1;
|
|
132
|
+
pending = null;
|
|
133
|
+
constructor() {
|
|
134
|
+
this.router.events.subscribe((e) => {
|
|
135
|
+
if (e instanceof NavigationStart)
|
|
136
|
+
this.onStart(e);
|
|
137
|
+
else if (e instanceof NavigationEnd)
|
|
138
|
+
this.onEnd(e);
|
|
139
|
+
else if (e instanceof NavigationCancel || e instanceof NavigationError)
|
|
140
|
+
this.onAbort();
|
|
141
|
+
else if (e instanceof NavigationSkipped)
|
|
142
|
+
this.pending = null;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/** The navigation in flight, if any. Valid while the router activates routes. */
|
|
146
|
+
get current() {
|
|
147
|
+
return this.pending;
|
|
148
|
+
}
|
|
149
|
+
/** URL of the history entry before the current one, or null. */
|
|
150
|
+
get previousUrl() {
|
|
151
|
+
return this.cursor > 0 ? this.entries[this.cursor - 1].url : null;
|
|
152
|
+
}
|
|
153
|
+
get currentUrl() {
|
|
154
|
+
return this.cursor >= 0 ? this.entries[this.cursor].url : null;
|
|
155
|
+
}
|
|
156
|
+
get canGoBack() {
|
|
157
|
+
return this.cursor > 0;
|
|
158
|
+
}
|
|
159
|
+
onStart(e) {
|
|
160
|
+
const nav = this.router.getCurrentNavigation();
|
|
161
|
+
const isHistory = e.navigationTrigger === 'popstate' || e.navigationTrigger === 'hashchange';
|
|
162
|
+
const restoredId = e.restoredState?.navigationId ?? null;
|
|
163
|
+
let historyDelta;
|
|
164
|
+
if (isHistory) {
|
|
165
|
+
const idx = this.indexOf(restoredId);
|
|
166
|
+
if (idx >= 0 && this.cursor >= 0)
|
|
167
|
+
historyDelta = idx - this.cursor;
|
|
168
|
+
else if (restoredId != null && this.cursor >= 0)
|
|
169
|
+
historyDelta = restoredId < this.entries[this.cursor].id ? -1 : 1;
|
|
170
|
+
}
|
|
171
|
+
const hint = isHistory ? undefined : readHint(nav?.extras.info, this.config.infoKey);
|
|
172
|
+
this.pending = {
|
|
173
|
+
id: e.id,
|
|
174
|
+
trigger: isHistory ? 'history' : 'imperative',
|
|
175
|
+
historyDelta,
|
|
176
|
+
hint: hint?.direction,
|
|
177
|
+
animated: hint?.animated,
|
|
178
|
+
replaceUrl: !!nav?.extras.replaceUrl,
|
|
179
|
+
skipLocationChange: !!nav?.extras.skipLocationChange,
|
|
180
|
+
restoredId,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
onEnd(e) {
|
|
184
|
+
const p = this.pending;
|
|
185
|
+
this.pending = null;
|
|
186
|
+
const entry = { id: e.id, url: e.urlAfterRedirects };
|
|
187
|
+
if (!p || this.cursor < 0) {
|
|
188
|
+
this.entries = [entry];
|
|
189
|
+
this.cursor = 0;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (p.trigger === 'history') {
|
|
193
|
+
const idx = this.indexOf(p.restoredId);
|
|
194
|
+
if (idx >= 0) {
|
|
195
|
+
this.cursor = idx;
|
|
196
|
+
this.entries[idx] = entry; // the router rewrites the entry's navigationId on popstate
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
this.entries = [entry];
|
|
200
|
+
this.cursor = 0;
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (p.skipLocationChange)
|
|
205
|
+
return;
|
|
206
|
+
if (p.replaceUrl) {
|
|
207
|
+
this.entries[this.cursor] = entry;
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
this.entries.splice(this.cursor + 1);
|
|
211
|
+
this.entries.push(entry);
|
|
212
|
+
this.cursor++;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Handles a history navigation the router refused. With
|
|
216
|
+
* `canceledNavigationResolution: 'computed'` the router walks the browser back
|
|
217
|
+
* to where it was, so nothing changes here. With the default `'replace'` it
|
|
218
|
+
* overwrites the entry the browser landed on with the current URL and the last
|
|
219
|
+
* successful id.
|
|
220
|
+
*/
|
|
221
|
+
onAbort() {
|
|
222
|
+
const p = this.pending;
|
|
223
|
+
this.pending = null;
|
|
224
|
+
if (!p || p.trigger !== 'history' || this.cursor < 0 || this.cancelResolution === 'computed')
|
|
225
|
+
return;
|
|
226
|
+
const idx = this.indexOf(p.restoredId);
|
|
227
|
+
if (idx < 0)
|
|
228
|
+
return;
|
|
229
|
+
this.entries[idx] = { ...this.entries[this.cursor] };
|
|
230
|
+
this.cursor = idx;
|
|
231
|
+
}
|
|
232
|
+
/** The entry carrying `id`, preferring the nearest one that is not the current entry. */
|
|
233
|
+
indexOf(id) {
|
|
234
|
+
if (id == null)
|
|
235
|
+
return -1;
|
|
236
|
+
let best = -1;
|
|
237
|
+
for (let i = 0; i < this.entries.length; i++) {
|
|
238
|
+
if (this.entries[i].id !== id)
|
|
239
|
+
continue;
|
|
240
|
+
if (best < 0 || best === this.cursor || (i !== this.cursor && Math.abs(i - this.cursor) < Math.abs(best - this.cursor)))
|
|
241
|
+
best = i;
|
|
242
|
+
}
|
|
243
|
+
return best;
|
|
244
|
+
}
|
|
245
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: StackNavHistory, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
246
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: StackNavHistory, providedIn: 'root' });
|
|
247
|
+
}
|
|
248
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: StackNavHistory, decorators: [{
|
|
249
|
+
type: Injectable,
|
|
250
|
+
args: [{ providedIn: 'root' }]
|
|
251
|
+
}], ctorParameters: () => [] });
|
|
252
|
+
function readHint(info, key) {
|
|
253
|
+
if (info == null || typeof info !== 'object')
|
|
254
|
+
return undefined;
|
|
255
|
+
const v = info[key];
|
|
256
|
+
if (v == null)
|
|
257
|
+
return undefined;
|
|
258
|
+
return typeof v === 'string' ? { direction: v } : v;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* A router outlet (`RouterOutletContract`) that keeps a stack of pages and moves
|
|
263
|
+
* between them with the iOS push/pop transition. Use it where you would use
|
|
264
|
+
* `<router-outlet>`. The router drives it the same way:
|
|
265
|
+
*
|
|
266
|
+
* ```html
|
|
267
|
+
* <sn-outlet />
|
|
268
|
+
* ```
|
|
269
|
+
*
|
|
270
|
+
* Pages beneath the top stay alive, keeping scroll position, form state and
|
|
271
|
+
* subscriptions. A swipe from the leading edge pops interactively, and the
|
|
272
|
+
* direction of every navigation is decided by the strategies configured in
|
|
273
|
+
* `provideStackNav()`. The element needs a height; it is the pages' scroll
|
|
274
|
+
* container.
|
|
275
|
+
*/
|
|
276
|
+
class StackNavOutlet {
|
|
277
|
+
/** Outlet name, as on `router-outlet`. Static. */
|
|
278
|
+
name = PRIMARY_OUTLET;
|
|
279
|
+
/** Per-outlet transition options, merged over `provideStackNav({ transition })`. */
|
|
280
|
+
transition;
|
|
281
|
+
/** Per-outlet gesture options, merged over `provideStackNav({ gesture })`. `false` disables the swipe. */
|
|
282
|
+
gesture;
|
|
283
|
+
/** Same as on `router-outlet`: available to pages through `ROUTER_OUTLET_DATA`. */
|
|
284
|
+
routerOutletData = input(undefined, /* @ts-ignore */
|
|
285
|
+
...(ngDevMode ? [{ debugName: "routerOutletData" }] : /* istanbul ignore next */ []));
|
|
286
|
+
/** A page component was created. */
|
|
287
|
+
activateEvents = new EventEmitter();
|
|
288
|
+
/** A page component was destroyed. */
|
|
289
|
+
deactivateEvents = new EventEmitter();
|
|
290
|
+
/** A kept page was shown again, or a detached one re-attached. */
|
|
291
|
+
attachEvents = new EventEmitter();
|
|
292
|
+
detachEvents = new EventEmitter();
|
|
293
|
+
/** Every activation, with the direction that was resolved for it. */
|
|
294
|
+
navigatedEvents = new EventEmitter();
|
|
295
|
+
parentContexts = inject(ChildrenOutletContexts);
|
|
296
|
+
location = inject(ViewContainerRef);
|
|
297
|
+
changeDetector = inject(ChangeDetectorRef);
|
|
298
|
+
host = inject(ElementRef).nativeElement;
|
|
299
|
+
config = inject(STACKNAV_CONFIG);
|
|
300
|
+
history = inject(StackNavHistory);
|
|
301
|
+
router = inject(Router);
|
|
302
|
+
browserLocation = inject(Location);
|
|
303
|
+
document = inject(DOCUMENT);
|
|
304
|
+
/** The underlying core stack. Subscribe to `progress` to drive your own chrome. */
|
|
305
|
+
stack;
|
|
306
|
+
/** The direction of the last activation. */
|
|
307
|
+
lastDirection = null;
|
|
308
|
+
/** Inputs are bound when the router was configured with `withComponentInputBinding()`. */
|
|
309
|
+
supportsBindingToComponentInputs;
|
|
310
|
+
views = [];
|
|
311
|
+
byEl = new Map();
|
|
312
|
+
detached = new Map();
|
|
313
|
+
activeView = null;
|
|
314
|
+
leaving = null;
|
|
315
|
+
activated = null;
|
|
316
|
+
_activatedRoute = null;
|
|
317
|
+
subs = new Subscription();
|
|
318
|
+
constructor() {
|
|
319
|
+
if (this.router.componentInputBindingEnabled)
|
|
320
|
+
this.supportsBindingToComponentInputs = true;
|
|
321
|
+
}
|
|
322
|
+
// ------------------------------------------------------------- lifecycle
|
|
323
|
+
ngOnInit() {
|
|
324
|
+
const gesture = this.gesture === false || this.config.gesture === false ? false : { ...(this.config.gesture || {}), ...(this.gesture || {}) };
|
|
325
|
+
this.stack = createIOSStack({
|
|
326
|
+
container: this.host,
|
|
327
|
+
transition: { ...this.config.transition, ...(this.transition || {}) },
|
|
328
|
+
gesture: gesture || {},
|
|
329
|
+
});
|
|
330
|
+
if (gesture === false)
|
|
331
|
+
this.stack.gesture.detach();
|
|
332
|
+
if (this.config.injectStyles)
|
|
333
|
+
injectStyles(this.document);
|
|
334
|
+
this.stack.on('pop', (e) => this.onStackRemoved(e.removed, e.source));
|
|
335
|
+
this.stack.on('replace', (e) => this.onStackRemoved(e.removed, e.source));
|
|
336
|
+
this.stack.on('reset', (e) => this.onStackRemoved(e.removed, e.source));
|
|
337
|
+
if (this.config.detachInactiveViews) {
|
|
338
|
+
this.stack.on('transitionstart', ({ lower, upper }) => {
|
|
339
|
+
for (const entry of [lower, upper]) {
|
|
340
|
+
const view = entry && this.byEl.get(entry.el);
|
|
341
|
+
if (view)
|
|
342
|
+
view.ref.changeDetectorRef.reattach();
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
for (const event of ['push', 'pop', 'replace', 'reset'])
|
|
346
|
+
this.stack.on(event, () => this.syncChangeDetection());
|
|
347
|
+
}
|
|
348
|
+
this.subs.add(this.router.events.subscribe((e) => {
|
|
349
|
+
if (e instanceof NavigationCancel || e instanceof NavigationError || e instanceof NavigationSkipped)
|
|
350
|
+
this.restorePending();
|
|
351
|
+
else if (e instanceof NavigationEnd && this.activeView)
|
|
352
|
+
this.activeView.url = e.urlAfterRedirects;
|
|
353
|
+
}));
|
|
354
|
+
this.parentContexts.onChildOutletCreated(this.name, this);
|
|
355
|
+
const context = this.parentContexts.getContext(this.name);
|
|
356
|
+
if (context?.route) {
|
|
357
|
+
if (context.attachRef)
|
|
358
|
+
this.attach(context.attachRef, context.route);
|
|
359
|
+
else
|
|
360
|
+
this.activateWith(context.route, context.injector);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
ngOnDestroy() {
|
|
364
|
+
if (this.parentContexts.getContext(this.name)?.outlet === this)
|
|
365
|
+
this.parentContexts.onChildOutletDestroyed(this.name);
|
|
366
|
+
this.subs.unsubscribe();
|
|
367
|
+
for (const view of this.byEl.values())
|
|
368
|
+
view.inputs?.unsubscribe();
|
|
369
|
+
this.stack?.destroy();
|
|
370
|
+
}
|
|
371
|
+
// -------------------------------------------------------- outlet contract
|
|
372
|
+
get isActivated() {
|
|
373
|
+
return !!this.activated;
|
|
374
|
+
}
|
|
375
|
+
get component() {
|
|
376
|
+
if (!this.activated)
|
|
377
|
+
throw new Error('Outlet is not activated');
|
|
378
|
+
return this.activated.instance;
|
|
379
|
+
}
|
|
380
|
+
get activatedComponentRef() {
|
|
381
|
+
return this.activated;
|
|
382
|
+
}
|
|
383
|
+
get activatedRoute() {
|
|
384
|
+
if (!this.activated)
|
|
385
|
+
throw new Error('Outlet is not activated');
|
|
386
|
+
return this._activatedRoute;
|
|
387
|
+
}
|
|
388
|
+
get activatedRouteData() {
|
|
389
|
+
return this._activatedRoute ? this._activatedRoute.snapshot.data : {};
|
|
390
|
+
}
|
|
391
|
+
/** Pages currently kept, bottom to top. The last one is on screen. */
|
|
392
|
+
get pages() {
|
|
393
|
+
return this.views;
|
|
394
|
+
}
|
|
395
|
+
/** Whether a swipe has a kept page to reveal. */
|
|
396
|
+
get canPop() {
|
|
397
|
+
return this.views.length > 1;
|
|
398
|
+
}
|
|
399
|
+
activateWith(activatedRoute, environmentInjector) {
|
|
400
|
+
if (this.activated)
|
|
401
|
+
throw new Error('Cannot activate an already activated outlet');
|
|
402
|
+
const snapshot = activatedRoute.snapshot;
|
|
403
|
+
const key = this.config.keyOf(snapshot);
|
|
404
|
+
const leaving = this.takeLeaving();
|
|
405
|
+
let existing = this.views.find((v) => v.key === key) ?? null;
|
|
406
|
+
if (existing === leaving)
|
|
407
|
+
existing = null;
|
|
408
|
+
const view = existing ?? this.createView(snapshot.component, activatedRoute, environmentInjector, key);
|
|
409
|
+
this.show(view, activatedRoute, leaving, !!existing);
|
|
410
|
+
}
|
|
411
|
+
deactivate() {
|
|
412
|
+
if (!this.activated)
|
|
413
|
+
return;
|
|
414
|
+
const view = this.activeView;
|
|
415
|
+
this.unbindInputs(view);
|
|
416
|
+
const context = this.parentContexts.getContext(this.name);
|
|
417
|
+
if (context)
|
|
418
|
+
view.savedContexts = context.children.contexts;
|
|
419
|
+
this.activated = null;
|
|
420
|
+
this._activatedRoute = null;
|
|
421
|
+
this.activeView = null;
|
|
422
|
+
if (view.pendingRemoval) {
|
|
423
|
+
this.destroyView(view);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
// The router deactivates before it activates, synchronously. If no
|
|
427
|
+
// activation follows, the outlet is really empty.
|
|
428
|
+
this.leaving = view;
|
|
429
|
+
queueMicrotask(() => {
|
|
430
|
+
if (this.leaving !== view)
|
|
431
|
+
return;
|
|
432
|
+
this.leaving = null;
|
|
433
|
+
this.views = [];
|
|
434
|
+
void this.stack.reset([]);
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
detach() {
|
|
438
|
+
if (!this.activated)
|
|
439
|
+
throw new Error('Outlet is not activated');
|
|
440
|
+
const view = this.activeView;
|
|
441
|
+
const ref = view.ref;
|
|
442
|
+
this.unbindInputs(view);
|
|
443
|
+
this.activated = null;
|
|
444
|
+
this._activatedRoute = null;
|
|
445
|
+
this.activeView = null;
|
|
446
|
+
this.byEl.delete(view.el);
|
|
447
|
+
this.views = this.views.filter((v) => v !== view);
|
|
448
|
+
this.detached.set(ref, view);
|
|
449
|
+
void this.stack.remove(view.el);
|
|
450
|
+
const i = this.location.indexOf(ref.hostView);
|
|
451
|
+
if (i >= 0)
|
|
452
|
+
this.location.detach(i);
|
|
453
|
+
this.detachEvents.emit(ref.instance);
|
|
454
|
+
return ref;
|
|
455
|
+
}
|
|
456
|
+
attach(ref, activatedRoute) {
|
|
457
|
+
const key = this.config.keyOf(activatedRoute.snapshot);
|
|
458
|
+
let view = this.detached.get(ref) ?? null;
|
|
459
|
+
this.detached.delete(ref);
|
|
460
|
+
if (!view)
|
|
461
|
+
view = this.wrap(ref, activatedRoute, key, null);
|
|
462
|
+
this.location.insert(ref.hostView);
|
|
463
|
+
this.park(view.el);
|
|
464
|
+
this.byEl.set(view.el, view);
|
|
465
|
+
const leaving = this.takeLeaving();
|
|
466
|
+
this.show(view, activatedRoute, leaving, true);
|
|
467
|
+
}
|
|
468
|
+
// -------------------------------------------------------------- internals
|
|
469
|
+
takeLeaving() {
|
|
470
|
+
const leaving = this.leaving;
|
|
471
|
+
this.leaving = null;
|
|
472
|
+
return leaving;
|
|
473
|
+
}
|
|
474
|
+
/** Decides the direction, places the page in the stack, and makes it active. */
|
|
475
|
+
show(view, activatedRoute, leaving, reused) {
|
|
476
|
+
const nav = this.history.current;
|
|
477
|
+
const from = leaving ?? this.views[this.views.length - 1] ?? null;
|
|
478
|
+
// Resolvers may have rerun, and a custom keyOf may group several snapshots, so the old snapshot cannot be trusted.
|
|
479
|
+
view.routeRef = this.routeRefOf(activatedRoute.snapshot, view.key);
|
|
480
|
+
const alreadyOnScreen = reused && !this.stack.busy && this.stack.top?.el === view.el;
|
|
481
|
+
let direction = this.config.resolve({
|
|
482
|
+
from: from?.routeRef ?? null,
|
|
483
|
+
to: view.routeRef,
|
|
484
|
+
trigger: nav?.trigger ?? 'imperative',
|
|
485
|
+
historyDelta: nav?.historyDelta,
|
|
486
|
+
hint: nav?.hint,
|
|
487
|
+
stack: this.views.map((v) => v.key),
|
|
488
|
+
});
|
|
489
|
+
// After a swipe the page beneath is already showing and the one that left
|
|
490
|
+
// is gone. A pop onto anything else has nothing to pop, so just show the page.
|
|
491
|
+
if (!leaving && this.stack.top && direction === 'pop' && !reused)
|
|
492
|
+
direction = 'replace';
|
|
493
|
+
const animated = this.config.animated && (nav?.animated ?? true) && (this.views.length > 0 || !!leaving);
|
|
494
|
+
view.route = activatedRoute;
|
|
495
|
+
view.proxy?.swap(activatedRoute);
|
|
496
|
+
const current = this.router.getCurrentNavigation();
|
|
497
|
+
view.url = current ? this.router.serializeUrl(current.finalUrl ?? current.extractedUrl) : this.router.url;
|
|
498
|
+
if (view.savedContexts) {
|
|
499
|
+
this.parentContexts.getOrCreateContext(this.name).children.onOutletReAttached(view.savedContexts);
|
|
500
|
+
view.savedContexts = null;
|
|
501
|
+
}
|
|
502
|
+
this.activated = view.ref;
|
|
503
|
+
this._activatedRoute = activatedRoute;
|
|
504
|
+
this.activeView = view;
|
|
505
|
+
this.lastDirection = direction;
|
|
506
|
+
this.place(view, direction, leaving);
|
|
507
|
+
if (!alreadyOnScreen) {
|
|
508
|
+
void this.stack.present(view.el, direction, { key: view.key, animated, source: sourceOf(nav?.trigger) });
|
|
509
|
+
}
|
|
510
|
+
this.changeDetector.markForCheck();
|
|
511
|
+
this.bindInputs(view);
|
|
512
|
+
// An animated page renders off screen during its first frames. A page that
|
|
513
|
+
// appears immediately (no animation, or a replace, which the stack never
|
|
514
|
+
// animates) would otherwise be blank until the next scheduled tick.
|
|
515
|
+
if ((!animated || direction === 'replace') && !alreadyOnScreen)
|
|
516
|
+
view.ref.changeDetectorRef.detectChanges();
|
|
517
|
+
(reused ? this.attachEvents : this.activateEvents).emit(view.ref.instance);
|
|
518
|
+
this.navigatedEvents.emit({ view, direction, animated, reused });
|
|
519
|
+
}
|
|
520
|
+
/** Mirrors what the stack will do, synchronously, so `pages` and the next direction stay correct. */
|
|
521
|
+
place(view, direction, leaving) {
|
|
522
|
+
const views = this.views;
|
|
523
|
+
const drop = (v) => {
|
|
524
|
+
const i = v ? views.indexOf(v) : -1;
|
|
525
|
+
if (i >= 0)
|
|
526
|
+
views.splice(i, 1);
|
|
527
|
+
};
|
|
528
|
+
if (direction === 'pop' && views.includes(view)) {
|
|
529
|
+
views.splice(views.indexOf(view) + 1);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (direction !== 'push')
|
|
533
|
+
drop(leaving);
|
|
534
|
+
drop(view);
|
|
535
|
+
views.push(view);
|
|
536
|
+
}
|
|
537
|
+
createView(component, route, environmentInjector, key) {
|
|
538
|
+
const childContexts = this.parentContexts.getOrCreateContext(this.name).children;
|
|
539
|
+
const proxy = new StackNavActivatedRoute(route);
|
|
540
|
+
const injector = Injector.create({
|
|
541
|
+
providers: [
|
|
542
|
+
{ provide: ActivatedRoute, useValue: proxy },
|
|
543
|
+
{ provide: ChildrenOutletContexts, useValue: childContexts },
|
|
544
|
+
{ provide: ROUTER_OUTLET_DATA, useValue: this.routerOutletData },
|
|
545
|
+
],
|
|
546
|
+
parent: this.location.injector,
|
|
547
|
+
});
|
|
548
|
+
const ref = this.location.createComponent(component, { index: this.location.length, injector, environmentInjector });
|
|
549
|
+
const view = this.wrap(ref, route, key, proxy);
|
|
550
|
+
this.park(view.el);
|
|
551
|
+
this.byEl.set(view.el, view);
|
|
552
|
+
return view;
|
|
553
|
+
}
|
|
554
|
+
wrap(ref, route, key, proxy) {
|
|
555
|
+
const el = ref.location.nativeElement;
|
|
556
|
+
return { ref, el, key, routeRef: this.routeRefOf(route.snapshot, key), url: '', route, proxy, savedContexts: null, pendingRemoval: false, inputs: null };
|
|
557
|
+
}
|
|
558
|
+
/** Hidden inside the container until the stack shows it, never a visible sibling of the outlet. */
|
|
559
|
+
park(el) {
|
|
560
|
+
el.classList.add(this.stack.pageClass);
|
|
561
|
+
if (el.parentElement !== this.host)
|
|
562
|
+
this.host.append(el);
|
|
563
|
+
}
|
|
564
|
+
routeRefOf(snapshot, key) {
|
|
565
|
+
return { key, segments: segmentsOf(key), level: this.config.levelOf(snapshot), data: snapshot.data, snapshot };
|
|
566
|
+
}
|
|
567
|
+
onStackRemoved(removed, source) {
|
|
568
|
+
for (const entry of removed) {
|
|
569
|
+
const view = this.byEl.get(entry.el);
|
|
570
|
+
if (!view)
|
|
571
|
+
continue;
|
|
572
|
+
if (source === 'gesture') {
|
|
573
|
+
view.pendingRemoval = true;
|
|
574
|
+
this.views = this.views.filter((v) => v !== view);
|
|
575
|
+
this.navigateBackAfterGesture();
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
this.destroyView(view);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
/** The swipe already revealed the page beneath. Bring the router in line with it. */
|
|
583
|
+
navigateBackAfterGesture() {
|
|
584
|
+
const lower = this.views[this.views.length - 1];
|
|
585
|
+
if (!lower)
|
|
586
|
+
return;
|
|
587
|
+
const previous = this.history.previousUrl;
|
|
588
|
+
if (previous != null && this.sameUrl(previous, lower.url)) {
|
|
589
|
+
this.browserLocation.back();
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
void this.router.navigateByUrl(lower.url, { info: { [this.config.infoKey]: { direction: 'pop', animated: false } } });
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
sameUrl(a, b) {
|
|
596
|
+
const norm = (u) => this.router.serializeUrl(this.router.parseUrl(u));
|
|
597
|
+
return norm(a) === norm(b);
|
|
598
|
+
}
|
|
599
|
+
/** The router refused the navigation the swipe asked for, so put the page back. */
|
|
600
|
+
restorePending() {
|
|
601
|
+
for (const view of this.byEl.values()) {
|
|
602
|
+
if (!view.pendingRemoval)
|
|
603
|
+
continue;
|
|
604
|
+
view.pendingRemoval = false;
|
|
605
|
+
this.views.push(view);
|
|
606
|
+
void this.stack.push(view.el, { animated: false, key: view.key, source: 'restore' });
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
destroyView(view) {
|
|
610
|
+
this.byEl.delete(view.el);
|
|
611
|
+
this.views = this.views.filter((v) => v !== view);
|
|
612
|
+
this.unbindInputs(view);
|
|
613
|
+
if (this.activeView === view) {
|
|
614
|
+
this.activeView = null;
|
|
615
|
+
this.activated = null;
|
|
616
|
+
this._activatedRoute = null;
|
|
617
|
+
}
|
|
618
|
+
const instance = view.ref.instance;
|
|
619
|
+
view.ref.destroy();
|
|
620
|
+
this.deactivateEvents.emit(instance);
|
|
621
|
+
}
|
|
622
|
+
bindInputs(view) {
|
|
623
|
+
if (!this.router.componentInputBindingEnabled)
|
|
624
|
+
return;
|
|
625
|
+
const mirror = reflectComponentType(view.ref.componentType);
|
|
626
|
+
if (!mirror)
|
|
627
|
+
return;
|
|
628
|
+
const route = view.route;
|
|
629
|
+
view.inputs = combineLatest([route.queryParams, route.params, route.data])
|
|
630
|
+
.pipe(switchMap(([queryParams, params, data], i) => (i === 0 ? of({ ...queryParams, ...params, ...data }) : from(Promise.resolve({ ...queryParams, ...params, ...data })))))
|
|
631
|
+
.subscribe((data) => {
|
|
632
|
+
if (this.activeView !== view)
|
|
633
|
+
return;
|
|
634
|
+
for (const { templateName } of mirror.inputs)
|
|
635
|
+
view.ref.setInput(templateName, data[templateName]);
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
unbindInputs(view) {
|
|
639
|
+
view.inputs?.unsubscribe();
|
|
640
|
+
view.inputs = null;
|
|
641
|
+
}
|
|
642
|
+
syncChangeDetection() {
|
|
643
|
+
const top = this.stack.top?.el;
|
|
644
|
+
for (const view of this.byEl.values()) {
|
|
645
|
+
if (view.el === top || view.pendingRemoval)
|
|
646
|
+
view.ref.changeDetectorRef.reattach();
|
|
647
|
+
else
|
|
648
|
+
view.ref.changeDetectorRef.detach();
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: StackNavOutlet, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
652
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.1.6", type: StackNavOutlet, isStandalone: true, selector: "sn-outlet", inputs: { name: { classPropertyName: "name", publicName: "name", isSignal: false, isRequired: false, transformFunction: null }, transition: { classPropertyName: "transition", publicName: "transition", isSignal: false, isRequired: false, transformFunction: null }, gesture: { classPropertyName: "gesture", publicName: "gesture", isSignal: false, isRequired: false, transformFunction: null }, routerOutletData: { classPropertyName: "routerOutletData", publicName: "routerOutletData", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { activateEvents: "activate", deactivateEvents: "deactivate", attachEvents: "attach", detachEvents: "detach", navigatedEvents: "navigated" }, host: { styleAttribute: "display: block" }, exportAs: ["snOutlet"], ngImport: i0 });
|
|
653
|
+
}
|
|
654
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: StackNavOutlet, decorators: [{
|
|
655
|
+
type: Directive,
|
|
656
|
+
args: [{
|
|
657
|
+
selector: 'sn-outlet',
|
|
658
|
+
exportAs: 'snOutlet',
|
|
659
|
+
host: { style: 'display: block' },
|
|
660
|
+
}]
|
|
661
|
+
}], ctorParameters: () => [], propDecorators: { name: [{
|
|
662
|
+
type: Input
|
|
663
|
+
}], transition: [{
|
|
664
|
+
type: Input
|
|
665
|
+
}], gesture: [{
|
|
666
|
+
type: Input
|
|
667
|
+
}], routerOutletData: [{ type: i0.Input, args: [{ isSignal: true, alias: "routerOutletData", required: false }] }], activateEvents: [{
|
|
668
|
+
type: Output,
|
|
669
|
+
args: ['activate']
|
|
670
|
+
}], deactivateEvents: [{
|
|
671
|
+
type: Output,
|
|
672
|
+
args: ['deactivate']
|
|
673
|
+
}], attachEvents: [{
|
|
674
|
+
type: Output,
|
|
675
|
+
args: ['attach']
|
|
676
|
+
}], detachEvents: [{
|
|
677
|
+
type: Output,
|
|
678
|
+
args: ['detach']
|
|
679
|
+
}], navigatedEvents: [{
|
|
680
|
+
type: Output,
|
|
681
|
+
args: ['navigated']
|
|
682
|
+
}] } });
|
|
683
|
+
function sourceOf(trigger) {
|
|
684
|
+
return trigger === 'history' ? 'history' : 'api';
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* The router's default strategy reuses a component when only the params change
|
|
689
|
+
* (`/items/1` → `/items/2`), so no outlet activation happens and no transition
|
|
690
|
+
* can run. This strategy asks for a fresh page whenever the URL of the matched
|
|
691
|
+
* route differs, which is what a navigation stack expects. Routes opt out with
|
|
692
|
+
* `data: { reuseRoute: true }`.
|
|
693
|
+
*
|
|
694
|
+
* It is not installed automatically. Provide it like any other strategy to get
|
|
695
|
+
* this behaviour:
|
|
696
|
+
*
|
|
697
|
+
* ```ts
|
|
698
|
+
* { provide: RouteReuseStrategy, useClass: StackNavRouteReuseStrategy }
|
|
699
|
+
* ```
|
|
700
|
+
*/
|
|
701
|
+
class StackNavRouteReuseStrategy extends BaseRouteReuseStrategy {
|
|
702
|
+
shouldReuseRoute(future, curr) {
|
|
703
|
+
if (future.routeConfig !== curr.routeConfig)
|
|
704
|
+
return false;
|
|
705
|
+
if (future.data?.['reuseRoute'] === true)
|
|
706
|
+
return true;
|
|
707
|
+
return urlOf(future) === urlOf(curr);
|
|
708
|
+
}
|
|
709
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: StackNavRouteReuseStrategy, deps: null, target: i0.ɵɵFactoryTarget.Injectable });
|
|
710
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: StackNavRouteReuseStrategy });
|
|
711
|
+
}
|
|
712
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: StackNavRouteReuseStrategy, decorators: [{
|
|
713
|
+
type: Injectable
|
|
714
|
+
}] });
|
|
715
|
+
function urlOf(s) {
|
|
716
|
+
return s.url.map((u) => u.toString()).join('/');
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Generated bundle index. Do not edit.
|
|
721
|
+
*/
|
|
722
|
+
|
|
723
|
+
export { STACKNAV_CONFIG, StackNavActivatedRoute, StackNavOutlet, StackNavRouteReuseStrategy, defaultKeyOf, defaultLevelOf, provideStackNav, resolveConfig };
|
|
724
|
+
//# sourceMappingURL=stacknav-angular.mjs.map
|