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: 8 additions & 9 deletions src/lib/Link/Link.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import { isRouteActive } from '$lib/public-utils.js';
import { getRouterContext } from '$lib/Router/Router.svelte';
import type { Hash, RouteStatus } from '$lib/types.js';
import { assertAllowedRoutingMode, joinStyles } from '$lib/utils.js';
import { assertAllowedRoutingMode, expandAriaAttributes, joinStyles } from '$lib/utils.js';
import { type Snippet } from 'svelte';
import type { AriaAttributes, HTMLAnchorAttributes } from 'svelte/elements';
Expand Down Expand Up @@ -101,15 +101,14 @@
return result;
});
const calcActiveStateAria = $derived.by(() => {
const result = {} as AriaAttributes;
for (let [k, v] of Object.entries({
...linkContext?.activeState?.aria,
...activeState?.aria
})) {
// @ts-expect-error TS7053 - Since k is typed as string, the relationship can't be established.
result[`aria-${k}`] = v;
if (!linkContext?.activeStateAria && !activeState?.aria) {
return { 'aria-current': 'page' } as AriaAttributes;
}
return result;
const localAria = expandAriaAttributes(activeState?.aria);
return {
...linkContext?.activeStateAria,
...localAria
};
});
const isActive = $derived(isRouteActive(router, activeFor));
const calcHref = $derived(href === '' ? location.url.href : calculateHref(
Expand Down
59 changes: 59 additions & 0 deletions src/lib/Link/Link.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,65 @@ function activeStateTests(setup: ReturnType<typeof createRouterTestSetup>) {
expect(anchor?.getAttribute('aria-current')).toBe('page');
});

test("Should apply aria-current with value 'page' when route is active and no aria object is provided.", () => {
// Arrange.
const { hash, router, context } = setup;
const href = "/test/path";
const activeKey = "test-route";
// Mock active route status
if (router) {
Object.defineProperty(router, 'routeStatus', {
value: { [activeKey]: { match: true } },
configurable: true
});
}

// Act.
const { container } = render(Link, {
props: {
hash,
href,
activeFor: activeKey,
children: content
},
context
});
const anchor = container.querySelector('a');

// Assert.
expect(anchor?.getAttribute('aria-current')).toBe('page');
});

test("Should not apply aria-current when route is active and activeState.aria is set to an empty object.", () => {
// Arrange.
const { hash, router, context } = setup;
const href = "/test/path";
const activeKey = "test-route";
// Mock active route status
if (router) {
Object.defineProperty(router, 'routeStatus', {
value: { [activeKey]: { match: true } },
configurable: true
});
}

// Act.
const { container } = render(Link, {
props: {
hash,
href,
activeFor: activeKey,
activeState: { aria: {} },
children: content
},
context
});
const anchor = container.querySelector('a');

// Assert.
expect(anchor?.getAttribute('aria-current')).toBeNull();
});

test("Should not apply active styles when route is not active.", async () => {
// Arrange.
const { hash, router, context } = setup;
Expand Down
8 changes: 8 additions & 0 deletions src/lib/LinkContext/LinkContext.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<script lang="ts" module>
import type { ActiveState, PreserveQuery } from '$lib/types.js';
import { expandAriaAttributes } from '$lib/utils.js';
import { getContext, setContext, type Snippet } from 'svelte';
import type { AriaAttributes } from 'svelte/elements';

export type ILinkContext = {
/**
Expand Down Expand Up @@ -40,19 +42,25 @@
* **IMPORTANT**: This only works if the component is within a `Router` component.
*/
activeState?: ActiveState;
/**
* Gets an object with expanded aria- attributes based on the `activeState.aria` property.
*/
readonly activeStateAria?: AriaAttributes;
};

class _LinkContext implements ILinkContext {
replace;
prependBasePath;
preserveQuery;
activeState;
activeStateAria;

constructor(replace: boolean | undefined, prependBasePath: boolean | undefined, preserveQuery: PreserveQuery | undefined, activeState: ActiveState | undefined) {
this.replace = $state(replace);
this.prependBasePath = $state(prependBasePath);
this.preserveQuery = $state(preserveQuery);
this.activeState = $state(activeState);
this.activeStateAria = $derived(expandAriaAttributes(this.activeState?.aria));
}
}

Expand Down
72 changes: 68 additions & 4 deletions src/lib/LinkContext/LinkContext.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { render } from "@testing-library/svelte";
import { linkCtxKey, type ILinkContext } from "./LinkContext.svelte";
import TestLinkContextWithContextSpy from "../../testing/TestLinkContextWithContextSpy.svelte";
import { flushSync } from "svelte";
import type { ActiveStateAriaAttributes } from "$lib/types.js";

describe("LinkContext", () => {
afterEach(() => {
Expand All @@ -26,6 +27,7 @@ describe("LinkContext", () => {
expect(linkCtx?.prependBasePath).toBeUndefined();
expect(linkCtx?.preserveQuery).toBeUndefined();
expect(linkCtx?.activeState).toBeUndefined();
expect(linkCtx?.activeStateAria).toBeUndefined();
});

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

// Act.
Expand All @@ -61,7 +63,7 @@ describe("LinkContext", () => {
replace: true,
prependBasePath: true,
preserveQuery: ['search', 'filter'],
activeState: { class: "active-link", style: "color: red;", aria: { 'aria-current': 'page' } }
activeState: { class: "active-link", style: "color: red;", aria: { current: 'page' } }
};
const context = new Map();
context.set(linkCtxKey, parentCtx);
Expand Down Expand Up @@ -109,7 +111,7 @@ describe("LinkContext", () => {
parentValue: false,
value: true,
},
])("Should override the parent context value for $property when set as a property.", ({property, parentValue, value}) => {
])("Should override the parent context value for $property when set as a property.", ({ property, parentValue, value }) => {
// Arrange.
const parentCtx: ILinkContext = {
replace: true,
Expand Down Expand Up @@ -186,11 +188,73 @@ describe("LinkContext", () => {
expect(linkCtx).toBeDefined();
expect(linkCtx?.[property]).toEqual(updated);
});
test("Should calculate expanded aria attributes from the values in activeState.aria.", () => {
// Arrange.
let linkCtx: ILinkContext | undefined;
const ctxProps: ILinkContext = {
activeState: { aria: { current: 'location' } }
};

// Act.
render(TestLinkContextWithContextSpy, {
props: {
...ctxProps,
get linkCtx() { return linkCtx; },
set linkCtx(v) { linkCtx = v; }
}
});

// Assert.
expect(linkCtx).toBeDefined();
expect(linkCtx?.activeStateAria).toEqual({ 'aria-current': 'location' });
});
test("Should update activeStateAria when activeState.aria changes (re-render).", async () => {
// Arrange.
let linkCtx: ILinkContext | undefined;
const { rerender } = render(TestLinkContextWithContextSpy, {
props: {
activeState: { aria: { current: 'location' } },
get linkCtx() { return linkCtx; },
set linkCtx(v) { linkCtx = v; }
}
});
expect(linkCtx).toBeDefined();
expect(linkCtx?.activeStateAria).toEqual({ 'aria-current': 'location' });

// Act.
await rerender({ activeState: { aria: { current: 'page' } } });

// Assert.
expect(linkCtx).toBeDefined();
expect(linkCtx?.activeStateAria).toEqual({ 'aria-current': 'page' });
});
test("Should update activeStateAria when activeState.aria changes (state change).", () => {
// Arrange.
let linkCtx: ILinkContext | undefined;
let aria = $state<ActiveStateAriaAttributes>({ current: 'location' });
render(TestLinkContextWithContextSpy, {
props: {
activeState: { aria },
get linkCtx() { return linkCtx; },
set linkCtx(v) { linkCtx = v; }
}
});
expect(linkCtx).toBeDefined();
expect(linkCtx?.activeStateAria).toEqual({ 'aria-current': 'location' });

// Act.
aria.current = 'page';
flushSync();

// Assert.
expect(linkCtx).toBeDefined();
expect(linkCtx?.activeStateAria).toEqual({ 'aria-current': 'page' });
});
});

describe('Parent Context Reactivity', () => {
test.each<{
property: keyof ILinkContext,
property: Exclude<keyof ILinkContext, 'activeStateAria'>,
initial: any,
updated: any
}>([
Expand Down
34 changes: 32 additions & 2 deletions src/lib/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { assertAllowedRoutingMode } from "./utils.js";
import { assertAllowedRoutingMode, expandAriaAttributes } from "./utils.js";
import { ALL_HASHES } from "../testing/test-utils.js";
import { resetRoutingOptions, setRoutingOptions } from "./kernel/options.js";
import type { ExtendedRoutingOptions, Hash } from "./types.js";
import type { ActiveStateAriaAttributes, ExtendedRoutingOptions, Hash } from "./types.js";
import type { AriaAttributes } from "svelte/elements";

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

Expand Down Expand Up @@ -43,3 +44,32 @@ describe("assertAllowedRoutingMode", () => {
expect(() => assertAllowedRoutingMode(hash)).toThrow();
});
});

describe("expandAriaAttributes", () => {
test("Should return undefined when input is undefined.", () => {
// Act.
const result = expandAriaAttributes(undefined);

// Assert.
expect(result).toBeUndefined();
});
test.each<{
input: ActiveStateAriaAttributes;
expected: AriaAttributes;
}>([
{
input: { current: 'page' },
expected: { 'aria-current': 'page' },
},
{
input: { disabled: true, hidden: false },
expected: { 'aria-disabled': true, 'aria-hidden': false },
},
])("Should expand $input as $expected .", ({ input, expected }) => {
// Act.
const result = expandAriaAttributes(input);

// Assert.
expect(result).toEqual(expected);
});
});
23 changes: 21 additions & 2 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ActiveState, Hash } from "./types.js";
import type { ActiveState, ActiveStateAriaAttributes, Hash } from "./types.js";
import { routingOptions } from "./kernel/options.js";
import type { HTMLAnchorAttributes } from "svelte/elements";
import type { AriaAttributes, HTMLAnchorAttributes } from "svelte/elements";

/**
* Asserts that the specified routing mode is allowed by the current routing options.
Expand Down Expand Up @@ -45,3 +45,22 @@ export function joinStyles(
.reduce((acc, [key, value]) => acc + `${key}: ${value}; `, '');
return baseStyle ? `${baseStyle} ${calculatedStyle}` : calculatedStyle;
}

/**
* Expands the keys of an `ActiveStateAriaAttributes` object into full `aria-` attributes.
* @param aria Shortcut version of an `AriaAttributes` object.
* @returns An `AriaAttributes` object that can be spread over HTML elements.
*/
export function expandAriaAttributes(aria: ActiveStateAriaAttributes | undefined): AriaAttributes | undefined {
if (!aria) {
return undefined;
}
const result = {} as AriaAttributes;
for (const [k, v] of Object.entries(aria)) {
if (v !== undefined) {
// @ts-expect-error TS7053 - We know this construction is correct.
result[`aria-${k}`] = v;
}
}
return result;
}