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.

406 lines (393 loc) 16.9 kB
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 };