@mmstack/router-core 21.0.2 → 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 -206
- package/fesm2022/mmstack-router-core.mjs +692 -414
- package/fesm2022/mmstack-router-core.mjs.map +1 -1
- package/package.json +5 -3
- package/types/mmstack-router-core.d.ts +218 -34
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,241 +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
|
-
|
|
37
|
-
template: `
|
|
38
|
-
<h1>User Profile</h1>
|
|
39
|
-
<p>User ID: {{ userId() ?? 'Unknown' }}</p>
|
|
40
|
-
`,
|
|
54
|
+
selector: 'app-header',
|
|
55
|
+
template: `<nav>Current path: {{ currentUrl() }}</nav>`,
|
|
41
56
|
})
|
|
42
|
-
export class
|
|
43
|
-
|
|
44
|
-
protected readonly userId = pathParam('id');
|
|
45
|
-
|
|
46
|
-
constructor() {
|
|
47
|
-
effect(() => {
|
|
48
|
-
const id = this.userId();
|
|
49
|
-
console.log('User ID changed:', id);
|
|
50
|
-
// Load user data, update UI, etc. based on id
|
|
51
|
-
});
|
|
52
|
-
}
|
|
57
|
+
export class HeaderComponent {
|
|
58
|
+
protected readonly currentUrl = url();
|
|
53
59
|
}
|
|
54
60
|
```
|
|
55
61
|
|
|
56
|
-
### queryParam
|
|
62
|
+
### `queryParam`
|
|
57
63
|
|
|
58
|
-
|
|
64
|
+
A `WritableSignal` that two-way binds with a URL query parameter.
|
|
59
65
|
|
|
60
|
-
- Reading
|
|
61
|
-
- Setting
|
|
62
|
-
- Setting
|
|
63
|
-
- Reacts to external navigation changes
|
|
64
|
-
-
|
|
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.
|
|
65
71
|
|
|
66
72
|
```typescript
|
|
73
|
+
import { Component } from '@angular/core';
|
|
74
|
+
import { FormsModule } from '@angular/forms';
|
|
75
|
+
import { queryParam } from '@mmstack/router-core';
|
|
76
|
+
|
|
67
77
|
@Component({
|
|
68
78
|
selector: 'app-search-page',
|
|
69
|
-
standalone: true,
|
|
70
79
|
imports: [FormsModule],
|
|
71
80
|
template: `
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
</
|
|
76
|
-
<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>
|
|
77
85
|
<p>Current search: {{ searchTerm() ?? 'None' }}</p>
|
|
78
86
|
`,
|
|
79
87
|
})
|
|
80
88
|
export class SearchPageComponent {
|
|
81
|
-
// Two-way bind the 'q' query parameter (?q=...)
|
|
82
89
|
protected readonly searchTerm = queryParam('q');
|
|
83
|
-
|
|
84
|
-
constructor() {
|
|
85
|
-
effect(() => {
|
|
86
|
-
const currentTerm = this.searchTerm();
|
|
87
|
-
console.log('Search term changed:', currentTerm);
|
|
88
|
-
// Trigger API call, update results, etc. based on currentTerm
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
90
|
}
|
|
92
91
|
```
|
|
93
92
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
Creates a read-only Signal that tracks the current router URL string.
|
|
97
|
-
|
|
98
|
-
- Updates after each successful navigation.
|
|
99
|
-
- Reflects the URL after any redirects (urlAfterRedirects).
|
|
100
|
-
- Initializes with the router's current URL synchronously.
|
|
93
|
+
---
|
|
101
94
|
|
|
102
|
-
|
|
103
|
-
import { Component, effect } from '@angular/core';
|
|
104
|
-
import { url } from '@mmstack/router-core';
|
|
95
|
+
## Resolver-driven UI
|
|
105
96
|
|
|
106
|
-
|
|
107
|
-
selector: 'app-header',
|
|
108
|
-
standalone: true,
|
|
109
|
-
template: `<nav>Current Path: {{ currentUrl() }}</nav>`,
|
|
110
|
-
})
|
|
111
|
-
export class HeaderComponent {
|
|
112
|
-
protected readonly currentUrl = url();
|
|
113
|
-
}
|
|
114
|
-
```
|
|
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.
|
|
115
98
|
|
|
116
|
-
|
|
99
|
+
### Title
|
|
117
100
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
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.
|
|
121
102
|
|
|
122
|
-
|
|
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';
|
|
123
108
|
|
|
124
|
-
|
|
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
|
+
```
|
|
125
128
|
|
|
126
|
-
|
|
129
|
+
#### Configuration (optional)
|
|
127
130
|
|
|
128
|
-
|
|
129
|
-
- Uses advanced path matching to identify the correct route to preload, even with route parameters and matrix parameters.
|
|
130
|
-
- Avoids preloading if the connection is slow (e.g., '2g' effective type) or if the user has data-saving enabled in their browser.
|
|
131
|
-
- Respects a `data: { preload: false }` flag in route configurations to explicitly disable preloading for specific routes.
|
|
132
|
-
- Prevents redundant preloading attempts for the same route path.
|
|
131
|
+
`provideTitleConfig` customizes title formatting and fallbacks:
|
|
133
132
|
|
|
134
|
-
|
|
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.
|
|
135
136
|
|
|
136
137
|
```typescript
|
|
137
|
-
import {
|
|
138
|
-
import {
|
|
139
|
-
import { provideRouter, withPreloading } from '@angular/router';
|
|
140
|
-
import { routes } from './app.routes';
|
|
138
|
+
import { provideRouter } from '@angular/router';
|
|
139
|
+
import { provideTitleConfig } from '@mmstack/router-core';
|
|
141
140
|
|
|
142
141
|
export const appConfig: ApplicationConfig = {
|
|
143
142
|
providers: [
|
|
144
|
-
|
|
145
|
-
|
|
143
|
+
provideRouter(appRoutes),
|
|
144
|
+
provideTitleConfig({
|
|
145
|
+
prefix: (title) => (title ? `${title} — MyApp` : 'MyApp'),
|
|
146
|
+
initialTitle: 'MyApp',
|
|
147
|
+
}),
|
|
146
148
|
],
|
|
147
149
|
};
|
|
148
150
|
```
|
|
149
151
|
|
|
150
|
-
###
|
|
151
|
-
|
|
152
|
-
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`.
|
|
153
|
-
|
|
154
|
-
- `preloadOn`: `input<'hover' | 'visible' | null>()` [default: 'hover'] specifies when to preload, `null` disables preloading
|
|
155
|
-
- `preloading` - `output<void>()` fires when route is registered for preloading (before load)
|
|
156
|
-
|
|
157
|
-
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
|
|
158
|
-
|
|
159
|
-
```typescript
|
|
160
|
-
import { Link } from '@mmstack/router-core';
|
|
161
|
-
import { RouterLink } from '@angular/router';
|
|
162
|
-
|
|
163
|
-
@Component({
|
|
164
|
-
selector: 'app-navigation',
|
|
165
|
-
standalone: true,
|
|
166
|
-
imports: [Link, RouterLink],
|
|
167
|
-
template: `
|
|
168
|
-
<nav>
|
|
169
|
-
<!-- preload on hover -->
|
|
170
|
-
<a [mmLink]="['/features']" preloadOn="hover">Features</a>
|
|
171
|
-
<!-- preload on visible -->
|
|
172
|
-
<a [mmLink]="['/pricing']" preloadOn="visible">Pricing</a>
|
|
173
|
-
<!-- no preload -->
|
|
174
|
-
<a [mmLink]="['/contact']" [preloadOn]="null">Contact</a>
|
|
175
|
-
<!-- preload on hover -->
|
|
176
|
-
<a [mmLink]="['/about']">About</a>
|
|
177
|
-
<!-- no preload, or just use [preloadOn]="null" -->
|
|
178
|
-
<a [routerLink]="['/terms']">Terms & Conditions</a>
|
|
179
|
-
</nav>
|
|
180
|
-
`,
|
|
181
|
-
})
|
|
182
|
-
export class NavigationComponent {}
|
|
183
|
-
```
|
|
184
|
-
|
|
185
|
-
## Headless breadcrumb utilities
|
|
186
|
-
|
|
187
|
-
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 :)
|
|
188
|
-
|
|
189
|
-
### Consuming breadcrumbs
|
|
152
|
+
### Breadcrumbs
|
|
190
153
|
|
|
191
|
-
|
|
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`.
|
|
192
155
|
|
|
193
156
|
```typescript
|
|
194
157
|
import { Component } from '@angular/core';
|
|
195
|
-
import {
|
|
158
|
+
import { RouterLink } from '@angular/router';
|
|
159
|
+
import { injectBreadcrumbs } from '@mmstack/router-core';
|
|
196
160
|
|
|
197
161
|
@Component({
|
|
198
162
|
selector: 'app-breadcrumbs',
|
|
199
|
-
|
|
163
|
+
imports: [RouterLink],
|
|
200
164
|
template: `
|
|
201
165
|
<nav aria-label="breadcrumb">
|
|
202
166
|
<ol>
|
|
203
167
|
@for (crumb of breadcrumbs(); track crumb.id) {
|
|
204
168
|
<li>
|
|
205
|
-
<a
|
|
169
|
+
<a
|
|
170
|
+
[routerLink]="crumb.link()"
|
|
171
|
+
[attr.aria-label]="crumb.ariaLabel()"
|
|
172
|
+
>
|
|
173
|
+
{{ crumb.label() }}
|
|
174
|
+
</a>
|
|
206
175
|
</li>
|
|
207
176
|
}
|
|
208
177
|
</ol>
|
|
209
178
|
</nav>
|
|
210
179
|
`,
|
|
211
180
|
})
|
|
212
|
-
export class
|
|
181
|
+
export class BreadcrumbsComponent {
|
|
213
182
|
protected readonly breadcrumbs = injectBreadcrumbs();
|
|
214
183
|
}
|
|
215
184
|
```
|
|
216
185
|
|
|
217
|
-
|
|
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.
|
|
218
187
|
|
|
219
|
-
|
|
188
|
+
#### Overriding a breadcrumb
|
|
220
189
|
|
|
221
|
-
|
|
222
|
-
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.
|
|
223
191
|
|
|
224
192
|
```typescript
|
|
225
193
|
import { Routes } from '@angular/router';
|
|
194
|
+
import { inject } from '@angular/core';
|
|
226
195
|
import { createBreadcrumb } from '@mmstack/router-core';
|
|
227
|
-
import { HomeComponent } from './home.component';
|
|
228
|
-
import { UserProfileComponent } from './user-profile.component';
|
|
229
196
|
import { UserStore } from './user.store';
|
|
230
|
-
import { inject } from '@angular/core';
|
|
231
|
-
import { AdminComponent } from './admin.component';
|
|
232
197
|
|
|
233
198
|
export const appRoutes: Routes = [
|
|
234
199
|
{
|
|
235
200
|
path: 'home',
|
|
236
201
|
component: HomeComponent,
|
|
237
202
|
resolve: {
|
|
238
|
-
//
|
|
239
|
-
breadcrumb: createBreadcrumb(
|
|
240
|
-
label: 'Home',
|
|
241
|
-
})),
|
|
203
|
+
// Shorthand for { label: 'Home' } — also accepts an options object or a factory returning either.
|
|
204
|
+
breadcrumb: createBreadcrumb('Home'),
|
|
242
205
|
},
|
|
243
206
|
},
|
|
244
207
|
{
|
|
245
208
|
path: 'admin',
|
|
246
209
|
component: AdminComponent,
|
|
247
|
-
data: {
|
|
248
|
-
skipBreadcrumb: true, // opt out of auto-generation for this specific route
|
|
249
|
-
},
|
|
210
|
+
data: { skipBreadcrumb: true }, // opt out of auto-generation for this route
|
|
250
211
|
},
|
|
251
212
|
{
|
|
252
213
|
path: 'users/:userId',
|
|
@@ -255,8 +216,9 @@ export const appRoutes: Routes = [
|
|
|
255
216
|
breadcrumb: createBreadcrumb(() => {
|
|
256
217
|
const userStore = inject(UserStore);
|
|
257
218
|
return {
|
|
258
|
-
label: () =>
|
|
259
|
-
ariaLabel: () =>
|
|
219
|
+
label: () => userStore.currentUser().name ?? 'Loading...',
|
|
220
|
+
ariaLabel: () =>
|
|
221
|
+
`View profile for ${userStore.currentUser().name ?? 'user'}`,
|
|
260
222
|
};
|
|
261
223
|
}),
|
|
262
224
|
},
|
|
@@ -264,94 +226,278 @@ export const appRoutes: Routes = [
|
|
|
264
226
|
];
|
|
265
227
|
```
|
|
266
228
|
|
|
267
|
-
|
|
229
|
+
#### Configuration (optional)
|
|
230
|
+
|
|
231
|
+
`provideBreadcrumbConfig` controls auto-generation behavior:
|
|
268
232
|
|
|
269
|
-
|
|
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.
|
|
270
235
|
|
|
271
236
|
```typescript
|
|
272
|
-
import {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
return leaf.route.data?.['navTitle'] || leaf.route.title || 'Default Title';
|
|
282
|
-
};
|
|
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';
|
|
283
246
|
};
|
|
247
|
+
|
|
284
248
|
export const appConfig: ApplicationConfig = {
|
|
285
|
-
providers: [
|
|
286
|
-
// ...rest
|
|
287
|
-
provideBreadcrumbConfig({
|
|
288
|
-
// generation: 'manual' // When set to 'manual' the system only uses explicitly defined breadcrumbs
|
|
289
|
-
generation: customLabelStrategy, // Or provide a custom generation function
|
|
290
|
-
}),
|
|
291
|
-
],
|
|
249
|
+
providers: [provideBreadcrumbConfig({ generation: customStrategy })],
|
|
292
250
|
};
|
|
293
251
|
```
|
|
294
252
|
|
|
295
|
-
|
|
253
|
+
### Nav menus
|
|
296
254
|
|
|
297
|
-
|
|
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.
|
|
298
256
|
|
|
299
|
-
|
|
257
|
+
```typescript
|
|
258
|
+
import { Component } from '@angular/core';
|
|
259
|
+
import { RouterLink } from '@angular/router';
|
|
260
|
+
import { injectNavItems } from '@mmstack/router-core';
|
|
300
261
|
|
|
301
|
-
|
|
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.
|
|
302
285
|
|
|
303
|
-
|
|
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.
|
|
304
289
|
|
|
305
290
|
```typescript
|
|
306
291
|
import { Routes } from '@angular/router';
|
|
307
|
-
import {
|
|
308
|
-
import { inject } from '@angular/core';
|
|
309
|
-
import { ProductStore } from './product.store';
|
|
292
|
+
import { createNavItems } from '@mmstack/router-core';
|
|
310
293
|
|
|
311
294
|
export const appRoutes: Routes = [
|
|
312
295
|
{
|
|
313
|
-
path: '
|
|
314
|
-
// Example 1: Static title
|
|
296
|
+
path: '',
|
|
315
297
|
resolve: {
|
|
316
|
-
|
|
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
|
+
]),
|
|
317
305
|
},
|
|
318
|
-
|
|
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
|
+
],
|
|
319
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 = [
|
|
320
343
|
{
|
|
321
|
-
path: '
|
|
322
|
-
|
|
344
|
+
path: '',
|
|
345
|
+
component: MyFeatureShellComponent,
|
|
323
346
|
resolve: {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
}),
|
|
347
|
+
nav: createNavItems([
|
|
348
|
+
{ label: 'Overview', link: 'overview' }, // → ${mount}/overview
|
|
349
|
+
{ label: 'Settings', link: 'settings' }, // → ${mount}/settings
|
|
350
|
+
]),
|
|
329
351
|
},
|
|
330
|
-
|
|
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),
|
|
331
371
|
},
|
|
332
372
|
];
|
|
333
373
|
```
|
|
334
374
|
|
|
335
|
-
|
|
375
|
+
#### Named scopes
|
|
336
376
|
|
|
337
|
-
|
|
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`.
|
|
338
378
|
|
|
339
379
|
```typescript
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
380
|
+
resolve: {
|
|
381
|
+
mainNav: createNavItems([...primary], { name: 'main' }),
|
|
382
|
+
sideNav: createNavItems([...secondary], { name: 'side' }),
|
|
383
|
+
}
|
|
344
384
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
// Prefix can be a static string...
|
|
350
|
-
// prefix: 'My Awesome App | '
|
|
385
|
+
// consumers
|
|
386
|
+
@Component({ ... }) class TopBar { items = injectNavItems('main'); }
|
|
387
|
+
@Component({ ... }) class SideBar { items = injectNavItems('side'); }
|
|
388
|
+
```
|
|
351
389
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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))],
|
|
356
448
|
};
|
|
357
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
|
+
```
|