@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.
406 lines (393 loc) • 16.9 kB
TypeScript
import * as _angular_core from '@angular/core';
import { Signal, InjectionToken, Provider, WritableSignal } from '@angular/core';
import * as i1 from '@angular/router';
import { ActivatedRouteSnapshot, ResolveFn, UrlTree, ActivatedRoute, Params, PreloadingStrategy, Route } from '@angular/router';
import { Observable } from 'rxjs';
/**
* Represents a single breadcrumb item within the navigation path.
* All dynamic properties are represented as Angular Signals to enable reactivity.
*/
type Breadcrumb = {
/**
* A unique identifier for the breadcrumb item. Generally the unresolved path for example `/posts/:id`.
* Useful for `@for` tracking in templates.
*/
id: string;
/**
* The visible text for the breadcrumb item.
* Updated reactively as the url/link based on
* either a provided definition, or the current route.
*/
label: Signal<string>;
/**
* An accessible label for the breadcrumb item.
* Defaults to the same value as `label` if not provided.
*/
ariaLabel: Signal<string>;
/**
* The URL link for the breadcrumb item.
* Updates as the route changes.
*/
link: Signal<string>;
};
/**
* @internal
*/
type ResolvedLeafRoute = {
route: ActivatedRouteSnapshot;
segment: {
path: string;
resolved: string;
};
path: string;
link: string;
};
/**
* A function that returns a custom label generation function.
* The outer function is called in a root injection context
* The returned function takes a `ResolvedLeafRoute` and produces a string label for the breadcrumb.
* As the inner function is wrapped in a computed, changes to signals called within it will update the breadcrumb label reactively.
*/
type GenerateBreadcrumbFn = () => (leaf: ResolvedLeafRoute) => string;
/**
* Configuration options for the breadcrumb system.
* Use `provideBreadcrumbConfig` to supply these options to your application.
*/
type BreadcrumbConfig = {
/**
* Defines how breadcrumb labels are generated.
* - If set to `'manual'`, breadcrumbs will only be displayed if manually registered
* via `createBreadcrumb`. Automatic generation based on routes is disabled.
* - Alternatively provide a custom label generation function
* If left undefined, the system will automatically generate labels based on the route's title, data, or path.
* @see GenerateBreadcrumbFn
* @example
* ```typescript
* // For custom label generation:
* // const myCustomLabelGenerator = () => (leaf: ResolvedLeafRoute) => {
* // return leaf.route.data?.['customTitle'] || leaf.route.routeConfig?.path || 'Default';
* // };
* //
* // config: { generation: myCustomLabelGenerator }
* ```
*/
generation?: 'manual' | GenerateBreadcrumbFn;
};
/**
* 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
* }),
* ]
* ```
*/
declare function provideBreadcrumbConfig(config: Partial<BreadcrumbConfig>): {
provide: InjectionToken<BreadcrumbConfig>;
useValue: {
generation?: ("manual" | GenerateBreadcrumbFn) | undefined;
};
};
/**
* Options for defining a breadcrumb.
*
*/
type CreateBreadcrumbOptions = {
/**
* The visible text for the breadcrumb.
* Can be a static string or a function for dynamic labels.
*/
label: string | (() => string);
/**
* An accessible label for the breadcrumb item.
* Defaults to the value of `label` if not provided.
* Can be a static string or a function returning a string for dynamic ARIA labels.
*/
ariaLabel?: string | (() => string);
/**
* If `true`, the route resolver will wait until the `label` signal has a value before `resolving`
*/
awaitValue?: boolean;
};
/**
* 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...
* };
* })
* },
* }
* ];
* ```
*/
declare function createBreadcrumb(factory: () => CreateBreadcrumbOptions): ResolveFn<void>;
/**
* 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();
* }
* ```
*/
declare function injectBreadcrumbs(): Signal<Breadcrumb[]>;
declare function injectTriggerPreload(): (link: string | any[] | UrlTree | null, relativeTo?: ActivatedRoute, queryParams?: Params, fragment?: string, queryParamsHandling?: "merge" | "preserve" | "") => void;
/**
* Configuration for the `mmLink` directive.
*/
type MMLinkConfig = {
/**
* The default preload behavior for links.
* Can be 'hover', 'visible', or null (no preloading).
* @default 'hover'
*/
preloadOn: 'hover' | 'visible' | null;
/**
* Whether to use mouse down events for preloading.
* @default false
*/
useMouseDown: boolean;
};
declare function provideMMLinkDefaultConfig(config: Partial<MMLinkConfig>): Provider;
declare class Link {
private readonly routerLink;
private readonly req;
private readonly router;
readonly target: _angular_core.InputSignal<string | undefined>;
readonly queryParams: _angular_core.InputSignal<Params | undefined>;
readonly fragment: _angular_core.InputSignal<string | undefined>;
readonly queryParamsHandling: _angular_core.InputSignal<"" | "merge" | "preserve" | undefined>;
readonly state: _angular_core.InputSignal<Record<string, any> | undefined>;
readonly info: _angular_core.InputSignal<unknown>;
readonly relativeTo: _angular_core.InputSignal<ActivatedRoute | undefined>;
readonly skipLocationChange: _angular_core.InputSignalWithTransform<boolean, unknown>;
readonly replaceUrl: _angular_core.InputSignalWithTransform<boolean, unknown>;
readonly mmLink: _angular_core.InputSignal<string | any[] | UrlTree | null>;
readonly preloadOn: _angular_core.InputSignal<"hover" | "visible" | null>;
readonly useMouseDown: _angular_core.InputSignalWithTransform<boolean, unknown>;
readonly beforeNavigate: _angular_core.InputSignal<(() => void) | undefined>;
readonly preloading: _angular_core.OutputEmitterRef<void>;
private readonly urlTree;
private readonly fullPath;
onHover(): void;
onMouseDown(button: number, ctrlKey: boolean, shiftKey: boolean, altKey: boolean, metaKey: boolean): boolean | undefined;
onClick(button: number, ctrlKey: boolean, shiftKey: boolean, altKey: boolean, metaKey: boolean): boolean | undefined;
constructor();
private requestPreload;
private trigger;
static ɵfac: _angular_core.ɵɵFactoryDeclaration<Link, never>;
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<Link, "[mmLink]", ["mmLink"], { "target": { "alias": "target"; "required": false; "isSignal": true; }; "queryParams": { "alias": "queryParams"; "required": false; "isSignal": true; }; "fragment": { "alias": "fragment"; "required": false; "isSignal": true; }; "queryParamsHandling": { "alias": "queryParamsHandling"; "required": false; "isSignal": true; }; "state": { "alias": "state"; "required": false; "isSignal": true; }; "info": { "alias": "info"; "required": false; "isSignal": true; }; "relativeTo": { "alias": "relativeTo"; "required": false; "isSignal": true; }; "skipLocationChange": { "alias": "skipLocationChange"; "required": false; "isSignal": true; }; "replaceUrl": { "alias": "replaceUrl"; "required": false; "isSignal": true; }; "mmLink": { "alias": "mmLink"; "required": false; "isSignal": true; }; "preloadOn": { "alias": "preloadOn"; "required": false; "isSignal": true; }; "useMouseDown": { "alias": "useMouseDown"; "required": false; "isSignal": true; }; "beforeNavigate": { "alias": "beforeNavigate"; "required": false; "isSignal": true; }; }, { "preloading": "preloading"; }, never, never, true, [{ directive: typeof i1.RouterLink; inputs: { "routerLink": "mmLink"; "target": "target"; "queryParams": "queryParams"; "fragment": "fragment"; "queryParamsHandling": "queryParamsHandling"; "state": "state"; "relativeTo": "relativeTo"; "skipLocationChange": "skipLocationChange"; "replaceUrl": "replaceUrl"; }; outputs: {}; }]>;
}
declare class PreloadStrategy implements PreloadingStrategy {
private readonly loading;
private readonly router;
private readonly req;
preload(route: Route, load: () => Observable<any>): Observable<any>;
static ɵfac: _angular_core.ɵɵFactoryDeclaration<PreloadStrategy, never>;
static ɵprov: _angular_core.ɵɵInjectableDeclaration<PreloadStrategy>;
}
/**
* 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());
* }
* }
* ```
*/
declare function queryParam(key: string | (() => string)): WritableSignal<string | null>;
/**
* Title configuration interface.
* Defines how createTitle should behave
* @see {createTitle}
*/
type TitleConfig = {
/**
* The title to be used when no title is set.
* If not provided it defaults to an empty string
* @default ''
*/
prefix?: string | ((title: string) => string);
/**
* if false, the title will change to the url, otherwise default to true as that is standard behavior
* @default true
*/
keepLastKnownTitle?: boolean;
};
/**
* used to provide the title configuration, will not be applied unless a `createTitle` resolver is used
*/
declare function provideTitleConfig(config?: TitleConfig): Provider;
/**
*
* 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`.
*/
declare function createTitle(fn: () => string | (() => string), awaitValue?: boolean): ResolveFn<string>;
/**
* 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
* });
* }
* }
* ```
*/
declare function url(): Signal<string>;
export { Link, PreloadStrategy, createBreadcrumb, createTitle, injectBreadcrumbs, injectTriggerPreload, provideBreadcrumbConfig, provideMMLinkDefaultConfig, provideTitleConfig, queryParam, url };
export type { Breadcrumb, TitleConfig };