@mmstack/router-core 19.3.14 → 19.3.15

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.
@@ -11,7 +11,7 @@ import { Title } from '@angular/platform-browser';
11
11
  /**
12
12
  * @internal
13
13
  */
14
- const token$1 = new InjectionToken('MMSTACK_BREADCRUMB_CONFIG');
14
+ const token$2 = new InjectionToken('@mmstack/router-core:breadcrumb-config');
15
15
  /**
16
16
  * Provides configuration for the breadcrumb system.
17
17
  * @param config - A partial `BreadcrumbConfig` object with the desired settings. *
@@ -43,7 +43,7 @@ const token$1 = new InjectionToken('MMSTACK_BREADCRUMB_CONFIG');
43
43
  */
44
44
  function provideBreadcrumbConfig(config) {
45
45
  return {
46
- provide: token$1,
46
+ provide: token$2,
47
47
  useValue: {
48
48
  ...config,
49
49
  },
@@ -53,7 +53,7 @@ function provideBreadcrumbConfig(config) {
53
53
  * @internal
54
54
  */
55
55
  function injectBreadcrumbConfig() {
56
- return (inject(token$1, {
56
+ return (inject(token$2, {
57
57
  optional: true,
58
58
  }) ?? {});
59
59
  }
@@ -95,8 +95,9 @@ function isNavigationEnd(e) {
95
95
  * }
96
96
  * ```
97
97
  */
98
- function url() {
99
- const router = inject(Router);
98
+ function url(router) {
99
+ if (!router)
100
+ router = inject(Router);
100
101
  return toSignal(router.events.pipe(filter(isNavigationEnd), map((e) => e.urlAfterRedirects)), {
101
102
  initialValue: router.url,
102
103
  });
@@ -515,10 +516,11 @@ function injectSnapshotPathResolver() {
515
516
  /**
516
517
  * Creates and registers a breadcrumb for a specific route.
517
518
  * This function is designed to be used as an Angular Route `ResolveFn`.
518
- * It handles the registration of the breadcrumb with the `BreadcrumbStore`
519
- * and ensures automatic deregistration when the route is destroyed.
520
519
  *
521
- * @param factory A function that returns a `CreateBreadcrumbOptions` object.
520
+ * Accepts a static label (`string`), a static options object, or a factory returning either —
521
+ * use a factory when you need `inject()` for dynamic data.
522
+ *
523
+ * @param factoryOrValue A static label, a static `CreateBreadcrumbOptions`, or a factory returning either.
522
524
  * @see CreateBreadcrumbOptions
523
525
  *
524
526
  * @example
@@ -528,25 +530,34 @@ function injectSnapshotPathResolver() {
528
530
  * path: 'home',
529
531
  * component: HomeComponent,
530
532
  * resolve: {
531
- * breadcrumb: createBreadcrumb(() => ({
532
- * label: 'Home',
533
- * });
533
+ * // shorthand for { label: 'Home' }
534
+ * breadcrumb: createBreadcrumb('Home'),
534
535
  * },
536
+ * },
537
+ * {
535
538
  * path: 'users/:userId',
536
539
  * component: UserProfileComponent,
537
540
  * resolve: {
538
541
  * breadcrumb: createBreadcrumb(() => {
539
542
  * const userStore = inject(UserStore);
540
543
  * return {
541
- * label: () => userStore.user().name ?? 'Loading...
542
- * };
543
- * })
544
+ * label: () => userStore.user().name ?? 'Loading...',
545
+ * };
546
+ * }),
544
547
  * },
545
- * }
548
+ * },
546
549
  * ];
547
550
  * ```
548
551
  */
549
- function createBreadcrumb(factory) {
552
+ function createBreadcrumb(factoryOrValue) {
553
+ const factory = typeof factoryOrValue === 'string'
554
+ ? () => ({ label: factoryOrValue })
555
+ : typeof factoryOrValue === 'function'
556
+ ? () => {
557
+ const result = factoryOrValue();
558
+ return typeof result === 'string' ? { label: result } : result;
559
+ }
560
+ : () => factoryOrValue;
550
561
  return async (route) => {
551
562
  const router = inject(Router);
552
563
  const store = inject(BreadcrumbStore);
@@ -650,6 +661,38 @@ function treeToSerializedUrl(router, urlTree) {
650
661
  return null;
651
662
  return router.serializeUrl(urlTree);
652
663
  }
664
+ /**
665
+ * Returns an imperative function that triggers preloading for an arbitrary link, using
666
+ * the same path resolution and {@link PreloadStrategy} pipeline as the {@link Link}
667
+ * (`mmLink`) directive.
668
+ *
669
+ * Use this when the `Link` directive isn't a fit — for example, preloading a route from
670
+ * an effect when a user opens a menu, hovers a non-link element, or reacts to a signal
671
+ * change — and you don't want to render an `<a [mmLink]>` just to request the preload.
672
+ *
673
+ * Requires {@link PreloadStrategy} to be wired up via `provideRouter(routes, withPreloading(PreloadStrategy))`,
674
+ * just like the directive.
675
+ *
676
+ * @returns A function accepting the same link descriptor shape as `mmLink` (`string`,
677
+ * commands array, `UrlTree`, or `null`). Passing `null` or an unresolvable link is a no-op.
678
+ *
679
+ * @example
680
+ * ```typescript
681
+ * @Component({ ... })
682
+ * export class CommandPaletteComponent {
683
+ * private readonly triggerPreload = injectTriggerPreload();
684
+ *
685
+ * protected readonly highlighted = signal<string | null>(null);
686
+ *
687
+ * constructor() {
688
+ * effect(() => {
689
+ * const target = this.highlighted();
690
+ * if (target) this.triggerPreload(target);
691
+ * });
692
+ * }
693
+ * }
694
+ * ```
695
+ */
653
696
  function injectTriggerPreload() {
654
697
  const req = inject(PreloadRequester);
655
698
  const router = inject(Router);
@@ -793,6 +836,229 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImpo
793
836
  ]]
794
837
  }] } });
795
838
 
839
+ /** @internal */
840
+ const token$1 = new InjectionToken('@mmstack/router-core:nav-config');
841
+ /**
842
+ * Provides global configuration for the nav system.
843
+ *
844
+ * @example
845
+ * ```typescript
846
+ * provideNavConfig({
847
+ * activeMatch: { queryParams: 'ignored' },
848
+ * }),
849
+ * ```
850
+ */
851
+ function provideNavConfig(config) {
852
+ return {
853
+ provide: token$1,
854
+ useValue: { ...config },
855
+ };
856
+ }
857
+ /** @internal */
858
+ function injectNavConfig() {
859
+ return inject(token$1, { optional: true }) ?? {};
860
+ }
861
+
862
+ /** @internal */
863
+ const NEVER_TRUE = computed(() => false);
864
+ function wrap(value, fallback) {
865
+ if (value === undefined)
866
+ return fallback ? fallback : computed(() => undefined);
867
+ if (typeof value === 'function')
868
+ return isSignal(value) ? value : computed(value);
869
+ return computed(() => value);
870
+ }
871
+ function isAbsoluteCommandArray(commands) {
872
+ return (commands.length > 0 &&
873
+ typeof commands[0] === 'string' &&
874
+ commands[0].startsWith('/'));
875
+ }
876
+ function resolveLinkTree(input, router, relativeTo) {
877
+ const raw = typeof input === 'function' ? input() : input;
878
+ if (raw === undefined || raw === null)
879
+ return null;
880
+ if (raw instanceof UrlTree)
881
+ return raw;
882
+ if (typeof raw === 'string') {
883
+ if (raw.startsWith('/'))
884
+ return router.parseUrl(raw);
885
+ const parsed = router.parseUrl('/' + raw);
886
+ const primary = parsed.root.children['primary'];
887
+ const segments = primary ? primary.segments.map((s) => s.path) : [];
888
+ return createUrlTreeFromSnapshot(relativeTo, segments, parsed.queryParams, parsed.fragment);
889
+ }
890
+ if (isAbsoluteCommandArray(raw))
891
+ return router.createUrlTree(raw);
892
+ return createUrlTreeFromSnapshot(relativeTo, raw);
893
+ }
894
+ function resolveMeta(input) {
895
+ if (input === undefined)
896
+ return {};
897
+ return typeof input === 'function' ? input() : input;
898
+ }
899
+ /**
900
+ * @internal
901
+ * Recursively builds an {@link InternalNavItem} tree from {@link CreateNavItem} input.
902
+ * Cascades parent `disabled`/`hidden` to descendants and computes `active` against the
903
+ * current router URL using `Router.isActive`.
904
+ */
905
+ function createInternalNavItem(input, router, relativeTo, configActiveMatch, parentDisabled, parentHidden, fallbackId) {
906
+ const label = wrap(input.label);
907
+ const ariaLabel = input.ariaLabel ? wrap(input.ariaLabel) : label;
908
+ const linkTree = computed(() => resolveLinkTree(input.link, router, relativeTo));
909
+ const link = computed(() => {
910
+ const tree = linkTree();
911
+ return tree ? router.serializeUrl(tree) : null;
912
+ });
913
+ const ownDisabled = wrap(input.disabled, NEVER_TRUE);
914
+ const ownHidden = wrap(input.hidden, NEVER_TRUE);
915
+ const disabled = computed(() => parentDisabled() || ownDisabled());
916
+ const hidden = computed(() => parentHidden() || ownHidden());
917
+ const metaInput = input.meta;
918
+ const meta = computed(() => resolveMeta(metaInput));
919
+ const id = input.id !== undefined
920
+ ? wrap(input.id)
921
+ : computed(() => link() ?? fallbackId);
922
+ const childItems = (input.children ?? []).map((childInput, i) => createInternalNavItem(childInput, router, relativeTo, configActiveMatch, disabled, hidden, `${fallbackId}.${i}`));
923
+ const children = computed(() => childItems.filter((c) => !c.hidden()));
924
+ const mergedActiveMatch = {
925
+ ...configActiveMatch,
926
+ ...input.activeMatch,
927
+ };
928
+ const trackNavigation = url(router);
929
+ const finalOptions = {
930
+ paths: 'subset',
931
+ fragment: 'ignored',
932
+ matrixParams: 'ignored',
933
+ queryParams: 'subset',
934
+ ...mergedActiveMatch,
935
+ };
936
+ const ownActive = computed(() => {
937
+ trackNavigation();
938
+ const tree = linkTree();
939
+ return tree ? router.isActive(tree, finalOptions) : false;
940
+ });
941
+ const orWithChildren = input.matchesWhenChildActive ?? input.activeMatch === undefined;
942
+ const active = computed(() => ownActive() ||
943
+ (orWithChildren &&
944
+ !!childItems.length &&
945
+ childItems.some((c) => c.active())));
946
+ return {
947
+ id,
948
+ label,
949
+ ariaLabel,
950
+ link,
951
+ active,
952
+ disabled,
953
+ hidden,
954
+ meta,
955
+ children,
956
+ };
957
+ }
958
+
959
+ /** @internal */
960
+ const DEFAULT_NAV_SCOPE = Symbol('mmstack.nav.default');
961
+ class NavStore {
962
+ map = mutable(new Map());
963
+ leafRoutes = injectLeafRoutes();
964
+ /** @internal */
965
+ register(scope, routePath, items) {
966
+ this.map.inline((m) => {
967
+ let scopeMap = m.get(scope);
968
+ if (!scopeMap) {
969
+ scopeMap = new Map();
970
+ m.set(scope, scopeMap);
971
+ }
972
+ scopeMap.set(routePath, items);
973
+ });
974
+ }
975
+ /** @internal */
976
+ scope(name) {
977
+ return computed(() => {
978
+ const scopeMap = this.map().get(name);
979
+ if (!scopeMap)
980
+ return [];
981
+ const leaves = this.leafRoutes();
982
+ for (let i = leaves.length - 1; i >= 0; i--) {
983
+ const items = scopeMap.get(leaves[i].path);
984
+ if (items) {
985
+ return items.filter((it) => !it.hidden());
986
+ }
987
+ }
988
+ return [];
989
+ });
990
+ }
991
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: NavStore, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
992
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: NavStore, providedIn: 'root' });
993
+ }
994
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: NavStore, decorators: [{
995
+ type: Injectable,
996
+ args: [{ providedIn: 'root' }]
997
+ }] });
998
+ /**
999
+ * Returns a reactive list of nav items for the requested scope.
1000
+ *
1001
+ * The returned signal reflects the nearest active ancestor route that registered items
1002
+ * for `name` via `createNavItems`. Hidden items are filtered out.
1003
+ *
1004
+ * @typeParam TMeta The shape of `NavItem.meta` for the consuming code. Untyped at the
1005
+ * registration site — this is a consumer-side assertion.
1006
+ *
1007
+ * @example
1008
+ * ```typescript
1009
+ * @Component({
1010
+ * template: `
1011
+ * @for (item of items(); track item) {
1012
+ * <a [href]="item.link()" [class.active]="item.active()">{{ item.label() }}</a>
1013
+ * }
1014
+ * `,
1015
+ * })
1016
+ * export class TopBar {
1017
+ * protected readonly items = injectNavItems();
1018
+ * }
1019
+ * ```
1020
+ */
1021
+ function injectNavItems(name) {
1022
+ const store = inject(NavStore);
1023
+ return store.scope(name ?? DEFAULT_NAV_SCOPE);
1024
+ }
1025
+
1026
+ /**
1027
+ * Registers a set of nav items for the activating route under the given scope.
1028
+ * Mirrors `createBreadcrumb` / `createTitle` — designed to be used in a route's
1029
+ * `resolve` map.
1030
+ *
1031
+ * Multiple scopes can be registered on a single route by giving each its own `name`
1032
+ * (and a unique key in the `resolve` map):
1033
+ *
1034
+ * ```typescript
1035
+ * resolve: {
1036
+ * mainNav: createNavItems([...], { name: 'main' }),
1037
+ * sideNav: createNavItems([...], { name: 'side' }),
1038
+ * }
1039
+ * ```
1040
+ *
1041
+ * Scope override semantics: when multiple routes in the active chain register items
1042
+ * under the same scope, the deepest active registration wins. Navigating away restores
1043
+ * the shallower registration.
1044
+ */
1045
+ function createNavItems(itemsOrFactory, options) {
1046
+ const factory = typeof itemsOrFactory === 'function'
1047
+ ? itemsOrFactory
1048
+ : () => itemsOrFactory;
1049
+ return async (route) => {
1050
+ const router = inject(Router);
1051
+ const store = inject(NavStore);
1052
+ const resolveRoutePath = injectSnapshotPathResolver();
1053
+ const config = injectNavConfig();
1054
+ const routePath = resolveRoutePath(route);
1055
+ const scope = options?.name ?? DEFAULT_NAV_SCOPE;
1056
+ const items = factory().map((input, i) => createInternalNavItem(input, router, route, config.activeMatch, NEVER_TRUE, NEVER_TRUE, `${routePath}#${i}`));
1057
+ store.register(scope, routePath, items);
1058
+ return Promise.resolve();
1059
+ };
1060
+ }
1061
+
796
1062
  /**
797
1063
  * Creates a WritableSignal that synchronizes with a specific URL query parameter,
798
1064
  * enabling two-way binding between the signal's state and the URL.
@@ -902,7 +1168,7 @@ function queryParam(key, route = inject(ActivatedRoute)) {
902
1168
  return toWritable(queryParam, set);
903
1169
  }
904
1170
 
905
- const token = new InjectionToken('MMSTACK_TITLE_CONFIG');
1171
+ const token = new InjectionToken('@mmstack/router-core:title-config');
906
1172
  /**
907
1173
  * used to provide the title configuration, will not be applied unless a `createTitle` resolver is used
908
1174
  */
@@ -914,6 +1180,7 @@ function provideTitleConfig(config) {
914
1180
  return {
915
1181
  provide: token,
916
1182
  useValue: {
1183
+ initialTitle: config?.initialTitle ?? '',
917
1184
  parser: prefixFn,
918
1185
  keepLastKnown: config?.keepLastKnownTitle ?? true,
919
1186
  },
@@ -926,25 +1193,28 @@ function injectTitleConfig() {
926
1193
  return (inject(token, {
927
1194
  optional: true,
928
1195
  }) ?? {
1196
+ initialTitle: '',
929
1197
  parser: identity,
930
1198
  keepLastKnown: true,
931
1199
  });
932
1200
  }
933
1201
 
934
1202
  class TitleStore {
935
- title = inject(Title);
936
1203
  map = mutable(new Map());
937
- leafRoutes = injectLeafRoutes();
938
1204
  constructor() {
939
- const reverseLeaves = computed(() => this.leafRoutes().toReversed());
1205
+ const { keepLastKnown, initialTitle } = injectTitleConfig();
1206
+ const leafRoutes = injectLeafRoutes();
1207
+ const title = inject(Title);
1208
+ const fallbackTitle = initialTitle || untracked(() => title.getTitle());
1209
+ const reverseLeaves = computed(() => leafRoutes().toReversed());
940
1210
  const currentResolvedTitles = computed(() => {
941
1211
  const map = this.map();
942
1212
  return reverseLeaves()
943
- .map((leaf) => map.get(leaf.path)?.() ?? leaf.route.title)
944
- .filter((v) => !!v);
1213
+ .map((leaf) => map.get(leaf.path)?.() ?? leaf.route.title ?? null)
1214
+ .filter((v) => v !== null);
945
1215
  });
946
1216
  const currentTitle = computed(() => currentResolvedTitles().at(0) ?? '');
947
- const heldTitle = injectTitleConfig().keepLastKnown
1217
+ const heldTitle = keepLastKnown
948
1218
  ? linkedSignal({
949
1219
  source: () => currentTitle(),
950
1220
  computation: (value, prev) => {
@@ -955,7 +1225,7 @@ class TitleStore {
955
1225
  })
956
1226
  : currentTitle;
957
1227
  effect(() => {
958
- this.title.setTitle(heldTitle());
1228
+ title.setTitle(heldTitle() || fallbackTitle);
959
1229
  });
960
1230
  }
961
1231
  register(id, titleFn) {
@@ -974,19 +1244,20 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImpo
974
1244
  *
975
1245
  * Creates a title resolver function that can be used in Angular's router.
976
1246
  *
977
- * @param fn
978
- * A function that returns a string or a Signal<string> representing the title.
1247
+ * @param factoryOrValue
1248
+ * A function that returns a string or a Signal<string> representing the title or just the string directly.
979
1249
  * @param awaitValue
980
1250
  * If `true`, the resolver will wait until the title signal has a value before resolving.
981
1251
  * Defaults to `false`.
982
1252
  */
983
- function createTitle(fn, awaitValue = false) {
1253
+ function createTitle(factoryOrValue, awaitValue = false) {
1254
+ const factory = typeof factoryOrValue === 'string' ? () => factoryOrValue : factoryOrValue;
984
1255
  return async (route) => {
985
1256
  const store = inject(TitleStore);
986
1257
  const resolver = injectSnapshotPathResolver();
987
1258
  const fp = resolver(route);
988
1259
  const { parser } = injectTitleConfig();
989
- const resolved = fn();
1260
+ const resolved = factory();
990
1261
  const titleSignal = typeof resolved === 'string'
991
1262
  ? computed(() => resolved)
992
1263
  : computed(resolved);
@@ -1002,5 +1273,5 @@ function createTitle(fn, awaitValue = false) {
1002
1273
  * Generated bundle index. Do not edit.
1003
1274
  */
1004
1275
 
1005
- export { Link, PreloadStrategy, createBreadcrumb, createTitle, injectBreadcrumbs, injectTriggerPreload, provideBreadcrumbConfig, provideMMLinkDefaultConfig, provideTitleConfig, queryParam, url };
1276
+ export { Link, PreloadStrategy, createBreadcrumb, createNavItems, createTitle, injectBreadcrumbs, injectNavItems, injectTriggerPreload, provideBreadcrumbConfig, provideMMLinkDefaultConfig, provideNavConfig, provideTitleConfig, queryParam, url };
1006
1277
  //# sourceMappingURL=mmstack-router-core.mjs.map