Files
nlg-creator/src/router.ts
2026-04-04 19:46:29 +01:00

154 lines
3.9 KiB
TypeScript

type RouteParams = Record<string, string>;
type QueryParams = Record<string, string>;
export interface RouteContext {
params: RouteParams;
query: QueryParams
}
export type NextFunction = () => void;
export type RouteHandler = (ctx: RouteContext) => HTMLElement;
export type RouteGuard = (ctx: RouteContext, next: NextFunction) => void;
export type LayoutHandler = () => { view: HTMLElement, slot: HTMLElement }
interface Route {
path: string;
regex: RegExp,
keys: string[],
handler: RouteHandler;
guards: RouteGuard[];
layout?: LayoutHandler
}
export class Router {
private routes: Route[] = [];
private rootElement: HTMLElement | null = null;
private isTransitioning = false;
private currentLayout: LayoutHandler | null = null;
private currentSlot: HTMLElement | null = null;
private static instance: Router | null = null;
constructor(rootId: string) {
this.rootElement = document.getElementById(rootId);
Router.instance = this;
window.addEventListener('hashchange', () => this.handleRoute());
window.addEventListener('load', () => this.handleRoute())
}
public static reload() {
if (Router.instance) {
Router.instance.handleRoute();
}
}
public addRoute(path: string, handler: RouteHandler, guards: RouteGuard[] = [], layout?: LayoutHandler) {
const keys: string[] = [];
const regexpPath = path.replace(/:(\w+)/g, (_, key) => {
keys.push(key);
return '([^/]+)'
})
this.routes.push({
path,
regex: new RegExp(`^${regexpPath}$`),
keys, handler, guards, layout
})
}
private handleRoute() {
if (this.isTransitioning) return;
const hash = window.location.hash.slice(1) || '/';
const [path, queryString] = hash.split("?");
const query: QueryParams = {};
new URLSearchParams(queryString).forEach((val, key) => {
query[key] = val
});
for (const route of this.routes) {
const match = path.match(route.regex);
if (match) {
const params: RouteParams = {};
route.keys.forEach((key, index) => params[key] = match[index + 1]);
const ctx: RouteContext = { params, query };
this.runGuards(route.guards, ctx, () => {
const pageElement = route.handler(ctx);
const layoutChanged = route.layout !== this.currentLayout;
const transitionTarget = layoutChanged ? this.rootElement : this.currentSlot
this.performTransition(() => {
if (layoutChanged) {
if (route.layout) {
const { view, slot } = route.layout();
this.rootElement!.innerHTML = '';
this.rootElement!.appendChild(view);
this.currentSlot = slot;
this.currentLayout = route.layout;
} else {
this.rootElement!.innerHTML = '';
this.currentSlot = this.rootElement;
this.currentLayout = null;
}
}
if (this.currentSlot) {
this.currentSlot.innerHTML = ''
this.currentSlot.appendChild(pageElement);
}
}, transitionTarget)
});
return;
}
}
}
private runGuards(guards: RouteGuard[], ctx: RouteContext, callback: () => void) {
let index = 0;
const next = () => {
if (index < guards.length) {
guards[index++](ctx, next)
} else {
callback();
}
}
next();
}
private performTransition(updateDomFn: () => void, targetElement: HTMLElement | null) {
const el = targetElement || this.rootElement;
if (!el) {
updateDomFn();
return;
}
this.isTransitioning = true;
this.rootElement.classList.add('fading');
setTimeout(() => {
updateDomFn();
this.rootElement?.classList.remove('fading');
this.isTransitioning = false;
}, 100)
}
static navigate(path: string) {
window.location.hash = path;
}
}