UNPKG

@wroud/navigation

Version:

A flexible, pattern-matching navigation system for JavaScript applications with built-in routing, browser integration, and navigation state management

228 lines (190 loc) 5.36 kB
import type { INavigation } from "./INavigation.js"; import type { INavigationState } from "./INavigationState.js"; import type { IRouteMatcher, RouteMatcherState } from "./IRouteMatcher.js"; import type { IRouteState } from "./IRouteState.js"; import type { IRouter } from "./IRouter.js"; import { NavigationType, type NavigationListener, } from "./NavigationListener.js"; import { Router } from "./Router.js"; import { LinkedList } from "./sdk/LinkedList.js"; export class Navigation<TMatcher extends IRouteMatcher = IRouteMatcher> implements INavigation<TMatcher> { get state(): RouteMatcherState<TMatcher> | null { return this.innerState.history.get(this.innerState.position)!; } get history(): RouteMatcherState<TMatcher>[] { return this.innerState.history.toArray(); } get position(): number { return this.innerState.position; } private innerState: INavigationState<RouteMatcherState<TMatcher>>; private readonly listeners: Set<NavigationListener>; readonly router: IRouter<TMatcher>; constructor(router?: IRouter<TMatcher>) { this.router = router || new Router(); this.listeners = new Set(); this.innerState = { position: -1, history: new LinkedList(), }; this.addListener = this.addListener.bind(this); this.getState = this.getState.bind(this); } /** * Get the current navigation state */ getState(): RouteMatcherState<TMatcher> | null { return this.state; } /** * Set the navigation state and position */ setState(position: number, state?: RouteMatcherState<TMatcher>[]) { this.innerState = { position, history: new LinkedList(state || []), }; } /** * Replace the current state with a new one */ async replace(state: RouteMatcherState<TMatcher> | null) { if (state) { const route = this.router.getRoute(state.id); if (!route) { throw new Error(`Route ${state.id} not found`); } } if ( !(await this.canDeactivate(state)) || !(await this.canActivate(state)) ) { return; } const previousState = this.state; if (state) { this.innerState.history.set(this.innerState.position, state); } else { this.innerState.history.setHead(null); } this.notifyListeners(NavigationType.Replace, previousState, state); } /** * Navigate to a new state */ async navigate(state: RouteMatcherState<TMatcher> | null) { if (state) { const route = this.router.getRoute(state.id); if (!route) { throw new Error(`Route ${state.id} not found`); } } if ( !(await this.canDeactivate(state)) || !(await this.canActivate(state)) ) { return; } const previousState = this.state; if (this.innerState.position < this.innerState.history.size - 1) { this.innerState.history.removeFrom(this.innerState.position + 1); } if (state) { this.innerState.history.push(state); } else { this.innerState.history.setHead(null); } this.innerState.position++; this.notifyListeners(NavigationType.Navigate, previousState, state); } /** * Navigate back to the previous state */ async goBack() { if (this.position === 0) { return; } const state = this.innerState.history.get(this.innerState.position - 1) || null; if ( !(await this.canDeactivate(state)) || !(await this.canActivate(state)) ) { return; } const previousState = this.state; this.innerState.position--; this.notifyListeners(NavigationType.Back, previousState, state); } /** * Add a navigation listener */ addListener(listener: NavigationListener) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; } /** * Remove a navigation listener */ removeListener(listener: NavigationListener): void { this.listeners.delete(listener); } /** * Notify all listeners of a navigation event */ private notifyListeners( type: NavigationType, from: IRouteState | null, to: IRouteState | null, ) { for (const listener of this.listeners) { listener(type, from, to); } } /** * Check if we can deactivate from the current route */ private async canDeactivate(state: IRouteState | null): Promise<boolean> { const currentState = this.state; if (!currentState) { return true; } const deactivationTree = this.router .getRouteTree(currentState.id) .reverse(); for (const route of deactivationTree) { if (route.canDeactivate) { const result = await route.canDeactivate(state, currentState); if (result === false) { return false; } } } return true; } /** * Check if we can activate the target route */ private async canActivate(state: IRouteState | null): Promise<boolean> { if (!state) { return true; } const currentState = this.state; const activationTree = this.router.getRouteTree(state.id); for (const route of activationTree) { if (route.canActivate) { const result = await route.canActivate(state, currentState || null); if (result === false) { return false; } } } return true; } }