272 lines
7.6 KiB
TypeScript
272 lines
7.6 KiB
TypeScript
import type { IRoutingConfig, IViewDefinition } from '../../interfaces/appconfig.js';
|
|
import type { ViewRegistry } from './view.registry.js';
|
|
|
|
export type TRouteChangeCallback = (viewId: string, params?: Record<string, string>) => void;
|
|
|
|
/**
|
|
* Router for managing view navigation and URL synchronization
|
|
*/
|
|
export class AppRouter {
|
|
private config: Required<Omit<IRoutingConfig, 'notFound'>> & Pick<IRoutingConfig, 'notFound'>;
|
|
private viewRegistry: ViewRegistry;
|
|
private listeners: Set<TRouteChangeCallback> = new Set();
|
|
private currentViewId: string | null = null;
|
|
private isInitialized: boolean = false;
|
|
|
|
constructor(config: IRoutingConfig, viewRegistry: ViewRegistry) {
|
|
this.config = {
|
|
mode: config.mode,
|
|
basePath: config.basePath || '',
|
|
defaultView: config.defaultView || '',
|
|
syncUrl: config.syncUrl ?? true,
|
|
notFound: config.notFound,
|
|
};
|
|
this.viewRegistry = viewRegistry;
|
|
}
|
|
|
|
/**
|
|
* Initialize the router
|
|
*/
|
|
public init(): void {
|
|
if (this.isInitialized) return;
|
|
|
|
if (this.config.mode === 'hash') {
|
|
window.addEventListener('hashchange', this.handleHashChange);
|
|
// Check initial hash
|
|
const initialView = this.getViewFromHash();
|
|
if (initialView) {
|
|
this.navigate(initialView, { source: 'initial' });
|
|
} else if (this.config.defaultView) {
|
|
this.navigate(this.config.defaultView, { source: 'initial' });
|
|
}
|
|
} else if (this.config.mode === 'history') {
|
|
window.addEventListener('popstate', this.handlePopState);
|
|
// Check initial path
|
|
const initialView = this.getViewFromPath();
|
|
if (initialView) {
|
|
this.navigate(initialView, { source: 'initial' });
|
|
} else if (this.config.defaultView) {
|
|
this.navigate(this.config.defaultView, { source: 'initial' });
|
|
}
|
|
} else if (this.config.mode === 'none' && this.config.defaultView) {
|
|
this.navigate(this.config.defaultView, { source: 'initial' });
|
|
}
|
|
// For 'external' mode, we don't set up listeners - the external router handles it
|
|
|
|
this.isInitialized = true;
|
|
}
|
|
|
|
/**
|
|
* Navigate to a view by ID
|
|
*/
|
|
public navigate(
|
|
viewId: string,
|
|
options: {
|
|
source?: 'navigation' | 'popstate' | 'initial' | 'programmatic';
|
|
replace?: boolean;
|
|
params?: Record<string, string>;
|
|
} = {}
|
|
): boolean {
|
|
const { source = 'programmatic', replace = false, params } = options;
|
|
|
|
const view = this.viewRegistry.get(viewId);
|
|
if (!view) {
|
|
console.warn(`Cannot navigate to unknown view: ${viewId}`);
|
|
if (this.config.notFound) {
|
|
if (typeof this.config.notFound === 'function') {
|
|
this.config.notFound();
|
|
} else {
|
|
return this.navigate(this.config.notFound, { source, replace: true });
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const previousViewId = this.currentViewId;
|
|
this.currentViewId = viewId;
|
|
|
|
// Update URL if configured
|
|
if (this.config.syncUrl && this.config.mode !== 'none' && this.config.mode !== 'external') {
|
|
this.updateUrl(view, replace);
|
|
}
|
|
|
|
// Notify listeners
|
|
this.notifyListeners(viewId, params);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Navigate back in history
|
|
*/
|
|
public back(): void {
|
|
if (this.config.mode === 'hash' || this.config.mode === 'history') {
|
|
window.history.back();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Navigate forward in history
|
|
*/
|
|
public forward(): void {
|
|
if (this.config.mode === 'hash' || this.config.mode === 'history') {
|
|
window.history.forward();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current view ID
|
|
*/
|
|
public getCurrentViewId(): string | null {
|
|
return this.currentViewId;
|
|
}
|
|
|
|
/**
|
|
* Add a route change listener
|
|
*/
|
|
public onRouteChange(callback: TRouteChangeCallback): () => void {
|
|
this.listeners.add(callback);
|
|
return () => this.listeners.delete(callback);
|
|
}
|
|
|
|
/**
|
|
* Handle external navigation (for external router mode)
|
|
*/
|
|
public handleExternalNavigation(viewId: string, params?: Record<string, string>): void {
|
|
if (this.config.mode !== 'external') {
|
|
console.warn('handleExternalNavigation should only be used in external mode');
|
|
}
|
|
|
|
const previousViewId = this.currentViewId;
|
|
this.currentViewId = viewId;
|
|
this.notifyListeners(viewId, params);
|
|
}
|
|
|
|
/**
|
|
* Sync state with URL (for external router integration)
|
|
*/
|
|
public syncWithUrl(): string | null {
|
|
if (this.config.mode === 'hash') {
|
|
return this.getViewFromHash();
|
|
} else if (this.config.mode === 'history') {
|
|
return this.getViewFromPath();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get the current route from the URL
|
|
*/
|
|
public getCurrentRoute(): string {
|
|
if (this.config.mode === 'hash') {
|
|
return window.location.hash.slice(1) || '';
|
|
} else if (this.config.mode === 'history') {
|
|
let path = window.location.pathname;
|
|
if (this.config.basePath && path.startsWith(this.config.basePath)) {
|
|
path = path.slice(this.config.basePath.length);
|
|
}
|
|
return path.replace(/^\//, '');
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Build a URL for a view
|
|
*/
|
|
public buildUrl(viewId: string): string {
|
|
const view = this.viewRegistry.get(viewId);
|
|
const route = view?.route || viewId;
|
|
|
|
if (this.config.mode === 'hash') {
|
|
return `#${route}`;
|
|
} else if (this.config.mode === 'history') {
|
|
return `${this.config.basePath}/${route}`;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Destroy the router
|
|
*/
|
|
public destroy(): void {
|
|
if (this.config.mode === 'hash') {
|
|
window.removeEventListener('hashchange', this.handleHashChange);
|
|
} else if (this.config.mode === 'history') {
|
|
window.removeEventListener('popstate', this.handlePopState);
|
|
}
|
|
this.listeners.clear();
|
|
this.isInitialized = false;
|
|
}
|
|
|
|
// Private methods
|
|
|
|
private handleHashChange = (): void => {
|
|
const viewId = this.getViewFromHash();
|
|
if (viewId && viewId !== this.currentViewId) {
|
|
this.navigate(viewId, { source: 'popstate' });
|
|
}
|
|
};
|
|
|
|
private handlePopState = (): void => {
|
|
const viewId = this.getViewFromPath();
|
|
if (viewId && viewId !== this.currentViewId) {
|
|
this.navigate(viewId, { source: 'popstate' });
|
|
}
|
|
};
|
|
|
|
private getViewFromHash(): string | null {
|
|
const hash = window.location.hash.slice(1); // Remove #
|
|
if (!hash) return null;
|
|
|
|
// Try to find view by route
|
|
const view = this.viewRegistry.findByRoute(hash);
|
|
return view?.id || null;
|
|
}
|
|
|
|
private getViewFromPath(): string | null {
|
|
let path = window.location.pathname;
|
|
|
|
// Remove base path if configured
|
|
if (this.config.basePath) {
|
|
if (path.startsWith(this.config.basePath)) {
|
|
path = path.slice(this.config.basePath.length);
|
|
}
|
|
}
|
|
|
|
// Remove leading slash
|
|
path = path.replace(/^\//, '');
|
|
|
|
if (!path) return null;
|
|
|
|
const view = this.viewRegistry.findByRoute(path);
|
|
return view?.id || null;
|
|
}
|
|
|
|
private updateUrl(view: IViewDefinition, replace: boolean): void {
|
|
const route = view.route || view.id;
|
|
|
|
if (this.config.mode === 'hash') {
|
|
const newHash = `#${route}`;
|
|
if (replace) {
|
|
window.history.replaceState(null, '', newHash);
|
|
} else {
|
|
window.history.pushState(null, '', newHash);
|
|
}
|
|
} else if (this.config.mode === 'history') {
|
|
const basePath = this.config.basePath || '';
|
|
const newPath = `${basePath}/${route}`;
|
|
if (replace) {
|
|
window.history.replaceState({ viewId: view.id }, '', newPath);
|
|
} else {
|
|
window.history.pushState({ viewId: view.id }, '', newPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
private notifyListeners(viewId: string, params?: Record<string, string>): void {
|
|
for (const listener of this.listeners) {
|
|
listener(viewId, params);
|
|
}
|
|
}
|
|
}
|