diff --git a/CHANGELOG.md b/CHANGELOG.md index 8720a2e..0c31e7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- Anchor click handler now ignores modifier keys (Ctrl, Meta, Shift, Alt), non-left clicks, and links with a `target` attribute (e.g. `target="_blank"`), allowing native browser behavior for opening links in new tabs/windows + ## [0.12.3] - 2026-04-02 ### Fixed diff --git a/packages/esroute/src/nav-opts.ts b/packages/esroute/src/nav-opts.ts index 59ffe83..f6f2f51 100644 --- a/packages/esroute/src/nav-opts.ts +++ b/packages/esroute/src/nav-opts.ts @@ -7,7 +7,7 @@ export interface NavMeta { state?: S | null; /** The location hash. */ hash?: string; - /** Whethe the history state shall be replaced. */ + /** Whether the history state shall be replaced. */ replace?: boolean; /** Whether the resolution was triggered by a popstate event. */ pop?: boolean; @@ -50,7 +50,7 @@ export class NavOpts implements NavMeta { href ??= target as string; if (!href.startsWith("/")) href = `/${href}`; if (!search) this._h = href; - const [, pathString, , searchString, , hash] = href.match( + const [, pathString, , searchString, , parsedHash] = href.match( /([^?#]+)(\?([^#]+))?(#(.+))?/ )!; this.path = pathString.split("/").filter(Boolean); @@ -58,7 +58,7 @@ export class NavOpts implements NavMeta { this.search = Object.fromEntries( new URLSearchParams(searchString).entries() ); - if (hash) this.hash = hash; + if (parsedHash) this.hash = parsedHash; } else this.path = target as string[]; if (hash != null) this.hash = hash; if (pop != null) this.pop = pop; diff --git a/packages/esroute/src/router.spec.ts b/packages/esroute/src/router.spec.ts index fdbfade..9dee536 100644 --- a/packages/esroute/src/router.spec.ts +++ b/packages/esroute/src/router.spec.ts @@ -63,6 +63,39 @@ describe("Router", () => { opts: expect.any(NavOpts), }); }); + + it("should not intercept clicks with modifier keys", async () => { + router.init(); + await router.resolution; + onResolve.mockClear(); + const anchor = document.createElement("a"); + document.body.appendChild(anchor); + anchor.href = "http://localhost/foo"; + vi.spyOn(location, "origin", "get").mockReturnValue("http://localhost"); + + for (const key of ["ctrlKey", "metaKey", "shiftKey", "altKey"] as const) { + anchor.dispatchEvent(new MouseEvent("click", { [key]: true, bubbles: true })); + } + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(onResolve).not.toHaveBeenCalled(); + }); + + it("should not intercept clicks on anchors with target attribute", async () => { + router.init(); + await router.resolution; + onResolve.mockClear(); + const anchor = document.createElement("a"); + document.body.appendChild(anchor); + anchor.href = "http://localhost/foo"; + anchor.target = "_blank"; + vi.spyOn(location, "origin", "get").mockReturnValue("http://localhost"); + + anchor.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(onResolve).not.toHaveBeenCalled(); + }); }); describe("go()", () => { diff --git a/packages/esroute/src/router.ts b/packages/esroute/src/router.ts index 259d396..c53bdb3 100644 --- a/packages/esroute/src/router.ts +++ b/packages/esroute/src/router.ts @@ -192,15 +192,28 @@ export const createRouter = ( }; const linkClickListener = (e: MouseEvent) => { + if ( + e.defaultPrevented || + e.button !== 0 || + e.metaKey || + e.ctrlKey || + e.shiftKey || + e.altKey + ) + return; const target = isAnchorElement(e.target) ? e.target : e.composedPath?.().find(isAnchorElement); - if (target && target.origin === location.origin) { + if ( + target && + (!target.target || target.target === "_self") && + target.origin === location.origin + ) { + e.preventDefault(); r.go({ href: target.href.substring(location.origin.length), replace: "replace" in target.dataset, }); - e.preventDefault(); } }; diff --git a/packages/esroute/src/scroll-restoration.ts b/packages/esroute/src/scroll-restoration.ts index b5689ef..33bb90c 100644 --- a/packages/esroute/src/scroll-restoration.ts +++ b/packages/esroute/src/scroll-restoration.ts @@ -25,12 +25,12 @@ export const restoreHandling = ({ }; const getStatePos = () => (history.state && history.state[stateProp]) ?? undefined; - const getHashPos = - hashScroll && - ((hash: string) => - (find(hash)?.getBoundingClientRect().top ?? 0) - - offset + - container.scrollTop); + const getHashPos = hashScroll + ? (hash: string) => + (find(hash)?.getBoundingClientRect().top ?? 0) - + offset + + container.scrollTop + : undefined; window.addEventListener("beforeunload", save); document.addEventListener("visibilitychange", save);