|
export default class Router {
|
|
constructor(options = {}) {
|
|
this.routes = new Map();
|
|
this.currentRoute = null;
|
|
this.basePath = options.basePath || '/';
|
|
this.transitionEnabled = true;
|
|
this.logger = options.logger || null;
|
|
this.appState = options.appState || null;
|
|
this._setupListeners();
|
|
}
|
|
|
|
register(path, handler, metadata = {}) {
|
|
this.routes.set(path, { handler, metadata });
|
|
return this;
|
|
}
|
|
|
|
async navigate(path, state = {}, skipTransition = false) {
|
|
try {
|
|
const route = this._matchRoute(path);
|
|
|
|
if (!route) {
|
|
this.logger?.warn(`No route found for: ${path}`);
|
|
return;
|
|
}
|
|
|
|
if (this.currentRoute?.path === path) return;
|
|
|
|
this.appState?.setState({ isLoading: true });
|
|
|
|
const performTransition = () => {
|
|
if (this.transitionEnabled && !skipTransition && document.startViewTransition) {
|
|
document.startViewTransition(() => {
|
|
this._executeRoute(path, route, state);
|
|
}).finished.then(() => {
|
|
this.appState?.setState({ isLoading: false });
|
|
}).catch(err => {
|
|
this.logger?.error('View transition failed', err);
|
|
this.appState?.setState({ isLoading: false });
|
|
});
|
|
} else {
|
|
this._executeRoute(path, route, state);
|
|
this.appState?.setState({ isLoading: false });
|
|
}
|
|
};
|
|
|
|
window.history.pushState(state, '', `${this.basePath}${path}`);
|
|
performTransition();
|
|
} catch (error) {
|
|
this.logger?.error(`Navigation error: ${error.message}`);
|
|
this.appState?.setState({ isLoading: false });
|
|
}
|
|
}
|
|
|
|
_executeRoute(path, route, state) {
|
|
const root = document.getElementById('app-root');
|
|
if (!root) return;
|
|
|
|
root.innerHTML = '';
|
|
|
|
this.currentRoute = { path, ...route };
|
|
|
|
try {
|
|
const result = route.handler(state);
|
|
|
|
if (result instanceof HTMLElement) {
|
|
root.appendChild(result);
|
|
} else if (typeof result === 'string') {
|
|
root.innerHTML = result;
|
|
}
|
|
|
|
this.appState?.setState({ currentPage: path });
|
|
window.dispatchEvent(new CustomEvent('route-changed', { detail: { path, state } }));
|
|
} catch (error) {
|
|
this.logger?.error(`Route handler failed for ${path}`, error);
|
|
root.innerHTML = '<h1>Error loading page</h1>';
|
|
}
|
|
}
|
|
|
|
_matchRoute(path) {
|
|
if (this.routes.has(path)) {
|
|
return this.routes.get(path);
|
|
}
|
|
|
|
for (const [routePath, route] of this.routes.entries()) {
|
|
const regex = this._pathToRegex(routePath);
|
|
if (regex.test(path)) {
|
|
return route;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
_pathToRegex(path) {
|
|
const pattern = path
|
|
.replace(/\//g, '\\/')
|
|
.replace(/:(\w+)/g, '(?<$1>[^/]+)')
|
|
.replace(/\*/g, '.*');
|
|
return new RegExp(`^${pattern}$`);
|
|
}
|
|
|
|
_setupListeners() {
|
|
window.addEventListener('popstate', (event) => {
|
|
const path = window.location.pathname.replace(this.basePath, '') || '/';
|
|
this.navigate(path, event.state, true);
|
|
});
|
|
|
|
document.addEventListener('click', (event) => {
|
|
const link = event.target.closest('a[data-nav]');
|
|
if (link) {
|
|
event.preventDefault();
|
|
const path = link.getAttribute('href') || link.getAttribute('data-nav');
|
|
this.navigate(path);
|
|
}
|
|
});
|
|
}
|
|
|
|
back() {
|
|
window.history.back();
|
|
}
|
|
|
|
forward() {
|
|
window.history.forward();
|
|
}
|
|
}
|