@mmstack/router-core 19.2.10 → 19.3.1

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
@@ -146,3 +146,178 @@ import { RouterLink } from '@angular/router';
146
146
  })
147
147
  export class NavigationComponent {}
148
148
  ```
149
+
150
+ ## Headless breadcrumb utilities
151
+
152
+ 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 :)
153
+
154
+ ### Consuming breadcrumbs
155
+
156
+ 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.
157
+
158
+ ```typescript
159
+ import { Component } from '@angular/core';
160
+ import { injectBreadcrumbs } from '@mmstack/router-core'; // Adjust path if needed
161
+
162
+ @Component({
163
+ selector: 'app-breadcrumbs',
164
+ standalone: true,
165
+ template: `
166
+ <nav aria-label="breadcrumb">
167
+ <ol>
168
+ @for (crumb of breadcrumbs(); track crumb.id) {
169
+ <li>
170
+ <a [href]="crumb.link()" [attr.aria-label]="crumb.ariaLabel()">{{ crumb.label() }}</a>
171
+ </li>
172
+ }
173
+ </ol>
174
+ </nav>
175
+ `,
176
+ })
177
+ export class CustomBreadcrumbsComponent {
178
+ protected readonly breadcrumbs = injectBreadcrumbs();
179
+ }
180
+ ```
181
+
182
+ ### Registering custom breadcrumbs
183
+
184
+ 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.
185
+
186
+ 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.
187
+ You can use injection in the factory function, as you would with any resolver, making translations or subscribing to dynamic data a breaze! :)
188
+
189
+ ```typescript
190
+ import { Routes } from '@angular/router';
191
+ import { createBreadcrumb } from '@mmstack/router-core';
192
+ import { HomeComponent } from './home.component';
193
+ import { UserProfileComponent } from './user-profile.component';
194
+ import { UserStore } from './user.store';
195
+ import { inject } from '@angular/core';
196
+ import { AdminComponent } from './admin.component';
197
+
198
+ export const appRoutes: Routes = [
199
+ {
200
+ path: 'home',
201
+ component: HomeComponent,
202
+ resolve: {
203
+ // Simple static breadcrumb
204
+ breadcrumb: createBreadcrumb(() => ({
205
+ label: 'Home',
206
+ })),
207
+ },
208
+ },
209
+ {
210
+ path: 'admin',
211
+ component: AdminComponent,
212
+ data: {
213
+ skipBreadcrumb: true, // opt out of auto-generation for this specific route
214
+ },
215
+ },
216
+ {
217
+ path: 'users/:userId',
218
+ component: UserProfileComponent,
219
+ resolve: {
220
+ breadcrumb: createBreadcrumb(() => {
221
+ const userStore = inject(UserStore);
222
+ return {
223
+ label: () => `Profile: ${userStore.currentUser().name}` ?? 'Loading...',
224
+ ariaLabel: () => `View profile for ${userStore.currentUser().name ?? 'user'}`,
225
+ };
226
+ }),
227
+ },
228
+ },
229
+ ];
230
+ ```
231
+
232
+ ### Configuration [optional]
233
+
234
+ 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.
235
+
236
+ ```typescript
237
+ import { provideRouter } from '@angular/router';
238
+ import { provideBreadcrumbConfig, BreadcrumbConfig, ResolvedLeafRoute } from '@mmstack/router-core'; // Adjust path
239
+ import { appRoutes } from './app.routes';
240
+ import { ApplicationConfig } from '@angular/core';
241
+
242
+ // Example: Custom label generation strategy
243
+ const customLabelStrategy = () => {
244
+ // you can inject root injectable services/stores here.
245
+ return (leaf: ResolvedLeafRoute): string => {
246
+ return leaf.route.data?.['navTitle'] || leaf.route.title || 'Default Title';
247
+ };
248
+ };
249
+ export const appConfig: ApplicationConfig = {
250
+ providers: [
251
+ // ...rest
252
+ provideBreadcrumbConfig({
253
+ // generation: 'manual' // When set to 'manual' the system only uses explicitly defined breadcrumbs
254
+ generation: customLabelStrategy, // Or provide a custom generation function
255
+ }),
256
+ ],
257
+ };
258
+ ```
259
+
260
+ ## Title utilities
261
+
262
+ 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.
263
+
264
+ 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.
265
+
266
+ ### Using `createTitle`
267
+
268
+ 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`).
269
+
270
+ ```typescript
271
+ import { Routes } from '@angular/router';
272
+ import { createTitle } from '@mmstack/router-core';
273
+ import { inject } from '@angular/core';
274
+ import { ProductStore } from './product.store';
275
+
276
+ export const appRoutes: Routes = [
277
+ {
278
+ path: 'about',
279
+ // Example 1: Static title
280
+ title: createTitle(() => 'About Us'), // static
281
+ loadComponent: () => import('./about.component').then((m) => m.AboutComponent),
282
+ },
283
+ {
284
+ path: 'products/:id',
285
+ // Example 2: Dynamic, signal-based title from a store
286
+ title: createTitle(() => {
287
+ const productStore = inject(ProductStore);
288
+ // The inner function creates a computed signal under the hood
289
+ return () => `Product: ${productStore.product().name ?? 'Loading...'}`;
290
+ }),
291
+ loadComponent: () => import('./product-detail.component').then((m) => m.ProductComponent),
292
+ },
293
+ {
294
+ path: 'home',
295
+ title: 'Home', // works normally
296
+ loadComponent: () => import('./home.component').then((m) => m.HomeComponent),
297
+ },
298
+ ];
299
+ ```
300
+
301
+ ### Configuration [optional]
302
+
303
+ You can provide a global configuration to prepend or append text to all titles using provideTitleConfig.
304
+
305
+ ```typescript
306
+ import { provideRouter } from '@angular/router';
307
+ import { provideTitleConfig } from '@mmstack/router-core';
308
+ import { appRoutes } from './app.routes';
309
+ import { ApplicationConfig } from '@angular/core';
310
+
311
+ export const appConfig: ApplicationConfig = {
312
+ providers: [
313
+ provideRouter(appRoutes),
314
+ provideTitleConfig({
315
+ // Prefix can be a static string...
316
+ // prefix: 'My Awesome App | '
317
+
318
+ // ...or a function for more control over the format
319
+ prefix: (title) => (title ? `${title} - MyApp` : 'MyApp'),
320
+ }),
321
+ ],
322
+ };
323
+ ```
@@ -1,23 +1,59 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, inject, computed, untracked, Injectable, DestroyRef, input, booleanAttribute, output, effect, Directive, isSignal } from '@angular/core';
2
+ import { InjectionToken, inject, computed, Injectable, effect, untracked, input, booleanAttribute, output, Directive, isSignal } from '@angular/core';
3
3
  import * as i1 from '@angular/router';
4
- import { EventType, Router, createUrlTreeFromSnapshot, PRIMARY_OUTLET, UrlTree, RouterLink, RouterLinkWithHref, ActivatedRoute } from '@angular/router';
4
+ import { EventType, Router, PRIMARY_OUTLET, createUrlTreeFromSnapshot, UrlTree, RouterLink, RouterLinkWithHref, ActivatedRoute } from '@angular/router';
5
5
  import { mutable, mapArray, until, elementVisibility, toWritable } from '@mmstack/primitives';
6
6
  import { toSignal } from '@angular/core/rxjs-interop';
7
7
  import { filter, map } from 'rxjs/operators';
8
8
  import { Subject, EMPTY, filter as filter$1, take, switchMap, finalize } from 'rxjs';
9
+ import { Title } from '@angular/platform-browser';
9
10
 
10
- const token = new InjectionToken('MMSTACK_BREADCRUMB_CONFIG');
11
+ /**
12
+ * @internal
13
+ */
14
+ const token$1 = new InjectionToken('MMSTACK_BREADCRUMB_CONFIG');
15
+ /**
16
+ * Provides configuration for the breadcrumb system.
17
+ * @param config - A partial `BreadcrumbConfig` object with the desired settings. *
18
+ * @see BreadcrumbConfig
19
+ * @example
20
+ * ```typescript
21
+ * // In your app.module.ts or a standalone component's providers:
22
+ * // import { provideBreadcrumbConfig } from './breadcrumb.config'; // Adjust path
23
+ * // import { ResolvedLeafRoute } from './breadcrumb.type'; // Adjust path
24
+ *
25
+ * // const customLabelStrategy: GenerateBreadcrumbFn = () => {
26
+ * // return (leaf: ResolvedLeafRoute): string => {
27
+ * // // Example: Prioritize a 'navTitle' data property
28
+ * // if (leaf.route.data?.['navTitle']) {
29
+ * // return leaf.route.data['navTitle'];
30
+ * // }
31
+ * // // Fallback to a default mechanism
32
+ * // return leaf.route.title || leaf.segment.resolved || 'Unnamed';
33
+ * // };
34
+ * // };
35
+ *
36
+ * export const appConfig = [
37
+ * // ...rest
38
+ * provideBreadcrumbConfig({
39
+ * generation: customLabelStrategy, // or 'manual' to disable auto-generation
40
+ * }),
41
+ * ]
42
+ * ```
43
+ */
11
44
  function provideBreadcrumbConfig(config) {
12
45
  return {
13
- provide: token,
46
+ provide: token$1,
14
47
  useValue: {
15
48
  ...config,
16
49
  },
17
50
  };
18
51
  }
52
+ /**
53
+ * @internal
54
+ */
19
55
  function injectBreadcrumbConfig() {
20
- return (inject(token, {
56
+ return (inject(token$1, {
21
57
  optional: true,
22
58
  }) ?? {});
23
59
  }
@@ -66,23 +102,6 @@ function url() {
66
102
  });
67
103
  }
68
104
 
69
- const INTERNAL_BREADCRUMB_SYMBOL = Symbol.for('MMSTACK_INTERNAL_BREADCRUMB');
70
- function getBreadcrumbInternals(breadcrumb) {
71
- return breadcrumb[INTERNAL_BREADCRUMB_SYMBOL];
72
- }
73
- function createInternalBreadcrumb(bc, active, registered = true) {
74
- return {
75
- ...bc,
76
- [INTERNAL_BREADCRUMB_SYMBOL]: {
77
- active,
78
- registered,
79
- },
80
- };
81
- }
82
- function isInternalBreadcrumb(breadcrumb) {
83
- return !!breadcrumb[INTERNAL_BREADCRUMB_SYMBOL];
84
- }
85
-
86
105
  function leafRoutes() {
87
106
  const router = inject(Router);
88
107
  const getLeafRoutes = (snapshot) => {
@@ -123,6 +142,51 @@ function leafRoutes() {
123
142
  });
124
143
  return leafRoutes;
125
144
  }
145
+ class RouteLeafStore {
146
+ leaves = leafRoutes();
147
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.3", ngImport: i0, type: RouteLeafStore, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
148
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.3", ngImport: i0, type: RouteLeafStore, providedIn: 'root' });
149
+ }
150
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.3", ngImport: i0, type: RouteLeafStore, decorators: [{
151
+ type: Injectable,
152
+ args: [{
153
+ providedIn: 'root',
154
+ }]
155
+ }] });
156
+ function injectLeafRoutes() {
157
+ const store = inject(RouteLeafStore);
158
+ return store.leaves;
159
+ }
160
+
161
+ /**
162
+ * @internal
163
+ */
164
+ const INTERNAL_BREADCRUMB_SYMBOL = Symbol.for('MMSTACK_INTERNAL_BREADCRUMB');
165
+ /**
166
+ * @internal
167
+ */
168
+ function getBreadcrumbInternals(breadcrumb) {
169
+ return breadcrumb[INTERNAL_BREADCRUMB_SYMBOL];
170
+ }
171
+ /**
172
+ * @internal
173
+ */
174
+ function createInternalBreadcrumb(bc, active, registered = true) {
175
+ return {
176
+ ...bc,
177
+ [INTERNAL_BREADCRUMB_SYMBOL]: {
178
+ active,
179
+ registered,
180
+ },
181
+ };
182
+ }
183
+ /**
184
+ * @internal
185
+ */
186
+ function isInternalBreadcrumb(breadcrumb) {
187
+ return !!breadcrumb[INTERNAL_BREADCRUMB_SYMBOL];
188
+ }
189
+
126
190
  function uppercaseFirst(str) {
127
191
  const lcs = str.toLowerCase();
128
192
  return lcs.charAt(0).toUpperCase() + lcs.slice(1);
@@ -194,7 +258,8 @@ class BreadcrumbStore {
194
258
  map = mutable(new Map());
195
259
  isManual = injectIsManual();
196
260
  autoGenerateLabelFn = injectGenerateLabelFn();
197
- all = mapArray(leafRoutes(), (leaf) => {
261
+ leafRoutes = injectLeafRoutes();
262
+ all = mapArray(this.leafRoutes, (leaf) => {
198
263
  const stableId = computed(() => leaf().path);
199
264
  return exposeActiveSignal(computed(() => {
200
265
  const id = stableId();
@@ -215,11 +280,23 @@ class BreadcrumbStore {
215
280
  });
216
281
  crumbs = computed(() => this.all().filter((c) => c.active()));
217
282
  unwrapped = computed(() => this.crumbs().map((c) => c()));
283
+ constructor() {
284
+ const activePaths = computed(() => this.leafRoutes().map((l) => l.path));
285
+ effect(() => {
286
+ const paths = activePaths();
287
+ if (!paths.length)
288
+ return this.map.inline((m) => m.clear());
289
+ this.map.inline((m) => {
290
+ for (const key of m.keys()) {
291
+ if (paths.includes(key))
292
+ continue;
293
+ m.delete(key);
294
+ }
295
+ });
296
+ });
297
+ }
218
298
  register(breadcrumb) {
219
299
  this.map.inline((m) => m.set(breadcrumb.id, breadcrumb));
220
- return () => {
221
- this.map.inline((m) => m.delete(breadcrumb.id));
222
- };
223
300
  }
224
301
  has(id) {
225
302
  return untracked(this.map).has(id);
@@ -232,56 +309,40 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.3", ngImpor
232
309
  args: [{
233
310
  providedIn: 'root',
234
311
  }]
235
- }] });
312
+ }], ctorParameters: () => [] });
313
+ /**
314
+ * Injects and provides access to a reactive list of breadcrumbs.
315
+ *
316
+ * The breadcrumbs are ordered and reflect the current active navigation path.
317
+ * @see Breadcrumb
318
+ * @returns `Signal<Breadcrumb[]>`
319
+ *
320
+ * @example
321
+ * ```typescript
322
+ * @Component({
323
+ * selector: 'app-breadcrumbs',
324
+ * template: `
325
+ * <nav aria-label="breadcrumb">
326
+ * <ol>
327
+ * @for (crumb of breadcrumbs(); track crumb.id) {
328
+ * <li>
329
+ * <a [href]="crumb.link()" [attr.aria-label]="crumb.ariaLabel()">{{ crumb.label() }}</a>
330
+ * </li>
331
+ * }
332
+ * </ol>
333
+ * </nav>
334
+ * `
335
+ * })
336
+ * export class MyBreadcrumbsComponent {
337
+ * breadcrumbs = injectBreadcrumbs();
338
+ * }
339
+ * ```
340
+ */
236
341
  function injectBreadcrumbs() {
237
342
  const store = inject(BreadcrumbStore);
238
343
  return store.unwrapped;
239
344
  }
240
345
 
241
- function createBreadcrumb(factory) {
242
- return async (route) => {
243
- const router = inject(Router);
244
- const store = inject(BreadcrumbStore);
245
- const segments = route.pathFromRoot.flatMap((snap) => snap.routeConfig?.path ?? []);
246
- const joinedSegments = segments.filter(Boolean).join('/');
247
- const fp = router.serializeUrl(router.parseUrl(joinedSegments));
248
- if (store.has(fp))
249
- return Promise.resolve();
250
- const tree = createUrlTreeFromSnapshot(route, [], route.queryParams, route.fragment);
251
- const provided = factory();
252
- const trigger = url();
253
- const link = computed(() => router.serializeUrl(tree));
254
- const { label, ariaLabel = label } = provided;
255
- const bc = {
256
- id: fp,
257
- ariaLabel: typeof ariaLabel === 'string'
258
- ? computed(() => ariaLabel)
259
- : computed(ariaLabel),
260
- label: typeof label === 'string' ? computed(() => label) : computed(label),
261
- link,
262
- };
263
- const deregister = store.register(createInternalBreadcrumb(bc, computed(() => route.data?.['skipBreadcrumb'] !== true)));
264
- inject(DestroyRef).onDestroy(deregister);
265
- if (provided.awaitValue)
266
- await until(trigger, (v) => !!v);
267
- return Promise.resolve();
268
- };
269
- }
270
-
271
- class PreloadService {
272
- preloadOnDemand$ = new Subject();
273
- preloadRequested$ = this.preloadOnDemand$.asObservable();
274
- startPreload(routePath) {
275
- this.preloadOnDemand$.next(routePath);
276
- }
277
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.3", ngImport: i0, type: PreloadService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
278
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.3", ngImport: i0, type: PreloadService, providedIn: 'root' });
279
- }
280
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.3", ngImport: i0, type: PreloadService, decorators: [{
281
- type: Injectable,
282
- args: [{ providedIn: 'root' }]
283
- }] });
284
-
285
346
  function parsePathSegment(segmentString) {
286
347
  const parts = segmentString.split(';');
287
348
  const pathPart = parts[0];
@@ -459,6 +520,90 @@ const findPath = (config, route) => {
459
520
  return normalizedPath;
460
521
  };
461
522
 
523
+ function injectSnapshotPathResolver() {
524
+ const router = inject(Router);
525
+ return (route) => {
526
+ const segments = route.pathFromRoot.flatMap((snap) => snap.routeConfig?.path ?? []);
527
+ const joinedSegments = segments.filter(Boolean).join('/');
528
+ return router.serializeUrl(router.parseUrl(joinedSegments));
529
+ };
530
+ }
531
+
532
+ /**
533
+ * Creates and registers a breadcrumb for a specific route.
534
+ * This function is designed to be used as an Angular Route `ResolveFn`.
535
+ * It handles the registration of the breadcrumb with the `BreadcrumbStore`
536
+ * and ensures automatic deregistration when the route is destroyed.
537
+ *
538
+ * @param factory A function that returns a `CreateBreadcrumbOptions` object.
539
+ * @see CreateBreadcrumbOptions
540
+ *
541
+ * @example
542
+ * ```typescript
543
+ * export const appRoutes: Routes = [
544
+ * {
545
+ * path: 'home',
546
+ * component: HomeComponent,
547
+ * resolve: {
548
+ * breadcrumb: createBreadcrumb(() => ({
549
+ * label: 'Home',
550
+ * });
551
+ * },
552
+ * path: 'users/:userId',
553
+ * component: UserProfileComponent,
554
+ * resolve: {
555
+ * breadcrumb: createBreadcrumb(() => {
556
+ * const userStore = inject(UserStore);
557
+ * return {
558
+ * label: () => userStore.user().name ?? 'Loading...
559
+ * };
560
+ * })
561
+ * },
562
+ * }
563
+ * ];
564
+ * ```
565
+ */
566
+ function createBreadcrumb(factory) {
567
+ return async (route) => {
568
+ const router = inject(Router);
569
+ const store = inject(BreadcrumbStore);
570
+ const resolver = injectSnapshotPathResolver();
571
+ const fp = resolver(route);
572
+ if (store.has(fp))
573
+ return Promise.resolve();
574
+ const tree = createUrlTreeFromSnapshot(route, [], route.queryParams, route.fragment);
575
+ const provided = factory();
576
+ const link = computed(() => router.serializeUrl(tree));
577
+ const { label, ariaLabel = label } = provided;
578
+ const bc = {
579
+ id: fp,
580
+ ariaLabel: typeof ariaLabel === 'string'
581
+ ? computed(() => ariaLabel)
582
+ : computed(ariaLabel),
583
+ label: typeof label === 'string' ? computed(() => label) : computed(label),
584
+ link,
585
+ };
586
+ store.register(createInternalBreadcrumb(bc, computed(() => route.data?.['skipBreadcrumb'] !== true)));
587
+ if (provided.awaitValue)
588
+ await until(bc.label, (v) => !!v);
589
+ return Promise.resolve();
590
+ };
591
+ }
592
+
593
+ class PreloadService {
594
+ preloadOnDemand$ = new Subject();
595
+ preloadRequested$ = this.preloadOnDemand$.asObservable();
596
+ startPreload(routePath) {
597
+ this.preloadOnDemand$.next(routePath);
598
+ }
599
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.3", ngImport: i0, type: PreloadService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
600
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.3", ngImport: i0, type: PreloadService, providedIn: 'root' });
601
+ }
602
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.3", ngImport: i0, type: PreloadService, decorators: [{
603
+ type: Injectable,
604
+ args: [{ providedIn: 'root' }]
605
+ }] });
606
+
462
607
  function hasSlowConnection() {
463
608
  if (globalThis.window &&
464
609
  'navigator' in globalThis.window &&
@@ -551,7 +696,7 @@ class LinkDirective {
551
696
  relativeTo = input();
552
697
  skipLocationChange = input(false, { transform: booleanAttribute });
553
698
  replaceUrl = input(false, { transform: booleanAttribute });
554
- mmLink = input.required();
699
+ mmLink = input(null);
555
700
  preloadOn = input('hover');
556
701
  preloading = output();
557
702
  urlTree = computed(() => {
@@ -585,7 +730,7 @@ class LinkDirective {
585
730
  return this.routerLink?.onClick(button, ctrlKey, shiftKey, altKey, metaKey);
586
731
  }
587
732
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.3", ngImport: i0, type: LinkDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
588
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "19.2.3", type: LinkDirective, isStandalone: true, selector: "[mmLink]", inputs: { target: { classPropertyName: "target", publicName: "target", isSignal: true, isRequired: false, transformFunction: null }, queryParams: { classPropertyName: "queryParams", publicName: "queryParams", isSignal: true, isRequired: false, transformFunction: null }, fragment: { classPropertyName: "fragment", publicName: "fragment", isSignal: true, isRequired: false, transformFunction: null }, queryParamsHandling: { classPropertyName: "queryParamsHandling", publicName: "queryParamsHandling", isSignal: true, isRequired: false, transformFunction: null }, state: { classPropertyName: "state", publicName: "state", isSignal: true, isRequired: false, transformFunction: null }, info: { classPropertyName: "info", publicName: "info", isSignal: true, isRequired: false, transformFunction: null }, relativeTo: { classPropertyName: "relativeTo", publicName: "relativeTo", isSignal: true, isRequired: false, transformFunction: null }, skipLocationChange: { classPropertyName: "skipLocationChange", publicName: "skipLocationChange", isSignal: true, isRequired: false, transformFunction: null }, replaceUrl: { classPropertyName: "replaceUrl", publicName: "replaceUrl", isSignal: true, isRequired: false, transformFunction: null }, mmLink: { classPropertyName: "mmLink", publicName: "mmLink", isSignal: true, isRequired: true, transformFunction: null }, preloadOn: { classPropertyName: "preloadOn", publicName: "preloadOn", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { preloading: "preloading" }, host: { listeners: { "mouseenter": "onHover()" } }, exportAs: ["mmLink"], hostDirectives: [{ directive: i1.RouterLink, inputs: ["routerLink", "mmLink", "target", "target", "queryParams", "queryParams", "fragment", "fragment", "queryParamsHandling", "queryParamsHandling", "state", "state", "relativeTo", "relativeTo", "skipLocationChange", "skipLocationChange", "replaceUrl", "replaceUrl"] }], ngImport: i0 });
733
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "19.2.3", type: LinkDirective, isStandalone: true, selector: "[mmLink]", inputs: { target: { classPropertyName: "target", publicName: "target", isSignal: true, isRequired: false, transformFunction: null }, queryParams: { classPropertyName: "queryParams", publicName: "queryParams", isSignal: true, isRequired: false, transformFunction: null }, fragment: { classPropertyName: "fragment", publicName: "fragment", isSignal: true, isRequired: false, transformFunction: null }, queryParamsHandling: { classPropertyName: "queryParamsHandling", publicName: "queryParamsHandling", isSignal: true, isRequired: false, transformFunction: null }, state: { classPropertyName: "state", publicName: "state", isSignal: true, isRequired: false, transformFunction: null }, info: { classPropertyName: "info", publicName: "info", isSignal: true, isRequired: false, transformFunction: null }, relativeTo: { classPropertyName: "relativeTo", publicName: "relativeTo", isSignal: true, isRequired: false, transformFunction: null }, skipLocationChange: { classPropertyName: "skipLocationChange", publicName: "skipLocationChange", isSignal: true, isRequired: false, transformFunction: null }, replaceUrl: { classPropertyName: "replaceUrl", publicName: "replaceUrl", isSignal: true, isRequired: false, transformFunction: null }, mmLink: { classPropertyName: "mmLink", publicName: "mmLink", isSignal: true, isRequired: false, transformFunction: null }, preloadOn: { classPropertyName: "preloadOn", publicName: "preloadOn", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { preloading: "preloading" }, host: { listeners: { "mouseenter": "onHover()" } }, exportAs: ["mmLink"], hostDirectives: [{ directive: i1.RouterLink, inputs: ["routerLink", "mmLink", "target", "target", "queryParams", "queryParams", "fragment", "fragment", "queryParamsHandling", "queryParamsHandling", "state", "state", "relativeTo", "relativeTo", "skipLocationChange", "skipLocationChange", "replaceUrl", "replaceUrl"] }], ngImport: i0 });
589
734
  }
590
735
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.3", ngImport: i0, type: LinkDirective, decorators: [{
591
736
  type: Directive,
@@ -725,9 +870,108 @@ function queryParam(key) {
725
870
  return toWritable(queryParam, set);
726
871
  }
727
872
 
873
+ const token = new InjectionToken('MMSTACK_TITLE_CONFIG');
874
+ /**
875
+ * used to provide the title configuration, will not be applied unless a `createTitle` resolver is used
876
+ */
877
+ function provideTitleConfig(config) {
878
+ const prefix = config?.prefix ?? '';
879
+ const prefixFn = typeof prefix === 'function'
880
+ ? prefix
881
+ : (title) => `${prefix}${title}`;
882
+ return {
883
+ provide: token,
884
+ useValue: {
885
+ parser: prefixFn,
886
+ },
887
+ };
888
+ }
889
+ function injectTitleConfig() {
890
+ return inject(token);
891
+ }
892
+
893
+ class TitleStore {
894
+ title = inject(Title);
895
+ map = mutable(new Map());
896
+ leafRoutes = injectLeafRoutes();
897
+ activeLeafPath = computed(() => this.leafRoutes().at(-1)?.path);
898
+ constructor() {
899
+ const currentTitleSignal = computed(() => {
900
+ const path = this.activeLeafPath();
901
+ if (!path)
902
+ return null;
903
+ return this.map().get(path) ?? null;
904
+ });
905
+ const fallback = computed(() => this.leafRoutes()
906
+ .toReversed()
907
+ .find((leaf) => leaf.route.title)?.route.title ?? '');
908
+ const currentTitle = computed(() => currentTitleSignal()?.() ?? fallback());
909
+ effect(() => {
910
+ this.title.setTitle(currentTitle());
911
+ });
912
+ effect(() => {
913
+ const activeLeafPath = this.activeLeafPath();
914
+ if (!activeLeafPath)
915
+ return this.map.inline((cur) => cur.clear());
916
+ this.map.inline((cur) => {
917
+ for (const key of cur.keys()) {
918
+ if (key === activeLeafPath)
919
+ continue;
920
+ cur.delete(key);
921
+ }
922
+ });
923
+ });
924
+ }
925
+ register(id, titleFn) {
926
+ this.map.inline((m) => m.set(id, titleFn));
927
+ }
928
+ get(id) {
929
+ const found = untracked(this.map).get(id);
930
+ return found ? untracked(found) : null;
931
+ }
932
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.3", ngImport: i0, type: TitleStore, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
933
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.3", ngImport: i0, type: TitleStore, providedIn: 'root' });
934
+ }
935
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.3", ngImport: i0, type: TitleStore, decorators: [{
936
+ type: Injectable,
937
+ args: [{
938
+ providedIn: 'root',
939
+ }]
940
+ }], ctorParameters: () => [] });
941
+ /**
942
+ *
943
+ * Creates a title resolver function that can be used in Angular's router.
944
+ *
945
+ * @param fn
946
+ * A function that returns a string or a Signal<string> representing the title.
947
+ * @param awaitValue
948
+ * If `true`, the resolver will wait until the title signal has a value before resolving.
949
+ * Defaults to `false`.
950
+ */
951
+ function createTitle(fn, awaitValue = false) {
952
+ return async (route) => {
953
+ const store = inject(TitleStore);
954
+ const resolver = injectSnapshotPathResolver();
955
+ const fp = resolver(route);
956
+ const found = store.get(fp);
957
+ if (found)
958
+ return Promise.resolve(found);
959
+ const { parser } = injectTitleConfig();
960
+ const resolved = fn();
961
+ const titleSignal = typeof resolved === 'string'
962
+ ? computed(() => resolved)
963
+ : computed(resolved);
964
+ const parsedTitleSignal = computed(() => parser(titleSignal()));
965
+ store.register(fp, parsedTitleSignal);
966
+ if (awaitValue)
967
+ await until(parsedTitleSignal, (v) => !!v);
968
+ return Promise.resolve(untracked(parsedTitleSignal));
969
+ };
970
+ }
971
+
728
972
  /**
729
973
  * Generated bundle index. Do not edit.
730
974
  */
731
975
 
732
- export { LinkDirective, PreloadStrategy, createBreadcrumb, injectBreadcrumbs, injectTriggerPreload, provideBreadcrumbConfig, queryParam, url };
976
+ export { LinkDirective, PreloadStrategy, createBreadcrumb, createTitle, injectBreadcrumbs, injectTriggerPreload, provideBreadcrumbConfig, provideTitleConfig, queryParam, url };
733
977
  //# sourceMappingURL=mmstack-router-core.mjs.map