import { godown, htmlSlot, styles } from "@godown/element";
import { type PropertyValueMap, type TemplateResult, css } from "lit";
import { property, state } from "lit/decorators.js";
import { Router as Mux, omit } from "sharekit";
import { GlobalStyle } from "../../internal/global-style.js";
interface RouteState {
pathname: string;
params: Record;
path: string;
}
interface RouteResult extends RouteState {
component: unknown;
}
interface RouteItem {
[key: PropertyKey]: unknown;
path: string;
render?: (state?: RouteState) => unknown;
component?: unknown;
}
const routerTypes = {
field: "field",
slotted: "slotted",
united: "united",
} as const;
type RouterType = keyof typeof routerTypes;
const protoName = "router";
/**
* {@linkcode Router} has basic routing control.
*
* To switch routes, use `router-link component`.
*
* It has two methods to collect routes.
*
* 1. From field `routes`, an array, each elements require "path".
* 2. From child elements, which have the slot attribute for matching routes.
*
* If only the method 1 is used, set `type` to `"field"`.
*
* If only the method 2 is used, set `type` to `"slotted"`.
*
* `type` defaults to `"united"`, which will try method 1, then method 2.
*
* If no routes are matched, the default value (no named slot) will be rendered.
*
* @slot - Display slot when there is no match.
* @slot * - Matching slot will be displayed.
* @category navigation
*/
@godown(protoName)
@styles(css`
:host {
display: contents;
}
`)
class Router extends GlobalStyle {
static routerInstances: Set = new Set();
private __fieldRoute: Mux = new Mux();
private __slottedRoute: Mux = new Mux();
private __cacheRecord = new Map();
private __routes: RouteItem[];
/**
* Render result.
*/
@state()
component: unknown | TemplateResult = null;
/**
* Dynamic parameters record.
*/
params?: Record;
/**
* Value of matched path in routes.
*/
@state()
path?: string;
/**
* Current pathname (equals to location.pathname).
*/
@property()
pathname: string = location.pathname;
/**
* Rendered content when there is no match.
*/
@state()
default: TemplateResult = htmlSlot();
/**
* The type of routing sources.
*
* If field, it won't collect the slot attribute of the child elements.
*
* This property should not be changed after the rendering is complete.
*/
@property()
type: RouterType = routerTypes.united;
/**
* Cache accessed records.
*
* Emptied at each re-collection.
*/
@property({ type: Boolean })
cache = false;
@state()
set routes(value) {
this.__routes = value;
this.collectFieldRoutes(value);
}
get routes(): RouteItem[] {
return this.__routes;
}
clear(): void {
this.__cacheRecord.clear();
}
protected render(): unknown {
let cached: RouteResult | undefined;
if (this.cache && (cached = this.__cacheRecord.get(this.pathname))) {
this.component = cached.component;
this.path = cached.path;
this.pathname = cached.pathname;
}
if (!cached) {
switch (this.type) {
case routerTypes.field:
this.component = this.fieldComponent();
break;
case routerTypes.slotted:
this.component = this.slottedComponent();
break;
default:
this.component = this.fieldComponent() ?? this.slottedComponent();
}
}
return this.component ?? this.default;
}
connectedCallback(): void {
super.connectedCallback();
Router.routerInstances.add(this);
if (this.type !== "field") {
this.observers.add(this, MutationObserver, this.collectSlottedRoutes, {
attributes: true,
attributeFilter: ["slot"],
subtree: true,
});
this.collectSlottedRoutes();
}
}
disconnectedCallback(): void {
super.disconnectedCallback();
Router.routerInstances.delete(this);
}
useRouter(): RouteResult {
return {
pathname: this.pathname,
params: this.params,
path: this.path,
component: this.component,
};
}
protected updated(changedProperties: PropertyValueMap): void {
const shouldDispatch = changedProperties.has("pathname") || changedProperties.has("path");
if (shouldDispatch) {
const ur = this.useRouter();
if (!this.__cacheRecord.has(this.pathname) && this.path) {
this.__cacheRecord.set(this.pathname, ur);
}
this.dispatchCustomEvent("change", ur);
}
}
/**
* Get component from {@linkcode routes} by query.
*/
fieldComponent(query?: string): unknown {
if (!query) {
const mux = this.__fieldRoute.search(this.pathname);
this.params = mux.params(this.pathname);
query = mux.pattern;
}
this.path = query;
if (!query) {
return null;
}
const route = this.routes.find((r) => r.path === query);
if (!route) {
return null;
}
if ("render" in route) {
return route.render?.(omit(this.useRouter(), "component")) || null;
}
return route.component;
}
/**
* Get component from slotted elements by query.
*/
slottedComponent(query?: string): TemplateResult<1> {
const slottedPaths = this._slottedNames;
if (!query) {
const mux = this.__fieldRoute.search(this.pathname);
this.params = mux.params(this.pathname);
query = mux.pattern;
}
this.path = query;
if (!query) {
return null;
}
this.path = slottedPaths.find((s) => s === query);
if (!this.path) {
return null;
}
return htmlSlot(this.path);
}
/**
* Reset the route tree, clear cache, collect routes from child elements.
*/
collectSlottedRoutes(): void {
this.__slottedRoute = new Mux();
this.clear();
this._slottedNames.forEach((slotName) => {
this.__slottedRoute.insert(slotName);
});
}
/**
* Reset the route tree, clear cache, collect routes from value.
*/
collectFieldRoutes(value: typeof this.routes): void {
this.__fieldRoute = new Mux();
this.clear();
value.forEach(({ path }) => {
this.__fieldRoute.insert(path);
});
}
static updateAll(): void {
this.routerInstances.forEach((i) => {
i.handlePopstate();
});
}
search(pathname: string): Mux {
return this.__fieldRoute.search(pathname) || this.__slottedRoute.search(pathname);
}
handlePopstate: () => void = this.events.add(window, "popstate", () => {
this.pathname = location.pathname;
});
}
export default Router;
export { Router };