Skip to content
Merged
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
17 changes: 17 additions & 0 deletions docs/latest/examples/active-links.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<a>` 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,
Expand Down
10 changes: 9 additions & 1 deletion packages/fresh/src/runtime/client/partials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
6 changes: 5 additions & 1 deletion packages/fresh/src/runtime/server/preact_hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
29 changes: 25 additions & 4 deletions packages/fresh/src/runtime/shared_internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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;
Expand All @@ -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<string, unknown>;
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";
Expand Down
80 changes: 80 additions & 0 deletions packages/fresh/tests/active_links_test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,86 @@ Deno.test({
},
});

Deno.test({
name: "active links - query params differentiate current vs ancestor",
fn: async () => {
function TabView() {
return (
<Doc>
<div>
<a href="/tabs?sort=name">Sort by name</a>
<a href="/tabs?sort=price">Sort by price</a>
<a href="/tabs">All</a>
</div>
</Doc>
);
}

const app = testApp()
.get("/tabs", (ctx) => {
return ctx.render(<TabView />);
});

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 (
<Doc>
<div>
<a href="/custom_aria" aria-current="step">Step link</a>
<a href="/custom_aria">Auto link</a>
</div>
</Doc>
);
}

const app = testApp()
.get("/custom_aria", (ctx) => {
return ctx.render(<View />);
});

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 () => {
Expand Down
Loading