Skip to content

Commit 4ff7199

Browse files
webJoseCopilot
andauthored
feat: Apply aria-current: 'page' for active links as default aria- value (#112)
* feat: Apply aria-current: 'page' for active links as default aria- value * Use const in loop Co-authored-by: Copilot <[email protected]> --------- Co-authored-by: Copilot <[email protected]>
1 parent 9e31476 commit 4ff7199

File tree

6 files changed

+196
-17
lines changed

6 files changed

+196
-17
lines changed

src/lib/Link/Link.svelte

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import { isRouteActive } from '$lib/public-utils.js';
88
import { getRouterContext } from '$lib/Router/Router.svelte';
99
import type { Hash, RouteStatus } from '$lib/types.js';
10-
import { assertAllowedRoutingMode, joinStyles } from '$lib/utils.js';
10+
import { assertAllowedRoutingMode, expandAriaAttributes, joinStyles } from '$lib/utils.js';
1111
import { type Snippet } from 'svelte';
1212
import type { AriaAttributes, HTMLAnchorAttributes } from 'svelte/elements';
1313
@@ -101,15 +101,14 @@
101101
return result;
102102
});
103103
const calcActiveStateAria = $derived.by(() => {
104-
const result = {} as AriaAttributes;
105-
for (let [k, v] of Object.entries({
106-
...linkContext?.activeState?.aria,
107-
...activeState?.aria
108-
})) {
109-
// @ts-expect-error TS7053 - Since k is typed as string, the relationship can't be established.
110-
result[`aria-${k}`] = v;
104+
if (!linkContext?.activeStateAria && !activeState?.aria) {
105+
return { 'aria-current': 'page' } as AriaAttributes;
111106
}
112-
return result;
107+
const localAria = expandAriaAttributes(activeState?.aria);
108+
return {
109+
...linkContext?.activeStateAria,
110+
...localAria
111+
};
113112
});
114113
const isActive = $derived(isRouteActive(router, activeFor));
115114
const calcHref = $derived(href === '' ? location.url.href : calculateHref(

src/lib/Link/Link.svelte.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,65 @@ function activeStateTests(setup: ReturnType<typeof createRouterTestSetup>) {
311311
expect(anchor?.getAttribute('aria-current')).toBe('page');
312312
});
313313

314+
test("Should apply aria-current with value 'page' when route is active and no aria object is provided.", () => {
315+
// Arrange.
316+
const { hash, router, context } = setup;
317+
const href = "/test/path";
318+
const activeKey = "test-route";
319+
// Mock active route status
320+
if (router) {
321+
Object.defineProperty(router, 'routeStatus', {
322+
value: { [activeKey]: { match: true } },
323+
configurable: true
324+
});
325+
}
326+
327+
// Act.
328+
const { container } = render(Link, {
329+
props: {
330+
hash,
331+
href,
332+
activeFor: activeKey,
333+
children: content
334+
},
335+
context
336+
});
337+
const anchor = container.querySelector('a');
338+
339+
// Assert.
340+
expect(anchor?.getAttribute('aria-current')).toBe('page');
341+
});
342+
343+
test("Should not apply aria-current when route is active and activeState.aria is set to an empty object.", () => {
344+
// Arrange.
345+
const { hash, router, context } = setup;
346+
const href = "/test/path";
347+
const activeKey = "test-route";
348+
// Mock active route status
349+
if (router) {
350+
Object.defineProperty(router, 'routeStatus', {
351+
value: { [activeKey]: { match: true } },
352+
configurable: true
353+
});
354+
}
355+
356+
// Act.
357+
const { container } = render(Link, {
358+
props: {
359+
hash,
360+
href,
361+
activeFor: activeKey,
362+
activeState: { aria: {} },
363+
children: content
364+
},
365+
context
366+
});
367+
const anchor = container.querySelector('a');
368+
369+
// Assert.
370+
expect(anchor?.getAttribute('aria-current')).toBeNull();
371+
});
372+
314373
test("Should not apply active styles when route is not active.", async () => {
315374
// Arrange.
316375
const { hash, router, context } = setup;

src/lib/LinkContext/LinkContext.svelte

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
<script lang="ts" module>
22
import type { ActiveState, PreserveQuery } from '$lib/types.js';
3+
import { expandAriaAttributes } from '$lib/utils.js';
34
import { getContext, setContext, type Snippet } from 'svelte';
5+
import type { AriaAttributes } from 'svelte/elements';
46
57
export type ILinkContext = {
68
/**
@@ -40,19 +42,25 @@
4042
* **IMPORTANT**: This only works if the component is within a `Router` component.
4143
*/
4244
activeState?: ActiveState;
45+
/**
46+
* Gets an object with expanded aria- attributes based on the `activeState.aria` property.
47+
*/
48+
readonly activeStateAria?: AriaAttributes;
4349
};
4450
4551
class _LinkContext implements ILinkContext {
4652
replace;
4753
prependBasePath;
4854
preserveQuery;
4955
activeState;
56+
activeStateAria;
5057
5158
constructor(replace: boolean | undefined, prependBasePath: boolean | undefined, preserveQuery: PreserveQuery | undefined, activeState: ActiveState | undefined) {
5259
this.replace = $state(replace);
5360
this.prependBasePath = $state(prependBasePath);
5461
this.preserveQuery = $state(preserveQuery);
5562
this.activeState = $state(activeState);
63+
this.activeStateAria = $derived(expandAriaAttributes(this.activeState?.aria));
5664
}
5765
}
5866

src/lib/LinkContext/LinkContext.svelte.test.ts

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { render } from "@testing-library/svelte";
33
import { linkCtxKey, type ILinkContext } from "./LinkContext.svelte";
44
import TestLinkContextWithContextSpy from "../../testing/TestLinkContextWithContextSpy.svelte";
55
import { flushSync } from "svelte";
6+
import type { ActiveStateAriaAttributes } from "$lib/types.js";
67

78
describe("LinkContext", () => {
89
afterEach(() => {
@@ -26,6 +27,7 @@ describe("LinkContext", () => {
2627
expect(linkCtx?.prependBasePath).toBeUndefined();
2728
expect(linkCtx?.preserveQuery).toBeUndefined();
2829
expect(linkCtx?.activeState).toBeUndefined();
30+
expect(linkCtx?.activeStateAria).toBeUndefined();
2931
});
3032

3133
test("Should transmit via context the explicitly set properties.", () => {
@@ -35,7 +37,7 @@ describe("LinkContext", () => {
3537
replace: true,
3638
prependBasePath: true,
3739
preserveQuery: ['search', 'filter'],
38-
activeState: { class: "active-link", style: "color: red;", aria: { 'aria-current': 'page' } }
40+
activeState: { class: "active-link", style: "color: red;", aria: { current: 'page' } }
3941
}
4042

4143
// Act.
@@ -61,7 +63,7 @@ describe("LinkContext", () => {
6163
replace: true,
6264
prependBasePath: true,
6365
preserveQuery: ['search', 'filter'],
64-
activeState: { class: "active-link", style: "color: red;", aria: { 'aria-current': 'page' } }
66+
activeState: { class: "active-link", style: "color: red;", aria: { current: 'page' } }
6567
};
6668
const context = new Map();
6769
context.set(linkCtxKey, parentCtx);
@@ -109,7 +111,7 @@ describe("LinkContext", () => {
109111
parentValue: false,
110112
value: true,
111113
},
112-
])("Should override the parent context value for $property when set as a property.", ({property, parentValue, value}) => {
114+
])("Should override the parent context value for $property when set as a property.", ({ property, parentValue, value }) => {
113115
// Arrange.
114116
const parentCtx: ILinkContext = {
115117
replace: true,
@@ -186,11 +188,73 @@ describe("LinkContext", () => {
186188
expect(linkCtx).toBeDefined();
187189
expect(linkCtx?.[property]).toEqual(updated);
188190
});
191+
test("Should calculate expanded aria attributes from the values in activeState.aria.", () => {
192+
// Arrange.
193+
let linkCtx: ILinkContext | undefined;
194+
const ctxProps: ILinkContext = {
195+
activeState: { aria: { current: 'location' } }
196+
};
197+
198+
// Act.
199+
render(TestLinkContextWithContextSpy, {
200+
props: {
201+
...ctxProps,
202+
get linkCtx() { return linkCtx; },
203+
set linkCtx(v) { linkCtx = v; }
204+
}
205+
});
206+
207+
// Assert.
208+
expect(linkCtx).toBeDefined();
209+
expect(linkCtx?.activeStateAria).toEqual({ 'aria-current': 'location' });
210+
});
211+
test("Should update activeStateAria when activeState.aria changes (re-render).", async () => {
212+
// Arrange.
213+
let linkCtx: ILinkContext | undefined;
214+
const { rerender } = render(TestLinkContextWithContextSpy, {
215+
props: {
216+
activeState: { aria: { current: 'location' } },
217+
get linkCtx() { return linkCtx; },
218+
set linkCtx(v) { linkCtx = v; }
219+
}
220+
});
221+
expect(linkCtx).toBeDefined();
222+
expect(linkCtx?.activeStateAria).toEqual({ 'aria-current': 'location' });
223+
224+
// Act.
225+
await rerender({ activeState: { aria: { current: 'page' } } });
226+
227+
// Assert.
228+
expect(linkCtx).toBeDefined();
229+
expect(linkCtx?.activeStateAria).toEqual({ 'aria-current': 'page' });
230+
});
231+
test("Should update activeStateAria when activeState.aria changes (state change).", () => {
232+
// Arrange.
233+
let linkCtx: ILinkContext | undefined;
234+
let aria = $state<ActiveStateAriaAttributes>({ current: 'location' });
235+
render(TestLinkContextWithContextSpy, {
236+
props: {
237+
activeState: { aria },
238+
get linkCtx() { return linkCtx; },
239+
set linkCtx(v) { linkCtx = v; }
240+
}
241+
});
242+
expect(linkCtx).toBeDefined();
243+
expect(linkCtx?.activeStateAria).toEqual({ 'aria-current': 'location' });
244+
245+
// Act.
246+
aria.current = 'page';
247+
flushSync();
248+
249+
// Assert.
250+
expect(linkCtx).toBeDefined();
251+
expect(linkCtx?.activeStateAria).toEqual({ 'aria-current': 'page' });
252+
});
189253
});
190254

191255
describe('Parent Context Reactivity', () => {
192256
test.each<{
193-
property: keyof ILinkContext,
257+
property: Exclude<keyof ILinkContext, 'activeStateAria'>,
194258
initial: any,
195259
updated: any
196260
}>([

src/lib/utils.test.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { afterEach, describe, expect, test, vi } from "vitest";
2-
import { assertAllowedRoutingMode } from "./utils.js";
2+
import { assertAllowedRoutingMode, expandAriaAttributes } from "./utils.js";
33
import { ALL_HASHES } from "../testing/test-utils.js";
44
import { resetRoutingOptions, setRoutingOptions } from "./kernel/options.js";
5-
import type { ExtendedRoutingOptions, Hash } from "./types.js";
5+
import type { ActiveStateAriaAttributes, ExtendedRoutingOptions, Hash } from "./types.js";
6+
import type { AriaAttributes } from "svelte/elements";
67

78
const hashValues = Object.values(ALL_HASHES).filter(x => x !== undefined);
89

@@ -43,3 +44,32 @@ describe("assertAllowedRoutingMode", () => {
4344
expect(() => assertAllowedRoutingMode(hash)).toThrow();
4445
});
4546
});
47+
48+
describe("expandAriaAttributes", () => {
49+
test("Should return undefined when input is undefined.", () => {
50+
// Act.
51+
const result = expandAriaAttributes(undefined);
52+
53+
// Assert.
54+
expect(result).toBeUndefined();
55+
});
56+
test.each<{
57+
input: ActiveStateAriaAttributes;
58+
expected: AriaAttributes;
59+
}>([
60+
{
61+
input: { current: 'page' },
62+
expected: { 'aria-current': 'page' },
63+
},
64+
{
65+
input: { disabled: true, hidden: false },
66+
expected: { 'aria-disabled': true, 'aria-hidden': false },
67+
},
68+
])("Should expand $input as $expected .", ({ input, expected }) => {
69+
// Act.
70+
const result = expandAriaAttributes(input);
71+
72+
// Assert.
73+
expect(result).toEqual(expected);
74+
});
75+
});

src/lib/utils.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import type { ActiveState, Hash } from "./types.js";
1+
import type { ActiveState, ActiveStateAriaAttributes, Hash } from "./types.js";
22
import { routingOptions } from "./kernel/options.js";
3-
import type { HTMLAnchorAttributes } from "svelte/elements";
3+
import type { AriaAttributes, HTMLAnchorAttributes } from "svelte/elements";
44

55
/**
66
* Asserts that the specified routing mode is allowed by the current routing options.
@@ -45,3 +45,22 @@ export function joinStyles(
4545
.reduce((acc, [key, value]) => acc + `${key}: ${value}; `, '');
4646
return baseStyle ? `${baseStyle} ${calculatedStyle}` : calculatedStyle;
4747
}
48+
49+
/**
50+
* Expands the keys of an `ActiveStateAriaAttributes` object into full `aria-` attributes.
51+
* @param aria Shortcut version of an `AriaAttributes` object.
52+
* @returns An `AriaAttributes` object that can be spread over HTML elements.
53+
*/
54+
export function expandAriaAttributes(aria: ActiveStateAriaAttributes | undefined): AriaAttributes | undefined {
55+
if (!aria) {
56+
return undefined;
57+
}
58+
const result = {} as AriaAttributes;
59+
for (const [k, v] of Object.entries(aria)) {
60+
if (v !== undefined) {
61+
// @ts-expect-error TS7053 - We know this construction is correct.
62+
result[`aria-${k}`] = v;
63+
}
64+
}
65+
return result;
66+
}

0 commit comments

Comments
 (0)