@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 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,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
- ## 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
- standalone: true,
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 UserProfileComponent {
43
- // Track the ':userId' path parameter from the route
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
- 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.
59
65
 
60
- - Reading the signal returns the parameter's current value (string) or null if absent.
61
- - Setting the signal to a string updates the URL parameter.
62
- - Setting the signal to null removes the parameter from the URL.
63
- - Reacts to external navigation changes affecting the parameter.
64
- - 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.
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
- <label>
73
- Search:
74
- <input [(ngModel)]="searchTerm" placeholder="Enter search term..." />
75
- </label>
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
- ### url
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
- ```typescript
103
- import { Component, effect } from '@angular/core';
104
- import { url } from '@mmstack/router-core';
95
+ ## Resolver-driven UI
105
96
 
106
- @Component({
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
- ## Preloading Utilities
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
- ### 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';
123
108
 
124
- 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
+ ```
125
128
 
126
- **Features**:
129
+ #### Configuration (optional)
127
130
 
128
- - Listens for preload requests triggered by the `Link` directive.
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
- 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.
135
136
 
136
137
  ```typescript
137
- import { PreloadStrategy } from '@mmstack/router-core';
138
- import { ApplicationConfig } from '@angular/core';
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
- //...other providers
145
- provideRouter(routes, withPreloading(PreloadStrategy)),
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
- ### Link (mmLink)
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
- 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`.
192
155
 
193
156
  ```typescript
194
157
  import { Component } from '@angular/core';
195
- import { injectBreadcrumbs } from '@mmstack/router-core'; // Adjust path if needed
158
+ import { RouterLink } from '@angular/router';
159
+ import { injectBreadcrumbs } from '@mmstack/router-core';
196
160
 
197
161
  @Component({
198
162
  selector: 'app-breadcrumbs',
199
- standalone: true,
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 [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>
206
175
  </li>
207
176
  }
208
177
  </ol>
209
178
  </nav>
210
179
  `,
211
180
  })
212
- export class CustomBreadcrumbsComponent {
181
+ export class BreadcrumbsComponent {
213
182
  protected readonly breadcrumbs = injectBreadcrumbs();
214
183
  }
215
184
  ```
216
185
 
217
- ### 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.
218
187
 
219
- 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
220
189
 
221
- 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.
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
- // Simple static breadcrumb
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: () => `Profile: ${userStore.currentUser().name}` ?? 'Loading...',
259
- ariaLabel: () => `View profile for ${userStore.currentUser().name ?? 'user'}`,
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
- ### Configuration [optional]
229
+ #### Configuration (optional)
230
+
231
+ `provideBreadcrumbConfig` controls auto-generation behavior:
268
232
 
269
- 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.
270
235
 
271
236
  ```typescript
272
- import { provideRouter } from '@angular/router';
273
- import { provideBreadcrumbConfig, BreadcrumbConfig, ResolvedLeafRoute } from '@mmstack/router-core'; // Adjust path
274
- import { appRoutes } from './app.routes';
275
- import { ApplicationConfig } from '@angular/core';
276
-
277
- // Example: Custom label generation strategy
278
- const customLabelStrategy = () => {
279
- // you can inject root injectable services/stores here.
280
- return (leaf: ResolvedLeafRoute): string => {
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
- ## Title utilities
253
+ ### Nav menus
296
254
 
297
- 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.
298
256
 
299
- 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';
300
261
 
301
- ### 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.
302
285
 
303
- 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.
304
289
 
305
290
  ```typescript
306
291
  import { Routes } from '@angular/router';
307
- import { createTitle } from '@mmstack/router-core';
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: 'about',
314
- // Example 1: Static title
296
+ path: '',
315
297
  resolve: {
316
- 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
+ ]),
317
305
  },
318
- 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
+ ],
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: 'products/:id',
322
- // Example 2: Dynamic, signal-based title from a store
344
+ path: '',
345
+ component: MyFeatureShellComponent,
323
346
  resolve: {
324
- title: createTitle(() => {
325
- const productStore = inject(ProductStore);
326
- // The inner function creates a computed signal under the hood
327
- return () => `Product: ${productStore.product().name ?? 'Loading...'}`;
328
- }),
347
+ nav: createNavItems([
348
+ { label: 'Overview', link: 'overview' }, // → ${mount}/overview
349
+ { label: 'Settings', link: 'settings' }, // ${mount}/settings
350
+ ]),
329
351
  },
330
- 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),
331
371
  },
332
372
  ];
333
373
  ```
334
374
 
335
- ### Configuration [optional]
375
+ #### Named scopes
336
376
 
337
- 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`.
338
378
 
339
379
  ```typescript
340
- import { provideRouter } from '@angular/router';
341
- import { provideTitleConfig } from '@mmstack/router-core';
342
- import { appRoutes } from './app.routes';
343
- import { ApplicationConfig } from '@angular/core';
380
+ resolve: {
381
+ mainNav: createNavItems([...primary], { name: 'main' }),
382
+ sideNav: createNavItems([...secondary], { name: 'side' }),
383
+ }
344
384
 
345
- export const appConfig: ApplicationConfig = {
346
- providers: [
347
- provideRouter(appRoutes),
348
- provideTitleConfig({
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
- // ...or a function for more control over the format
353
- prefix: (title) => (title ? `${title} - MyApp` : 'MyApp'),
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
+ ```