From 0c092a1088a9bf298a057127ab65db5e3e8c6b0e Mon Sep 17 00:00:00 2001 From: Junaid Khan Date: Mon, 9 Nov 2026 15:39:14 +0530 Subject: [PATCH] feat(A): optional activeOptions.search for query-aware active state Allow links to treat search params as part of active / aria-current matching so filter tabs are not all active at once (#518). --- .changeset/a-active-search-params.md | 4 ++ src/components.tsx | 64 ++++++++++++++++++++++- test/anchor.spec.tsx | 77 ++++++++++++++++++++++++++++ test/ssr/anchor.spec.tsx | 39 ++++++++++++++ 4 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 .changeset/a-active-search-params.md diff --git a/.changeset/a-active-search-params.md b/.changeset/a-active-search-params.md new file mode 100644 index 00000000..679577f4 --- /dev/null +++ b/.changeset/a-active-search-params.md @@ -0,0 +1,4 @@ +--- +"@solidjs/router": minor +--- +Add optional `activeOptions.search` on `` so active / `aria-current` can require the link's search params to match the current location (#518). diff --git a/src/components.tsx b/src/components.tsx index fcf2bab8..37a14f01 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -6,6 +6,46 @@ import { useHref, useLocation, useNavigate, useResolvedPath } from "./routing.js import type { Location, Navigator } from "./types.js"; import { normalizePath } from "./utils.js"; +/** + * True when every search param on `linkHref` is present with the same value on + * `locationSearch`. Extra keys on the location are ignored (partial / subset + * match), matching the usual filter-link case from #518. + * + * A link with no query string only matches a location that also has no search + * (so an "All" tab with `activeOptions.search` does not stay active on every + * filter). Repeated keys are compared as a multiset via `getAll`. + */ +function linkSearchMatchesLocation(linkHref: string, locationSearch: string): boolean { + let linkSearch = ""; + const q = linkHref.indexOf("?"); + if (q !== -1) { + const hash = linkHref.indexOf("#", q + 1); + linkSearch = hash === -1 ? linkHref.slice(q + 1) : linkHref.slice(q + 1, hash); + } + const linkParams = new URLSearchParams(linkSearch); + const locationParams = new URLSearchParams( + locationSearch.startsWith("?") ? locationSearch.slice(1) : locationSearch + ); + const linkEntries = [...linkParams]; + if (linkEntries.length === 0) { + return [...locationParams].length === 0; + } + // Consume location values so `?tag=a&tag=b` does not match twice against the + // first `tag=a` on the location. + const remaining = new Map(); + for (const key of new Set(locationParams.keys())) { + remaining.set(key, locationParams.getAll(key)); + } + for (const [key, value] of linkEntries) { + const values = remaining.get(key); + if (!values) return false; + const at = values.indexOf(value); + if (at === -1) return false; + values.splice(at, 1); + } + return true; +} + declare module "solid-js" { namespace JSX { interface AnchorHTMLAttributes { @@ -18,6 +58,16 @@ declare module "solid-js" { } } +export type AnchorActiveOptions = { + /** + * When true, active / `aria-current` also require the link's search params to + * match the current location. Each key present on the link must equal the + * location (extra location keys are allowed). Default is false so existing + * path-only matching is unchanged. + */ + search?: boolean | undefined; +}; + export interface AnchorProps extends Omit, "state"> { href: string; replace?: boolean | undefined; @@ -26,6 +76,7 @@ export interface AnchorProps extends Omit on a page normalizes the same `location.pathname`, so a one entry cache // collapses that work to once per navigation instead of once per link. Pure function @@ -49,7 +100,8 @@ const SPLIT_PROPS = [ "class", "activeClass", "inactiveClass", - "end" + "end", + "activeOptions" ] as const satisfies readonly (keyof AnchorProps)[]; // The fast path renders children explicitly, so a link that only has children still @@ -85,7 +137,15 @@ export function A(props: AnchorProps) { // trailing slashes are ignored so `/route` and `/route/` share active state const path = normalizePath(to_.split(/[?#]/, 1)[0]).toLowerCase().replace(/\/$/, ""); const loc = normalizeLocationPath(location.pathname); - return [props.end ? path === loc : loc.startsWith(path + "/") || loc === path, path === loc]; + const pathActive = props.end + ? path === loc + : loc.startsWith(path + "/") || loc === path; + const pathExact = path === loc; + if (!props.activeOptions?.search) { + return [pathActive, pathExact]; + } + const searchMatches = linkSearchMatchesLocation(to_, location.search); + return [pathActive && searchMatches, pathExact && searchMatches]; }); // One read, so a getter backed `state` is not observed twice const state = () => { diff --git a/test/anchor.spec.tsx b/test/anchor.spec.tsx index 6c631dfd..ed95c085 100644 --- a/test/anchor.spec.tsx +++ b/test/anchor.spec.tsx @@ -92,6 +92,83 @@ describe("", () => { on.dispose(); }); + test("ignores search params by default (path-only active)", () => { + const { anchor, dispose } = mount("/docs/intro?example=1", () => ( + 2 + )); + expect(anchor().className).toBe("active"); + expect(anchor().getAttribute("aria-current")).toBe("page"); + dispose(); + }); + + test("activeOptions.search requires matching search params", () => { + const wrong = mount("/docs/intro?example=1", () => ( + + 2 + + )); + expect(wrong.anchor().className).toBe("inactive"); + expect(wrong.anchor().hasAttribute("aria-current")).toBe(false); + wrong.dispose(); + + const right = mount("/docs/intro?example=1", () => ( + + 1 + + )); + expect(right.anchor().className).toBe("active"); + expect(right.anchor().getAttribute("aria-current")).toBe("page"); + right.dispose(); + }); + + test("activeOptions.search allows extra location search keys", () => { + const { anchor, dispose } = mount("/docs/intro?example=1&page=2", () => ( + + 1 + + )); + expect(anchor().className).toBe("active"); + expect(anchor().getAttribute("aria-current")).toBe("page"); + dispose(); + }); + + test("activeOptions.search treats a query-less href as match only with empty location search", () => { + const withQuery = mount("/docs/intro?example=1", () => ( + + all + + )); + expect(withQuery.anchor().className).toBe("inactive"); + withQuery.dispose(); + + const empty = mount("/docs/intro", () => ( + + all + + )); + expect(empty.anchor().className).toBe("active"); + expect(empty.anchor().getAttribute("aria-current")).toBe("page"); + empty.dispose(); + }); + + test("activeOptions.search matches repeated keys as a multiset", () => { + const match = mount("/docs/intro?tag=a&tag=b", () => ( + + tags + + )); + expect(match.anchor().className).toBe("active"); + match.dispose(); + + const partial = mount("/docs/intro?tag=a", () => ( + + tags + + )); + expect(partial.anchor().className).toBe("inactive"); + partial.dispose(); + }); + test("keeps a user supplied class alongside the active state class", () => { const { anchor, dispose } = mount("/docs/intro", () => ( diff --git a/test/ssr/anchor.spec.tsx b/test/ssr/anchor.spec.tsx index d52e86ef..5049eda4 100644 --- a/test/ssr/anchor.spec.tsx +++ b/test/ssr/anchor.spec.tsx @@ -61,6 +61,45 @@ describe(" server rendering", () => { ).toBe("off"); }); + test("activeOptions.search distinguishes query filters", () => { + expect( + classOf( + renderAt( + () => ( + + 2 + + ), + "http://localhost/docs/intro?example=1" + ) + ) + ).toBe("inactive"); + expect( + classOf( + renderAt( + () => ( + + 1 + + ), + "http://localhost/docs/intro?example=1" + ) + ) + ).toContain("active"); + expect( + classOf( + renderAt( + () => ( + + all + + ), + "http://localhost/docs/intro?example=1" + ) + ) + ).toBe("inactive"); + }); + test("folds a user class into the state class", () => { expect( classOf(