) => OrMatrix<$ContainerContentType>;
+ events = new $EventManager<$RouteEventMap>().register('opened', 'closed')
+ readonly rendered: boolean = false;
+ constructor(options?: $RouteOptions) {
+ super('route', options);
+ }
+
+ path(): $RoutePathType;
+ path(pathname: P): $Route
: ''>;
+ path(pathname?: $RoutePathType): $RoutePathType | $Route { return $.fluent(this, arguments, () => this.#path, () => this.#path = pathname ?? this.#path ) }
+
+ builder(builder: (record: $RouteRecord) => OrMatrix<$ContainerContentType>) {
+ this.#builder = builder;
+ return this;
+ }
+
+ render(options: {params: Path}) {
+ if (this.#builder) this.content(this.#builder({
+ $route: this,
+ params: options.params
+ }));
+ (this as Mutable<$Route>).rendered = true;
+ return this;
+ }
+
+ build(options: {params: Path}) {
+ return new $Route({dom: this.dom.cloneNode() as HTMLElement}).self(($route) => {
+ if (this.#builder) $route.builder(this.#builder as any).render({params: options.params as any})
+ })
+ }
+}
+
+interface $RouteRecord {
+ $route: $Route;
+ params: Path
+}
+interface $RouteEventMap {
+ opened: [];
+ closed: []
+}
+export type $RoutePathType = string | string[];
+type PathParams = Path extends `${infer Segment}/${infer Rest}`
+ ? Segment extends `${string}:${infer Param}` ? Record & PathParams : PathParams
+ : Path extends `${string}:${infer Param}` ? Record : {}
\ No newline at end of file
diff --git a/lib/$Router.ts b/lib/$Router.ts
new file mode 100644
index 0000000..1bb6656
--- /dev/null
+++ b/lib/$Router.ts
@@ -0,0 +1,181 @@
+import { $EventManager } from "elexis";
+import { $Route, $RoutePathType } from "./$Route";
+import { $View, $ViewOptions } from "@elexis/view";
+
+export interface $RouterOptions extends $ViewOptions {}
+export class $Router extends $View {
+ #base: string = '';
+ routes = new Map<$RoutePathType, $Route>();
+ static routers = new Set<$Router>();
+ static events = new $EventManager<$RouterEventMap>().register('stateChange')
+ static navigationDirection: $RouterNavigationDirection
+ static historyIndex = 0;
+ static url = new URL(location.href);
+ private static scrollHistoryKey = `$ROUTER_SCROLL_HISTORY`;
+ constructor(options?: $RouterOptions) {
+ super({tagname: 'router', ...options});
+ $Router.routers.add(this);
+ }
+
+ base(): string;
+ base(pathname: string): this;
+ base(pathname?: string) { return $.fluent(this, arguments, () => this.#base, () => { this.#base = pathname ?? this.#base }) }
+
+ map(routes: OrMatrix<$Route>) {
+ routes = $.orArrayResolve(routes);
+ for (const route of routes) {
+ if (route instanceof Array) this.map(route);
+ else {
+ this.routes.set(route.path(), route);
+ }
+ }
+ this.resolve();
+ return this;
+ }
+
+ protected resolve(): Promise<$RouterResolveResult> {
+ return new Promise(resolve => {
+ if (!location.pathname.startsWith(this.#base)) return resolve($RouterResolveResult.NotMatchBase);
+ const locationPath = location.pathname.replace(this.#base, '/').replace('//', '/') // /a/b
+ const locationParts = locationPath.split('/').map(path => `/${path}`);
+ const find = () => {
+ const matchedRoutes: {deep: number, $route: $Route, params: {[key: string]: string}, pathId: string}[] = []
+ for (const [routePathResolve, $route] of this.routes) {
+ const routePathList = $.orArrayResolve(routePathResolve);
+ for (const routePath of routePathList) {
+ let deep = 0, params = {};
+ if (routePath.startsWith('#')) {
+ // multiple route path will set the first path as pathId
+ if (routePath === location.hash) return {deep, $route, params, pathId: routePathList[0]};
+ else continue;
+ }
+ const routeParts = routePath.split('/').map(path => `/${path}`)
+ if (locationParts.length < routeParts.length) continue;
+ for (let i = 0; i < routeParts.length; i ++) {
+ if (routeParts[i].startsWith('/:')) { deep++; Object.assign(params, {[routeParts[i].replace('/:', '')]: locationParts[i].replace('/', '')}); continue; }
+ else if (routeParts[i] === locationParts[i]) { deep++; continue; }
+ else { break; }
+ }
+ // route path with params will set the locationPath as pathId
+ matchedRoutes.push({deep, $route, params, pathId: Object.keys(params).length === 0 ? routePathList[0] : locationPath})
+ }
+ }
+ return matchedRoutes.sort((a, b) => b.deep - a.deep).at(0);
+ }
+
+ const $routeData = find();
+ if (!$routeData) return resolve($RouterResolveResult.NotFound);
+ const {$route, params, pathId} = $routeData;
+ if (pathId === this.contentId) return resolve($RouterResolveResult.OK); // current route
+ this.events.once('rendered', ({nextContent, previousContent}) => {
+ if (previousContent instanceof $Route) previousContent.events.fire('closed');
+ if (nextContent instanceof $Route) nextContent.events.fire('opened');
+ resolve($RouterResolveResult.OK);
+ });
+ if (!this.viewCache.get(pathId)) {
+ const $buildedRoute = $route.build({params});
+ this.setView(pathId, $buildedRoute);
+ }
+ this.switchView(pathId);
+ })
+ }
+
+ static init() {
+ if (!history.state || 'index' in history.state === false) {
+ const state: $RouterState = { index: $Router.historyIndex }
+ history.replaceState(state, '')
+ } else {
+ $Router.historyIndex = history.state.index
+ }
+ $Router.navigationDirection = $RouterNavigationDirection.Forward;
+ $Router.resolve();
+ window.addEventListener('popstate', () => $Router.popstate());
+ window.addEventListener('scroll', () => { this.setScrollHistory(this.historyIndex, location.href, document.documentElement.scrollTop) })
+ history.scrollRestoration = 'manual';
+ return this;
+ }
+
+ static open(url: string | URL | undefined) {
+ if (url === undefined) return this;
+ url = new URL(url);
+ if (url.href === this.url.href) return this;
+ this.historyIndex++;
+ history.pushState($Router.historyState, '', url);
+ this.stateChange($RouterNavigationDirection.Forward);
+ $Router.resolve();
+ return this;
+ }
+
+ static back() {
+ this.historyIndex--;
+ history.back()
+ this.stateChange($RouterNavigationDirection.Back);
+ return this;
+ }
+
+ static replace(url: string | URL | undefined) {
+ if (url === undefined) return this;
+ if (typeof url === 'string' && !url.startsWith(location.origin)) url = location.origin + url;
+ history.replaceState($Router.historyState, '', url);
+ this.stateChange($RouterNavigationDirection.Replace);
+ $Router.resolve();
+ return this;
+ }
+
+ protected static popstate() {
+ const direction: $RouterNavigationDirection
+ = history.state.index > $Router.historyIndex
+ ? $RouterNavigationDirection.Forward
+ : history.state.index < $Router.historyIndex
+ ? $RouterNavigationDirection.Back
+ : $RouterNavigationDirection.Replace
+
+ $Router.historyIndex = history.state.index;
+ $Router.stateChange(direction);
+ $Router.resolve();
+ }
+
+ protected static async resolve() {
+ await Promise.all([...$Router.routers.values()].map($router => $router.resolve()));
+ this.scrollRestoration();
+ }
+
+ protected static get historyState() { return { index: $Router.historyIndex, } }
+
+ protected static stateChange(direction: $RouterNavigationDirection) {
+ const beforeURL = this.url;
+ const afterURL = new URL(location.href);
+ this.url = afterURL;
+ $Router.events.fire('stateChange', {beforeURL, afterURL, direction})
+ $Router.navigationDirection = direction;
+ }
+
+ protected static setScrollHistory(index: number, url: string, value: number) {
+ const record = this.getScrollHistory()
+ if (!record) return sessionStorage.setItem(this.scrollHistoryKey, JSON.stringify({[index]: {url, value}}));
+ record[index] = {url, value};
+ sessionStorage.setItem(this.scrollHistoryKey, JSON.stringify(record));
+ }
+
+ protected static getScrollHistory() {
+ const data = sessionStorage.getItem(this.scrollHistoryKey);
+ if (!data) return undefined;
+ else return JSON.parse(data) as $RouterScrollHistoryData;
+ }
+
+ protected static scrollRestoration() {
+ const record = this.getScrollHistory();
+ if (!record) return;
+ document.documentElement.scrollTop = record[this.historyIndex]?.value ?? 0;
+ }
+}
+
+enum $RouterResolveResult { OK, NotFound, NotMatchBase }
+export enum $RouterNavigationDirection { Forward, Back, Replace }
+interface $RouterState { index: number }
+export interface $RouterEventMap {
+ 'stateChange': [{beforeURL: URL, afterURL: URL, direction: $RouterNavigationDirection}]
+}
+interface $RouterScrollHistoryData {[index: number]: {url: string, value: number}}
+
+$Router.init();
\ No newline at end of file
diff --git a/lib/Route.ts b/lib/Route.ts
deleted file mode 100644
index 56193ba..0000000
--- a/lib/Route.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { $EventMethod, $Node, $EventManager } from "elexis";
-import { $Util } from "elexis/lib/$Util";
-
-export class Route {
- path: string | PathResolverFn;
- builder: (req: RouteRequest) => RouteContent;
- constructor(path: Path, builder: ((req: RouteRequest) => RouteContent) | RouteContent) {
- this.path = path;
- this.builder = builder instanceof Function ? builder : (req: RouteRequest) => builder;
- }
-}
-
-type PathParams = Path extends `${infer Segment}/${infer Rest}`
- ? Segment extends `${string}:${infer Param}` ? Record & PathParams : PathParams
- : Path extends `${string}:${infer Param}` ? Record : {}
-
-export type PathResolverFn = (path: string) => undefined | string;
-
-type PathParamResolver = P extends PathResolverFn
-? undefined : PathParams
-
-// type PathResolverRecord
= {
-// [key in keyof ReturnType
]: ReturnType
[key]
-// }
-
-
-export interface RouteRecord extends $EventMethod {};
-export class RouteRecord {
- id: string;
- readonly content?: $Node;
- events = new $EventManager().register('open', 'load')
- constructor(id: string) {
- this.id = id;
- }
-}
-$Util.mixin(RouteRecord, $EventMethod)
-export interface RouteRecordEventMap {
- 'open': [{path: string, record: RouteRecord}];
- 'load': [{path: string, record: RouteRecord}];
-}
-
-export interface RouteRequest {
- params: PathParamResolver,
- record: RouteRecord,
- loaded: () => void;
-}
-
-export type RouteContent = $Node | string | void;
\ No newline at end of file
diff --git a/lib/Router.ts b/lib/Router.ts
deleted file mode 100644
index ec67a1d..0000000
--- a/lib/Router.ts
+++ /dev/null
@@ -1,244 +0,0 @@
-import { $EventMethod, $View, $EventManager, $Text } from "elexis";
-import { $Util } from "elexis/lib/$Util";
-import { PathResolverFn, Route, RouteRecord } from "./Route";
-export interface Router extends $EventMethod {};
-export class Router {
- routeMap = new Map>();
- recordMap = new Map();
- $view: $View;
- static index: number = 0;
- static events = new $EventManager().register('pathchange', 'notfound', 'load');
- events = new $EventManager().register('notfound', 'load');
- basePath: string;
- static currentPath: URL = new URL(location.href);
- static readonly SCROLL_HISTORY_KEY = '$router_scroll_history';
- constructor(basePath: string, view?: $View) {
- this.basePath = basePath;
- this.$view = view ?? new $View();
- }
-
- /**Add route to Router. @example Router.addRoute(new Route('/', 'Hello World')) */
- addRoute(routes: OrArray>) {
- routes = $.orArrayResolve(routes);
- for (const route of routes) this.routeMap.set(route.path, route);
- return this;
- }
-
- /**Start listen to the path change */
- listen() {
- if (!history.state || 'index' in history.state === false) {
- const routeData: RouteData = {index: Router.index, data: {}}
- history.replaceState(routeData, '')
- } else {
- Router.index = history.state.index
- }
- addEventListener('popstate', this.popstate)
- $.routers.add(this);
- this.resolvePath();
- Router.events.fire('pathchange', {prevURL: undefined, nextURL: Router.currentPath, navigation: 'Forward'});
- return this;
- }
-
- /**Open URL */
- static open(url: string | URL | undefined) {
- if (url === undefined) return this;
- url = new URL(url);
- if (url.origin !== location.origin) return this;
- if (url.href === location.href) return this;
- const prevPath = Router.currentPath;
- this.index += 1;
- const routeData: RouteData = { index: this.index, data: {} };
- history.pushState(routeData, '', url);
- Router.currentPath = new URL(location.href);
- $.routers.forEach(router => router.resolvePath())
- Router.recoveryScrollPosition();
- Router.events.fire('pathchange', {prevURL: prevPath, nextURL: Router.currentPath, navigation: 'Forward'});
- return this;
- }
-
- /**Back to previous page */
- static back() {
- const prevPath = Router.currentPath;
- history.back();
- Router.currentPath = new URL(location.href);
- Router.events.fire('pathchange', {prevURL: prevPath, nextURL: Router.currentPath, navigation: 'Back'});
- return this
- }
-
- static replace(url: string | URL | undefined) {
- if (url === undefined) return this;
- if (typeof url === 'string' && !url.startsWith(location.origin)) url = location.origin + url;
- url = new URL(url);
- if (url.origin !== location.origin) return this;
- if (url.href === location.href) return this;
- const prevPath = Router.currentPath;
- history.replaceState({index: Router.index}, '', url)
- Router.currentPath = new URL(location.href);
- $.routers.forEach(router => router.resolvePath(url.pathname));
- Router.events.fire('pathchange', {prevURL: prevPath, nextURL: Router.currentPath, navigation: 'Forward'});
- return this;
- }
-
- setStateData(key: string, value: any) {
- if (history.state.data === undefined) history.state.data = {};
- history.state.data[key] = value;
- return this;
- }
-
- private popstate = (() => {
- // Forward
- if (history.state.index > Router.index) { }
- // Back
- else if (history.state.index < Router.index) { }
- const prevPath = Router.currentPath;
- Router.index = history.state.index;
- this.resolvePath();
- Router.recoveryScrollPosition();
- Router.currentPath = new URL(location.href);
- Router.events.fire('pathchange', {prevURL: prevPath, nextURL: Router.currentPath, navigation: 'Forward'});
- }).bind(this)
-
- private resolvePath(path = location.pathname) {
- if (!path.startsWith(this.basePath)) return;
- path = path.replace(this.basePath, '/').replace('//', '/');
- let found = false;
- const openCached = (pathId: string) => {
- const record = this.recordMap.get(pathId);
- if (record) {
- found = true;
- if (record.content && !this.$view.contains(record.content)) this.$view.switchView(pathId);
- record.events.fire('open', {path, record});
- return true;
- }
- return false;
- }
- const create = (pathId: string, route: Route, data: any) => {
- const record = new RouteRecord(pathId);
- let content = route.builder({
- params: data,
- record: record,
- loaded: () => {
- record.events.fire('load', {path: pathId, record});
- this.events.fire('load', {path: pathId});
- }
- });
- if (typeof content === 'string') content = new $Text(content);
- if (content === undefined) return;
- (record as Mutable).content = content;
- this.recordMap.set(pathId, record);
- this.$view.setView(pathId, content).switchView(pathId);
- record.events.fire('open', {path, record});
- found = true;
- }
- for (const [pathResolver, route] of this.routeMap.entries()) {
- // PathResolverFn
- if (pathResolver instanceof Function) {
- const routeId = pathResolver(path)
- if (routeId) { if (!openCached(routeId)) create(routeId, route, undefined) }
- continue;
- }
- // string
- const [_routeParts, _pathParts] = [pathResolver.split('/').map(p => `/${p}`), path.split('/').map(p => `/${p}`)];
- _routeParts.shift(); _pathParts.shift();
- const data = {};
- let pathString = '';
- for (let i = 0; i < _pathParts.length; i++) {
- const [routePart, pathPart] = [_routeParts.at(i), _pathParts.at(i)];
- if (!routePart || !pathPart) continue;
- if (routePart === pathPart) {
- pathString += pathPart;
- if (routePart === _routeParts.at(-1)) {
- if (!openCached(pathString)) create(pathString, route, data);
- return;
- }
- }
- else if (routePart.includes(':')) {
- const [prefix, param] = routePart.split(':');
- if (!pathPart.startsWith(prefix)) continue;
- Object.assign(data, {[param]: pathPart.replace(prefix, '')})
- pathString += pathPart;
- if (routePart === _routeParts.at(-1)) {
- if (!openCached(pathString)) create(pathString, route, data);
- return;
- }
- }
- }
- }
-
- if (!found) {
- let preventDefaultState = false;
- const preventDefault = () => preventDefaultState = true;
- this.events.fire('notfound', {path, preventDefault});
- if (!preventDefaultState) this.$view.clear();
- }
- }
-
- static recoveryScrollPosition() {
- const history = this.getScrollHistory(this.index, location.href);
- if (!history) {
- document.documentElement.scrollTop = 0;
- this.setScrollHistory(this.index, location.href, 0);
- }
- else document.documentElement.scrollTop = history.scroll;
- }
-
- static getScrollHistory(pageIndex: number, href: string) {
- const data = this.scrollHistoryData;
- if (!data || !data[pageIndex]) return null;
- return data[pageIndex].href === href ? data[pageIndex] : null;
- }
-
- static setScrollHistory(pageIndex: number, href: string, scroll: number) {
- let history = this.scrollHistoryData;
- if (!history) {
- history = {[pageIndex]: {href, scroll}};
- sessionStorage.setItem(this.SCROLL_HISTORY_KEY, JSON.stringify(history));
- }
- else {
- const data = history[pageIndex];
- if (data && data.href !== href) {
- let i = 0;
- while (history[pageIndex + i]) {
- delete history[pageIndex + i];
- i++;
- }
- }
- history[pageIndex] = {href, scroll}
- sessionStorage.setItem(this.SCROLL_HISTORY_KEY, JSON.stringify(history));
- }
- return history[pageIndex];
- }
-
- static get scrollHistoryData() {
- const json = sessionStorage.getItem(this.SCROLL_HISTORY_KEY)
- if (!json) return null;
- return JSON.parse(json) as {[key: number]: RouteScrollHistoryData};
- }
-
- static on(type: K, callback: (...args: RouterGlobalEventMap[K]) => any) { this.events.on(type, callback); return this }
- static off(type: K, callback: (...args: RouterGlobalEventMap[K]) => any) { this.events.off(type, callback); return this }
- static once(type: K, callback: (...args: RouterGlobalEventMap[K]) => any) { this.events.once(type, callback); return this }
-}
-$Util.mixin(Router, $EventMethod);
-interface RouterEventMap {
- notfound: [{path: string, preventDefault: () => any}];
- load: [{path: string}];
-}
-
-interface RouterGlobalEventMap {
- pathchange: [{prevURL?: URL, nextURL: URL, navigation: 'Back' | 'Forward'}];
-}
-
-type RouteData = {
- index: number;
- data: {[key: string]: any};
-}
-
-type RouteScrollHistoryData = {
- href: string;
- scroll: number;
-}
-
-window.addEventListener('scroll', () => {
- Router.setScrollHistory(Router.index, location.href, document.documentElement.scrollTop);
-})
\ No newline at end of file
diff --git a/package.json b/package.json
index 3fe6546..ab8c7ab 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "@elexis/router",
"description": "A simple router for ElexisJS",
- "version": "0.1.1",
+ "version": "0.2.0",
"author": {
"name": "defaultkavy",
"email": "defaultkavy@gmail.com",
@@ -20,6 +20,7 @@
"license": "ISC",
"type": "module",
"dependencies": {
- "elexis": "^0.1.0"
+ "@elexis/view": "../view",
+ "elexis": "../../elexis"
}
}
\ No newline at end of file