diff --git a/docs/latest/examples/active-links.md b/docs/latest/examples/active-links.md index 6e66308dcf5..99e5baebd61 100644 --- a/docs/latest/examples/active-links.md +++ b/docs/latest/examples/active-links.md @@ -10,10 +10,27 @@ current page within a set of pages. - `aria-current="page"` - Added to links with an exact path match, enhancing accessibility by indicating the current page to assistive technologies. +- `aria-current="true"` - Added to ancestor links (e.g. `/docs` when the current + page is `/docs/intro`). As we aim to improve accessibility, we encourage the use of aria-current for styling current links where applicable. +### Query parameters + +When a link's `href` includes query parameters, Fresh considers them during +matching. A link to `/products?sort=name` will only receive +`aria-current="page"` when the current URL also has `?sort=name`. If the query +parameters differ, the link is treated as an ancestor instead. Links without +query parameters in their `href` match regardless of the current URL's query +string. + +### Preserving custom `aria-current` + +If you set `aria-current` on an `` element yourself, Fresh will leave it +untouched. This is useful when integrating with component libraries (e.g. +daisyUI tabs) that manage their own active state. + ## Styling with CSS The aria-current attribute is easily styled with CSS using attribute selectors, diff --git a/packages/fresh/src/runtime/client/partials.ts b/packages/fresh/src/runtime/client/partials.ts index bf8cf78aa92..3b38b5d59c9 100644 --- a/packages/fresh/src/runtime/client/partials.ts +++ b/packages/fresh/src/runtime/client/partials.ts @@ -304,7 +304,15 @@ document.addEventListener("submit", async (e) => { function updateLinks(url: URL) { document.querySelectorAll("a").forEach((link) => { - const match = matchesUrl(url.pathname, link.href); + // Don't override aria-current if it was explicitly set by the user + // (detected by absence of data-current/data-ancestor attributes which + // Fresh always sets alongside aria-current) + const hasFreshAria = link.hasAttribute(DATA_CURRENT) || + link.hasAttribute(DATA_ANCESTOR); + const hasUserAria = !hasFreshAria && link.hasAttribute("aria-current"); + if (hasUserAria) return; + + const match = matchesUrl(url.pathname, link.href, url.search); if (match === UrlMatchKind.Current) { link.setAttribute(DATA_CURRENT, "true"); diff --git a/packages/fresh/src/runtime/server/preact_hooks.ts b/packages/fresh/src/runtime/server/preact_hooks.ts index b7ad8f8e733..80c81096960 100644 --- a/packages/fresh/src/runtime/server/preact_hooks.ts +++ b/packages/fresh/src/runtime/server/preact_hooks.ts @@ -132,7 +132,11 @@ options[OptionsType.VNODE] = (vnode) => { if (RENDER_STATE !== null) { RENDER_STATE.owners.set(vnode, RENDER_STATE!.ownerStack.at(-1)!); if (vnode.type === "a") { - setActiveUrl(vnode, RENDER_STATE.ctx.url.pathname); + setActiveUrl( + vnode, + RENDER_STATE.ctx.url.pathname, + RENDER_STATE.ctx.url.search, + ); } } assetHashingHook(vnode, BUILD_ID); diff --git a/packages/fresh/src/runtime/shared_internal.ts b/packages/fresh/src/runtime/shared_internal.ts index d91d6e642ed..bd3e999b7ee 100644 --- a/packages/fresh/src/runtime/shared_internal.ts +++ b/packages/fresh/src/runtime/shared_internal.ts @@ -42,8 +42,15 @@ export const enum UrlMatchKind { Current, } -export function matchesUrl(current: string, needle: string): UrlMatchKind { - let href = new URL(needle, "http://localhost").pathname; +export function matchesUrl( + current: string, + needle: string, + currentSearch?: string, +): UrlMatchKind { + const needleUrl = new URL(needle, "http://localhost"); + let href = needleUrl.pathname; + const needleSearch = needleUrl.search; + if (href !== "/" && href.endsWith("/")) { href = href.slice(0, -1); } @@ -53,6 +60,13 @@ export function matchesUrl(current: string, needle: string): UrlMatchKind { } if (current === href) { + // If the link has query params, only mark as current if they match + if ( + needleSearch && currentSearch !== undefined && + needleSearch !== currentSearch + ) { + return UrlMatchKind.Ancestor; + } return UrlMatchKind.Current; } else if (current.startsWith(href + "/") || href === "/") { return UrlMatchKind.Ancestor; @@ -65,11 +79,18 @@ export function matchesUrl(current: string, needle: string): UrlMatchKind { * Mark active or ancestor link * Note: This function is used both on the server and the client */ -export function setActiveUrl(vnode: VNode, pathname: string): void { +export function setActiveUrl( + vnode: VNode, + pathname: string, + search?: string, +): void { const props = vnode.props as Record; const hrefProp = props.href; if (typeof hrefProp === "string" && hrefProp.startsWith("/")) { - const match = matchesUrl(pathname, hrefProp); + // Don't override aria-current if it's already set by the user + if (props["aria-current"] !== undefined) return; + + const match = matchesUrl(pathname, hrefProp, search); if (match === UrlMatchKind.Current) { props[DATA_CURRENT] = "true"; props["aria-current"] = "page"; diff --git a/packages/fresh/tests/active_links_test.tsx b/packages/fresh/tests/active_links_test.tsx index 2d5262e8a07..2356d80735b 100644 --- a/packages/fresh/tests/active_links_test.tsx +++ b/packages/fresh/tests/active_links_test.tsx @@ -83,6 +83,86 @@ Deno.test({ }, }); +Deno.test({ + name: "active links - query params differentiate current vs ancestor", + fn: async () => { + function TabView() { + return ( + +
+ Sort by name + Sort by price + All +
+ + ); + } + + const app = testApp() + .get("/tabs", (ctx) => { + return ctx.render(); + }); + + const server = new FakeServer(app.handler()); + + // When visiting /tabs?sort=name, only that link is "current" + let res = await server.get("/tabs?sort=name"); + let doc = parseHtml(await res.text()); + + // Exact match including query params + assertSelector(doc, `a[href='/tabs?sort=name'][data-current]`); + assertSelector(doc, `a[href='/tabs?sort=name'][aria-current="page"]`); + + // Different query params → ancestor, not current + assertNotSelector(doc, `a[href='/tabs?sort=price'][data-current]`); + assertSelector(doc, `a[href='/tabs?sort=price'][data-ancestor]`); + + // Link without query params matches regardless + assertSelector(doc, `a[href='/tabs'][data-current]`); + + // When visiting /tabs (no query), all links on same path are current + res = await server.get("/tabs"); + doc = parseHtml(await res.text()); + assertSelector(doc, `a[href='/tabs'][data-current]`); + // Links with query params → ancestor since current URL has no query + assertSelector(doc, `a[href='/tabs?sort=name'][data-ancestor]`); + assertSelector(doc, `a[href='/tabs?sort=price'][data-ancestor]`); + }, +}); + +Deno.test({ + name: "active links - respects user-set aria-current", + fn: async () => { + function View() { + return ( + +
+ Step link + Auto link +
+
+ ); + } + + const app = testApp() + .get("/custom_aria", (ctx) => { + return ctx.render(); + }); + + const server = new FakeServer(app.handler()); + const res = await server.get("/custom_aria"); + const doc = parseHtml(await res.text()); + + // User-set aria-current should be preserved, not overwritten + assertSelector(doc, `a[aria-current="step"]`); + assertNotSelector(doc, `a[aria-current="step"][data-current]`); + + // The other link (no user-set aria-current) should get Fresh's attributes + assertSelector(doc, `a[href='/custom_aria'][data-current]`); + assertSelector(doc, `a[href='/custom_aria'][aria-current="page"]`); + }, +}); + Deno.test({ name: "active links - updates outside of vdom", fn: async () => {