Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/a-active-search-params.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
"@solidjs/router": minor
---
Add optional `activeOptions.search` on `<A>` so active / `aria-current` can require the link's search params to match the current location (#518).
64 changes: 62 additions & 2 deletions src/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]>();
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<T> {
Expand All @@ -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<JSX.AnchorHTMLAttributes<HTMLAnchorElement>, "state"> {
href: string;
replace?: boolean | undefined;
Expand All @@ -26,6 +76,7 @@ export interface AnchorProps extends Omit<JSX.AnchorHTMLAttributes<HTMLAnchorEle
inactiveClass?: string | undefined;
activeClass?: string | undefined;
end?: boolean | undefined;
activeOptions?: AnchorActiveOptions | undefined;
}
// Every <A> 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
Expand All @@ -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
Expand Down Expand Up @@ -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 = () => {
Expand Down
77 changes: 77 additions & 0 deletions test/anchor.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,83 @@ describe("<A>", () => {
on.dispose();
});

test("ignores search params by default (path-only active)", () => {
const { anchor, dispose } = mount("/docs/intro?example=1", () => (
<A href="/docs/intro?example=2">2</A>
));
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", () => (
<A href="/docs/intro?example=2" activeOptions={{ search: true }}>
2
</A>
));
expect(wrong.anchor().className).toBe("inactive");
expect(wrong.anchor().hasAttribute("aria-current")).toBe(false);
wrong.dispose();

const right = mount("/docs/intro?example=1", () => (
<A href="/docs/intro?example=1" activeOptions={{ search: true }}>
1
</A>
));
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", () => (
<A href="/docs/intro?example=1" activeOptions={{ search: true }}>
1
</A>
));
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", () => (
<A href="/docs/intro" activeOptions={{ search: true }}>
all
</A>
));
expect(withQuery.anchor().className).toBe("inactive");
withQuery.dispose();

const empty = mount("/docs/intro", () => (
<A href="/docs/intro" activeOptions={{ search: true }}>
all
</A>
));
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", () => (
<A href="/docs/intro?tag=a&tag=b" activeOptions={{ search: true }}>
tags
</A>
));
expect(match.anchor().className).toBe("active");
match.dispose();

const partial = mount("/docs/intro?tag=a", () => (
<A href="/docs/intro?tag=a&tag=b" activeOptions={{ search: true }}>
tags
</A>
));
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", () => (
<A href="/other" class="btn">
Expand Down
39 changes: 39 additions & 0 deletions test/ssr/anchor.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,45 @@ describe("<A> server rendering", () => {
).toBe("off");
});

test("activeOptions.search distinguishes query filters", () => {
expect(
classOf(
renderAt(
() => (
<A href="/docs/intro?example=2" activeOptions={{ search: true }}>
2
</A>
),
"http://localhost/docs/intro?example=1"
)
)
).toBe("inactive");
expect(
classOf(
renderAt(
() => (
<A href="/docs/intro?example=1" activeOptions={{ search: true }}>
1
</A>
),
"http://localhost/docs/intro?example=1"
)
)
).toContain("active");
expect(
classOf(
renderAt(
() => (
<A href="/docs/intro" activeOptions={{ search: true }}>
all
</A>
),
"http://localhost/docs/intro?example=1"
)
)
).toBe("inactive");
});

test("folds a user class into the state class", () => {
expect(
classOf(
Expand Down