@virtualstate/navigation
Version:
Native JavaScript [navigation](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API) implementation
321 lines (291 loc) • 8.93 kB
text/typescript
import { NavigateEvent } from "../spec/navigation";
import {isURLPatternPlainPathname, isURLPatternStringPlain, URLPattern} from "./url-pattern";
import { Event } from "../event-target";
import { like } from "../is";
import {
ErrorFn,
PatternErrorFn,
PatternRouteFn,
PatternThenFn,
RouteFn,
RouteRecord,
RouterListenTarget,
ThenFn
} from "./types";
import {transitionEvent} from "./transition";
const Routes = Symbol.for("@virtualstate/navigation/routes/routes");
const Attached = Symbol.for("@virtualstate/navigation/routes/attached");
const Detach = Symbol.for("@virtualstate/navigation/routes/detach");
const Target = Symbol.for("@virtualstate/navigation/routes/target");
const TargetType = Symbol.for("@virtualstate/navigation/routes/target/type");
/**
* @internal
*/
export function getRouterRoutes<E extends Event, R>(router: Router<E, R>): RouteRecord<E, R> {
return router[Routes];
}
export function isRouter<E extends Event = NavigateEvent, R = void | unknown>(
value: unknown
): value is Router<E, R> {
function isRouterLike(value: unknown): value is { [Routes]: unknown } {
return !!value;
}
return isRouterLike(value) && !!value[Routes];
}
function getPatternString(pattern?: string | URLPattern): string | undefined {
if (!pattern) return undefined;
if (typeof pattern === "string") {
if (isURLPatternStringPlain(pattern)) {
return pattern;
} else {
return undefined;
}
}
if (isURLPatternPlainPathname(pattern)) {
return pattern.pathname;
}
return undefined;
}
function getPattern(pattern?: string | URLPattern): URLPattern {
if (!pattern) return undefined;
if (typeof pattern !== "string") {
return pattern;
}
return new URLPattern({ pathname: pattern });
}
export class Router<
E extends Event = NavigateEvent,
R = void | unknown,
T extends RouterListenTarget<E> = RouterListenTarget<E>
> {
[Routes]: RouteRecord<E, R> = {
router: [],
route: [],
reject: [],
resolve: [],
};
[Attached] = new Set<Router<E, R>>();
[Target]: T;
[TargetType]: E["type"];
private listening = false;
constructor(target?: T, type?: E["type"]) {
this[Target] = target;
this[TargetType] = type;
// Catch use override types with
// arrow functions so need to bind manually
this.routes = this.routes.bind(this);
this.route = this.route.bind(this);
this.then = this.then.bind(this);
this.catch = this.catch.bind(this);
}
routes(pattern: string | URLPattern, router: Router<E, R>): this;
routes(router: Router<E, R>): this;
routes(...args: [string | URLPattern, Router<E, R>] | [Router<E, R>]): this;
routes(...args: [string | URLPattern, Router<E, R>] | [Router<E, R>]): this {
let router, pattern;
if (args.length === 1) {
[router] = args;
} else if (args.length === 2) {
[pattern, router] = args;
}
if (router[Attached].has(this)) {
throw new Error("Router already attached");
}
this[Routes].router.push({
string: getPatternString(pattern),
pattern: getPattern(pattern),
router,
});
router[Attached].add(this);
this.#init();
return this;
}
then(pattern: string | URLPattern, fn: PatternThenFn<E, R>): this;
then(pattern: string | URLPattern, fn: PatternThenFn<E, R>, errorFn: PatternErrorFn<E>): this;
then(fn: ThenFn<E, R>): this;
then(fn: ThenFn<E, R>, catchFn: ErrorFn<E>): this;
then(
...args: [string | URLPattern, PatternThenFn<E, R>] | [string | URLPattern, PatternThenFn<E, R>, PatternErrorFn<E>] | [ThenFn<E, R>] | [ThenFn<E, R>, ErrorFn<E>]
): this;
then(
...args: [string | URLPattern, PatternThenFn<E, R>] | [string | URLPattern, PatternThenFn<E, R>, PatternErrorFn<E>] | [ThenFn<E, R>] | [ThenFn<E, R>, ErrorFn<E>]
): this {
if (args.length === 1) {
const [fn] = args;
this[Routes].resolve.push({
fn,
});
} else if (args.length === 2 && isThenError(args)) {
const [fn, errorFn] = args;
this[Routes].resolve.push({
fn,
});
this[Routes].reject.push({
fn: errorFn,
});
} else {
const [pattern, fn, errorFn] = args;
this[Routes].resolve.push({
string: getPatternString(pattern),
pattern: getPattern(pattern),
fn,
});
if (errorFn) {
this[Routes].reject.push({
string: getPatternString(pattern),
pattern: getPattern(pattern),
fn: errorFn
})
}
}
// No init for just then
return this;
function isThenError(args: [unknown, unknown]): args is [ThenFn<E, R>, ErrorFn<E>] {
const [left, right] = args;
return typeof left === "function" && typeof right === "function";
}
}
catch(pattern: string | URLPattern, fn: PatternErrorFn<E>): this;
catch(fn: ErrorFn<E>): this;
catch(...args: [string | URLPattern, PatternErrorFn<E>] | [ErrorFn<E>]): this;
catch(
...args: [string | URLPattern, PatternErrorFn<E>] | [ErrorFn<E>]
): this {
if (args.length === 1) {
const [fn] = args;
this[Routes].reject.push({
fn,
});
} else {
const [pattern, fn] = args;
this[Routes].reject.push({
string: getPatternString(pattern),
pattern: getPattern(pattern),
fn,
});
}
// No init for just catch
return this;
}
route(pattern: string | URLPattern, fn: PatternRouteFn<E, R>): this;
route(fn: RouteFn<E, R>): this;
route(
...args: [string | URLPattern, PatternRouteFn<E, R>] | [RouteFn<E, R>]
): this;
route(
...args: [string | URLPattern, PatternRouteFn<E, R>] | [RouteFn<E, R>]
): this {
if (args.length === 1) {
const [fn] = args;
this[Routes].route.push({
fn,
});
} else {
const [pattern, fn] = args;
this[Routes].route.push({
string: getPatternString(pattern),
pattern: getPattern(pattern),
fn,
});
}
this.#init();
return this;
}
[Detach](router: Router<E, R>) {
const index = this[Routes].router.findIndex(
(route) => route.router === router
);
if (index > -1) {
this[Routes].router.splice(index, 1);
}
const length = Object.values(this[Routes]).reduce(
(sum, routes) => sum + routes.length,
0
);
if (length === 0) {
this.#deinit();
}
}
detach = () => {
if (this.listening) {
this.#deinit();
}
for (const attached of this[Attached]) {
if (isRouter<E, R>(attached)) {
attached[Detach](this);
}
}
this[Attached] = new Set();
};
#init = () => {
if (this.listening) {
return;
}
const target = this[Target];
if (!target) return;
this.listening = true;
if (typeof target === "function") {
return target(this.#event);
}
const type = this[TargetType] ?? "navigate";
target.addEventListener(type, this.#event);
};
#deinit = () => {
if (!this.listening) {
return;
}
const target = this[Target];
if (!target) return;
if (typeof target === "function") {
throw new Error("Cannot stop listening");
}
this.listening = false;
const type = this[TargetType] ?? "navigate";
target.removeEventListener(type, this.#event);
};
#event = (event: E) => {
if (!event.canIntercept) return;
if (isIntercept(event)) {
event.intercept({
handler: () => {
return this.#transition(event)
}
});
} else if (isTransitionWhile(event)) {
event.transitionWhile(this.#transition(event));
} else if (isWaitUntil(event)) {
event.waitUntil(this.#transition(event));
} else if (isRespondWith(event)) {
event.respondWith(this.#transition(event));
} else {
return this.#transition(event);
}
function isIntercept(event: E): event is E & { intercept(options?: { handler(): Promise<unknown> }): void } {
return (
like<{ intercept: unknown }>(event) &&
typeof event.intercept === "function"
)
}
function isTransitionWhile(event: E): event is E & { transitionWhile(promise: Promise<unknown>): void } {
return (
like<{ transitionWhile: unknown }>(event) &&
typeof event.transitionWhile === "function"
)
}
function isRespondWith(event: E): event is E & { respondWith(promise: Promise<unknown>): void } {
return (
like<{ respondWith: unknown }>(event) &&
typeof event.respondWith === "function"
)
}
function isWaitUntil(event: E): event is E & { waitUntil(promise: Promise<unknown>): void } {
return (
like<{ waitUntil: unknown }>(event) &&
typeof event.waitUntil === "function"
)
}
};
#transition = async (event: E): Promise<void> => {
return transitionEvent(this, event);
};
}