Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to customize navigation announcer #966

Closed
wants to merge 4 commits into from
Closed
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
5 changes: 5 additions & 0 deletions .changeset/happy-insects-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

Introduce \$app/a11y and make navigation announcer customizable
8 changes: 8 additions & 0 deletions documentation/docs/05-modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ The stores themselves attach to the correct context at the point of subscription
- `page` is a readable store whose value reflects the object passed to `load` functions — it contains `host`, `path`, `params` and `query`
- `session` is a [writable store](https://svelte.dev/tutorial/writable-stores) whose initial value is whatever was returned from [`getSession`](#hooks-getsession). It can be written to, but this will _not_ cause changes to persist on the server — this is something you must implement yourself.

### $app/a11y

```js
import { setNavigationAnnouncer } from '$app/a11y';
```

- `setNavigationAnnouncer(announcer)` — Set a navigation announcer for route changes. The parameter is a function that receives the `document.title` and should return a `string`. The innermost announcer takes precedence. This means you can for example set a generic announcer in your root `$layout`, and for specific subroutes, set a specific different announcer. If no announcer is set, a default announcer will emit `Navigated to ${title}` after each navigation.

### $lib

This is a simple alias to `src/lib`, or whatever directory is specified as [`config.kit.files.lib`]. It allows you to access common components and utility modules without `../../../../` nonsense.
Expand Down
1 change: 1 addition & 0 deletions packages/kit/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default [
'app/stores': 'src/runtime/app/stores.js',
'app/paths': 'src/runtime/app/paths.js',
'app/env': 'src/runtime/app/env.js',
'app/a11y': 'src/runtime/app/a11y.js',
paths: 'src/runtime/paths.js',
env: 'src/runtime/env.js'
},
Expand Down
5 changes: 4 additions & 1 deletion packages/kit/src/core/create_app/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ function generate_app(manifest_data, base) {
let navigated = false;
let title = null;

$: navigationAnnouncer = stores.navigationAnnouncer;
$: navigationAnnouncement = $navigationAnnouncer.length > 0 ? $navigationAnnouncer[$navigationAnnouncer.length - 1](title) : ('Navigated to ' + title);
Copy link
Member Author

@dummdidumm dummdidumm Apr 11, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be try {} catch {}ed?


onMount(() => {
const unsubscribe = stores.page.subscribe(() => {
if (mounted) {
Expand All @@ -187,7 +190,7 @@ function generate_app(manifest_data, base) {
{#if mounted}
<div id="svelte-announcer" aria-live="assertive" aria-atomic="true">
{#if navigated}
Navigated to {title}
{navigationAnnouncement}
{/if}
</div>
{/if}
Expand Down
19 changes: 19 additions & 0 deletions packages/kit/src/runtime/app/a11y.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { getContext, onDestroy } from 'svelte';

/**
* @type {import('$app/a11y').setNavigationAnnouncer}
*/
export const setNavigationAnnouncer = (announcer) => {
// <script> tags are run sequentially, the innermost child is run last
// This means the innermost route which sets the announcer "wins" because it's last in the queue

/**
* @type {{navigationAnnouncer: import('svelte/store').Writable<Array<(title: string) => string>>}}
*/
const stores = getContext('__svelte__');
stores.navigationAnnouncer.update((announcers) => [...announcers, announcer]);

onDestroy(() =>
stores.navigationAnnouncer.update((announcers) => announcers.filter((a) => a !== announcer))
);
};
3 changes: 2 additions & 1 deletion packages/kit/src/runtime/client/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ export class Renderer {
this.stores = {
page: page_store({}),
navigating: writable(null),
session: writable(session)
session: writable(session),
navigationAnnouncer: writable([])
};

this.$session = null;
Expand Down
3 changes: 2 additions & 1 deletion packages/kit/src/runtime/server/page/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ export async function render_response({
stores: {
page: writable(null),
navigating: writable(null),
session
session,
navigationAnnouncer: writable([])
},
page,
components: branch.map(({ node }) => node.module.default)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
<nav><a href="/accessibility/a">a</a> <a href="/accessibility/b">b</a></nav>
<nav>
<a href="/accessibility/a">a</a>
<a href="/accessibility/b">b</a>
<a href="/accessibility/c">c</a>
<a href="/accessibility/d/d_a">d_a</a>
<a href="/accessibility/d/d_b">d_b</a>
</nav>

<slot />
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,65 @@ export default function (test) {
assert.equal(await page.innerHTML('[aria-live]'), '');

await clicknav('[href="/accessibility/b"]');
assert.equal(await page.innerHTML('[aria-live]'), 'Navigated to b'); // TODO i18n
assert.equal(await page.innerHTML('[aria-live]'), 'Navigated to b');
} else {
assert.ok(!has_live_region);
}
});

test(
'announces client-side navigation (custom override)',
'/accessibility/a',
async ({ page, clicknav, js }) => {
await clicknav('[href="/accessibility/c"]');
if (js) {
assert.equal(await page.innerHTML('[aria-live]'), 'Seitennavigation zu c');
}
}
);

test(
'announces client-side navigation (custom override in $layout)',
'/accessibility/a',
async ({ page, clicknav, js }) => {
await clicknav('[href="/accessibility/d/d_a"]');
if (js) {
assert.equal(await page.innerHTML('[aria-live]'), 'Subnav to d_a');
}
}
);

test(
'announces client-side navigation (custom overrides)',
'/accessibility/a',
async ({ page, clicknav, js }) => {
await clicknav('[href="/accessibility/d/d_b"]');
if (js) {
assert.equal(await page.innerHTML('[aria-live]'), 'Subnavigation zu d_b');
}
}
);

test(
'announces client-side navigation (custom overrides on varying routes)',
'/accessibility/a',
async ({ page, clicknav, js }) => {
await clicknav('[href="/accessibility/d/d_b"]');
if (js) {
assert.equal(await page.innerHTML('[aria-live]'), 'Subnavigation zu d_b');
}
await clicknav('[href="/accessibility/d/d_a"]');
if (js) {
assert.equal(await page.innerHTML('[aria-live]'), 'Subnav to d_a');
}
await clicknav('[href="/accessibility/b"]');
if (js) {
assert.equal(await page.innerHTML('[aria-live]'), 'Navigated to b');
}
await clicknav('[href="/accessibility/c"]');
if (js) {
assert.equal(await page.innerHTML('[aria-live]'), 'Seitennavigation zu c');
}
}
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<script>
import { setNavigationAnnouncer } from '$app/a11y';

setNavigationAnnouncer(title => 'Seitennavigation zu ' + title);
</script>

<svelte:head>
<title>c</title>
</svelte:head>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script>
import { setNavigationAnnouncer } from '$app/a11y';

setNavigationAnnouncer(title => 'Subnav to ' + title);
</script>

<slot />
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<svelte:head>
<title>d_a</title>
</svelte:head>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<script>
import { setNavigationAnnouncer } from '$app/a11y';

setNavigationAnnouncer(title => 'Subnavigation zu ' + title);
</script>

<svelte:head>
<title>d_b</title>
</svelte:head>
7 changes: 7 additions & 0 deletions packages/kit/types.ambient.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@ declare module '$app/stores' {
export const session: Writable<any>;
}

declare module '$app/a11y' {
/**
* Set a navigation announcer for accessibility. The innermost announcer takes precedence.
*/
export function setNavigationAnnouncer(announcer: (title: string) => string): void;
}

declare module '$service-worker' {
/**
* An array of URL strings representing the files generated by Vite, suitable for caching with `cache.addAll(build)`.
Expand Down