UNPKG

@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
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