import type { IRoutingConfig, IViewDefinition } from '../../interfaces/appconfig.js'; import type { ViewRegistry } from './view.registry.js'; export type TRouteChangeCallback = (viewId: string, params?: Record) => void; /** * Router for managing view navigation and URL synchronization */ export class AppRouter { private config: Required> & Pick; private viewRegistry: ViewRegistry; private listeners: Set = 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; } = {} ): 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): 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): void { for (const listener of this.listeners) { listener(viewId, params); } } }