diff --git a/README.md b/README.md index 873042d..a447d20 100644 --- a/README.md +++ b/README.md @@ -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 Go to home! diff --git a/src/lib/model.ts b/src/lib/model.ts index bed6efe..30c2e8d 100644 --- a/src/lib/model.ts +++ b/src/lib/model.ts @@ -11,6 +11,8 @@ export interface IRouterSlot 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); + }); });