extends HTMLElement {
constructAbsolutePath: ((path: PathFragment) => string);
parent: IRouterSlot | null | undefined;
queryParentRouterSlot: (() => IRouterSlot
| null);
+ // Return the matched route if one found for the given path
+ getRouteMatch(path: string | PathFragment): IRouteMatch | null;
}
export type IRoutingInfo = {
@@ -174,4 +176,4 @@ declare global {
"navigationerror": NavigationErrorEvent,
"willchangestate": WillChangeStateEvent
}
-}
\ No newline at end of file
+}
diff --git a/src/lib/router-slot.ts b/src/lib/router-slot.ts
index db07ac7..c292b88 100644
--- a/src/lib/router-slot.ts
+++ b/src/lib/router-slot.ts
@@ -1,6 +1,6 @@
import { GLOBAL_ROUTER_EVENTS_TARGET, ROUTER_SLOT_TAG_NAME } from "./config";
import { Cancel, EventListenerSubscription, GlobalRouterEvent, IPathFragments, IRoute, IRouteMatch, IRouterSlot, IRoutingInfo, Params, PathFragment, RouterSlotEvent } from "./model";
-import { addListener, constructAbsolutePath, dispatchGlobalRouterEvent, dispatchRouteChangeEvent, ensureAnchorHistory, ensureHistoryEvents, handleRedirect, isRedirectRoute, isResolverRoute, matchRoutes, pathWithoutBasePath, queryParentRouterSlot, removeListeners, resolvePageComponent, shouldNavigate } from "./util";
+import { addListener, AnchorHandler, constructAbsolutePath, dispatchGlobalRouterEvent, dispatchRouteChangeEvent, ensureHistoryEvents, handleRedirect, IAnchorHandler, isRedirectRoute, isResolverRoute, matchRoutes, pathWithoutBasePath, queryParentRouterSlot, removeListeners, resolvePageComponent, shouldNavigate } from "./util";
const template = document.createElement("template");
template.innerHTML = ``;
@@ -8,9 +8,6 @@ template.innerHTML = ``;
// Patches the history object and ensures the correct events.
ensureHistoryEvents();
-// Ensure the anchor tags uses the history API
-ensureAnchorHistory();
-
/**
* Slot for a node in the router tree.
* @slot - Default content.
@@ -89,6 +86,11 @@ export class RouterSlot extends HTMLElement implements IRouter
return this.match != null ? this.match.params : null;
}
+ /**
+ * The anchor link handler for the router slot
+ */
+ private anchorHandler?: IAnchorHandler;
+
/**
* Hooks up the element.
*/
@@ -107,6 +109,7 @@ export class RouterSlot extends HTMLElement implements IRouter
*/
connectedCallback () {
this.parent = this.queryParentRouterSlot();
+ this.setupAnchorListener();
}
/**
@@ -114,6 +117,7 @@ export class RouterSlot extends HTMLElement implements IRouter
*/
disconnectedCallback () {
this.detachListeners();
+ this.detachAnchorListener()
}
/**
@@ -155,6 +159,15 @@ export class RouterSlot extends HTMLElement implements IRouter
this._routes.length = 0;
}
+ /**
+ * Return a route match for a given path or null if there's no match
+ * @param path
+ */
+ getRouteMatch(path: string | PathFragment): IRouteMatch | null {
+ const match = matchRoutes(this._routes, path);
+ return match;
+ }
+
/**
* Each time the path changes, load the new path.
*/
@@ -179,6 +192,22 @@ export class RouterSlot extends HTMLElement implements IRouter
await this.renderPath(pathFragment);
}
+ /**
+ * Attach the anchor listener
+ */
+ protected setupAnchorListener(): void {
+ // only bind the AnchorHandler to the root router
+ // otherwise, we get multiple click handlers,
+ // each responding to a different router
+ if (!this.isRoot) return;
+ this.anchorHandler = new AnchorHandler(this);
+ this.anchorHandler?.setup();
+ }
+
+ protected detachAnchorListener(): void {
+ this.anchorHandler?.teardown();
+ }
+
/**
* Attaches listeners, either globally or on the parent router.
*/
@@ -219,7 +248,7 @@ export class RouterSlot extends HTMLElement implements IRouter
protected async renderPath (path: string | PathFragment): Promise {
// Find the corresponding route.
- const match = matchRoutes(this._routes, path);
+ const match = this.getRouteMatch(path);
// Ensure that a route was found, otherwise we just clear the current state of the route.
if (match == null) {
diff --git a/src/lib/util/anchor.ts b/src/lib/util/anchor.ts
index 0a36683..2352290 100644
--- a/src/lib/util/anchor.ts
+++ b/src/lib/util/anchor.ts
@@ -1,10 +1,40 @@
+import type { IRouterSlot } from "../model";
+
+export interface IAnchorHandler {
+ setup(): void;
+ teardown(): void;
+}
+
/**
- * Hook up a click listener to the window that, for all anchor tags
- * that has a relative HREF, uses the history API instead.
+ * The AnchorHandler allows the RouterSlot to observe all anchor clicks
+ * and either handle the click or let the browser handle it.
*/
-export function ensureAnchorHistory () {
- window.addEventListener("click", (e: MouseEvent) => {
+export class AnchorHandler implements IAnchorHandler {
+ routerSlot?: IRouterSlot;
+
+ constructor(routerSlot?: IRouterSlot) {
+ this.routerSlot = routerSlot;
+ }
+
+ setup(): void {
+ // store a reference to the bound event handler so we can unbind later
+ this.boundEventHandler = this.handleEvent.bind(this);
+ window.addEventListener(
+ 'click',
+ this.boundEventHandler
+ );
+ }
+
+ teardown(): void {
+ window.removeEventListener(
+ 'click',
+ this.boundEventHandler
+ );
+ }
+
+ private boundEventHandler?: any;
+ private handleEvent(e: MouseEvent): void {
// Find the target by using the composed path to get the element through the shadow boundaries.
const $anchor = ("composedPath" in e as any) ? e.composedPath().find($elem => $elem instanceof HTMLAnchorElement) : e.target;
@@ -13,26 +43,40 @@ export function ensureAnchorHistory () {
return;
}
- // Get the HREF value from the anchor tag
- const href = $anchor.href;
-
// Only handle the anchor tag if the follow holds true:
- // - The HREF is relative to the origin of the current location.
- // - The target is targeting the current frame.
- // - The anchor doesn't have the attribute [data-router-slot]="disabled"
- if (!href.startsWith(location.origin) ||
- ($anchor.target !== "" && $anchor.target !== "_self") ||
- $anchor.dataset["routerSlot"] === "disabled") {
+ // - 1. The HREF is relative to the origin of the current location.
+ const hrefIsRelative = $anchor.href.startsWith(location.origin);
+
+ // - 2. The target is targeting the current frame.
+ const differentFrameTargetted = $anchor.target !== "" && $anchor.target !== "_self";
+
+ // - 3. The anchor doesn't have the attribute [data-router-slot]="disabled"
+ const isDisabled = $anchor.dataset["routerSlot"] === "disabled";
+
+ // - 4. The router can handle the route
+ const routeMatched = this.routerSlot?.getRouteMatch($anchor.pathname);
+
+ // - 5. User is not holding down the meta key, (Command on Mac, Control on Windows)
+ // which is typically used to open a new tab.
+ const userIsHoldingMetaKey = e.metaKey;
+
+ if (
+ !hrefIsRelative ||
+ differentFrameTargetted ||
+ isDisabled ||
+ !routeMatched ||
+ userIsHoldingMetaKey
+ ) {
return;
}
// Remove the origin from the start of the HREF to get the path
- const path = $anchor.pathname;
+ const path = `${$anchor.pathname}${$anchor.search}`;
// Prevent the default behavior
e.preventDefault();
// Change the history!
history.pushState(null, "", path);
- });
-}
\ No newline at end of file
+ }
+}
diff --git a/src/test/anchor.test.ts b/src/test/anchor.test.ts
index 5122b96..91a79e7 100644
--- a/src/test/anchor.test.ts
+++ b/src/test/anchor.test.ts
@@ -1,56 +1,143 @@
-import { ensureAnchorHistory } from "../lib/util/anchor";
+import { RouterSlot } from "../lib";
import { ensureHistoryEvents } from "../lib/util/history";
import { path } from "../lib/util/url";
import { addBaseTag, clearHistory } from "./test-helpers";
const testPath = `/about`;
-describe("anchor", () => {
+describe("AnchorHandler", () => {
const {expect} = chai;
let $anchor!: HTMLAnchorElement;
+ let $slot = new RouterSlot();
+ let $windowPushstateHandler: () => void;
+
+ const addTestRoute = () => {
+ $slot.add([
+ {
+ path: testPath,
+ component: () => document.createElement("div")
+ }
+ ])
+ }
before(() => {
ensureHistoryEvents();
- ensureAnchorHistory();
addBaseTag();
+ // we instantiate the AnchorHandler when the router-slot is connected
+ document.body.appendChild($slot);
});
beforeEach(() => {
- document.body.innerHTML = `
- Anchor
- `;
-
+ const anchor = document.createElement('a');
+ anchor.id = "anchor";
+ anchor.href = testPath;
+ anchor.innerHTML = "Anchor";
+ document.body.appendChild(anchor);
$anchor = document.body.querySelector("#anchor")!;
});
+ afterEach(() => {
+ $anchor.remove();
+ $slot.clear();
+ window.removeEventListener('pushstate', $windowPushstateHandler);
+ });
after(() => {
clearHistory();
+ $slot.remove();
});
- it("[ensureAnchorHistory] should change anchors to use history API", done => {
- window.addEventListener("pushstate", () => {
+ it("[AnchorHandler] should change anchors to use history API", done => {
+ addTestRoute();
+
+ $windowPushstateHandler = () => {
expect(path({end: false})).to.equal(testPath);
done();
- });
+ };
+
+ window.addEventListener("pushstate", $windowPushstateHandler);
$anchor.click();
});
- it("[ensureAnchorHistory] should not change anchors with target _blank", done => {
- window.addEventListener("pushstate", () => {
+ it("[AnchorHandler] should not change anchors with target _blank", done => {
+ addTestRoute();
+
+ $windowPushstateHandler = () => {
expect(true).to.equal(false);
- });
+ }
+
+ window.addEventListener("pushstate", $windowPushstateHandler);
$anchor.target = "_blank";
$anchor.click();
done();
});
- it("[ensureAnchorHistory] should not change anchors with [data-router-slot]='disabled'", done => {
- window.addEventListener("pushstate", () => {
+ it("[AnchorHandler] should not change anchors with [data-router-slot]='disabled'", done => {
+ addTestRoute();
+
+ $windowPushstateHandler = () => {
expect(true).to.equal(false);
- });
+ }
+
+ window.addEventListener("pushstate", $windowPushstateHandler);
$anchor.setAttribute("data-router-slot", "disabled");
$anchor.click();
done();
});
+
+ it("[AnchorHandler] should not change anchors that are not supported by the router", done => {
+ // there are no routes added to the $slot in this test
+ // so the router will not attempt to handle it
+
+ $windowPushstateHandler = () => {
+ expect(true).to.equal(false);
+ }
+
+ window.addEventListener("pushstate", $windowPushstateHandler);
+
+ $anchor.click();
+ done();
+ });
+
+ it("[AnchorHandler] should change anchors if there is a catch-all route", done => {
+ $slot.add([
+ {
+ path: '**',
+ component: () => document.createElement("div")
+ }
+ ]);
+
+ $windowPushstateHandler = () => {
+ expect(path({ end: false })).to.equal(testPath);
+ done();
+ }
+ window.addEventListener("pushstate", $windowPushstateHandler);
+
+ $anchor.click();
+ });
+
+ it("[AnchorHandler] removes the listener when `teardown()` called", done => {
+ // the router should be handling this because we have a catch-all, but
+ // we're tearing down the anchorHandler before the click so it shouldn't handle
+ $slot.add([
+ {
+ path: '**',
+ component: () => document.createElement("div")
+ }
+ ]);
+
+ // remove the slot, which should tear down the handler
+ $slot.remove();
+
+ $windowPushstateHandler = () => {
+ // should never reach here
+ expect(true).to.equal(false);
+ }
+ window.addEventListener("pushstate", $windowPushstateHandler);
+
+ $anchor.click();
+ done();
+ // set back up for future tests
+ document.body.appendChild($slot);
+ });
});