Skip to content

Commit fc0ff66

Browse files
bartlomiejuclaude
andauthored
fix: active links consider query params and respect existing aria-current (#3755)
## Summary - `matchesUrl()` now considers query parameters — links with query params that don't match the current URL get `Ancestor` instead of `Current` status - Links with a user-set `aria-current` attribute are left untouched (both server and client side), fixing daisyUI tab components and similar use cases - Client-side `updateLinks()` passes search params and respects user-set attributes Closes #3474 --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 895bcac commit fc0ff66

5 files changed

Lines changed: 136 additions & 6 deletions

File tree

docs/latest/examples/active-links.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,27 @@ current page within a set of pages.
1010

1111
- `aria-current="page"` - Added to links with an exact path match, enhancing
1212
accessibility by indicating the current page to assistive technologies.
13+
- `aria-current="true"` - Added to ancestor links (e.g. `/docs` when the current
14+
page is `/docs/intro`).
1315

1416
As we aim to improve accessibility, we encourage the use of aria-current for
1517
styling current links where applicable.
1618

19+
### Query parameters
20+
21+
When a link's `href` includes query parameters, Fresh considers them during
22+
matching. A link to `/products?sort=name` will only receive
23+
`aria-current="page"` when the current URL also has `?sort=name`. If the query
24+
parameters differ, the link is treated as an ancestor instead. Links without
25+
query parameters in their `href` match regardless of the current URL's query
26+
string.
27+
28+
### Preserving custom `aria-current`
29+
30+
If you set `aria-current` on an `<a>` element yourself, Fresh will leave it
31+
untouched. This is useful when integrating with component libraries (e.g.
32+
daisyUI tabs) that manage their own active state.
33+
1734
## Styling with CSS
1835

1936
The aria-current attribute is easily styled with CSS using attribute selectors,

packages/fresh/src/runtime/client/partials.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,15 @@ document.addEventListener("submit", async (e) => {
304304

305305
function updateLinks(url: URL) {
306306
document.querySelectorAll("a").forEach((link) => {
307-
const match = matchesUrl(url.pathname, link.href);
307+
// Don't override aria-current if it was explicitly set by the user
308+
// (detected by absence of data-current/data-ancestor attributes which
309+
// Fresh always sets alongside aria-current)
310+
const hasFreshAria = link.hasAttribute(DATA_CURRENT) ||
311+
link.hasAttribute(DATA_ANCESTOR);
312+
const hasUserAria = !hasFreshAria && link.hasAttribute("aria-current");
313+
if (hasUserAria) return;
314+
315+
const match = matchesUrl(url.pathname, link.href, url.search);
308316

309317
if (match === UrlMatchKind.Current) {
310318
link.setAttribute(DATA_CURRENT, "true");

packages/fresh/src/runtime/server/preact_hooks.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,11 @@ options[OptionsType.VNODE] = (vnode) => {
132132
if (RENDER_STATE !== null) {
133133
RENDER_STATE.owners.set(vnode, RENDER_STATE!.ownerStack.at(-1)!);
134134
if (vnode.type === "a") {
135-
setActiveUrl(vnode, RENDER_STATE.ctx.url.pathname);
135+
setActiveUrl(
136+
vnode,
137+
RENDER_STATE.ctx.url.pathname,
138+
RENDER_STATE.ctx.url.search,
139+
);
136140
}
137141
}
138142
assetHashingHook(vnode, BUILD_ID);

packages/fresh/src/runtime/shared_internal.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,15 @@ export const enum UrlMatchKind {
4242
Current,
4343
}
4444

45-
export function matchesUrl(current: string, needle: string): UrlMatchKind {
46-
let href = new URL(needle, "http://localhost").pathname;
45+
export function matchesUrl(
46+
current: string,
47+
needle: string,
48+
currentSearch?: string,
49+
): UrlMatchKind {
50+
const needleUrl = new URL(needle, "http://localhost");
51+
let href = needleUrl.pathname;
52+
const needleSearch = needleUrl.search;
53+
4754
if (href !== "/" && href.endsWith("/")) {
4855
href = href.slice(0, -1);
4956
}
@@ -53,6 +60,13 @@ export function matchesUrl(current: string, needle: string): UrlMatchKind {
5360
}
5461

5562
if (current === href) {
63+
// If the link has query params, only mark as current if they match
64+
if (
65+
needleSearch && currentSearch !== undefined &&
66+
needleSearch !== currentSearch
67+
) {
68+
return UrlMatchKind.Ancestor;
69+
}
5670
return UrlMatchKind.Current;
5771
} else if (current.startsWith(href + "/") || href === "/") {
5872
return UrlMatchKind.Ancestor;
@@ -65,11 +79,18 @@ export function matchesUrl(current: string, needle: string): UrlMatchKind {
6579
* Mark active or ancestor link
6680
* Note: This function is used both on the server and the client
6781
*/
68-
export function setActiveUrl(vnode: VNode, pathname: string): void {
82+
export function setActiveUrl(
83+
vnode: VNode,
84+
pathname: string,
85+
search?: string,
86+
): void {
6987
const props = vnode.props as Record<string, unknown>;
7088
const hrefProp = props.href;
7189
if (typeof hrefProp === "string" && hrefProp.startsWith("/")) {
72-
const match = matchesUrl(pathname, hrefProp);
90+
// Don't override aria-current if it's already set by the user
91+
if (props["aria-current"] !== undefined) return;
92+
93+
const match = matchesUrl(pathname, hrefProp, search);
7394
if (match === UrlMatchKind.Current) {
7495
props[DATA_CURRENT] = "true";
7596
props["aria-current"] = "page";

packages/fresh/tests/active_links_test.tsx

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,86 @@ Deno.test({
8383
},
8484
});
8585

86+
Deno.test({
87+
name: "active links - query params differentiate current vs ancestor",
88+
fn: async () => {
89+
function TabView() {
90+
return (
91+
<Doc>
92+
<div>
93+
<a href="/tabs?sort=name">Sort by name</a>
94+
<a href="/tabs?sort=price">Sort by price</a>
95+
<a href="/tabs">All</a>
96+
</div>
97+
</Doc>
98+
);
99+
}
100+
101+
const app = testApp()
102+
.get("/tabs", (ctx) => {
103+
return ctx.render(<TabView />);
104+
});
105+
106+
const server = new FakeServer(app.handler());
107+
108+
// When visiting /tabs?sort=name, only that link is "current"
109+
let res = await server.get("/tabs?sort=name");
110+
let doc = parseHtml(await res.text());
111+
112+
// Exact match including query params
113+
assertSelector(doc, `a[href='/tabs?sort=name'][data-current]`);
114+
assertSelector(doc, `a[href='/tabs?sort=name'][aria-current="page"]`);
115+
116+
// Different query params → ancestor, not current
117+
assertNotSelector(doc, `a[href='/tabs?sort=price'][data-current]`);
118+
assertSelector(doc, `a[href='/tabs?sort=price'][data-ancestor]`);
119+
120+
// Link without query params matches regardless
121+
assertSelector(doc, `a[href='/tabs'][data-current]`);
122+
123+
// When visiting /tabs (no query), all links on same path are current
124+
res = await server.get("/tabs");
125+
doc = parseHtml(await res.text());
126+
assertSelector(doc, `a[href='/tabs'][data-current]`);
127+
// Links with query params → ancestor since current URL has no query
128+
assertSelector(doc, `a[href='/tabs?sort=name'][data-ancestor]`);
129+
assertSelector(doc, `a[href='/tabs?sort=price'][data-ancestor]`);
130+
},
131+
});
132+
133+
Deno.test({
134+
name: "active links - respects user-set aria-current",
135+
fn: async () => {
136+
function View() {
137+
return (
138+
<Doc>
139+
<div>
140+
<a href="/custom_aria" aria-current="step">Step link</a>
141+
<a href="/custom_aria">Auto link</a>
142+
</div>
143+
</Doc>
144+
);
145+
}
146+
147+
const app = testApp()
148+
.get("/custom_aria", (ctx) => {
149+
return ctx.render(<View />);
150+
});
151+
152+
const server = new FakeServer(app.handler());
153+
const res = await server.get("/custom_aria");
154+
const doc = parseHtml(await res.text());
155+
156+
// User-set aria-current should be preserved, not overwritten
157+
assertSelector(doc, `a[aria-current="step"]`);
158+
assertNotSelector(doc, `a[aria-current="step"][data-current]`);
159+
160+
// The other link (no user-set aria-current) should get Fresh's attributes
161+
assertSelector(doc, `a[href='/custom_aria'][data-current]`);
162+
assertSelector(doc, `a[href='/custom_aria'][aria-current="page"]`);
163+
},
164+
});
165+
86166
Deno.test({
87167
name: "active links - updates outside of vdom",
88168
fn: async () => {

0 commit comments

Comments
 (0)