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/README.md ADDED
@@ -0,0 +1,760 @@
1
+ <h1 align="center">ngx-stack</h1>
2
+
3
+ <p align="center">
4
+ Native-feeling page stacks for Angular — push/pop transitions and an interactive
5
+ <br />iOS swipe-to-go-back, without pulling in a UI framework.
6
+ </p>
7
+
8
+ <p align="center">
9
+ <a href="https://www.npmjs.com/package/ngx-stack"><img alt="npm" src="https://img.shields.io/npm/v/ngx-stack?color=%23007aff"></a>
10
+ <a href="https://www.npmjs.com/package/ngx-stack"><img alt="downloads" src="https://img.shields.io/npm/dm/ngx-stack"></a>
11
+ <a href="https://github.com/AppsGanin/ngx-stack/actions/workflows/ci.yml"><img alt="ci" src="https://github.com/AppsGanin/ngx-stack/actions/workflows/ci.yml/badge.svg"></a>
12
+ <a href="https://bundlephobia.com/package/ngx-stack"><img alt="bundle size" src="https://img.shields.io/bundlephobia/minzip/ngx-stack"></a>
13
+ <img alt="license" src="https://img.shields.io/npm/l/ngx-stack">
14
+ </p>
15
+
16
+ <p align="center">
17
+ <a href="https://appsganin.github.io/ngx-stack/"><b>Live demo</b></a> ·
18
+ <a href="#quick-start">Quick start</a> ·
19
+ <a href="#api">API</a> ·
20
+ <a href="#configuration">Configuration</a>
21
+ </p>
22
+
23
+ <!-- Absolute, not relative: this README is also the npm package page, and npm does not resolve
24
+ relative image paths the way GitHub does. -->
25
+ <p align="center">
26
+ <img src="https://raw.githubusercontent.com/AppsGanin/ngx-stack/main/projects/ngx-stack/docs/swipe.png" alt="A swipe-back caught mid-gesture: the detail page follows the finger while the list behind it parallaxes at a third of the speed and dims" width="360">
27
+ </p>
28
+
29
+ <p align="center">
30
+ <sub>Caught mid-swipe. The page follows the finger 1:1; the one behind parallaxes at a third of<br />the speed and dims — and it kept its scroll position, because it was never unmounted.</sub>
31
+ </p>
32
+
33
+ ---
34
+
35
+ - 🎯 **The gesture follows your finger.** Not an approximation of it — the transition is scrubbed by
36
+ the drag, and can be abandoned halfway.
37
+ - 🍎 **The UIKit push on iOS, Material everywhere else** — or a transition you write yourself, per
38
+ platform.
39
+ - 📱 **Capacitor and Cordova**, including the Android hardware back button.
40
+ - 🗂 **Tabs with independent stacks.** Drill three deep in one, switch away, come back to exactly
41
+ where you were.
42
+ - 🔗 **Deep links that behave.** Open the app three screens in and the pages that should be beneath
43
+ are there, so the swipe works from the first frame.
44
+ - ♿️ **Focus and screen-reader announcements** on every page change; `prefers-reduced-motion` honoured.
45
+ - 🌍 **RTL** — transitions mirror, the gesture moves to the right edge.
46
+ - 🪶 **Unstyled and zoneless.** Signals throughout. No UI framework, no CSS to override.
47
+
48
+ ## Compatibility
49
+
50
+ The library's major version **is** the Angular major version. There is no table to consult and no
51
+ guessing — `npm i ngx-stack` on Angular 22 installs 22.x.
52
+
53
+ | Angular | ngx-stack | npm tag |
54
+ | ------- | --------- | -------- |
55
+ | 22.x | 22.x | `latest` |
56
+
57
+ `ng update ngx-stack` runs migrations, so a major bump doesn't leave you reading a changelog to find
58
+ out why the build broke.
59
+
60
+ ## Why this exists
61
+
62
+ Angular's router destroys the outgoing component the moment you navigate. That is the right default,
63
+ and completely incompatible with a swipe-back: the page you are swiping back to has to already be
64
+ mounted and painted **before** the navigation that reveals it, or there is nothing to drag into view.
65
+
66
+ The other half of the problem is the animation. A CSS transition or a View Transition is
67
+ fire-and-forget — it runs on its own clock. A swipe-back is the opposite: the finger owns the clock,
68
+ the transition has to follow it, and the user may change their mind halfway and drag right back. So
69
+ every transition here is a set of paused Web Animations that the gesture seeks by writing
70
+ `currentTime` directly. That is the whole trick.
71
+
72
+ ## Install
73
+
74
+ ```bash
75
+ npm i ngx-stack
76
+ ```
77
+
78
+ ## Quick start
79
+
80
+ Four things. Three of them are one line.
81
+
82
+ **1.** Add the provider.
83
+
84
+ ```ts
85
+ // app.config.ts
86
+ import { provideRouter } from '@angular/router';
87
+ import { provideNgxStack } from 'ngx-stack';
88
+
89
+ export const appConfig: ApplicationConfig = {
90
+ providers: [
91
+ provideRouter(routes),
92
+ provideNgxStack(), // iOS gets the UIKit push + swipe-back; Android and web get their own
93
+ ],
94
+ };
95
+ ```
96
+
97
+ **2.** Swap the outlet.
98
+
99
+ ```ts
100
+ // app.ts
101
+ import { NgxStackOutlet } from 'ngx-stack';
102
+
103
+ @Component({
104
+ selector: 'app-root',
105
+ imports: [NgxStackOutlet],
106
+ template: `<ngx-stack-outlet />`,
107
+ styles: `
108
+ :host {
109
+ display: block;
110
+ height: 100%;
111
+ }
112
+ `,
113
+ })
114
+ export class App {}
115
+ ```
116
+
117
+ **3.** Give it a height. This is the one thing that will silently waste your afternoon: the outlet is
118
+ `position: relative` with absolutely positioned pages inside it, so if nothing above it has a size,
119
+ it collapses to zero and you get a blank screen with **no error**.
120
+
121
+ ```scss
122
+ // styles.scss
123
+ html,
124
+ body {
125
+ height: 100%;
126
+ margin: 0;
127
+ }
128
+
129
+ app-root {
130
+ display: block;
131
+ height: 100dvh;
132
+ }
133
+ ```
134
+
135
+ **4.** Routes stay exactly as they were. Ordinary components, ordinary `router.navigate()`.
136
+
137
+ ```ts
138
+ export const routes: Routes = [
139
+ { path: 'list', component: ListPage },
140
+ { path: 'item/:id', component: ItemPage }, // or loadComponent
141
+ ];
142
+ ```
143
+
144
+ Done. Tapping through to `/item/42` pushes; a drag from the left edge pops.
145
+
146
+ ## API
147
+
148
+ | Export | What |
149
+ | -------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
150
+ | `provideNgxStack(config?)` | Sets it all up. See [Configuration](#configuration). |
151
+ | `<ngx-stack-outlet>` | Replaces `<router-outlet>`. Holds one stack per tab. |
152
+ | `<ngx-stack>` | A stack with no URLs — wizards, sheets. [Details](#imperative-stack). |
153
+ | `NgxStackNav` | `forward()`, `back()`, `root()` — navigation with an explicit stack intent. |
154
+ | `NgxStackTabs` | Which tab is active, and where each one was last seen. |
155
+ | `NgxStackSwipe` | The app-wide swipe switch: `enable()`, `disable()`, `enabled()`. |
156
+ | `NgxStackPage` | Page lifecycle hooks, and `ngxCanSwipeBack()`. |
157
+ | `provideCapacitorBack(App)` | Android hardware back → the stack. |
158
+ | `provideCordovaBack()` | The same, for Cordova. |
159
+ | `iosTransition` · `androidTransition` · `webTransition` · `noneTransition` | One per platform. [Transitions](#transitions). |
160
+ | `slideTransition(o)` · `riseTransition(o)` | The two shapes the built-ins are made of. Retune either. |
161
+
162
+ ## Router-driven stack
163
+
164
+ Pages get real URLs, deep links work, and the browser's back button is just another way to
165
+ pop the stack.
166
+
167
+ Navigate with the plain router — no special API:
168
+
169
+ ```ts
170
+ router.navigate(['/item', 42]); // pushes — /item/42 isn't on the stack
171
+ location.back(); // pops — and so does a swipe from the left edge
172
+ ```
173
+
174
+ The direction is inferred: if the incoming URL is already on the stack, it's a pop back to that
175
+ page; otherwise it's a push. When that inference is wrong, say so explicitly with `NgxStackNav`.
176
+
177
+ ### The plain Angular way keeps working
178
+
179
+ You don't have to learn a navigation API. `routerLink`, `router.navigate()`, `router.navigateByUrl()`,
180
+ the browser's back button and `location.back()` all work, and so do resolvers, guards, lazy
181
+ `loadComponent`, and relative navigation.
182
+
183
+ | What you write | What the stack does |
184
+ | -------------------------------------------------- | ----------------------------------------------------------------------------------------- |
185
+ | `[routerLink]="['/item', 42]"` | Pushes. `/item/42` isn't on the stack. |
186
+ | `router.navigate(['/list'])` when `/list` is below | **Pops back to it**, destroying what was above. A stack unwinds to a page it already has. |
187
+ | `router.navigate([…], { replaceUrl: true })` | **Swaps the top page.** History didn't grow, so neither does the stack. |
188
+ | `?query` change on the same route | Nothing. Same page, same component, `ActivatedRoute` emits — as it always did. |
189
+ | Browser back / `location.back()` | Pops. Prefer `nav.back()` once you have tabs — see below. |
190
+
191
+ Two of those are worth pausing on, because they're where a stack genuinely differs from a router:
192
+
193
+ **Navigating to a URL already on the stack unwinds to it.** Home → Details → `navigate(['/home'])`
194
+ takes you back to the _existing_ Home and throws Details away, rather than stacking a second Home
195
+ on top. That's what a stack is for, and it's what makes the browser back button work for free. If
196
+ you really want a second copy, say so: `nav.forward(['/home'])`.
197
+
198
+ **`nav.back()` is not the same as `location.back()`.** They agree in a plain app. They part company
199
+ once you have tabs, or a cold deep link — see those sections for why.
200
+
201
+ The outlet needs a height. It is `position: relative` with absolutely positioned pages
202
+ inside, so give its parent a real size (`height: 100dvh` on the host is usually it).
203
+
204
+ ### `NgxStackNav`
205
+
206
+ Injectable. Navigation with an explicit stack intent — for the cases where the inference
207
+ above gets it wrong.
208
+
209
+ | Method | What it does |
210
+ | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
211
+ | `forward(commands, extras?)` | **Always pushes**, even if the URL is already on the stack. Without this, navigating Home → Details → Home unwinds to the existing Home and throws Details away. Use it when you genuinely want a second copy on top. |
212
+ | `back(commands?, extras?)` | With no arguments, a real `history.back()` — so the URL, the browser's back button and the stack stay in agreement. This is what the swipe gesture calls. Pass commands to pop to a specific URL instead. |
213
+ | `root(commands, extras?)` | **Replaces the entire stack** with one page, destroying everything else. After login, after logout, after a flow completes. Uses `replaceUrl`, so the discarded pages don't linger in browser history either. |
214
+
215
+ All three return `Promise<boolean>` from the router, and take the usual `NavigationExtras`.
216
+
217
+ ```ts
218
+ const nav = inject(NgxStackNav);
219
+
220
+ nav.forward(['/list']); // push a *second* /list rather than unwinding to the first
221
+ nav.back(); // pop one page
222
+ nav.root(['/home']); // wipe the stack, start again
223
+ ```
224
+
225
+ ## Imperative stack
226
+
227
+ For navigation that has no business being in the address bar — a wizard inside a modal, a
228
+ drill-down in one tab. Same transition, same swipe-back, no URLs.
229
+
230
+ ```html
231
+ <ngx-stack [root]="Step1" #wizard />
232
+ ```
233
+
234
+ ```ts
235
+ const stack = inject(NgxStack); // from any page on the stack
236
+ ```
237
+
238
+ ### Methods
239
+
240
+ Every one returns a `Promise<void>` that resolves when the transition has finished.
241
+
242
+ | Method | What it does |
243
+ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
244
+ | `push(component, inputs?)` | Mount `component` and slide it in on top. `inputs` is a plain object applied with `setInput`, so signal inputs and `@Input()` both work. The page below stays mounted and keeps its state. |
245
+ | `pop()` | Drop the top page and go back one. Destroys the popped component. A no-op at the root — safe to call blindly. |
246
+ | `popTo(index)` | Unwind to the page at `index` (0 is the root), destroying every page above it. Skips whole sections of a flow in a single transition instead of popping them one at a time. |
247
+ | `popToRoot()` | `popTo(0)`. Back to the first page, everything above it destroyed. The "start over" button of a wizard. |
248
+ | `setRoot(component, inputs?)` | Throw the whole stack away and start again from `component`. The imperative twin of `NgxStackNav.root()`. |
249
+
250
+ ### Inputs
251
+
252
+ | Input | Type | What it does |
253
+ | ------------ | ------------------------- | ------------------------------------------------------------------------------------------ |
254
+ | `root` | `Type<unknown> \| null` | The bottom page, mounted on init without a transition. Use `setRoot()` to change it later. |
255
+ | `rootInputs` | `Record<string, unknown>` | Inputs for that first page. |
256
+ | `swipeBack` | `boolean \| null` | Swipe-back for this stack only. `null` (default) defers to the app-wide `NgxStackSwipe`. |
257
+
258
+ ### Signals
259
+
260
+ `pages` (the mounted `StackEntry[]`, bottom first), `depth`, `canGoBack`, `animating`. The
261
+ routed outlet exposes the same four, so `<ngx-stack-outlet #outlet>` plus
262
+ `@if (outlet.canGoBack())` is how you show a back button.
263
+
264
+ Stacks nest. A swipe inside an inner stack pops the inner stack; when the inner stack is at
265
+ its own root it declines the gesture and the swipe falls through to the outer one.
266
+
267
+ ## Tabs
268
+
269
+ Set `tabs` to your top-level path prefixes and each one gets its own independent stack:
270
+
271
+ ```ts
272
+ provideNgxStack({ tabs: ['inbox', 'search', 'settings'] });
273
+ ```
274
+
275
+ ### Why you have to say this
276
+
277
+ Nothing in a route config distinguishes _"these are siblings you switch between, each keeping its
278
+ own history"_ from _"these are just different URLs"_. `/settings` is a tab and `/settings/sheet` is a
279
+ page inside it — that's a fact about your app's shape, and only you have it.
280
+
281
+ It isn't repeated anywhere, though. `NgxStackTabs` exposes the same list as `tabs()`, so the one
282
+ declaration also renders your tab bar:
283
+
284
+ ```html
285
+ @for (tab of tabs.tabs(); track tab) { <button (click)="tabs.select(tab)">{{ tab }}</button> }
286
+ ```
287
+
288
+ **Routes need nothing.** A page is filed by its URL: everything under `/inbox/…` belongs to the
289
+ Inbox stack. Write your routes exactly as you would have anyway.
290
+
291
+ The one exception is a page whose URL _doesn't_ say which tab it's in — a flat `/starred` that
292
+ belongs to Inbox. That one says so:
293
+
294
+ ```ts
295
+ { path: 'starred', component: StarredPage, data: { tab: 'inbox' } }
296
+ ```
297
+
298
+ Without it, the page lands in the no-tab stack: mounted, outside the tab bar, and stranded the
299
+ moment you switch tabs. (A `tab` naming something that isn't in the list throws, rather than quietly
300
+ filing the page somewhere no button can reach.)
301
+
302
+ If you write **nested** routes, declare it once on the tab's root and every page under it inherits —
303
+ whether or not the root has a component:
304
+
305
+ ```ts
306
+ {
307
+ path: 'settings',
308
+ data: { tab: 'settings' }, // once, for the whole subtree
309
+ children: [
310
+ { path: '', component: SettingsPage },
311
+ { path: 'sheet', component: SheetPage },
312
+ ],
313
+ }
314
+ ```
315
+
316
+ A route that genuinely belongs to no tab — a login screen, a full-screen modal — gets `tab: ''` and
317
+ its own stack, which is normally exactly what you want.
318
+
319
+ ```ts
320
+ export const routes: Routes = [
321
+ { path: 'inbox', component: InboxPage },
322
+ { path: 'inbox/item/:id', component: ItemPage }, // → the Inbox stack
323
+ { path: 'search', component: SearchPage },
324
+ { path: 'search/result/:id', component: ResultPage }, // → the Search stack
325
+ ];
326
+ ```
327
+
328
+ Drill three deep in Inbox, switch to Search, come back: Inbox is exactly as you left it, still
329
+ three deep, still scrolled where it was. Nothing was unmounted — a tab switch is a cut, not a
330
+ push, so it doesn't animate and it doesn't destroy anything.
331
+
332
+ The tab _bar_ is yours to draw; the library ships no visual design. `NgxStackTabs` is the only
333
+ part you need, and it exists to remember where each tab was:
334
+
335
+ | Member | What it does |
336
+ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
337
+ | `active()` | Signal. The tab on screen. |
338
+ | `tabs()` | Signal. The tabs you configured, in order. |
339
+ | `select(tab)` | Go to `tab`, landing back on the page you last had open there. Selecting the tab you're already on pops it to its root — as every native tab bar does. |
340
+ | `urlOf(tab)` | Where that tab was last seen. |
341
+
342
+ ```html
343
+ @for (tab of tabs.tabs(); track tab) {
344
+ <button [class.active]="tabs.active() === tab" (click)="tabs.select(tab)">{{ tab }}</button>
345
+ }
346
+ ```
347
+
348
+ **One thing to know.** Browser history is a single linear thread; tabs are several stacks. Once
349
+ you switch tabs, the history entry behind you belongs to a _different_ tab, so a plain
350
+ `history.back()` would jump sideways out of the stack you're in. `nav.back()` and the swipe both
351
+ notice this: they use real `history.back()` when it happens to land on the page below, and
352
+ navigate to the page they actually mean when it doesn't. A route outside every tab — a login
353
+ screen, a full-screen modal — gets `tab: ''` and its own stack, which is usually what you want.
354
+
355
+ ## Deep links: opening the app partway in
356
+
357
+ A push notification, a shared URL, a hard refresh three screens deep. The router puts you straight
358
+ on the detail page and the stack has exactly **one** entry. Nothing is beneath it — so there's
359
+ nothing for a swipe to drag into view, and `history.back()` walks straight out of the app.
360
+
361
+ ### The back button — usually nothing to do
362
+
363
+ Where a page sits is normally obvious from its URL, and the route config already knows it:
364
+ `/inbox/item/12/notes` → `/inbox/item/12` → `/inbox`, dropping segments until what's left is a real
365
+ page. So the back button just works. `canGoBack()` is true even with one page on the stack, and
366
+ `nav.back()` builds the parent and animates to it as a _back_ — exactly as though it had been there
367
+ all along. Chains unwind a level at a time.
368
+
369
+ Only when the URL doesn't tell the truth do you have to say so:
370
+
371
+ ```ts
372
+ // Flat: nothing about /ticket/42 says it sits under the inbox.
373
+ { path: 'ticket/:id', component: TicketPage, data: { parent: '/inbox' } }
374
+ ```
375
+
376
+ What none of this gives you is the _gesture_: a swipe has to reveal a page that is already mounted
377
+ and painted before the finger lands, and the parent is only built on demand. For that, read on.
378
+
379
+ ### The gesture too — `deepLinks: true`
380
+
381
+ A swipe has to reveal a page that is already mounted and painted before the finger lands, so for the
382
+ _gesture_ the ancestors have to genuinely be there. The only honest way to get them there is to have
383
+ navigated to them — so that's what this does, at startup, silently, before the app is shown:
384
+
385
+ ```ts
386
+ provideNgxStack({
387
+ deepLinks: true,
388
+ });
389
+ ```
390
+
391
+ That's the whole setup — one flag in the config you already have. Nothing to keep in sync either:
392
+ the ancestors come from your route config. `data: { parent }` where you've declared one, otherwise
393
+ the URL's own nesting (`/inbox/item/12/notes` → `/inbox/item/12` → `/inbox`, dropping segments until
394
+ what's left is a real page). Open that URL cold and the stack comes up as `[inbox, item/12, notes]`,
395
+ with `notes` on screen and the swipe live from the first frame.
396
+
397
+ The deep page is still constructed **exactly once**. The first navigation is intercepted at
398
+ `NavigationStart` — before anything is recognised or activated — so asking for a different URL there
399
+ supersedes it and it never lands. (Correcting _after_ it lands would build the deep page, throw it
400
+ away, and build it again, running its resolvers twice for the privilege.)
401
+
402
+ Two things it can't work out on its own, both fixed by `data: { parent }`:
403
+
404
+ - **A flat URL.** `/ticket/42` doesn't say where it sits; `data: { parent: '/inbox' }` does.
405
+ - **Routes behind `loadChildren`.** The children live inside a dynamic import and don't exist yet, so
406
+ rather than guess, it declines.
407
+
408
+ Or pass your own `deepLinks: (url) => string | null` to override the lot.
409
+
410
+ What it does cost: the ancestors are built and **their resolvers run**, for pages the user may never
411
+ look at. If your list page does something expensive on load, leave `deepLinks` off, keep
412
+ `data: { parent }`, and let the first back be a tap.
413
+
414
+ ## Turning the swipe on and off
415
+
416
+ `provideNgxStack({ swipeBack })` only sets the starting value. Three layers can change or
417
+ override it at runtime, narrowest last, **and any of them can veto**:
418
+
419
+ **1. App-wide** — `NgxStackSwipe`. Reach for this when something global is eating the drag: a
420
+ modal is open, a map has the screen, a payment is in flight.
421
+
422
+ ```ts
423
+ const swipe = inject(NgxStackSwipe);
424
+
425
+ swipe.enabled(); // Signal<boolean> — read it in a template
426
+ swipe.disable();
427
+ swipe.enable();
428
+ swipe.set(isEnabled);
429
+ swipe.reset(); // back to whatever the config resolved to on this device
430
+ ```
431
+
432
+ **2. One stack** — `[swipeBack]` on the outlet or on `<ngx-stack>`. `true` or `false` overrides
433
+ the app-wide switch in either direction; `null` (the default) defers to it.
434
+
435
+ ```html
436
+ <ngx-stack-outlet [swipeBack]="false" />
437
+ ```
438
+
439
+ **3. One page** — a veto only; it can't switch the gesture on where the layers above turned it
440
+ off. Either declare it on the route, for a page that is simply never swipeable:
441
+
442
+ ```ts
443
+ { path: 'checkout', component: CheckoutPage, data: { swipeBack: false } }
444
+ ```
445
+
446
+ …or decide in the moment. `ngxCanSwipeBack()` is asked on _every_ touch that lands in the edge
447
+ zone, so it can read live state:
448
+
449
+ ```ts
450
+ export class EditorPage implements NgxStackPage {
451
+ readonly dirty = signal(false);
452
+
453
+ ngxCanSwipeBack(): boolean {
454
+ return !this.dirty(); // no swiping away from unsaved work
455
+ }
456
+ }
457
+ ```
458
+
459
+ Note that a veto stops the _gesture_, not navigation. Your back button and `nav.back()` still
460
+ work — which is usually what you want, since the button is where you can put a confirm dialog.
461
+
462
+ ## Transitions
463
+
464
+ Three built-ins, one per platform — built out of two shapes:
465
+
466
+ | Platform | Default | Shape | What it does |
467
+ | -------- | ------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
468
+ | iOS | `iosTransition` | `slideTransition()` | The UIKit push. The incoming page slides the full width over the outgoing one, which drifts the other way at a third of the speed and dims beneath it. |
469
+ | Android | `androidTransition` | `riseTransition()` | Material's rise-and-fade. The page below stays put and is simply covered. |
470
+ | Web | `webTransition` | `riseTransition()` | **Identical to Android's today** — same numbers, still its own transition. |
471
+
472
+ The web gets Material's rather than a slide of its own, because a full-width slide is a phone idiom:
473
+ it says _"this screen came from over there"_, which is true of something you swiped into view and not
474
+ of something you clicked — and on a wide monitor it is a great many pixels moving for no reason.
475
+
476
+ `androidTransition` and `webTransition` stay **two transitions rather than one**, even though they
477
+ are the same animation right now. "What a phone does" and "what a browser does" are two decisions
478
+ that merely happen to agree today; if they were a single object, the first person to want a different
479
+ feel on the desktop would have to change both.
480
+
481
+ Override any of them:
482
+
483
+ ```ts
484
+ provideNgxStack({
485
+ transitions: {
486
+ ios: iosTransition,
487
+ android: androidTransition,
488
+ web: noneTransition, // no animation in the browser at all
489
+ },
490
+ });
491
+ ```
492
+
493
+ Or pass a single function to use everywhere: `transitions: myTransition`.
494
+
495
+ Both shapes are exported, so re-tuning one is a few numbers rather than a new transition:
496
+
497
+ ```ts
498
+ import { riseTransition } from 'ngx-stack';
499
+
500
+ const snappierWeb = riseTransition({ travel: 6, easing: 'ease-out', durationScale: 0.4 });
501
+ ```
502
+
503
+ > **If you want the swipe gesture on Android or the web**, that platform needs a transition that
504
+ > moves **horizontally**. Material's doesn't, so a finger dragging sideways would scrub a page moving
505
+ > vertically — which is exactly why `swipeBack: 'auto'` only arms the gesture on iOS.
506
+ > `slideTransition()` is there for it.
507
+
508
+ ### Writing one
509
+
510
+ A transition is a pure function from "which two pages, which direction" to keyframes:
511
+
512
+ ```ts
513
+ import { scrimOf, type StackTransition } from 'ngx-stack';
514
+
515
+ export const myTransition: StackTransition = (ctx) => {
516
+ const forward = ctx.direction === 'forward';
517
+ // The page that ends up on top; on 'back' this is the one being uncovered.
518
+ const { enteringEl, leavingEl, width, duration } = ctx;
519
+
520
+ return {
521
+ duration,
522
+ easing: 'cubic-bezier(0.32, 0.72, 0, 1)',
523
+ animations: [
524
+ {
525
+ el: enteringEl,
526
+ keyframes: forward
527
+ ? [{ transform: 'translateX(100%)' }, { transform: 'translateX(0)' }]
528
+ : [{ transform: 'translateX(-33%)' }, { transform: 'translateX(0)' }],
529
+ },
530
+ // scrimOf(el) is the dim overlay we put on every page for you.
531
+ ],
532
+ };
533
+ };
534
+ ```
535
+
536
+ One rule: **it must be scrubbable.** The swipe-back seeks your `back` keyframes to arbitrary
537
+ progress, so they have to be continuous and reversible — no discrete steps, nothing that
538
+ can't be interpolated. The engine handles the rest: during a drag it runs your keyframes on
539
+ a linear clock so the page tracks the finger exactly, then on release it snapshots what's on
540
+ screen and animates from there to the target with your easing. (Re-applying an easing curve
541
+ to a half-finished timeline would snap the page at the moment the user lets go — hence the
542
+ handoff.)
543
+
544
+ ## Swipe-back
545
+
546
+ On by default on iOS, off elsewhere (`swipeBack: 'auto'`). Android's back gesture belongs to
547
+ the OS, and the default web transition is too subtle to scrub usefully.
548
+
549
+ ```ts
550
+ provideNgxStack({
551
+ swipeBack: true,
552
+ swipeEdgeWidth: 50, // px from the left edge where a drag can start
553
+ swipeThreshold: 0.5, // release past halfway completes the pop
554
+ swipeVelocityThreshold: 0.35, // px/ms — a fast flick decides on its own
555
+ swipeWithMouse: true, // drag with a mouse. Development only.
556
+ });
557
+ ```
558
+
559
+ ## Capacitor and Cordova
560
+
561
+ Both are the easy target: their webviews have no back gesture of their own, so nothing fights us for
562
+ the screen edge. Everything here is plain web — the swipe, the transitions, the stacks, the tabs all
563
+ work in either shell unchanged.
564
+
565
+ The one thing that isn't web is the **Android hardware back button**, and each shell delivers it its
566
+ own way:
567
+
568
+ ```ts
569
+ // Capacitor — pass the plugin in, so @capacitor/app stays out of the dependency graph of
570
+ // anyone shipping only to the web.
571
+ import { App } from '@capacitor/app';
572
+ providers: [provideRouter(routes), provideNgxStack(), provideCapacitorBack(App)];
573
+ ```
574
+
575
+ ```ts
576
+ // Cordova / PhoneGap — nothing to pass in; its API is a global.
577
+ providers: [provideRouter(routes), provideNgxStack(), provideCordovaBack()];
578
+ ```
579
+
580
+ Both pop the stack, and both exit the app at the root — back on the first screen closes it rather
581
+ than doing nothing, which is what Android users expect.
582
+
583
+ Neither asks the _shell_ whether it can go back. Capacitor's `backButton` event and Cordova's both
584
+ report the **webview's history**, and history is a single linear thread while tabs are several
585
+ stacks — so it says yes when the only thing behind you is a different tab. They ask the stack.
586
+
587
+ Platform detection knows both shells (`platform.isCapacitor`, `isCordova`, `isNative`), and that
588
+ matters for more than the back button: `hasSystemBackGesture` keys off `isNative`, so an iOS
589
+ **Cordova** app doesn't needlessly concede the first 16px of the screen edge to a gesture its
590
+ webview doesn't have.
591
+
592
+ ## The iOS browser caveat, honestly
593
+
594
+ In **iOS Safari and iOS PWAs** — not Capacitor — WebKit reserves the screen edge for its own
595
+ interactive back navigation, and there is no API to turn that off. This is a real,
596
+ long-standing limitation, not something this library is failing to work around. Your options
597
+ are all imperfect, so pick one:
598
+
599
+ | `systemGesture` | What it does |
600
+ | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
601
+ | `'inset'` | **Default.** Start our zone `systemEdgeInset` px (16) inland so the two don't fight. WebKit still owns the outermost pixels; when it navigates, we detect it via `hasUAVisualTransition` and skip our animation rather than double-animating. |
602
+ | `'suppress'` | `preventDefault()` the touchstart in the edge zone, which does stop WebKit. But it also suppresses the synthetic `click` for touches starting there — don't use it if you have tappable UI near the left edge. |
603
+ | `'ignore'` | Do nothing and let both gestures coexist. |
604
+
605
+ None of this applies under Capacitor.
606
+
607
+ ## Page lifecycle
608
+
609
+ Stacked pages are not destroyed when you navigate away, which is the point — it's what
610
+ preserves their state and lets a swipe reveal them instantly. It also means `ngOnDestroy` no
611
+ longer means "the user left this page". These hooks do:
612
+
613
+ ```ts
614
+ export class ItemPage implements NgxStackPage {
615
+ ngxViewWillEnter() {}
616
+ ngxViewDidEnter() {}
617
+ ngxViewWillLeave() {}
618
+ ngxViewDidLeave() {} // still alive — unless it was popped
619
+ }
620
+ ```
621
+
622
+ Scroll position needs no work on your part: buried pages get `content-visibility: hidden`,
623
+ which skips their rendering but preserves their internal state, scroll offsets included.
624
+
625
+ ## Styling
626
+
627
+ The library ships no visual design. Four custom properties, no `!important` needed anywhere —
628
+ the page defaults are wrapped in `:where()`, so a plain `:host { display: flex }` in your page
629
+ component takes the layout over.
630
+
631
+ ```css
632
+ --ngx-stack-page-background: #fff;
633
+ --ngx-stack-page-shadow: -6px 0 20px rgb(0 0 0 / 12%);
634
+ --ngx-stack-scrim-color: #000;
635
+ ```
636
+
637
+ ## RTL
638
+
639
+ Nothing to configure. `direction: 'auto'` reads the computed direction off the host, so
640
+ `<html dir="rtl">` is the entire integration — and because it's read per transition rather than
641
+ cached, a language switcher that flips `dir` at runtime works without a reload.
642
+
643
+ Everything horizontal mirrors: pages arrive from the left, the parallax runs the other way, and
644
+ the swipe moves to the **right** edge and pulls left. Custom transitions get `ctx.rtl` and should
645
+ respect it, or they'll feel backwards in Arabic and Hebrew — the new page appearing to come from
646
+ where the user just came from.
647
+
648
+ ## Memory: `maxDepth`
649
+
650
+ Pages are cheap but not free, and a stack you can descend forever — a chat, a wiki, a file
651
+ browser — will happily hold fifty live components.
652
+
653
+ ```ts
654
+ provideNgxStack({ maxDepth: 10 }); // 0, the default, means no cap
655
+ ```
656
+
657
+ Pages that fall off the bottom are destroyed. Walking back past that horizon still works: the
658
+ page is rebuilt from its URL and **still animates as a back**, because that's what actually
659
+ happened, whatever the DOM currently remembers. What it loses is its state. That's the trade you
660
+ asked for by setting a cap, and it's why the default is off.
661
+
662
+ ## Accessibility
663
+
664
+ A stack navigation is invisible to assistive tech — no document load, no focus change it can
665
+ infer anything from, just the DOM quietly rearranging. So by default (`manageFocus: true`) the
666
+ stack moves focus into the page that arrived and announces its route `title` through a polite
667
+ live region. Buried pages are `inert`, so nothing off-screen is reachable by keyboard.
668
+
669
+ Focus lands on the page container unless you point it somewhere better:
670
+
671
+ ```html
672
+ <h1 ngxStackAutofocus tabindex="-1">Message 42</h1>
673
+ ```
674
+
675
+ `prefers-reduced-motion: reduce` makes transitions instant. The swipe keeps its animation, on
676
+ purpose: motion the user is dragging with their own finger is direct manipulation, which the
677
+ guidance exempts — and a gesture with no visual response isn't reduced motion, it's broken.
678
+
679
+ ## Events
680
+
681
+ ```html
682
+ <ngx-stack-outlet (transitionStart)="…" (transitionEnd)="onDone($event)" />
683
+ ```
684
+
685
+ Both carry a `StackTransitionEvent`: `direction`, `entering`, `leaving`, `tab`, `animated`, and
686
+ `interactive` — true when a finger drove it rather than a navigation.
687
+
688
+ ## Known limits
689
+
690
+ - **`withComponentInputBinding()` is not supported.** Angular's router binds route params to
691
+ component inputs through a private token that a third-party outlet cannot reach. Read params
692
+ from `ActivatedRoute` instead.
693
+ - **`RouteReuseStrategy` is claimed.** `provideNgxStack()` installs `NgxStackRouteReuseStrategy`,
694
+ which is required — Angular's default recycles a component when only params change, quietly
695
+ merging `/item/1` and `/item/2` into one page. A strategy of your own that returns
696
+ `shouldDetach: true` will fight the stack, and the outlet throws if it's asked to detach.
697
+ - **Component-less and redirect routes** can't be pages. `loadComponent` is fine.
698
+ - **No SSR.** The stack is DOM-driven throughout.
699
+ - **Android's predictive back is not wired up, and can't be.** Capacitor forwards the back
700
+ _event_, which `provideCapacitorBack()` handles — but the OS never forwards the gesture's
701
+ progress into the webview, so there is nothing to drive an interactive animation with. Anything
702
+ claiming otherwise is faking it. On Android the back gesture stays the OS's, and it's
703
+ instantaneous.
704
+ - **Async `canDeactivate` guards can't gate the gesture.** A touch handler cannot wait for a
705
+ promise. The default `guardPolicy: 'block'` refuses the swipe on any guarded route for that
706
+ reason; `ngxCanSwipeBack()` is the synchronous way back in.
707
+
708
+ ## Configuration
709
+
710
+ Every option, with its default. All of them are optional — `provideNgxStack()` with no arguments is
711
+ a working setup.
712
+
713
+ ```ts
714
+ provideNgxStack({/* … */});
715
+ ```
716
+
717
+ **Look and feel**
718
+
719
+ | Option | Default | What |
720
+ | ---------------------- | -------- | ------------------------------------------------------------------------------------------------------------- |
721
+ | `platform` | `'auto'` | `'ios'` · `'android'` · `'web'`. Forced, for testing an iOS build on a laptop. `'auto'` reads the OS. |
722
+ | `direction` | `'auto'` | `'ltr'` · `'rtl'`. `'auto'` reads the computed direction off the host, so `<html dir="rtl">` just works. |
723
+ | `transitions` | built-in | One `StackTransition`, or `{ ios, android, web }`. See [Transitions](#transitions). |
724
+ | `duration` | `420` | Base ms. The built-ins scale it — Android and web run shorter than iOS. |
725
+ | `animateRoot` | `false` | Animate the very first page onto an empty stack. |
726
+ | `respectReducedMotion` | `true` | `prefers-reduced-motion` makes transitions instant. The swipe keeps its animation — it's direct manipulation. |
727
+
728
+ **Swipe-back**
729
+
730
+ | Option | Default | What |
731
+ | ------------------------ | --------- | ------------------------------------------------------------------------------------------------------- |
732
+ | `swipeBack` | `'auto'` | `'auto'` arms it on iOS only. Changed at runtime with `NgxStackSwipe`. |
733
+ | `swipeEdgeWidth` | `50` | px of edge in which a drag can begin. Mirrored in RTL. |
734
+ | `swipeThreshold` | `0.5` | Release past this fraction and the pop completes. |
735
+ | `swipeVelocityThreshold` | `0.35` | px/ms. A faster flick decides on its own, whatever the distance. |
736
+ | `swipeWithMouse` | `false` | Drag with a mouse. For developing the gesture on a laptop — leave it off in production. |
737
+ | `guardPolicy` | `'block'` | A route with a `canDeactivate` guard isn't swipeable unless it implements `ngxCanSwipeBack()`. |
738
+ | `systemGesture` | `'inset'` | What to do about WebKit's own edge gesture. [The iOS browser caveat](#the-ios-browser-caveat-honestly). |
739
+ | `systemEdgeInset` | `16` | With `'inset'`, px at the very edge conceded to the browser. |
740
+
741
+ **Structure**
742
+
743
+ | Option | Default | What |
744
+ | ------------- | ------- | ---------------------------------------------------------------------------------------------------------- |
745
+ | `tabs` | — | `['inbox', 'search']` — path prefixes, each with its own stack. [Tabs](#tabs). |
746
+ | `deepLinks` | `false` | Rebuild the ancestors when the app opens partway in. [Deep links](#deep-links-opening-the-app-partway-in). |
747
+ | `maxDepth` | `0` | Cap on pages kept mounted per stack. `0` is no cap. [Memory](#memory-maxdepth). |
748
+ | `manageFocus` | `true` | Move focus into the entering page and announce it. [Accessibility](#accessibility). |
749
+
750
+ ## Contributing
751
+
752
+ Issues and pull requests are welcome. The development setup, the release process, and how a new
753
+ Angular major gets supported are all in the [workspace README](https://github.com/AppsGanin/ngx-stack).
754
+
755
+ Commits follow [Conventional Commits](https://www.conventionalcommits.org/) — the convention Angular
756
+ itself invented — and a `pre-commit` hook runs ESLint and Prettier over what you staged.
757
+
758
+ ## License
759
+
760
+ [MIT](https://github.com/AppsGanin/ngx-stack/blob/main/LICENSE) © Dmitry Ganin