Files
el/ui/runtime/src/router.js
T

137 lines
4.3 KiB
JavaScript

/**
* router.js — Graph-based routing for el-ui.
*
* Routes are nodes in the graph. Navigation activates the target route node,
* which spreads through the graph to any components subscribed to routing.
* This means route changes propagate with the same activation semantics as
* state changes — route-dependent components update automatically.
*
* Route nodes have:
* type: 'route'
* name: the path string
* content: the path string
* importance: starts at 0.5, boosted on activation (recently visited routes
* become more salient — models browser history behavior)
*/
export class Router {
/**
* @param {import('./graph.js').Graph} graph
* @param {Record<string, any>} routes path → Component class
*/
constructor(graph, routes) {
this.graph = graph;
this.routes = routes;
this.currentPath = window.location.pathname;
this._routeNodes = {};
this._subscribers = [];
// Seed each route as a node in the graph
for (const [path] of Object.entries(routes)) {
const id = graph.seed({ type: 'route', name: path, content: path, importance: 0.3 });
this._routeNodes[path] = id;
}
// Connect route nodes in path order (parent → child activation chain)
// e.g., / → /about → /about/team
const paths = Object.keys(routes).sort();
for (let i = 0; i < paths.length - 1; i++) {
const parent = paths[i];
const child = paths[i + 1];
if (child.startsWith(parent) && parent !== child) {
graph.connect(this._routeNodes[parent], this._routeNodes[child], {
weight: 0.8,
relation: 'subroute',
});
}
}
// Listen for browser back/forward
window.addEventListener('popstate', () => {
this._activate(window.location.pathname);
});
}
/**
* Navigate to a path, update history, and activate the route graph node.
* @param {string} path
* @param {boolean} [replace=false] use replaceState instead of pushState
*/
navigate(path, replace = false) {
if (path === this.currentPath) return;
if (replace) {
history.replaceState({}, '', path);
} else {
history.pushState({}, '', path);
}
this._activate(path);
}
/**
* Get the Component class for the current path.
* Falls back to the '*' wildcard route if present.
* @returns {any}
*/
currentComponent() {
return this.routes[this.currentPath] ?? this.routes['*'] ?? null;
}
/**
* Subscribe to route changes.
* @param {function(string): void} callback called with the new path
* @returns {function} unsubscribe
*/
subscribe(callback) {
this._subscribers.push(callback);
return () => {
const idx = this._subscribers.indexOf(callback);
if (idx >= 0) this._subscribers.splice(idx, 1);
};
}
/**
* Generate an href-compatible link string.
* @param {string} path
* @returns {string}
*/
href(path) {
return path;
}
/** @private */
_activate(path) {
const prevPath = this.currentPath;
this.currentPath = path;
// Find the best matching route
const matchedPath = this._matchPath(path);
if (matchedPath && this._routeNodes[matchedPath]) {
// Spreading activation from the new route node
this.graph.activate(this._routeNodes[matchedPath]);
// Boost importance of the newly visited route
const node = this.graph.get(this._routeNodes[matchedPath]);
if (node) node.importance = Math.min(1.0, node.importance + 0.2);
}
// Notify route subscribers
if (path !== prevPath) {
this._subscribers.forEach(cb => cb(path));
}
}
/** @private */
_matchPath(path) {
// Exact match first
if (this.routes[path]) return path;
// Prefix match (longest prefix wins)
const candidates = Object.keys(this.routes)
.filter(r => r !== '*' && path.startsWith(r))
.sort((a, b) => b.length - a.length);
if (candidates.length > 0) return candidates[0];
// Wildcard
return '*';
}
}