154 lines
3.9 KiB
TypeScript
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;
|
|
}
|
|
} |