@furystack/shades
Version:
A lightweight UI framework for FuryStack with JSX support
226 lines • 10.4 kB
TypeScript
import type { Injector } from '@furystack/inject';
import type { MatchOptions, MatchResult } from 'path-to-regexp';
import type { RenderOptions } from '../models/render-options.js';
import type { ViewTransitionConfig } from '../view-transition.js';
import type { HashLiterals, QueryValidator } from './nested-route-types.js';
/**
* Options passed to a dynamic title resolver function.
* @typeParam TMatchResult - The type of matched URL parameters
*/
export type TitleResolverOptions<TMatchResult = unknown> = {
match: MatchResult<TMatchResult extends object ? TMatchResult : object>;
injector: Injector;
};
/**
* Metadata associated with a route entry.
* Used by consumers (breadcrumbs, document title, navigation trees) to
* derive display information from the route hierarchy.
*
* This is an `interface` so that applications can augment it with custom fields
* via declaration merging:
*
* @example
* ```typescript
* declare module '@furystack/shades' {
* interface NestedRouteMeta {
* icon?: IconDefinition
* hidden?: boolean
* }
* }
* ```
*
* @typeParam TMatchResult - The type of matched URL parameters
*/
export interface NestedRouteMeta<TMatchResult = unknown> {
title?: string | ((options: TitleResolverOptions<TMatchResult>) => string | Promise<string>);
}
/**
* A single route entry in a NestedRouter configuration.
* Unlike flat `Route`, the URL is the Record key (not a field), and the
* `component` receives an `outlet` for rendering matched child content.
*
* Routes may additionally declare:
* - `query`: a validator that parses the deserialized query string into a
* typed shape; `component` receives the parsed value (or `null` when
* validation fails). The route still matches on path alone — an invalid
* query never prevents navigation.
* - `hash`: a readonly tuple of allowed URL hash literals; `component`
* receives the current hash when it matches one of the listed literals,
* or `undefined` otherwise.
*
* @typeParam TMatchResult - The type of matched URL parameters
* @typeParam TQuery - The typed query shape parsed from the URL search string (defaults to `never`)
* @typeParam THash - The readonly tuple of allowed hash literals (defaults to `never`)
*/
export type NestedRoute<TMatchResult = unknown, TQuery = any, THash extends HashLiterals = readonly any[]> = {
meta?: NestedRouteMeta<TMatchResult>;
component: (options: {
currentUrl: string;
match: MatchResult<TMatchResult extends object ? TMatchResult : object>;
query: TQuery | null;
hash: THash[number] | undefined;
outlet?: JSX.Element;
}) => JSX.Element;
routingOptions?: MatchOptions;
/**
* Called after the route's DOM has been mounted. When view transitions are enabled,
* this runs after the transition's update callback has completed and the new DOM is in place.
* Use for imperative side effects like data fetching or focus management — not for visual
* animations, which are handled by the View Transition API when `viewTransition` is enabled.
*/
onVisit?: (options: RenderOptions<unknown> & {
element: JSX.Element;
}) => Promise<void>;
/**
* Called before the route's DOM is removed (and before the view transition starts, if enabled).
* Use for cleanup or teardown logic — not for exit animations, which are handled by the
* View Transition API when `viewTransition` is enabled.
*/
onLeave?: (options: RenderOptions<unknown> & {
element: JSX.Element;
}) => Promise<void>;
children?: Record<string, NestedRoute<any, any, any>>;
viewTransition?: boolean | ViewTransitionConfig;
/**
* Optional validator that narrows the deserialized URL query string into a
* typed shape. Return `null` when the URL's query does not satisfy the route's
* contract — the route still matches on path, but `component` receives `null`.
*/
query?: QueryValidator<TQuery>;
/**
* Optional readonly tuple of URL hash literals the route understands. Declare
* with `as const` to preserve literal types, e.g. `hash: ['tab1', 'tab2'] as const`.
* The router forwards the current hash to `component` only when it matches one
* of the listed literals; otherwise `component.hash` is `undefined`.
*/
hash?: THash;
};
/**
* Props for the NestedRouter component.
* Routes are defined as a Record where keys are URL patterns.
*/
export type NestedRouterProps = {
routes: Record<string, NestedRoute<any, any, any>>;
notFound?: JSX.Element;
viewTransition?: boolean | ViewTransitionConfig;
};
/**
* A single entry in a match chain, pairing a matched route with its match
* result and the typed `query` / `hash` values derived from the URL for that
* route's declared schema.
*/
export type MatchChainEntry = {
route: NestedRoute<unknown, any, any>;
match: MatchResult<object>;
query: unknown;
hash: string | undefined;
};
/**
* Internal state for the NestedRouter component.
* `matchChain` is `null` when a notFound fallback has been rendered,
* distinguishing it from the initial empty array (not yet processed).
*/
export type NestedRouterState = {
matchChain: MatchChainEntry[] | null;
jsx: JSX.Element;
chainElements: JSX.Element[];
};
/**
* Recursively builds a match chain from outermost to innermost matched route.
*
* For routes with children, a prefix match (`end: false`) is attempted first.
* If a child matches the remaining URL, the parent and child chain are combined.
* If no child matches, an exact match on the parent alone is attempted.
*
* For leaf routes (no children), only exact matching is used.
*
* The returned entries contain placeholder `query: null` / `hash: undefined`
* values; callers are expected to populate them via {@link enrichMatchChain}.
*
* @param routes - The route definitions to match against
* @param currentUrl - The URL path to match
* @returns An array of matched chain entries from outermost to innermost, or null if no match
*/
export declare const buildMatchChain: (routes: Record<string, NestedRoute<any, any, any>>, currentUrl: string) => MatchChainEntry[] | null;
/**
* Populates each chain entry's `query` and `hash` fields by running the route's
* declared validator against the URL's deserialized query string, and matching
* the current URL hash against the route's declared literal tuple.
*
* Entries whose route declares neither `query` nor `hash` are returned with
* `query: null` / `hash: undefined`.
*
* When no entry in the chain declares either `query` or `hash`, the input
* array is returned unchanged to avoid a per-navigation allocation on the
* common path-only case.
*
* @param chain - The chain produced by {@link buildMatchChain}
* @param deserializedSearch - The deserialized URL query string
* @param currentHash - The current URL hash (without the leading `#`)
*/
export declare const enrichMatchChain: (chain: MatchChainEntry[], deserializedSearch: Record<string, unknown>, currentHash: string) => MatchChainEntry[];
/**
* Finds the first index where two match chains diverge, considering route
* identity and matched path parameters only. Used to scope lifecycle hooks
* (`onLeave` / `onVisit`) so that a query string or hash change does not
* fire spurious mount / unmount callbacks.
*
* Returns the length of the shorter chain if one is a prefix of the other.
*/
export declare const findDivergenceIndex: (oldChain: MatchChainEntry[], newChain: MatchChainEntry[]) => number;
/**
* Returns true when any chain entry differs in its `query` value or `hash`
* segment, ignoring path-level fields (route identity and params). Used to
* force a re-render when the URL's query string or hash changes without the
* matched route chain itself changing.
*
* Query values are compared with a key-order-independent shallow equality —
* sufficient for the typed shapes a route's `query` validator surfaces.
*/
export declare const hasQueryOrHashChanged: (oldChain: MatchChainEntry[], newChain: MatchChainEntry[]) => boolean;
/**
* The result of rendering a match chain, containing both the fully composed
* JSX tree and per-entry elements for scoped lifecycle animations.
*/
export type RenderMatchChainResult = {
jsx: JSX.Element;
chainElements: JSX.Element[];
};
/**
* Renders a match chain inside-out: starts with the innermost (leaf) route
* rendered with `outlet: undefined`, then passes its JSX as `outlet` to
* each successive parent up the chain.
*
* Returns per-entry elements so that lifecycle hooks (`onLeave`/`onVisit`)
* receive only the element for their own route level, not the full tree.
*
* @param chain - The match chain from outermost to innermost
* @param currentUrl - The current URL path
* @returns The fully composed JSX element and per-entry rendered elements
*/
export declare const renderMatchChain: (chain: MatchChainEntry[], currentUrl: string) => RenderMatchChainResult;
/**
* Resolves the effective view transition config for a navigation by merging
* the router-level default with the innermost (leaf) route's override.
* A per-route `false` disables transitions even when the router default is on.
*/
export declare const resolveViewTransition: (routerConfig: boolean | ViewTransitionConfig | undefined, newChain: MatchChainEntry[]) => ViewTransitionConfig | false;
/**
* A nested router component that supports hierarchical route definitions
* with parent/child relationships. Parent routes receive an `outlet` prop
* containing the rendered child route, enabling layout composition.
*
* Routes are defined as a Record where keys are URL patterns (following the
* RestApi pattern). The matching algorithm builds a chain from outermost to
* innermost route, then renders inside-out so each parent wraps its child.
*
* The router subscribes to path, query string and hash changes; path-level
* changes drive `onLeave` / `onVisit` lifecycle hooks while query / hash
* changes re-render the chain without firing lifecycle callbacks.
*/
export declare const NestedRouter: (props: NestedRouterProps & Omit<Partial<HTMLElement>, "style"> & {
style?: Partial<CSSStyleDeclaration>;
} & {
ref?: import("../models/render-options.js").RefObject<Element>;
}, children?: import("../index.js").ChildrenList) => JSX.Element;
//# sourceMappingURL=nested-router.d.ts.map