/** * Hash-based Client-Side Router * @module router */ /** * @typedef {Object} Route * @property {string} path - Current path * @property {Object} params - Route and query params * @property {string} componentTag - Web component tag name */ /** * Hash-based router for SPA navigation * @class */ class Router { constructor() { /** @type {Map} */ this.routes = new Map() /** @type {Route|null} */ this.currentRoute = null /** @type {HTMLElement|null} */ this.outlet = null /** @type {Function|null} */ this.beforeNavigate = null /** @type {Function|null} */ this.afterNavigate = null window.addEventListener('hashchange', () => this.handleRouteChange()) } /** * Set the outlet element where components will be rendered * @param {HTMLElement} element - Container element */ setOutlet(element) { this.outlet = element } /** * Register a route * @param {string} path - Route path (e.g., '/listing/:id') * @param {string} componentTag - Web component tag name * @param {Function} [loader] - Optional dynamic import function for lazy loading * @returns {Router} this for chaining */ register(path, componentTag, loader) { this.routes.set(path, { componentTag, loader }) return this } /** * Parse current hash into path and query params * @private * @returns {{path: string, params: Object}} */ parseHash() { const hash = window.location.hash.slice(1) || '/' const [path, queryString] = hash.split('?') const params = new URLSearchParams(queryString || '') return { path, params: Object.fromEntries(params) } } /** * Match a path to a registered route * @private * @param {string} path - Path to match * @returns {{componentTag: string, params: Object}|null} */ matchRoute(path) { if (this.routes.has(path)) { const { componentTag, loader } = this.routes.get(path) return { componentTag, loader, params: {} } } for (const [routePath, route] of this.routes) { const routeParts = routePath.split('/') const pathParts = path.split('/') if (routeParts.length !== pathParts.length) continue const params = {} let match = true for (let i = 0; i < routeParts.length; i++) { if (routeParts[i].startsWith(':')) { params[routeParts[i].slice(1)] = pathParts[i] } else if (routeParts[i] !== pathParts[i]) { match = false break } } if (match) { return { componentTag: route.componentTag, loader: route.loader, params } } } return null } /** * Handle hash change event * @private */ async handleRouteChange() { const { path, params: queryParams } = this.parseHash() const match = this.matchRoute(path) if (this.beforeNavigate) { const shouldContinue = await this.beforeNavigate(path) if (!shouldContinue) return } if (!match) { this.renderNotFound() return } const { componentTag, loader, params: routeParams } = match if (loader && !customElements.get(componentTag)) { await loader() } this.currentRoute = { path, params: { ...routeParams, ...queryParams }, componentTag } this.render() if (this.afterNavigate) { this.afterNavigate(this.currentRoute) } } /** * Render current route's component into outlet * @private */ render() { if (!this.outlet || !this.currentRoute) return const { componentTag, params } = this.currentRoute const oldComponent = this.outlet.firstElementChild const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches const component = document.createElement(componentTag) Object.entries(params).forEach(([key, value]) => { component.setAttribute(`data-${key}`, value) }) if (prefersReducedMotion || !oldComponent) { if (oldComponent) oldComponent.remove() this.outlet.appendChild(component) return } component.classList.add('animate__animated', 'animate__fadeIn', 'animate__faster') oldComponent.classList.add('animate__animated', 'animate__fadeOut', 'animate__faster') const fallbackTimer = setTimeout(() => { oldComponent.remove() this.outlet.appendChild(component) }, 300) oldComponent.addEventListener('animationend', () => { clearTimeout(fallbackTimer) oldComponent.remove() this.outlet.appendChild(component) }, { once: true }) } /** @private */ async renderNotFound() { if (!this.outlet) return if (!customElements.get('page-not-found') && this._notFoundLoader) { await this._notFoundLoader() } this.outlet.innerHTML = '' const notFound = document.createElement('page-not-found') this.outlet.appendChild(notFound) } /** * Set the loader for the 404 page * @param {Function} loader - Dynamic import function */ setNotFoundLoader(loader) { this._notFoundLoader = loader } /** * Navigate to a path * @param {string} path - Target path * @param {Object} [params={}] - Query parameters */ navigate(path, params = {}) { let url = `#${path}` if (Object.keys(params).length > 0) { const queryString = new URLSearchParams(params).toString() url += `?${queryString}` } window.location.hash = url.slice(1) } /** Navigate back in history */ back() { window.history.back() } /** Navigate forward in history */ forward() { window.history.forward() } /** * Get current route info * @returns {Route|null} */ getCurrentRoute() { return this.currentRoute } } export const router = new Router()