@mmstack/router-core
Version:
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.
1,001 lines (983 loc) • 40.4 kB
JavaScript
import * as i0 from '@angular/core';
import { InjectionToken, inject, computed, Injectable, input, booleanAttribute, output, untracked, effect, HostListener, Directive, isSignal, linkedSignal } from '@angular/core';
import * as i1 from '@angular/router';
import { EventType, Router, PRIMARY_OUTLET, createUrlTreeFromSnapshot, UrlTree, RouterLink, RouterLinkWithHref, ActivatedRoute } from '@angular/router';
import { mutable, mapArray, until, elementVisibility, toWritable } from '@mmstack/primitives';
import { toSignal } from '@angular/core/rxjs-interop';
import { filter, map } from 'rxjs/operators';
import { Subject, EMPTY, filter as filter$1, take, switchMap, finalize } from 'rxjs';
import { Title } from '@angular/platform-browser';
/**
* @internal
*/
const token$1 = new InjectionToken('MMSTACK_BREADCRUMB_CONFIG');
/**
* Provides configuration for the breadcrumb system.
* @param config - A partial `BreadcrumbConfig` object with the desired settings. *
* @see BreadcrumbConfig
* @example
* ```typescript
* // In your app.module.ts or a standalone component's providers:
* // import { provideBreadcrumbConfig } from './breadcrumb.config'; // Adjust path
* // import { ResolvedLeafRoute } from './breadcrumb.type'; // Adjust path
*
* // const customLabelStrategy: GenerateBreadcrumbFn = () => {
* // return (leaf: ResolvedLeafRoute): string => {
* // // Example: Prioritize a 'navTitle' data property
* // if (leaf.route.data?.['navTitle']) {
* // return leaf.route.data['navTitle'];
* // }
* // // Fallback to a default mechanism
* // return leaf.route.title || leaf.segment.resolved || 'Unnamed';
* // };
* // };
*
* export const appConfig = [
* // ...rest
* provideBreadcrumbConfig({
* generation: customLabelStrategy, // or 'manual' to disable auto-generation
* }),
* ]
* ```
*/
function provideBreadcrumbConfig(config) {
return {
provide: token$1,
useValue: {
...config,
},
};
}
/**
* @internal
*/
function injectBreadcrumbConfig() {
return (inject(token$1, {
optional: true,
}) ?? {});
}
/**
* Type guard to check if a Router Event is a NavigationEnd event.
* @internal
*/
function isNavigationEnd(e) {
return 'type' in e && e.type === EventType.NavigationEnd;
}
/**
* Creates a Signal that tracks the current router URL.
*
* The signal emits the URL string reflecting the router state *after* redirects
* have completed for each successful navigation. It initializes with the router's
* current URL state.
*
* @returns {Signal<string>} A Signal emitting the `urlAfterRedirects` upon successful navigation.
*
* @example
* ```ts
* import { Component, effect } from '@angular/core';
* import { url } from '@mmstack/router-core'; // Adjust import path
*
* @Component({
* selector: 'app-root',
* template: `Current URL: {{ currentUrl() }}`
* })
* export class AppComponent {
* currentUrl = url();
*
* constructor() {
* effect(() => {
* console.log('Navigation ended. New URL:', this.currentUrl());
* // e.g., track page view with analytics
* });
* }
* }
* ```
*/
function url() {
const router = inject(Router);
return toSignal(router.events.pipe(filter(isNavigationEnd), map((e) => e.urlAfterRedirects)), {
initialValue: router.url,
});
}
function leafRoutes() {
const router = inject(Router);
const getLeafRoutes = (snapshot) => {
const routes = [];
let route = snapshot.root;
const processed = new Set();
while (route) {
const allSegments = route.pathFromRoot.flatMap((snap) => snap.routeConfig?.path ?? []);
const segments = allSegments.filter(Boolean);
const path = router.serializeUrl(router.parseUrl(segments.join('/')));
if (processed.has(path)) {
route = route.firstChild;
continue;
}
processed.add(path);
const parts = route.pathFromRoot
.flatMap((snap) => snap.url ?? [])
.map((u) => u.path)
.filter(Boolean);
const link = router.serializeUrl(router.parseUrl(parts.join('/')));
routes.push({
route,
segment: {
path: segments.at(-1) ?? '',
resolved: parts.at(-1) ?? '',
},
path,
link,
});
route = route.firstChild;
}
return routes;
};
const currentUrl = url();
const leafRoutes = computed(() => {
currentUrl();
return getLeafRoutes(router.routerState.snapshot);
}, ...(ngDevMode ? [{ debugName: "leafRoutes" }] : []));
return leafRoutes;
}
class RouteLeafStore {
leaves = leafRoutes();
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: RouteLeafStore, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: RouteLeafStore, providedIn: 'root' });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: RouteLeafStore, decorators: [{
type: Injectable,
args: [{
providedIn: 'root',
}]
}] });
function injectLeafRoutes() {
const store = inject(RouteLeafStore);
return store.leaves;
}
/**
* @internal
*/
const INTERNAL_BREADCRUMB_SYMBOL = Symbol.for('MMSTACK_INTERNAL_BREADCRUMB');
/**
* @internal
*/
function getBreadcrumbInternals(breadcrumb) {
return breadcrumb[INTERNAL_BREADCRUMB_SYMBOL];
}
/**
* @internal
*/
function createInternalBreadcrumb(bc, active, registered = true) {
return {
...bc,
[INTERNAL_BREADCRUMB_SYMBOL]: {
active,
registered,
},
};
}
/**
* @internal
*/
function isInternalBreadcrumb(breadcrumb) {
return !!breadcrumb[INTERNAL_BREADCRUMB_SYMBOL];
}
function uppercaseFirst(str) {
const lcs = str.toLowerCase();
return lcs.charAt(0).toUpperCase() + lcs.slice(1);
}
function removeMatrixAndQueryParams(path) {
const [cleanPath] = path.split(';');
return cleanPath.split('?')[0];
}
function parsePathSegment$1(pathSegment) {
return pathSegment
.split('/')
.flatMap((part) => part.split('.'))
.flatMap((part) => part.split('-'))
.map((part) => uppercaseFirst(removeMatrixAndQueryParams(part)))
.join(' ');
}
function generateLabel(leaf) {
const title = leaf.route.title ?? leaf.route.data?.['title'];
if (title && typeof title === 'string')
return title;
if (leaf.segment.path.includes(':'))
return leaf.segment.resolved;
return parsePathSegment$1(leaf.segment.path);
}
function autoGenerateBreadcrumb(id, leaf, autoGenerateFn) {
const label = computed(() => autoGenerateFn()(leaf()), ...(ngDevMode ? [{ debugName: "label" }] : []));
return createInternalBreadcrumb({
id,
label,
ariaLabel: label,
link: computed(() => leaf().link),
}, computed(() => leaf().route.data?.['skipBreadcrumb'] !== true &&
id !== '' &&
id !== '/' &&
leaf().segment.path !== '' &&
leaf().segment.path !== '/' &&
!leaf().segment.path.endsWith('/') &&
!!label()));
}
function injectGenerateLabelFn() {
const { generation } = injectBreadcrumbConfig();
if (typeof generation !== 'function')
return computed(() => generateLabel);
const provided = generation();
return computed(() => provided);
}
function injectIsManual() {
return injectBreadcrumbConfig().generation === 'manual';
}
function exposeActiveSignal(crumbSignal, manual) {
const active = manual
? computed(() => {
const crumb = crumbSignal();
return (isInternalBreadcrumb(crumb) &&
getBreadcrumbInternals(crumb).registered &&
getBreadcrumbInternals(crumb).active());
})
: computed(() => {
const crumb = crumbSignal();
if (!isInternalBreadcrumb(crumb))
return true;
return getBreadcrumbInternals(crumb).active();
});
const sig = crumbSignal;
sig.active = active;
return sig;
}
class BreadcrumbStore {
map = mutable(new Map());
isManual = injectIsManual();
autoGenerateLabelFn = injectGenerateLabelFn();
leafRoutes = injectLeafRoutes();
all = mapArray(this.leafRoutes, (leaf) => {
const stableId = computed(() => leaf().path, ...(ngDevMode ? [{ debugName: "stableId" }] : []));
return exposeActiveSignal(computed(() => {
const id = stableId();
const found = this.map().get(id);
if (!found)
return autoGenerateBreadcrumb(id, leaf, this.autoGenerateLabelFn);
if (!id.includes(':'))
return found;
return {
...found,
link: computed(() => leaf().link),
};
}, {
equal: (a, b) => a.id === b.id,
}), this.isManual);
}, {
equal: (a, b) => a.link === b.link,
});
crumbs = computed(() => this.all().filter((c) => c.active()), ...(ngDevMode ? [{ debugName: "crumbs" }] : []));
unwrapped = computed(() => this.crumbs().map((c) => c()), ...(ngDevMode ? [{ debugName: "unwrapped" }] : []));
register(breadcrumb) {
this.map.inline((m) => m.set(breadcrumb.id, breadcrumb));
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: BreadcrumbStore, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: BreadcrumbStore, providedIn: 'root' });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: BreadcrumbStore, decorators: [{
type: Injectable,
args: [{
providedIn: 'root',
}]
}] });
/**
* Injects and provides access to a reactive list of breadcrumbs.
*
* The breadcrumbs are ordered and reflect the current active navigation path.
* @see Breadcrumb
* @returns `Signal<Breadcrumb[]>`
*
* @example
* ```typescript
* @Component({
* selector: 'app-breadcrumbs',
* template: `
* <nav aria-label="breadcrumb">
* <ol>
* @for (crumb of breadcrumbs(); track crumb.id) {
* <li>
* <a [href]="crumb.link()" [attr.aria-label]="crumb.ariaLabel()">{{ crumb.label() }}</a>
* </li>
* }
* </ol>
* </nav>
* `
* })
* export class MyBreadcrumbsComponent {
* breadcrumbs = injectBreadcrumbs();
* }
* ```
*/
function injectBreadcrumbs() {
const store = inject(BreadcrumbStore);
return store.unwrapped;
}
function parsePathSegment(segmentString) {
const parts = segmentString.split(';');
const pathPart = parts[0];
const matrixParams = {};
for (let i = 1; i < parts.length; i++) {
const [key, value = 'true'] = parts[i].split('=');
if (key) {
matrixParams[key] = value;
}
}
return { pathPart, matrixParams };
}
function createBasePredicate(path) {
const partPredicates = path
.split('/')
.filter((part) => !!part.trim())
.map((configSegmentString) => {
const { pathPart: configPathPart, matrixParams: configMatrixParams } = parsePathSegment(configSegmentString);
let singlePathPartPredicate;
if (configPathPart.startsWith(':')) {
singlePathPartPredicate = () => true;
}
else {
singlePathPartPredicate = (linkSegmentPathPart) => linkSegmentPathPart === configPathPart;
}
const configSegmentHasMatrixParams = Object.keys(configMatrixParams).length > 0;
return (linkSegmentString) => {
const { pathPart: linkPathPart, matrixParams: linkMatrixParams } = parsePathSegment(linkSegmentString);
if (!singlePathPartPredicate(linkPathPart)) {
return false;
}
if (!configSegmentHasMatrixParams) {
return true;
}
return Object.entries(configMatrixParams).every(([key, value]) => Object.prototype.hasOwnProperty.call(linkMatrixParams, key) &&
linkMatrixParams[key] === value);
};
});
return (path) => {
const linkPathOnly = path.split(/[?#]/).at(0) ?? '';
if (!linkPathOnly && partPredicates.length > 0)
return false;
if (!linkPathOnly && partPredicates.length === 0)
return true;
const parts = linkPathOnly.split('/').filter((part) => !!part.trim());
if (parts.length < partPredicates.length)
return false;
return parts.every((seg, idx) => {
const pred = partPredicates.at(idx);
if (!pred)
return true;
return pred(seg);
});
};
}
function singleSegmentMatches(configSegment, linkSegment) {
if (configSegment.pathPart === ':') {
return true;
}
else if (configSegment.pathPart !== linkSegment.pathPart) {
return false;
}
const configMatrix = configSegment.matrixParams;
const linkMatrix = linkSegment.matrixParams;
for (const key in configMatrix) {
if (!Object.prototype.hasOwnProperty.call(linkMatrix, key) ||
linkMatrix[key] !== configMatrix[key]) {
return false;
}
}
return true;
}
function matchSegmentsRecursive(configSegments, linkSegments, configIdx, linkIdx) {
if (configIdx === configSegments.length) {
return linkIdx === linkSegments.length;
}
if (linkIdx === linkSegments.length) {
for (let i = configIdx; i < configSegments.length; i++) {
if (configSegments[i].pathPart !== '**') {
return false;
}
}
return true;
}
const currentConfigSegment = configSegments[configIdx];
if (currentConfigSegment.pathPart === '**') {
if (matchSegmentsRecursive(configSegments, linkSegments, configIdx + 1, linkIdx)) {
return true;
}
if (linkIdx < linkSegments.length) {
if (matchSegmentsRecursive(configSegments, linkSegments, configIdx, linkIdx + 1)) {
return true;
}
}
return false;
}
else {
if (linkIdx < linkSegments.length &&
singleSegmentMatches(currentConfigSegment, linkSegments[linkIdx])) {
return matchSegmentsRecursive(configSegments, linkSegments, configIdx + 1, linkIdx + 1);
}
return false;
}
}
function createWildcardPredicate(path) {
const configSegments = path
.split('/')
.filter((p) => !!p.trim())
.map((segment) => parsePathSegment(segment));
return (linkPath) => {
const linkPathOnly = linkPath.split(/[?#]/).at(0) ?? '';
const linkSegments = linkPathOnly
.split('/')
.filter((p) => !!p.trim())
.map((segment) => parsePathSegment(segment));
return matchSegmentsRecursive(configSegments, linkSegments, 0, 0);
};
}
function createRoutePredicate(path) {
return path.includes('**')
? createWildcardPredicate(path)
: createBasePredicate(path);
}
// The following functions are adapted from ngx-quicklink,
// (https://github.com/mgechev/ngx-quicklink)
// Copyright (c) Minko Gechev and contributors, licensed under the MIT License.
function isPrimaryRoute(route) {
return route.outlet === PRIMARY_OUTLET || !route.outlet;
}
const findPath = (config, route) => {
const configQueue = config.slice();
const parent = new Map();
const visited = new Set();
while (configQueue.length) {
const el = configQueue.shift();
if (!el) {
continue;
}
visited.add(el);
if (el === route) {
break;
}
(el.children || []).forEach((childRoute) => {
if (!visited.has(childRoute)) {
parent.set(childRoute, el);
configQueue.push(childRoute);
}
});
const lazyRoutes = el._loadedRoutes || [];
if (Array.isArray(lazyRoutes)) {
lazyRoutes.forEach((lazyRoute) => {
if (lazyRoute && !visited.has(lazyRoute)) {
parent.set(lazyRoute, el);
configQueue.push(lazyRoute);
}
});
}
}
let path = '';
let currentRoute = route;
while (currentRoute) {
const currentPath = currentRoute.path || '';
if (isPrimaryRoute(currentRoute)) {
path = `/${currentPath}${path}`;
}
else {
path = `/(${currentRoute.outlet}:${currentPath})${path}`;
}
currentRoute = parent.get(currentRoute);
}
let normalizedPath = path.replaceAll(/\/+/g, '/');
if (normalizedPath !== '/' && normalizedPath.endsWith('/')) {
normalizedPath = normalizedPath.slice(0, -1);
}
return normalizedPath;
};
function injectSnapshotPathResolver() {
const router = inject(Router);
return (route) => {
const segments = route.pathFromRoot.flatMap((snap) => snap.routeConfig?.path ?? []);
const joinedSegments = segments.filter(Boolean).join('/');
return router.serializeUrl(router.parseUrl(joinedSegments));
};
}
/**
* Creates and registers a breadcrumb for a specific route.
* This function is designed to be used as an Angular Route `ResolveFn`.
* It handles the registration of the breadcrumb with the `BreadcrumbStore`
* and ensures automatic deregistration when the route is destroyed.
*
* @param factory A function that returns a `CreateBreadcrumbOptions` object.
* @see CreateBreadcrumbOptions
*
* @example
* ```typescript
* export const appRoutes: Routes = [
* {
* path: 'home',
* component: HomeComponent,
* resolve: {
* breadcrumb: createBreadcrumb(() => ({
* label: 'Home',
* });
* },
* path: 'users/:userId',
* component: UserProfileComponent,
* resolve: {
* breadcrumb: createBreadcrumb(() => {
* const userStore = inject(UserStore);
* return {
* label: () => userStore.user().name ?? 'Loading...
* };
* })
* },
* }
* ];
* ```
*/
function createBreadcrumb(factory) {
return async (route) => {
const router = inject(Router);
const store = inject(BreadcrumbStore);
const resolver = injectSnapshotPathResolver();
const fp = resolver(route);
const tree = createUrlTreeFromSnapshot(route, [], route.queryParams, route.fragment);
const provided = factory();
const link = computed(() => router.serializeUrl(tree), ...(ngDevMode ? [{ debugName: "link" }] : []));
const { label, ariaLabel = label } = provided;
const bc = {
id: fp,
ariaLabel: typeof ariaLabel === 'string'
? computed(() => ariaLabel)
: computed(ariaLabel),
label: typeof label === 'string' ? computed(() => label) : computed(label),
link,
};
store.register(createInternalBreadcrumb(bc, computed(() => route.data?.['skipBreadcrumb'] !== true)));
if (provided.awaitValue)
await until(bc.label, (v) => !!v);
return Promise.resolve();
};
}
class PreloadRequester {
preloadOnDemand$ = new Subject();
preloadRequested$ = this.preloadOnDemand$.asObservable();
startPreload(routePath) {
this.preloadOnDemand$.next(routePath);
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: PreloadRequester, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: PreloadRequester, providedIn: 'root' });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: PreloadRequester, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}] });
function hasSlowConnection() {
if (globalThis.window &&
'navigator' in globalThis.window &&
'connection' in globalThis.window.navigator &&
typeof globalThis.window.navigator.connection === 'object' &&
!!globalThis.window.navigator.connection) {
const is2g = 'effectiveType' in globalThis.window.navigator.connection &&
typeof globalThis.window.navigator.connection.effectiveType ===
'string' &&
globalThis.window.navigator.connection.effectiveType.endsWith('2g');
if (is2g)
return true;
if ('saveData' in globalThis.window.navigator.connection &&
typeof globalThis.window.navigator.connection.saveData === 'boolean' &&
globalThis.window.navigator.connection.saveData)
return true;
}
return false;
}
function noPreload(route) {
return route.data && route.data['preload'] === false;
}
class PreloadStrategy {
loading = new Set();
router = inject(Router);
req = inject(PreloadRequester);
preload(route, load) {
if (noPreload(route) || hasSlowConnection())
return EMPTY;
const fp = findPath(this.router.config, route);
if (this.loading.has(fp))
return EMPTY;
const predicate = createRoutePredicate(fp);
return this.req.preloadRequested$.pipe(filter$1((path) => path === fp || predicate(path)), take(1), switchMap(() => load()), finalize(() => this.loading.delete(fp)));
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: PreloadStrategy, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: PreloadStrategy, providedIn: 'root' });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: PreloadStrategy, decorators: [{
type: Injectable,
args: [{
providedIn: 'root',
}]
}] });
function inputToUrlTree(router, link, relativeTo, queryParams, fragment, queryParamsHandling, routerLinkUrlTree) {
if (!link)
return null;
if (routerLinkUrlTree)
return routerLinkUrlTree;
if (link instanceof UrlTree)
return link;
const arr = Array.isArray(link) ? link : [link];
return router.createUrlTree(arr, {
relativeTo,
queryParams,
fragment,
queryParamsHandling,
});
}
function treeToSerializedUrl(router, urlTree) {
if (!urlTree)
return null;
return router.serializeUrl(urlTree);
}
function injectTriggerPreload() {
const req = inject(PreloadRequester);
const router = inject(Router);
return (link, relativeTo, queryParams, fragment, queryParamsHandling) => {
const urlTree = inputToUrlTree(router, link, relativeTo, queryParams, fragment, queryParamsHandling);
const fullPath = treeToSerializedUrl(router, urlTree);
if (!fullPath)
return;
req.startPreload(fullPath);
};
}
const configToken = new InjectionToken('MMSTACK_LINK_CONFIG');
function provideMMLinkDefaultConfig(config) {
const cfg = {
preloadOn: 'hover',
useMouseDown: false,
...config,
};
return {
provide: configToken,
useValue: cfg,
};
}
function injectConfig() {
const cfg = inject(configToken, { optional: true });
return {
preloadOn: 'hover',
useMouseDown: false,
...cfg,
};
}
class Link {
routerLink = inject(RouterLink, {
self: true,
optional: true,
}) ?? inject(RouterLinkWithHref, { self: true, optional: true });
req = inject(PreloadRequester);
router = inject(Router);
target = input(...(ngDevMode ? [undefined, { debugName: "target" }] : []));
queryParams = input(...(ngDevMode ? [undefined, { debugName: "queryParams" }] : []));
fragment = input(...(ngDevMode ? [undefined, { debugName: "fragment" }] : []));
queryParamsHandling = input(...(ngDevMode ? [undefined, { debugName: "queryParamsHandling" }] : []));
state = input(...(ngDevMode ? [undefined, { debugName: "state" }] : []));
info = input(...(ngDevMode ? [undefined, { debugName: "info" }] : []));
relativeTo = input(...(ngDevMode ? [undefined, { debugName: "relativeTo" }] : []));
skipLocationChange = input(false, ...(ngDevMode ? [{ debugName: "skipLocationChange", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
replaceUrl = input(false, ...(ngDevMode ? [{ debugName: "replaceUrl", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
mmLink = input(null, ...(ngDevMode ? [{ debugName: "mmLink" }] : []));
preloadOn = input(injectConfig().preloadOn, ...(ngDevMode ? [{ debugName: "preloadOn" }] : []));
useMouseDown = input(injectConfig().useMouseDown, ...(ngDevMode ? [{ debugName: "useMouseDown", transform: booleanAttribute }] : [{
transform: booleanAttribute,
}]));
beforeNavigate = input(...(ngDevMode ? [undefined, { debugName: "beforeNavigate" }] : []));
preloading = output();
urlTree = computed(() => {
return inputToUrlTree(this.router, this.mmLink(), this.relativeTo(), this.queryParams(), this.fragment(), this.queryParamsHandling(), this.routerLink?.urlTree);
}, ...(ngDevMode ? [{ debugName: "urlTree" }] : []));
fullPath = computed(() => {
return treeToSerializedUrl(this.router, this.urlTree());
}, ...(ngDevMode ? [{ debugName: "fullPath" }] : []));
onHover() {
if (untracked(this.preloadOn) !== 'hover')
return;
this.requestPreload();
}
onMouseDown(button, ctrlKey, shiftKey, altKey, metaKey) {
if (!untracked(this.useMouseDown))
return;
return this.trigger(button, ctrlKey, shiftKey, altKey, metaKey);
}
onClick(button, ctrlKey, shiftKey, altKey, metaKey) {
if (untracked(this.useMouseDown))
return;
return this.trigger(button, ctrlKey, shiftKey, altKey, metaKey);
}
constructor() {
const intersection = elementVisibility();
effect(() => {
if (this.preloadOn() !== 'visible')
return;
if (intersection.visible())
this.requestPreload();
});
}
requestPreload() {
const fp = untracked(this.fullPath);
if (!this.routerLink || !fp)
return;
this.req.startPreload(fp);
this.preloading.emit();
}
trigger(button, ctrlKey, shiftKey, altKey, metaKey) {
untracked(this.beforeNavigate)?.();
return this.routerLink?.onClick(button, ctrlKey, shiftKey, altKey, metaKey);
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: Link, deps: [], target: i0.ɵɵFactoryTarget.Directive });
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "20.3.1", type: Link, 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 }, useMouseDown: { classPropertyName: "useMouseDown", publicName: "useMouseDown", isSignal: true, isRequired: false, transformFunction: null }, beforeNavigate: { classPropertyName: "beforeNavigate", publicName: "beforeNavigate", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { preloading: "preloading" }, host: { listeners: { "mouseenter": "onHover()", "mousedown": "onMouseDown($event.button,$event.ctrlKey,$event.shiftKey,$event.altKey,$event.metaKey)", "click": "onClick($event.button,$event.ctrlKey,$event.shiftKey,$event.altKey,$event.metaKey)" } }, 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 });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: Link, decorators: [{
type: Directive,
args: [{
selector: '[mmLink]',
exportAs: 'mmLink',
host: {
'(mouseenter)': 'onHover()',
},
hostDirectives: [
{
directive: RouterLink,
inputs: [
'routerLink: mmLink',
'target',
'queryParams',
'fragment',
'queryParamsHandling',
'state',
'relativeTo',
'skipLocationChange',
'replaceUrl',
],
},
],
}]
}], ctorParameters: () => [], propDecorators: { onMouseDown: [{
type: HostListener,
args: ['mousedown', [
'$event.button',
'$event.ctrlKey',
'$event.shiftKey',
'$event.altKey',
'$event.metaKey',
]]
}], onClick: [{
type: HostListener,
args: ['click', [
'$event.button',
'$event.ctrlKey',
'$event.shiftKey',
'$event.altKey',
'$event.metaKey',
]]
}] } });
/**
* Creates a WritableSignal that synchronizes with a specific URL query parameter,
* enabling two-way binding between the signal's state and the URL.
*
* Reading the signal provides the current value of the query parameter (or null if absent).
* Setting the signal updates the URL query parameter using `Router.navigate`, triggering
* navigation and causing the signal to update reactively if the navigation is successful.
*
* @param key The key of the query parameter to synchronize with.
* Can be a static string (e.g., `'search'`) or a function/signal returning a string
* for dynamic keys (e.g., `() => this.userId() + '_filter'` or `computed(() => this.category() + '_sort')`).
* The signal will reactively update if the key returned by the function/signal changes.
* @returns {WritableSignal<string | null>} A signal representing the query parameter's value.
* - Reading returns the current value string, or `null` if the parameter is absent in the URL.
* - Setting the signal to a string updates the query parameter in the URL (e.g., `signal.set('value')` results in `?key=value`).
* - Setting the signal to `null` removes the query parameter from the URL (e.g., `signal.set(null)` results in `?otherParam=...`).
* - Automatically reflects changes if the query parameters update due to external navigation.
* @remarks
* - Requires Angular's `ActivatedRoute` and `Router` to be available in the injection context.
* - Uses `Router.navigate` with `queryParamsHandling: 'merge'` to preserve other existing query parameters during updates.
* - Handles dynamic keys reactively. If the result of the `key` function/signal changes, the signal will start reflecting the value of the *new* query parameter key.
* - During Server-Side Rendering (SSR), it reads the initial value from the route snapshot. Write operations (`set`) might have limited or no effect on the server depending on the platform configuration.
*
* @example
* ```ts
* import { Component, computed, effect, signal } from '@angular/core';
* import { queryParam } from '@mmstack/router-core'; // Adjust import path as needed
* // import { FormsModule } from '@angular/forms'; // If using ngModel
*
* @Component({
* selector: 'app-product-list',
* standalone: true,
* // imports: [FormsModule], // If using ngModel
* template: `
* <div>
* Sort By:
* <select [value]="sortSignal() ?? ''" (change)="sortSignal.set($any($event.target).value || null)">
* <option value="">Default</option>
* <option value="price_asc">Price Asc</option>
* <option value="price_desc">Price Desc</option>
* <option value="name">Name</option>
* </select>
* <button (click)="sortSignal.set(null)" [disabled]="!sortSignal()">Clear Sort</button>
* </div>
* <div>
* Page:
* <input type="number" min="1" [value]="pageSignal() ?? '1'" #p (input)="setPage(p.value)"/>
* </div>
* * `
* })
* export class ProductListComponent {
* // Two-way bind the 'sort' query parameter (?sort=...)
* // Defaults to null if param is missing
* sortSignal = queryParam('sort');
*
* // Example with a different type (needs serialization or separate logic)
* // For simplicity, we treat page as string | null here
* pageSignal = queryParam('page');
*
* constructor() {
* effect(() => {
* const currentSort = this.sortSignal();
* const currentPage = this.pageSignal(); // Read as string | null
* console.log('Sort/Page changed, reloading products for:', { sort: currentSort, page: currentPage });
* // --- Fetch data based on currentSort and currentPage ---
* });
* }
*
* setPage(value: string): void {
* const pageNum = parseInt(value, 10);
* // Set to null if page is 1 (to remove param), otherwise set string value
* this.pageSignal.set(isNaN(pageNum) || pageNum <= 1 ? null : pageNum.toString());
* }
* }
* ```
*/
function queryParam(key) {
const route = inject(ActivatedRoute);
const router = inject(Router);
const keySignal = typeof key === 'string'
? computed(() => key)
: isSignal(key)
? key
: computed(key);
const queryParamMap = toSignal(route.queryParamMap, {
initialValue: route.snapshot.queryParamMap,
});
const queryParams = toSignal(route.queryParams, {
initialValue: route.snapshot.queryParams,
});
const queryParam = computed(() => queryParamMap().get(keySignal()), ...(ngDevMode ? [{ debugName: "queryParam" }] : []));
const set = (newValue) => {
const next = {
...untracked(queryParams),
};
const key = untracked(keySignal);
if (newValue === null) {
delete next[key];
}
else {
next[key] = newValue;
}
router.navigate([], {
relativeTo: route,
queryParams: next,
queryParamsHandling: 'merge',
});
};
return toWritable(queryParam, set);
}
const token = new InjectionToken('MMSTACK_TITLE_CONFIG');
/**
* used to provide the title configuration, will not be applied unless a `createTitle` resolver is used
*/
function provideTitleConfig(config) {
const prefix = config?.prefix ?? '';
const prefixFn = typeof prefix === 'function'
? prefix
: (title) => `${prefix}${title}`;
return {
provide: token,
useValue: {
parser: prefixFn,
keepLastKnown: config?.keepLastKnownTitle ?? true,
},
};
}
function injectTitleConfig() {
return inject(token);
}
class TitleStore {
title = inject(Title);
map = mutable(new Map());
leafRoutes = injectLeafRoutes();
constructor() {
const reverseLeaves = computed(() => this.leafRoutes().toReversed(), ...(ngDevMode ? [{ debugName: "reverseLeaves" }] : []));
const currentResolvedTitles = computed(() => {
const map = this.map();
return reverseLeaves()
.map((leaf) => map.get(leaf.path)?.() ?? leaf.route.title)
.filter((v) => !!v);
}, ...(ngDevMode ? [{ debugName: "currentResolvedTitles" }] : []));
const currentTitle = computed(() => currentResolvedTitles().at(0) ?? '', ...(ngDevMode ? [{ debugName: "currentTitle" }] : []));
const heldTitle = injectTitleConfig().keepLastKnown
? linkedSignal({
source: () => currentTitle(),
computation: (value, prev) => {
if (!value)
return prev?.value ?? '';
return value;
},
})
: currentTitle;
effect(() => {
this.title.setTitle(heldTitle());
});
}
register(id, titleFn) {
this.map.inline((m) => m.set(id, titleFn));
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: TitleStore, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: TitleStore, providedIn: 'root' });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: TitleStore, decorators: [{
type: Injectable,
args: [{
providedIn: 'root',
}]
}], ctorParameters: () => [] });
/**
*
* Creates a title resolver function that can be used in Angular's router.
*
* @param fn
* A function that returns a string or a Signal<string> representing the title.
* @param awaitValue
* If `true`, the resolver will wait until the title signal has a value before resolving.
* Defaults to `false`.
*/
function createTitle(fn, awaitValue = false) {
return async (route) => {
const store = inject(TitleStore);
const resolver = injectSnapshotPathResolver();
const fp = resolver(route);
const { parser } = injectTitleConfig();
const resolved = fn();
const titleSignal = typeof resolved === 'string'
? computed(() => resolved)
: computed(resolved);
const parsedTitleSignal = computed(() => parser(titleSignal()), ...(ngDevMode ? [{ debugName: "parsedTitleSignal" }] : []));
store.register(fp, parsedTitleSignal);
if (awaitValue)
await until(parsedTitleSignal, (v) => !!v);
return Promise.resolve(untracked(parsedTitleSignal));
};
}
/**
* Generated bundle index. Do not edit.
*/
export { Link, PreloadStrategy, createBreadcrumb, createTitle, injectBreadcrumbs, injectTriggerPreload, provideBreadcrumbConfig, provideMMLinkDefaultConfig, provideTitleConfig, queryParam, url };
//# sourceMappingURL=mmstack-router-core.mjs.map