@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 +175 -0
- package/fesm2022/mmstack-router-core.mjs +318 -74
- package/fesm2022/mmstack-router-core.mjs.map +1 -1
- package/index.d.ts +1 -0
- package/lib/breadcrumb/breadcrumb.config.d.ts +60 -1
- package/lib/breadcrumb/breadcrumb.resolver.d.ts +50 -0
- package/lib/breadcrumb/breadcrumb.store.d.ts +31 -1
- package/lib/breadcrumb/breadcrumb.type.d.ts +30 -10
- package/lib/link.directive.d.ts +1 -1
- package/lib/title/public_api.d.ts +2 -0
- package/lib/title/title.config.d.ts +24 -0
- package/lib/title/title.store.d.ts +25 -0
- package/lib/util/index.d.ts +2 -0
- package/lib/util/leaf.store.d.ts +21 -0
- package/lib/util/snapshot-path.d.ts +2 -0
- package/package.json +3 -2
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|