@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @mmstack/router-core
2
2
 
3
- Core utilities and Signal-based primitives for enhancing development with `@angular/router`. This library provides helpers for common routing tasks, reactive integration with router state, and intelligent module preloading.
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
- ## Signal Utilities
15
+ ## Features
16
16
 
17
- This library includes helpers to interact with router state reactively using Angular Signals.
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
- ### pathParam
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
- Creates a read-only Signal that tracks a specific route path parameter (e.g., `:id` in `/users/:id`).
43
+ A read-only Signal tracking the current router URL.
24
44
 
25
- - Reading the signal returns the parameter's current value (string) or null if absent.
26
- - Reacts to navigation changes affecting the parameter.
27
- - Traverses parent routes to find the parameter.
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, effect } from '@angular/core';
32
- import { pathParam } from '@mmstack/router-core';
50
+ import { Component } from '@angular/core';
51
+ import { url } from '@mmstack/router-core';
33
52
 
34
53
  @Component({
35
- selector: 'app-user-profile',
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 UserProfileComponent {
42
- // Track the ':userId' path parameter from the route
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
- Creates a WritableSignal that synchronizes with a specific URL query parameter, enabling two-way binding between the signal's state and the URL.
64
+ A `WritableSignal` that two-way binds with a URL query parameter.
58
65
 
59
- - Reading the signal returns the parameter's current value (string) or null if absent.
60
- - Setting the signal to a string updates the URL parameter.
61
- - Setting the signal to null removes the parameter from the URL.
62
- - Reacts to external navigation changes affecting the parameter.
63
- - Supports static or dynamic (function/signal) keys.
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
- <label>
71
- Search:
72
- <input [(ngModel)]="searchTerm" placeholder="Enter search term..." />
73
- </label>
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
- ### url
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
- ```typescript
101
- import { Component, effect } from '@angular/core';
102
- import { url } from '@mmstack/router-core';
95
+ ## Resolver-driven UI
103
96
 
104
- @Component({
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
- ## Preloading Utilities
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
- ### PreloadStrategy
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
- This is a custom Angular PreloadingStrategy that works in tandem with the mmstack `Link` (via an internal `PreloadService`) to intelligently preload lazy-loaded modules.
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
- **Features**:
129
+ #### Configuration (optional)
124
130
 
125
- - Listens for preload requests triggered by the `Link` directive.
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
- To enable this preloading strategy, you need to provide it in your application's main routing configuration.
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 { PreloadStrategy } from '@mmstack/router-core';
135
- import { ApplicationConfig } from '@angular/core';
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
- //...other providers
142
- provideRouter(routes, withPreloading(PreloadStrategy)),
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
- ### Link (mmLink)
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
- The primary way to access the breadcrumb data is via the `injectBreadcrumbs` function. It returns a `Signal<Breadcrumb[]>` that updates automatically as navigation changes. Each `Breadcrumb` object in the array contains reactive signals for its `label`, `link`, `ariaLabel`, and a static id for iteration purposes.
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 { injectBreadcrumbs } from '@mmstack/router-core'; // Adjust path if needed
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 [href]="crumb.link()" [attr.aria-label]="crumb.ariaLabel()">{{ crumb.label() }}</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 CustomBreadcrumbsComponent {
181
+ export class BreadcrumbsComponent {
208
182
  protected readonly breadcrumbs = injectBreadcrumbs();
209
183
  }
210
184
  ```
211
185
 
212
- ### Registering custom breadcrumbs
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
- For routes where automatic breadcrumb generation isn't sufficient or when you need more control, you can manually define breadcrumbs using the `createBreadcrumb` route resolver.
188
+ #### Overriding a breadcrumb
215
189
 
216
- This function allows you to specify the label (static or dynamic via a function) and other properties for a breadcrumb associated with a particular route.
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
- // Simple static breadcrumb
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: () => `Profile: ${userStore.currentUser().name}` ?? 'Loading...',
254
- ariaLabel: () => `View profile for ${userStore.currentUser().name ?? 'user'}`,
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
- ### Configuration [optional]
229
+ #### Configuration (optional)
230
+
231
+ `provideBreadcrumbConfig` controls auto-generation behavior:
263
232
 
264
- The breadcrumb system can be configured globally using `provideBreadcrumbConfig`. This allows you to, for example, set the system to 'manual' mode (disabling all automatic generation) or provide a custom function for generating breadcrumb labels.
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 { provideRouter } from '@angular/router';
268
- import { provideBreadcrumbConfig, BreadcrumbConfig, ResolvedLeafRoute } from '@mmstack/router-core'; // Adjust path
269
- import { appRoutes } from './app.routes';
270
- import { ApplicationConfig } from '@angular/core';
271
-
272
- // Example: Custom label generation strategy
273
- const customLabelStrategy = () => {
274
- // you can inject root injectable services/stores here.
275
- return (leaf: ResolvedLeafRoute): string => {
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
- ## Title utilities
253
+ ### Nav menus
291
254
 
292
- This library provides a helper function, `createTitle`, to set the document title dynamically from within your route configuration. It integrates seamlessly with Angular's built-in `title` property on routes, allowing for both static and signal-based reactive titles.
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
- By default, the system will use a title defined on a route's `data` or `title` property. createTitle enhances this by allowing titles to be derived from reactive state.
257
+ ```typescript
258
+ import { Component } from '@angular/core';
259
+ import { RouterLink } from '@angular/router';
260
+ import { injectNavItems } from '@mmstack/router-core';
295
261
 
296
- ### Using `createTitle`
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
- The `createTitle` function is a route resolver that returns a title string. You use it directly in the `title` property of a route definition. It can accept a function that returns a static string or a function that returns a dynamic string (which will be converted to a `signal`).
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 { createTitle } from '@mmstack/router-core';
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: 'about',
309
- // Example 1: Static title
296
+ path: '',
310
297
  resolve: {
311
- title: createTitle(() => 'About Us'), // static
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
- loadComponent: () => import('./about.component').then((m) => m.AboutComponent),
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: 'products/:id',
317
- // Example 2: Dynamic, signal-based title from a store
344
+ path: '',
345
+ component: MyFeatureShellComponent,
318
346
  resolve: {
319
- title: createTitle(() => {
320
- const productStore = inject(ProductStore);
321
- // The inner function creates a computed signal under the hood
322
- return () => `Product: ${productStore.product().name ?? 'Loading...'}`;
323
- }),
347
+ nav: createNavItems([
348
+ { label: 'Overview', link: 'overview' }, // → ${mount}/overview
349
+ { label: 'Settings', link: 'settings' }, // ${mount}/settings
350
+ ]),
324
351
  },
325
- loadComponent: () => import('./product-detail.component').then((m) => m.ProductComponent),
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
- ### Configuration [optional]
375
+ #### Named scopes
331
376
 
332
- You can provide a global configuration to prepend or append text to all titles using provideTitleConfig.
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
- import { provideRouter } from '@angular/router';
336
- import { provideTitleConfig } from '@mmstack/router-core';
337
- import { appRoutes } from './app.routes';
338
- import { ApplicationConfig } from '@angular/core';
380
+ resolve: {
381
+ mainNav: createNavItems([...primary], { name: 'main' }),
382
+ sideNav: createNavItems([...secondary], { name: 'side' }),
383
+ }
339
384
 
340
- export const appConfig: ApplicationConfig = {
341
- providers: [
342
- provideRouter(appRoutes),
343
- provideTitleConfig({
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
- // ...or a function for more control over the format
348
- prefix: (title) => (title ? `${title} - MyApp` : 'MyApp'),
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
+ ```