Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Don't change anchor links to use the history API if the router doesn't have a match #18

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ Go [`here`](https://developer.mozilla.org/en-US/docs/Web/API/History) to read mo

#### Anchor element

Normally an [`anchor element`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a) reloads the page when clicked. This library however changes the default behavior of all anchor element to use the history API instead.
Normally an [`anchor element`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a) reloads the page when clicked. This library however changes the default behavior of all anchor element to use the history API if the router supports the route.

```html
<a href="/home">Go to home!</a>
Expand Down
4 changes: 3 additions & 1 deletion src/lib/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface IRouterSlot<D = any, P = any> extends HTMLElement {
constructAbsolutePath: ((path: PathFragment) => string);
parent: IRouterSlot<P> | null | undefined;
queryParentRouterSlot: (() => IRouterSlot<P> | null);
// Return the matched route if one found for the given path
getRouteMatch(path: string | PathFragment): IRouteMatch<D> | null;
}

export type IRoutingInfo<D = any, P = any> = {
Expand Down Expand Up @@ -174,4 +176,4 @@ declare global {
"navigationerror": NavigationErrorEvent,
"willchangestate": WillChangeStateEvent
}
}
}
39 changes: 34 additions & 5 deletions src/lib/router-slot.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
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 = `<slot></slot>`;

// 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.
Expand Down Expand Up @@ -89,6 +86,11 @@ export class RouterSlot<D = any, P = any> 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.
*/
Expand All @@ -107,13 +109,15 @@ export class RouterSlot<D = any, P = any> extends HTMLElement implements IRouter
*/
connectedCallback () {
this.parent = this.queryParentRouterSlot();
this.setupAnchorListener();
}

/**
* Tears down the element.
*/
disconnectedCallback () {
this.detachListeners();
this.detachAnchorListener()
}

/**
Expand Down Expand Up @@ -155,6 +159,15 @@ export class RouterSlot<D = any, P = any> 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<D> | null {
const match = matchRoutes(this._routes, path);
return match;
}

/**
* Each time the path changes, load the new path.
*/
Expand All @@ -179,6 +192,22 @@ export class RouterSlot<D = any, P = any> 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.
*/
Expand Down Expand Up @@ -219,7 +248,7 @@ export class RouterSlot<D = any, P = any> extends HTMLElement implements IRouter
protected async renderPath (path: string | PathFragment): Promise<boolean> {

// 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) {
Expand Down
76 changes: 60 additions & 16 deletions src/lib/util/anchor.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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);
});
}
}
}
119 changes: 103 additions & 16 deletions src/test/anchor.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<a id="anchor" href="${testPath}">Anchor</a>
`;

const anchor = document.createElement('a');
anchor.id = "anchor";
anchor.href = testPath;
anchor.innerHTML = "Anchor";
document.body.appendChild(anchor);
$anchor = document.body.querySelector<HTMLAnchorElement>("#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);
});
});