Created
April 8, 2025 02:28
-
-
Save elliots/eed4836e2be26197111a4fff7349d2f2 to your computer and use it in GitHub Desktop.
@lit-labs/router but it uses Navigation API
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @license | |
* Copyright 2021 Google LLC | |
* SPDX-License-Identifier: BSD-3-Clause | |
* | |
* Adapted to use the Navigation API. | |
*/ | |
import { Routes } from './routes.js' | |
/** | |
* A root-level router that uses the Navigation API to intercept navigation. | |
* | |
* This class extends Routes so that it can also have a route configuration. | |
* | |
* There should only be one Router instance on a page, since the Router | |
* installs global event listeners. Nested routes should be configured | |
* with the `Routes` class. | |
*/ | |
export class Router extends Routes { | |
override hostConnected() { | |
super.hostConnected() | |
// Set up Navigation API handlers | |
window.navigation.addEventListener('navigate', this._onNavigate) | |
window.navigation.addEventListener('currententrychange', this._onCurrentEntryChange) | |
// Kick off routed rendering by going to the current URL state | |
// Use void to explicitly ignore the promise returned by the async handler | |
void this._handleNavigation() | |
} | |
override hostDisconnected() { | |
super.hostDisconnected() | |
window.navigation.removeEventListener('navigate', this._onNavigate) | |
window.navigation.removeEventListener('currententrychange', this._onCurrentEntryChange) | |
} | |
/** | |
* Programmatically navigate to a new path using the Navigation API. | |
* @param path The path to navigate to. | |
* @param replaceState If true, replaces the current history entry instead of pushing a new one. | |
*/ | |
navigate(path: string, replaceState = false): void { | |
const url = new URL(path, window.location.origin) | |
const navigationOptions = { | |
history: replaceState ? 'replace' : ('push' as 'replace' | 'push'), | |
} | |
window.navigation.navigate(url.toString(), navigationOptions) | |
} | |
/** | |
* Handles the 'navigate' event from the Navigation API. | |
* Intercepts client-side navigations. | |
*/ | |
private _onNavigate = (event: NavigateEvent) => { | |
// Intercept navigations that we can handle client-side. | |
// The actual route update is handled by _onCurrentEntryChange. | |
if (event.canIntercept && !event.downloadRequest && !event.formData) { | |
event.intercept() // Signal that we'll handle this navigation | |
} | |
} | |
/** | |
* Handles the 'currententrychange' event from the Navigation API. | |
* Triggers the route update. | |
*/ | |
private _onCurrentEntryChange = () => { | |
// Use void to explicitly ignore the promise returned by the async handler | |
void this._handleNavigation() | |
} | |
/** | |
* Updates the route based on the current URL. | |
* Includes a microtask delay to ensure previous route components disconnect. | |
*/ | |
private async _handleNavigation() { | |
const pathname = window.location.pathname | |
this.goto(pathname) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @license | |
* Copyright 2021 Google LLC | |
* SPDX-License-Identifier: BSD-3-Clause | |
*/ | |
/// <reference types="urlpattern-polyfill" /> | |
import type { ReactiveController, ReactiveControllerHost } from 'lit' | |
export interface BaseRouteConfig { | |
name?: string | undefined | |
render?: (params: { [key: string]: string | undefined }) => unknown | |
enter?: (params: { [key: string]: string | undefined }) => Promise<boolean> | boolean | |
} | |
/** | |
* A RouteConfig that matches against a `path` string. `path` must be a | |
* [`URLPattern` compatible pathname pattern](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern/pathname). | |
*/ | |
export interface PathRouteConfig extends BaseRouteConfig { | |
path: string | |
} | |
/** | |
* A RouteConfig that matches against a given [`URLPattern`](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern) | |
* | |
* While `URLPattern` can match against protocols, hostnames, and ports, | |
* routes will only be checked for matches if they're part of the current | |
* origin. This means that the pattern is limited to checking `pathname` and | |
* `search`. | |
*/ | |
export interface URLPatternRouteConfig extends BaseRouteConfig { | |
pattern: URLPattern | |
} | |
/** | |
* A description of a route, which path or pattern to match against, and a | |
* render() callback used to render a match to the outlet. | |
*/ | |
export type RouteConfig = PathRouteConfig | URLPatternRouteConfig | |
// A cache of URLPatterns created for PathRouteConfig. | |
// Rather than converting all given RoutConfigs to URLPatternRouteConfig, this | |
// lets us make `routes` mutable so users can add new PathRouteConfigs | |
// dynamically. | |
const patternCache = new WeakMap<PathRouteConfig, URLPattern>() | |
const isPatternConfig = (route: RouteConfig): route is URLPatternRouteConfig => (route as URLPatternRouteConfig).pattern !== undefined | |
const getPattern = (route: RouteConfig) => { | |
if (isPatternConfig(route)) { | |
return route.pattern | |
} | |
let pattern = patternCache.get(route) | |
if (pattern === undefined) { | |
patternCache.set(route, (pattern = new URLPattern({ pathname: route.path }))) | |
} | |
return pattern | |
} | |
/** | |
* A reactive controller that performs location-based routing using a | |
* configuration of URL patterns and associated render callbacks. | |
*/ | |
export class Routes implements ReactiveController { | |
private readonly _host: ReactiveControllerHost & HTMLElement | |
/* | |
* The currently installed set of routes in precedence order. | |
* | |
* This array is mutable. To dynamically add a new route you can write: | |
* | |
* ```ts | |
* this._routes.routes.push({ | |
* path: '/foo', | |
* render: () => html`<p>Foo</p>`, | |
* }); | |
* ``` | |
* | |
* Mutating this property does not trigger any route transitions. If the | |
* changes may result is a different route matching for the current path, you | |
* must instigate a route update with `goto()`. | |
*/ | |
routes: Array<RouteConfig> = [] | |
/** | |
* A default fallback route which will always be matched if none of the | |
* {@link routes} match. Implicitly matches to the path "/*". | |
*/ | |
fallback?: BaseRouteConfig | |
/* | |
* The current set of child Routes controllers. These are connected via | |
* the routes-connected event. | |
*/ | |
private readonly _childRoutes: Array<Routes> = [] | |
private _parentRoutes: Routes | undefined | |
/* | |
* State related to the current matching route. | |
* | |
* We keep this so that consuming code can access current parameters, and so | |
* that we can propagate tail matches to child routes if they are added after | |
* navigation / matching. | |
*/ | |
private _currentPathname: string | undefined | |
private _currentRoute: RouteConfig | undefined | |
private _currentParams: { | |
[key: string]: string | undefined | |
} = {} | |
constructor(host: ReactiveControllerHost & HTMLElement, routes: Array<RouteConfig>, options?: { fallback?: BaseRouteConfig }) { | |
;(this._host = host).addController(this) | |
this.routes = [...routes] | |
this.fallback = options?.fallback | |
} | |
/** | |
* Returns a URL string of the current route, including parent routes, | |
* optionally replacing the local path with `pathname`. | |
*/ | |
link(pathname?: string): string { | |
if (pathname?.startsWith('/')) { | |
return pathname | |
} | |
if (pathname?.startsWith('.')) { | |
throw new Error('Not implemented') | |
} | |
pathname ??= this._currentPathname | |
return (this._parentRoutes?.link() ?? '') + pathname | |
} | |
/** | |
* Navigates this routes controller to `pathname`. | |
* | |
* This does not navigate parent routes, so it isn't (yet) a general page | |
* navigation API. It does navigate child routes if pathname matches a | |
* pattern with a tail wildcard pattern (`/*`). | |
*/ | |
async goto(pathname: string) { | |
// TODO (justinfagnani): handle absolute vs relative paths separately. | |
// TODO (justinfagnani): do we need to detect when goto() is called while | |
// a previous goto() call is still pending? | |
// TODO (justinfagnani): generalize this to handle query params and | |
// fragments. It currently only handles path names because it's easier to | |
// completely disregard the origin for now. The click handler only does | |
// an in-page navigation if the origin matches anyway. | |
let tailGroup: string | undefined | |
if (this.routes.length === 0 && this.fallback === undefined) { | |
// If a routes controller has none of its own routes it acts like it has | |
// one route of `/*` so that it passes the whole pathname as a tail | |
// match. | |
tailGroup = pathname | |
this._currentPathname = '' | |
// Simulate a tail group with the whole pathname | |
this._currentParams = { 0: tailGroup } | |
} else { | |
const route = this._getRoute(pathname) | |
if (route === undefined) { | |
throw new Error(`No route found for ${pathname} on ${this._host.tagName}`) | |
} | |
const pattern = getPattern(route) | |
const result = pattern.exec({ pathname }) | |
const params = result?.pathname.groups ?? {} | |
tailGroup = getTailGroup(params) | |
if (typeof route.enter === 'function') { | |
const success = await route.enter(params) | |
// If enter() returns false, cancel this navigation | |
if (success === false) { | |
return | |
} | |
} | |
// Only update route state if the enter handler completes successfully | |
this._currentRoute = route | |
this._currentParams = params | |
this._currentPathname = tailGroup === undefined ? pathname : pathname.substring(0, pathname.length - tailGroup.length) | |
} | |
// Request an update on the host to render the new route | |
this._host.requestUpdate() | |
// Wait for the host to complete rendering before updating child routes | |
await this._host.updateComplete | |
// After the parent has rendered, update any connected child routes | |
// with the tail segment. This handles subsequent navigations where the | |
// child might already be connected. | |
if (tailGroup !== undefined) { | |
for (const child of this._childRoutes) { | |
// Use void to explicitly ignore the promise returned by the async goto | |
void child.goto(tailGroup) | |
} | |
} | |
} | |
/** | |
* The result of calling the current route's render() callback. | |
*/ | |
outlet() { | |
return this._currentRoute?.render?.(this._currentParams) | |
} | |
/** | |
* The current parsed route parameters. | |
*/ | |
get params() { | |
return this._currentParams | |
} | |
/** | |
* Matches `url` against the installed routes and returns the first match. | |
*/ | |
private _getRoute(pathname: string): RouteConfig | undefined { | |
const matchedRoute = this.routes.find(r => getPattern(r).test({ pathname: pathname })) | |
if (matchedRoute || this.fallback === undefined) { | |
return matchedRoute | |
} | |
if (this.fallback) { | |
// The fallback route behaves like it has a "/*" path. This is hidden from | |
// the public API but is added here to return a valid RouteConfig. | |
return { ...this.fallback, path: '/*' } | |
} | |
return undefined | |
} | |
hostConnected() { | |
this._host.addEventListener(RoutesConnectedEvent.eventName, this._onRoutesConnected) | |
const event = new RoutesConnectedEvent(this) | |
this._host.dispatchEvent(event) | |
} | |
hostDisconnected() { | |
this._parentRoutes?._disconnect(this) | |
this._parentRoutes = undefined | |
} | |
private _onRoutesConnected = (e: RoutesConnectedEvent) => { | |
// Don't handle the event fired by this routes controller, which we get | |
// because we do this.dispatchEvent(...) | |
if (e.routes === this) { | |
return | |
} | |
const childRoutes = e.routes | |
this._childRoutes.push(childRoutes) | |
childRoutes._parentRoutes = this | |
e.stopImmediatePropagation() | |
const tailGroup = getTailGroup(this._currentParams) | |
if (tailGroup !== undefined) { | |
// Use void to explicitly ignore the promise returned by the async goto | |
void childRoutes.goto(tailGroup) | |
} | |
} | |
private _disconnect(routes: Routes) { | |
// Remove route from this._childRoutes: | |
// `>>> 0` converts -1 to 2**32-1 | |
this._childRoutes?.splice(this._childRoutes.indexOf(routes) >>> 0, 1) | |
} | |
} | |
/** | |
* Returns the tail of a pathname groups object. This is the match from a | |
* wildcard at the end of a pathname pattern, like `/foo/*` | |
*/ | |
const getTailGroup = (groups: { [key: string]: string | undefined }) => { | |
let tailKey: string | undefined | |
for (const key of Object.keys(groups)) { | |
if (/\d+/.test(key) && (tailKey === undefined || key > tailKey!)) { | |
tailKey = key | |
} | |
} | |
return tailKey && groups[tailKey] | |
} | |
/** | |
* This event is fired from Routes controllers when their host is connected to | |
* announce the child route and potentially connect to a parent routes controller. | |
*/ | |
export class RoutesConnectedEvent extends Event { | |
static readonly eventName = 'lit-routes-connected' | |
readonly routes: Routes | |
constructor(routes: Routes) { | |
super(RoutesConnectedEvent.eventName, { | |
bubbles: true, | |
composed: true, | |
cancelable: false, | |
}) | |
this.routes = routes | |
} | |
} | |
declare global { | |
interface HTMLElementEventMap { | |
[RoutesConnectedEvent.eventName]: RoutesConnectedEvent | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment