@mmstack/router-core 21.0.3 → 21.0.4
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 +352 -201
- package/fesm2022/mmstack-router-core.mjs +331 -60
- package/fesm2022/mmstack-router-core.mjs.map +1 -1
- package/package.json +1 -1
- package/types/mmstack-router-core.d.ts +217 -32
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @mmstack/router-core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Signal-based primitives for `@angular/router` — reactive router state, resolver-driven UI (titles, breadcrumbs, headless nav menus), and on-demand module preloading.
|
|
4
4
|
|
|
5
5
|
Part of the `@mmstack` ecosystem, designed to complement [@mmstack/primitives](https://www.npmjs.com/package/@mmstack/primitives).
|
|
6
6
|
|
|
@@ -12,236 +12,202 @@ Part of the `@mmstack` ecosystem, designed to complement [@mmstack/primitives](h
|
|
|
12
12
|
npm install @mmstack/router-core
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
##
|
|
15
|
+
## Features
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
- **Reactive router state** — read the current URL, path params, and query params as Angular Signals.
|
|
18
|
+
- **Resolver-driven UI** — declare your document title, breadcrumbs, and one or more nav menus from your `Routes` config; consume them reactively from any component.
|
|
19
|
+
- **Smart preloading** — a `RouterLink` replacement and `PreloadingStrategy` that preload lazy-loaded route modules on hover, visibility, or imperatively.
|
|
20
|
+
|
|
21
|
+
## Table of contents
|
|
22
|
+
|
|
23
|
+
- [Reactive router state](#reactive-router-state)
|
|
24
|
+
- [`url`](#url)
|
|
25
|
+
- [`queryParam`](#queryparam)
|
|
26
|
+
- [Resolver-driven UI](#resolver-driven-ui)
|
|
27
|
+
- [Title — `createTitle`](#title)
|
|
28
|
+
- [Breadcrumbs — `createBreadcrumb` / `injectBreadcrumbs`](#breadcrumbs)
|
|
29
|
+
- [Nav menus — `createNavItems` / `injectNavItems`](#nav-menus)
|
|
30
|
+
- [Preloading](#preloading)
|
|
31
|
+
- [`PreloadStrategy`](#preloadstrategy)
|
|
32
|
+
- [`Link` (`mmLink`)](#link-mmlink)
|
|
33
|
+
- [`injectTriggerPreload`](#injecttriggerpreload)
|
|
18
34
|
|
|
19
35
|
---
|
|
20
36
|
|
|
21
|
-
|
|
37
|
+
## Reactive router state
|
|
38
|
+
|
|
39
|
+
Helpers that expose router state as Angular Signals — read them anywhere you'd read a signal (templates, computeds, effects).
|
|
40
|
+
|
|
41
|
+
### `url`
|
|
22
42
|
|
|
23
|
-
|
|
43
|
+
A read-only Signal tracking the current router URL.
|
|
24
44
|
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
- Supports static or dynamic (function/signal) keys.
|
|
45
|
+
- Updates after every successful navigation.
|
|
46
|
+
- Reflects the URL after redirects (`urlAfterRedirects`).
|
|
47
|
+
- Initializes synchronously with the router's current URL.
|
|
29
48
|
|
|
30
49
|
```typescript
|
|
31
|
-
import { Component
|
|
32
|
-
import {
|
|
50
|
+
import { Component } from '@angular/core';
|
|
51
|
+
import { url } from '@mmstack/router-core';
|
|
33
52
|
|
|
34
53
|
@Component({
|
|
35
|
-
selector: 'app-
|
|
36
|
-
template:
|
|
37
|
-
<h1>User Profile</h1>
|
|
38
|
-
<p>User ID: {{ userId() ?? 'Unknown' }}</p>
|
|
39
|
-
`,
|
|
54
|
+
selector: 'app-header',
|
|
55
|
+
template: `<nav>Current path: {{ currentUrl() }}</nav>`,
|
|
40
56
|
})
|
|
41
|
-
export class
|
|
42
|
-
|
|
43
|
-
protected readonly userId = pathParam('id');
|
|
44
|
-
|
|
45
|
-
constructor() {
|
|
46
|
-
effect(() => {
|
|
47
|
-
const id = this.userId();
|
|
48
|
-
console.log('User ID changed:', id);
|
|
49
|
-
// Load user data, update UI, etc. based on id
|
|
50
|
-
});
|
|
51
|
-
}
|
|
57
|
+
export class HeaderComponent {
|
|
58
|
+
protected readonly currentUrl = url();
|
|
52
59
|
}
|
|
53
60
|
```
|
|
54
61
|
|
|
55
|
-
### queryParam
|
|
62
|
+
### `queryParam`
|
|
56
63
|
|
|
57
|
-
|
|
64
|
+
A `WritableSignal` that two-way binds with a URL query parameter.
|
|
58
65
|
|
|
59
|
-
- Reading
|
|
60
|
-
- Setting
|
|
61
|
-
- Setting
|
|
62
|
-
- Reacts to external navigation changes
|
|
63
|
-
-
|
|
66
|
+
- Reading returns the current value (or `null` if absent).
|
|
67
|
+
- Setting to a string updates the URL.
|
|
68
|
+
- Setting to `null` removes the parameter.
|
|
69
|
+
- Reacts to external navigation changes.
|
|
70
|
+
- Uses `queryParamsHandling: 'merge'`, so unrelated params survive updates.
|
|
64
71
|
|
|
65
72
|
```typescript
|
|
73
|
+
import { Component } from '@angular/core';
|
|
74
|
+
import { FormsModule } from '@angular/forms';
|
|
75
|
+
import { queryParam } from '@mmstack/router-core';
|
|
76
|
+
|
|
66
77
|
@Component({
|
|
67
78
|
selector: 'app-search-page',
|
|
68
79
|
imports: [FormsModule],
|
|
69
80
|
template: `
|
|
70
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
</
|
|
74
|
-
<button (click)="searchTerm.set(null)" [disabled]="!searchTerm()">Clear</button>
|
|
81
|
+
<input [(ngModel)]="searchTerm" placeholder="Enter search term..." />
|
|
82
|
+
<button (click)="searchTerm.set(null)" [disabled]="!searchTerm()">
|
|
83
|
+
Clear
|
|
84
|
+
</button>
|
|
75
85
|
<p>Current search: {{ searchTerm() ?? 'None' }}</p>
|
|
76
86
|
`,
|
|
77
87
|
})
|
|
78
88
|
export class SearchPageComponent {
|
|
79
|
-
// Two-way bind the 'q' query parameter (?q=...)
|
|
80
89
|
protected readonly searchTerm = queryParam('q');
|
|
81
|
-
|
|
82
|
-
constructor() {
|
|
83
|
-
effect(() => {
|
|
84
|
-
const currentTerm = this.searchTerm();
|
|
85
|
-
console.log('Search term changed:', currentTerm);
|
|
86
|
-
// Trigger API call, update results, etc. based on currentTerm
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
90
|
}
|
|
90
91
|
```
|
|
91
92
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
Creates a read-only Signal that tracks the current router URL string.
|
|
95
|
-
|
|
96
|
-
- Updates after each successful navigation.
|
|
97
|
-
- Reflects the URL after any redirects (urlAfterRedirects).
|
|
98
|
-
- Initializes with the router's current URL synchronously.
|
|
93
|
+
---
|
|
99
94
|
|
|
100
|
-
|
|
101
|
-
import { Component, effect } from '@angular/core';
|
|
102
|
-
import { url } from '@mmstack/router-core';
|
|
95
|
+
## Resolver-driven UI
|
|
103
96
|
|
|
104
|
-
|
|
105
|
-
selector: 'app-header',
|
|
106
|
-
template: `<nav>Current Path: {{ currentUrl() }}</nav>`,
|
|
107
|
-
})
|
|
108
|
-
export class HeaderComponent {
|
|
109
|
-
protected readonly currentUrl = url();
|
|
110
|
-
}
|
|
111
|
-
```
|
|
97
|
+
Three helpers (`createTitle`, `createBreadcrumb`, `createNavItems`) hook into Angular's route `resolve` map (or `title` map for titles) to populate the document title, a breadcrumb trail, and one or more nav menus from your `Routes` config. They share the same pattern: declare on the route, consume reactively from a component.
|
|
112
98
|
|
|
113
|
-
|
|
99
|
+
### Title
|
|
114
100
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
Enhance your application's performance by preloading Angular modules. This library provides a flexible directive and a smart preloading strategy to load modules just before they are needed.
|
|
101
|
+
`createTitle` is a route resolver that sets the document title. Use it directly in the route's `title` property — it accepts static strings or signal-driven dynamic titles.
|
|
118
102
|
|
|
119
|
-
|
|
103
|
+
```typescript
|
|
104
|
+
import { Routes } from '@angular/router';
|
|
105
|
+
import { inject } from '@angular/core';
|
|
106
|
+
import { createTitle } from '@mmstack/router-core';
|
|
107
|
+
import { ProductStore } from './product.store';
|
|
120
108
|
|
|
121
|
-
|
|
109
|
+
export const appRoutes: Routes = [
|
|
110
|
+
{
|
|
111
|
+
path: 'about',
|
|
112
|
+
title: createTitle('About Us'),
|
|
113
|
+
loadComponent: () =>
|
|
114
|
+
import('./about.component').then((m) => m.AboutComponent),
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
path: 'products/:id',
|
|
118
|
+
// Signal-driven title — the inner function becomes a computed under the hood.
|
|
119
|
+
title: createTitle(() => {
|
|
120
|
+
const products = inject(ProductStore);
|
|
121
|
+
return () => `Product: ${products.product().name ?? 'Loading...'}`;
|
|
122
|
+
}),
|
|
123
|
+
loadComponent: () =>
|
|
124
|
+
import('./product-detail.component').then((m) => m.ProductComponent),
|
|
125
|
+
},
|
|
126
|
+
];
|
|
127
|
+
```
|
|
122
128
|
|
|
123
|
-
|
|
129
|
+
#### Configuration (optional)
|
|
124
130
|
|
|
125
|
-
|
|
126
|
-
- Uses advanced path matching to identify the correct route to preload, even with route parameters and matrix parameters.
|
|
127
|
-
- Avoids preloading if the connection is slow (e.g., '2g' effective type) or if the user has data-saving enabled in their browser.
|
|
128
|
-
- Respects a `data: { preload: false }` flag in route configurations to explicitly disable preloading for specific routes.
|
|
129
|
-
- Prevents redundant preloading attempts for the same route path.
|
|
131
|
+
`provideTitleConfig` customizes title formatting and fallbacks:
|
|
130
132
|
|
|
131
|
-
|
|
133
|
+
- **`prefix`** — `string | (title: string) => string`. Static prefix, or a full formatter for complete control over the result.
|
|
134
|
+
- **`keepLastKnownTitle`** (default `true`) — when navigating to a route that doesn't provide a title, hold the last route-driven title instead of clearing it.
|
|
135
|
+
- **`initialTitle`** — explicit fallback when no route title is active. If omitted, `TitleStore` captures `Title.getTitle()` once at construction (typically the `<title>` from `index.html`). Set this explicitly if you set the document title imperatively before the router has bootstrapped.
|
|
132
136
|
|
|
133
137
|
```typescript
|
|
134
|
-
import {
|
|
135
|
-
import {
|
|
136
|
-
import { provideRouter, withPreloading } from '@angular/router';
|
|
137
|
-
import { routes } from './app.routes';
|
|
138
|
+
import { provideRouter } from '@angular/router';
|
|
139
|
+
import { provideTitleConfig } from '@mmstack/router-core';
|
|
138
140
|
|
|
139
141
|
export const appConfig: ApplicationConfig = {
|
|
140
142
|
providers: [
|
|
141
|
-
|
|
142
|
-
|
|
143
|
+
provideRouter(appRoutes),
|
|
144
|
+
provideTitleConfig({
|
|
145
|
+
prefix: (title) => (title ? `${title} — MyApp` : 'MyApp'),
|
|
146
|
+
initialTitle: 'MyApp',
|
|
147
|
+
}),
|
|
143
148
|
],
|
|
144
149
|
};
|
|
145
150
|
```
|
|
146
151
|
|
|
147
|
-
###
|
|
148
|
-
|
|
149
|
-
The `Link` directive (used with the `mmLink` attribute) is an enhancement for Angular's standard `RouterLink` directive. It adds the capability to preload the JavaScript modules associated with the linked route, based on user interaction or visibility. Other than the added `preloadOn` input & `preloading` output it directly proxies `RouterLink`.
|
|
150
|
-
|
|
151
|
-
- `preloadOn`: `input<'hover' | 'visible' | null>()` [default: 'hover'] specifies when to preload, `null` disables preloading
|
|
152
|
-
- `preloading` - `output<void>()` fires when route is registered for preloading (before load)
|
|
153
|
-
|
|
154
|
-
To use it simply replace any exiting routerLinks that you would like to enable preloading on with the mmLink, you can keep all existing inputs the same. And add the mmstack `PreloadStrategy` in your configuration
|
|
155
|
-
|
|
156
|
-
```typescript
|
|
157
|
-
import { Link } from '@mmstack/router-core';
|
|
158
|
-
import { RouterLink } from '@angular/router';
|
|
159
|
-
|
|
160
|
-
@Component({
|
|
161
|
-
selector: 'app-navigation',
|
|
162
|
-
imports: [Link, RouterLink],
|
|
163
|
-
template: `
|
|
164
|
-
<nav>
|
|
165
|
-
<!-- preload on hover -->
|
|
166
|
-
<a [mmLink]="['/features']" preloadOn="hover">Features</a>
|
|
167
|
-
<!-- preload on visible -->
|
|
168
|
-
<a [mmLink]="['/pricing']" preloadOn="visible">Pricing</a>
|
|
169
|
-
<!-- no preload -->
|
|
170
|
-
<a [mmLink]="['/contact']" [preloadOn]="null">Contact</a>
|
|
171
|
-
<!-- preload on hover -->
|
|
172
|
-
<a [mmLink]="['/about']">About</a>
|
|
173
|
-
<!-- no preload, or just use [preloadOn]="null" -->
|
|
174
|
-
<a [routerLink]="['/terms']">Terms & Conditions</a>
|
|
175
|
-
</nav>
|
|
176
|
-
`,
|
|
177
|
-
})
|
|
178
|
-
export class NavigationComponent {}
|
|
179
|
-
```
|
|
180
|
-
|
|
181
|
-
## Headless breadcrumb utilities
|
|
182
|
-
|
|
183
|
-
This library includes a signal-based, headless toolkit for generating and managing breadcrumbs in your Angular application. It provides the logic to derive breadcrumb data from your routes, allowing you to easily build a completely custom breadcrumb UI component & let the library worry about active routes :)
|
|
184
|
-
|
|
185
|
-
### Consuming breadcrumbs
|
|
152
|
+
### Breadcrumbs
|
|
186
153
|
|
|
187
|
-
|
|
154
|
+
A signal-based, headless breadcrumb toolkit. Breadcrumbs are auto-generated from route segments by default, with per-route overrides via `createBreadcrumb`. Consume the reactive list with `injectBreadcrumbs`.
|
|
188
155
|
|
|
189
156
|
```typescript
|
|
190
157
|
import { Component } from '@angular/core';
|
|
191
|
-
import {
|
|
158
|
+
import { RouterLink } from '@angular/router';
|
|
159
|
+
import { injectBreadcrumbs } from '@mmstack/router-core';
|
|
192
160
|
|
|
193
161
|
@Component({
|
|
194
162
|
selector: 'app-breadcrumbs',
|
|
163
|
+
imports: [RouterLink],
|
|
195
164
|
template: `
|
|
196
165
|
<nav aria-label="breadcrumb">
|
|
197
166
|
<ol>
|
|
198
167
|
@for (crumb of breadcrumbs(); track crumb.id) {
|
|
199
168
|
<li>
|
|
200
|
-
<a
|
|
169
|
+
<a
|
|
170
|
+
[routerLink]="crumb.link()"
|
|
171
|
+
[attr.aria-label]="crumb.ariaLabel()"
|
|
172
|
+
>
|
|
173
|
+
{{ crumb.label() }}
|
|
174
|
+
</a>
|
|
201
175
|
</li>
|
|
202
176
|
}
|
|
203
177
|
</ol>
|
|
204
178
|
</nav>
|
|
205
179
|
`,
|
|
206
180
|
})
|
|
207
|
-
export class
|
|
181
|
+
export class BreadcrumbsComponent {
|
|
208
182
|
protected readonly breadcrumbs = injectBreadcrumbs();
|
|
209
183
|
}
|
|
210
184
|
```
|
|
211
185
|
|
|
212
|
-
|
|
186
|
+
> **Heads up:** `crumb.link()` is a serialized URL string. Bind it to `[routerLink]` (or `[mmLink]` if you want preloading) — `[href]` would trigger a full page reload.
|
|
213
187
|
|
|
214
|
-
|
|
188
|
+
#### Overriding a breadcrumb
|
|
215
189
|
|
|
216
|
-
|
|
217
|
-
You can use injection in the factory function, as you would with any resolver, making translations or subscribing to dynamic data a breaze! :)
|
|
190
|
+
When auto-generation isn't enough, register a custom breadcrumb in the route's `resolve` map. The factory runs in an injection context, so you can pull labels from stores, i18n services, etc.
|
|
218
191
|
|
|
219
192
|
```typescript
|
|
220
193
|
import { Routes } from '@angular/router';
|
|
194
|
+
import { inject } from '@angular/core';
|
|
221
195
|
import { createBreadcrumb } from '@mmstack/router-core';
|
|
222
|
-
import { HomeComponent } from './home.component';
|
|
223
|
-
import { UserProfileComponent } from './user-profile.component';
|
|
224
196
|
import { UserStore } from './user.store';
|
|
225
|
-
import { inject } from '@angular/core';
|
|
226
|
-
import { AdminComponent } from './admin.component';
|
|
227
197
|
|
|
228
198
|
export const appRoutes: Routes = [
|
|
229
199
|
{
|
|
230
200
|
path: 'home',
|
|
231
201
|
component: HomeComponent,
|
|
232
202
|
resolve: {
|
|
233
|
-
//
|
|
234
|
-
breadcrumb: createBreadcrumb(
|
|
235
|
-
label: 'Home',
|
|
236
|
-
})),
|
|
203
|
+
// Shorthand for { label: 'Home' } — also accepts an options object or a factory returning either.
|
|
204
|
+
breadcrumb: createBreadcrumb('Home'),
|
|
237
205
|
},
|
|
238
206
|
},
|
|
239
207
|
{
|
|
240
208
|
path: 'admin',
|
|
241
209
|
component: AdminComponent,
|
|
242
|
-
data: {
|
|
243
|
-
skipBreadcrumb: true, // opt out of auto-generation for this specific route
|
|
244
|
-
},
|
|
210
|
+
data: { skipBreadcrumb: true }, // opt out of auto-generation for this route
|
|
245
211
|
},
|
|
246
212
|
{
|
|
247
213
|
path: 'users/:userId',
|
|
@@ -250,8 +216,9 @@ export const appRoutes: Routes = [
|
|
|
250
216
|
breadcrumb: createBreadcrumb(() => {
|
|
251
217
|
const userStore = inject(UserStore);
|
|
252
218
|
return {
|
|
253
|
-
label: () =>
|
|
254
|
-
ariaLabel: () =>
|
|
219
|
+
label: () => userStore.currentUser().name ?? 'Loading...',
|
|
220
|
+
ariaLabel: () =>
|
|
221
|
+
`View profile for ${userStore.currentUser().name ?? 'user'}`,
|
|
255
222
|
};
|
|
256
223
|
}),
|
|
257
224
|
},
|
|
@@ -259,94 +226,278 @@ export const appRoutes: Routes = [
|
|
|
259
226
|
];
|
|
260
227
|
```
|
|
261
228
|
|
|
262
|
-
|
|
229
|
+
#### Configuration (optional)
|
|
230
|
+
|
|
231
|
+
`provideBreadcrumbConfig` controls auto-generation behavior:
|
|
263
232
|
|
|
264
|
-
|
|
233
|
+
- **`generation: 'manual'`** — disable auto-generation entirely; only routes with `createBreadcrumb` produce breadcrumbs.
|
|
234
|
+
- **`generation: () => (leaf: ResolvedLeafRoute) => string`** — supply a custom label generator. The outer function runs in a root injection context, so you can inject stores or i18n services there.
|
|
265
235
|
|
|
266
236
|
```typescript
|
|
267
|
-
import {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
return leaf.route.data?.['navTitle'] || leaf.route.title || 'Default Title';
|
|
277
|
-
};
|
|
237
|
+
import {
|
|
238
|
+
provideBreadcrumbConfig,
|
|
239
|
+
type BreadcrumbConfig,
|
|
240
|
+
type ResolvedLeafRoute,
|
|
241
|
+
} from '@mmstack/router-core';
|
|
242
|
+
|
|
243
|
+
const customStrategy: BreadcrumbConfig['generation'] = () => {
|
|
244
|
+
return (leaf: ResolvedLeafRoute): string =>
|
|
245
|
+
leaf.route.data?.['navTitle'] ?? leaf.route.title ?? 'Default';
|
|
278
246
|
};
|
|
247
|
+
|
|
279
248
|
export const appConfig: ApplicationConfig = {
|
|
280
|
-
providers: [
|
|
281
|
-
// ...rest
|
|
282
|
-
provideBreadcrumbConfig({
|
|
283
|
-
// generation: 'manual' // When set to 'manual' the system only uses explicitly defined breadcrumbs
|
|
284
|
-
generation: customLabelStrategy, // Or provide a custom generation function
|
|
285
|
-
}),
|
|
286
|
-
],
|
|
249
|
+
providers: [provideBreadcrumbConfig({ generation: customStrategy })],
|
|
287
250
|
};
|
|
288
251
|
```
|
|
289
252
|
|
|
290
|
-
|
|
253
|
+
### Nav menus
|
|
291
254
|
|
|
292
|
-
|
|
255
|
+
A headless, scope-aware navigation menu primitive. Routes declare nav items via `createNavItems`; components consume them via `injectNavItems()` as `Signal<NavItem[]>`. When multiple routes in the active chain register items for the same scope, the deepest active registration wins — navigating away restores the shallower one.
|
|
293
256
|
|
|
294
|
-
|
|
257
|
+
```typescript
|
|
258
|
+
import { Component } from '@angular/core';
|
|
259
|
+
import { RouterLink } from '@angular/router';
|
|
260
|
+
import { injectNavItems } from '@mmstack/router-core';
|
|
295
261
|
|
|
296
|
-
|
|
262
|
+
@Component({
|
|
263
|
+
selector: 'app-top-bar',
|
|
264
|
+
imports: [RouterLink],
|
|
265
|
+
template: `
|
|
266
|
+
<nav>
|
|
267
|
+
@for (item of items(); track item.id()) {
|
|
268
|
+
<a
|
|
269
|
+
[routerLink]="item.link()"
|
|
270
|
+
[class.active]="item.active()"
|
|
271
|
+
[attr.aria-disabled]="item.disabled()"
|
|
272
|
+
>
|
|
273
|
+
{{ item.label() }}
|
|
274
|
+
</a>
|
|
275
|
+
}
|
|
276
|
+
</nav>
|
|
277
|
+
`,
|
|
278
|
+
})
|
|
279
|
+
export class TopBar {
|
|
280
|
+
protected readonly items = injectNavItems();
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
> **Heads up:** `item.link()` is a serialized URL string. Bind it to `[routerLink]` (or `[mmLink]` for preloading) — `[href]` would cause a full page reload.
|
|
297
285
|
|
|
298
|
-
|
|
286
|
+
#### Registering items
|
|
287
|
+
|
|
288
|
+
Items are declared in a route's `resolve` map. Links resolve **relative to the route the resolver is attached to**, matching Angular's `routerLink` convention — a leading slash makes a link absolute.
|
|
299
289
|
|
|
300
290
|
```typescript
|
|
301
291
|
import { Routes } from '@angular/router';
|
|
302
|
-
import {
|
|
303
|
-
import { inject } from '@angular/core';
|
|
304
|
-
import { ProductStore } from './product.store';
|
|
292
|
+
import { createNavItems } from '@mmstack/router-core';
|
|
305
293
|
|
|
306
294
|
export const appRoutes: Routes = [
|
|
307
295
|
{
|
|
308
|
-
path: '
|
|
309
|
-
// Example 1: Static title
|
|
296
|
+
path: '',
|
|
310
297
|
resolve: {
|
|
311
|
-
|
|
298
|
+
// Root menu — visible on every page unless a deeper route overrides.
|
|
299
|
+
// Absolute paths work fine for top-level app menus:
|
|
300
|
+
nav: createNavItems([
|
|
301
|
+
{ label: 'Home', link: '/' },
|
|
302
|
+
{ label: 'Products', link: '/products' },
|
|
303
|
+
{ label: 'About', link: '/about' },
|
|
304
|
+
]),
|
|
312
305
|
},
|
|
313
|
-
|
|
306
|
+
children: [
|
|
307
|
+
{
|
|
308
|
+
path: 'products',
|
|
309
|
+
loadComponent: () =>
|
|
310
|
+
import('./products.component').then((m) => m.ProductsComponent),
|
|
311
|
+
resolve: {
|
|
312
|
+
// Inside /products, the menu changes — root menu is shadowed until we navigate away.
|
|
313
|
+
// Relative links work too — these resolve against /products:
|
|
314
|
+
nav: createNavItems([
|
|
315
|
+
{ label: 'All', link: '/products' },
|
|
316
|
+
{ label: 'Featured', link: 'featured' }, // → /products/featured
|
|
317
|
+
{ label: 'Categories', link: 'categories' }, // → /products/categories
|
|
318
|
+
]),
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
],
|
|
314
322
|
},
|
|
323
|
+
];
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
`NavItem.active` is computed against the current URL with `subsetMatchOptions` defaults (prefix-match paths, subset query params, ignore matrix/fragment). Override per-item with `activeMatch: Partial<IsActiveMatchOptions>` or globally with `provideNavConfig({ activeMatch })`.
|
|
327
|
+
|
|
328
|
+
#### Link resolution rules
|
|
329
|
+
|
|
330
|
+
| Input | Resolved to (when the resolver route is mounted at `/myLib`) |
|
|
331
|
+
| -------------------- | ------------------------------------------------------------ |
|
|
332
|
+
| `'a'` or `'a/b'` | `/myLib/a`, `/myLib/a/b` |
|
|
333
|
+
| `['a', 'b']` | `/myLib/a/b` |
|
|
334
|
+
| `'/elsewhere'` | `/elsewhere` (absolute escape) |
|
|
335
|
+
| `['/fooBar', 'baz']` | `/fooBar/baz` (absolute escape) |
|
|
336
|
+
| `UrlTree` | passed through unchanged |
|
|
337
|
+
|
|
338
|
+
Relative-by-default makes nav items portable across mount points — particularly useful for **nx feature libraries** that export `Routes` without knowing where the consuming app will mount them:
|
|
339
|
+
|
|
340
|
+
```typescript
|
|
341
|
+
// libs/my-feature/src/lib/routes.ts
|
|
342
|
+
export const myFeatureRoutes: Routes = [
|
|
315
343
|
{
|
|
316
|
-
path: '
|
|
317
|
-
|
|
344
|
+
path: '',
|
|
345
|
+
component: MyFeatureShellComponent,
|
|
318
346
|
resolve: {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
}),
|
|
347
|
+
nav: createNavItems([
|
|
348
|
+
{ label: 'Overview', link: 'overview' }, // → ${mount}/overview
|
|
349
|
+
{ label: 'Settings', link: 'settings' }, // → ${mount}/settings
|
|
350
|
+
]),
|
|
324
351
|
},
|
|
325
|
-
|
|
352
|
+
children: [
|
|
353
|
+
{ path: 'overview', component: OverviewComponent },
|
|
354
|
+
{ path: 'settings', component: SettingsComponent },
|
|
355
|
+
],
|
|
356
|
+
},
|
|
357
|
+
];
|
|
358
|
+
|
|
359
|
+
// apps/host/src/app/app.routes.ts — the consumer picks the mount path.
|
|
360
|
+
export const appRoutes: Routes = [
|
|
361
|
+
{
|
|
362
|
+
path: 'my-feature',
|
|
363
|
+
loadChildren: () =>
|
|
364
|
+
import('@org/my-feature').then((m) => m.myFeatureRoutes),
|
|
365
|
+
},
|
|
366
|
+
// Same lib, same nav items, different mount — links resolve correctly:
|
|
367
|
+
{
|
|
368
|
+
path: 'admin/tools',
|
|
369
|
+
loadChildren: () =>
|
|
370
|
+
import('@org/my-feature').then((m) => m.myFeatureRoutes),
|
|
326
371
|
},
|
|
327
372
|
];
|
|
328
373
|
```
|
|
329
374
|
|
|
330
|
-
|
|
375
|
+
#### Named scopes
|
|
331
376
|
|
|
332
|
-
|
|
377
|
+
Pass `{ name }` when a route declares more than one menu (e.g. top bar + side bar). The `resolve` key is just a unique handle Angular requires; the store keys on `name`.
|
|
333
378
|
|
|
334
379
|
```typescript
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
380
|
+
resolve: {
|
|
381
|
+
mainNav: createNavItems([...primary], { name: 'main' }),
|
|
382
|
+
sideNav: createNavItems([...secondary], { name: 'side' }),
|
|
383
|
+
}
|
|
339
384
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
// Prefix can be a static string...
|
|
345
|
-
// prefix: 'My Awesome App | '
|
|
385
|
+
// consumers
|
|
386
|
+
@Component({ ... }) class TopBar { items = injectNavItems('main'); }
|
|
387
|
+
@Component({ ... }) class SideBar { items = injectNavItems('side'); }
|
|
388
|
+
```
|
|
346
389
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
390
|
+
#### Children, hidden, disabled
|
|
391
|
+
|
|
392
|
+
Items can declare `children` for nested menus. By default a parent is active when its own link matches OR any descendant is active — useful for grouping headers with no own link. Setting `activeMatch` explicitly disables the OR; pass `matchesWhenChildActive: true` to re-enable it.
|
|
393
|
+
|
|
394
|
+
`hidden` filters the item (and its subtree) out of the consumer-facing array. `disabled` is preserved on the item and cascades to descendants — useful for permission-gated subtrees:
|
|
395
|
+
|
|
396
|
+
```typescript
|
|
397
|
+
createNavItems(() => [
|
|
398
|
+
{
|
|
399
|
+
label: 'Admin',
|
|
400
|
+
link: 'admin',
|
|
401
|
+
hidden: () => !permissions.isAdmin(), // signal-driven
|
|
402
|
+
children: [
|
|
403
|
+
{ label: 'Users', link: 'admin/users' },
|
|
404
|
+
{ label: 'Settings', link: 'admin/settings' },
|
|
405
|
+
],
|
|
406
|
+
},
|
|
407
|
+
]);
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
#### Typed metadata
|
|
411
|
+
|
|
412
|
+
`CreateNavItem` and `NavItem` carry a `TMeta` generic so consumers can attach app-specific fields (icons, badges, etc.) without the library imposing a shape:
|
|
413
|
+
|
|
414
|
+
```typescript
|
|
415
|
+
type NavMeta = { icon: string };
|
|
416
|
+
|
|
417
|
+
createNavItems<NavMeta>([{ label: 'Home', link: '/', meta: { icon: 'home' } }]);
|
|
418
|
+
|
|
419
|
+
// in the component
|
|
420
|
+
items = injectNavItems<NavMeta>();
|
|
421
|
+
// items()[0].meta().icon → 'home'
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
---
|
|
425
|
+
|
|
426
|
+
## Preloading
|
|
427
|
+
|
|
428
|
+
Two complementary primitives speed up lazy-loaded routes: a `PreloadingStrategy` that listens for preload requests, and a `RouterLink` replacement that issues them on hover or visibility. An imperative escape hatch (`injectTriggerPreload`) covers the cases where the directive isn't a fit.
|
|
429
|
+
|
|
430
|
+
### `PreloadStrategy`
|
|
431
|
+
|
|
432
|
+
A custom `PreloadingStrategy` that defers preloading until something asks for a specific route. It pairs with the `Link` (`mmLink`) directive or `injectTriggerPreload` — neither preloads anything on its own.
|
|
433
|
+
|
|
434
|
+
- Listens for preload requests triggered by `Link` / `injectTriggerPreload`.
|
|
435
|
+
- Path-matches the requested URL against the route config (supports route params, matrix params, and wildcards).
|
|
436
|
+
- Skips preloading on slow connections (`effectiveType: '2g'`) or when the browser reports `saveData`.
|
|
437
|
+
- Respects `data: { preload: false }` on a route config to opt that route out.
|
|
438
|
+
- Deduplicates: each path is preloaded at most once.
|
|
439
|
+
|
|
440
|
+
Provide it alongside `provideRouter`:
|
|
441
|
+
|
|
442
|
+
```typescript
|
|
443
|
+
import { PreloadStrategy } from '@mmstack/router-core';
|
|
444
|
+
import { provideRouter, withPreloading } from '@angular/router';
|
|
445
|
+
|
|
446
|
+
export const appConfig: ApplicationConfig = {
|
|
447
|
+
providers: [provideRouter(routes, withPreloading(PreloadStrategy))],
|
|
351
448
|
};
|
|
352
449
|
```
|
|
450
|
+
|
|
451
|
+
### `Link` (`mmLink`)
|
|
452
|
+
|
|
453
|
+
The `Link` directive (used as `mmLink`) wraps Angular's `RouterLink` and adds preloading. All standard `routerLink` inputs (`queryParams`, `fragment`, `state`, `relativeTo`, etc.) are proxied through unchanged.
|
|
454
|
+
|
|
455
|
+
- **`preloadOn`** — `input<'hover' | 'visible' | null>()` (default: `'hover'`). `null` disables preloading.
|
|
456
|
+
- **`preloading`** — `output<void>()` fires when the route is queued for preload (before the JS actually loads).
|
|
457
|
+
|
|
458
|
+
Replace existing `routerLink`s with `mmLink` to opt them in:
|
|
459
|
+
|
|
460
|
+
```typescript
|
|
461
|
+
import { Component } from '@angular/core';
|
|
462
|
+
import { Link } from '@mmstack/router-core';
|
|
463
|
+
|
|
464
|
+
@Component({
|
|
465
|
+
selector: 'app-navigation',
|
|
466
|
+
imports: [Link],
|
|
467
|
+
template: `
|
|
468
|
+
<nav>
|
|
469
|
+
<!-- preload on hover (default) -->
|
|
470
|
+
<a [mmLink]="['/features']">Features</a>
|
|
471
|
+
<!-- preload when scrolled into view -->
|
|
472
|
+
<a [mmLink]="['/pricing']" preloadOn="visible">Pricing</a>
|
|
473
|
+
<!-- no preload -->
|
|
474
|
+
<a [mmLink]="['/contact']" [preloadOn]="null">Contact</a>
|
|
475
|
+
</nav>
|
|
476
|
+
`,
|
|
477
|
+
})
|
|
478
|
+
export class NavigationComponent {}
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
### `injectTriggerPreload`
|
|
482
|
+
|
|
483
|
+
When the directive isn't a fit — preloading from a signal effect, on a keyboard shortcut, when a command palette opens — `injectTriggerPreload()` returns a function that runs the same preload pipeline imperatively. Same `PreloadStrategy` requirement.
|
|
484
|
+
|
|
485
|
+
```typescript
|
|
486
|
+
import { Component, effect, signal } from '@angular/core';
|
|
487
|
+
import { injectTriggerPreload } from '@mmstack/router-core';
|
|
488
|
+
|
|
489
|
+
@Component({
|
|
490
|
+
/* ... */
|
|
491
|
+
})
|
|
492
|
+
export class CommandPaletteComponent {
|
|
493
|
+
private readonly triggerPreload = injectTriggerPreload();
|
|
494
|
+
protected readonly highlighted = signal<string | null>(null);
|
|
495
|
+
|
|
496
|
+
constructor() {
|
|
497
|
+
effect(() => {
|
|
498
|
+
const target = this.highlighted();
|
|
499
|
+
if (target) this.triggerPreload(target);
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
```
|