235 lines
6.5 KiB
JavaScript
235 lines
6.5 KiB
JavaScript
/**
|
|
* Hash-based Client-Side Router
|
|
* @module router
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} Route
|
|
* @property {string} path - Current path
|
|
* @property {Object<string, string>} params - Route and query params
|
|
* @property {string} componentTag - Web component tag name
|
|
*/
|
|
|
|
/**
|
|
* Hash-based router for SPA navigation
|
|
* @class
|
|
*/
|
|
class Router {
|
|
constructor() {
|
|
/** @type {Map<string, string>} */
|
|
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<string, string>} [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()
|