Skip to content

Commit

Permalink
Merge pull request #32 from swup/feature/customize-announcements
Browse files Browse the repository at this point in the history
Customize announcements
  • Loading branch information
daun authored Sep 19, 2023
2 parents f2a8a3b + be380d3 commit 86ab83a
Show file tree
Hide file tree
Showing 2 changed files with 207 additions and 63 deletions.
127 changes: 108 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ shortcomings for screen reader users. This plugin will improve that:

- **Announce page visits** to screenreaders by reading the new page title
- **Focus the main content area** after swapping out the content
- **Skip animations** for users with a preference for reduced motion

## Installation

Expand Down Expand Up @@ -52,7 +53,17 @@ See the options below for customizing what elements to look for.
</main>
```

If you want the announcement to be different from the text content, use `aria-label`:
## Announcements

The plugin will announce the new page to screen readers after navigating to it. It will look for the
following and announce the first one found:

- Main heading label: `<h1 aria-label="About"></h1>`
- Main heading content: `<h1>About</h1>`
- Document title: `<title>About</title>`
- Page URL: `/about/`

The easiest way to announce a page title differing from the main heading is using `aria-label`:

```html
<h1 aria-label="Homepage">Project Title</h1> <!-- will announce 'Homepage' -->
Expand Down Expand Up @@ -81,9 +92,11 @@ All options with their default values:
{
contentSelector: 'main',
headingSelector: 'h1, h2, [role=heading]',
announcementTemplate: 'Navigated to: {title}',
urlTemplate: 'New page at {url}',
respectReducedMotion: false
respectReducedMotion: false,
announcements: {
visit: 'Navigated to: {title}',
url: 'New page at {url}'
}
}
```

Expand All @@ -99,16 +112,6 @@ The selector for finding headings **inside the main content area**.

The first heading's content will be read to screen readers after a new page was loaded.

### announcementTemplate

How to announce the new page title.

### urlTemplate

How to announce the new page url.

Only used as fallback if neither a title tag nor a heading were found.

### respectReducedMotion

Whether to respects users' preference for reduced motion.
Expand All @@ -117,21 +120,95 @@ Disable animated page transitions and animated scrolling if a user has enabled a
setting on their device to minimize the amount of non-essential motion. Learn more about
[prefers-reduced-motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion).

### announcements

How the new page is announced. A visit is announced differently depending on whether the new page
has a title or not. If found, the main heading or document title is announced. If neither is found,
the new url will be announced instead:

- **Title found?** Read `announcements.visit`, replacing `{title}` with the new title
- **No title?** Read `announcements.visit` too, but replacing `{title}` with the content of `announcements.url`

```js
{
announcements: {
visit: 'Navigated to: {title}',
url: 'New page at {url}'
}
}
```

#### Translations

For multi-language sites, pass in a nested object keyed by locale. The locale must match the
`html` element's [lang](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/lang) attribute
exactly. Use an asterisk `*` to declare fallback translations.

> **Note**: Swup will not update the lang attribute on its own. For that, you can either install the
[Head Plugin](https://swup.js.org/plugins/head-plugin/) to do it automatically, or you can do update
it yourself in the `content:replace` hook.

```js
{
announcements: {
'en-US': {
visit: 'Navigated to: {title}',
url: 'New page at {url}'
},
'de-DE': {
visit: 'Navigiert zu: {title}',
url: 'Neue Seite unter {url}'
},
'fr-FR': {
visit: 'Navigué vers : {title}',
url: 'Nouvelle page à {url}'
},
'*': {
visit: '{title}',
url: '{url}'
}
}
}
```

#### Deprecated options

The following two options are now grouped in the `announcements` object and deprecated.

- `announcementTemplate`: equivalent to `announcements.visit`
- `urlTemplate`: equivalent to `announcements.url`

## Visit object

The plugin extends the visit object with a new `a11y` key that can be used to customize the
behavior on the fly.

```js
{
from: {},
to: {},
from: { ... },
to: { ... },
a11y: {
announce: 'Navigated to: About',
focus: 'main'
}
}
```

### visit.a11y.announce

The text to announce after the new page was loaded. This is the final text after choosing the
correct language from the [announcements](#announcements) option and filling in any placeholders.
Modify it to read a custom announcement.

Since the text can only be populated once the new page was fetched and its contents are available,
the only place to inspect or modify this would be right before the `content:announce` hook.

```js
swup.hooks.before('content:announce', (visit) => {
visit.a11y.announce = 'New page loaded';
});
```

### visit.a11y.focus

The element to receive focus after the new page was loaded. This is taken directly from the
Expand All @@ -140,9 +217,21 @@ selector `string` to select an element, or set it to `false` to not move the foc

## Hooks

The plugin adds a new hook: `content:focus`. It is run after `content:replace`, when the new
content is already in the DOM.
The plugin adds two new hooks: `content:announce` and `content:focus`. Both run directly
after the internal `content:replace` handler, when the new content is already in the DOM.

### content:announce

Executes the announcement of the new page title.

```js
swup.hooks.on('content:announce', () => console.log('New content was announced'));
```

### content:focus

Executes the focussing of the new main content container.

```js
swup.hooks.on('content:focus', () => console.log('Swup has focussed new content'));
swup.hooks.on('content:focus', () => console.log('New content received focus'));
```
143 changes: 99 additions & 44 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Visit, nextTick } from 'swup';
import { Location, Visit, nextTick } from 'swup';
import Plugin from '@swup/plugin';
import OnDemandLiveRegion from 'on-demand-live-region';

import 'focus-options-polyfill';

export interface VisitA11y {
/** How to announce the new content after it inserted */
announce: string | false | undefined;
/** The element to focus after the content is replaced */
focus: string | false;
}
Expand All @@ -15,21 +17,40 @@ declare module 'swup' {
a11y: VisitA11y;
}
export interface HookDefinitions {
'content:announce': undefined;
'content:focus': undefined;
}
}

/** Templates for announcements of the new page content. */
type Announcements = {
/** How to announce the new page. */
visit: string;
/** How to read a page url. Used as fallback if no heading was found. */
url: string;
};

/** Translations of announcements, keyed by language. */
type AnnouncementTranslations = {
[lang: string]: Announcements;
} & {
[key in keyof Announcements]: string;
};

type Options = {
/** The selector for matching the main content area of the page. */
contentSelector: string;
/** The selector for finding headings inside the main content area. */
headingSelector: string;
/** How to announce the new page title. */
announcementTemplate: string;
/** How to announce the new page url. Used as fallback if no heading was found. */
urlTemplate: string;
/** Whether to skip animations for users that prefer reduced motion. */
respectReducedMotion: boolean;
/** How to announce the new page title and url. */
announcements: Announcements | AnnouncementTranslations;

/** How to announce the new page. @deprecated Use the `announcements` option. */
announcementTemplate?: string;
/** How to announce a url. @deprecated Use the `announcements` option. */
urlTemplate?: string;
};

export default class SwupA11yPlugin extends Plugin {
Expand All @@ -40,9 +61,11 @@ export default class SwupA11yPlugin extends Plugin {
defaults: Options = {
contentSelector: 'main',
headingSelector: 'h1, h2, [role=heading]',
announcementTemplate: 'Navigated to: {title}',
urlTemplate: 'New page at {url}',
respectReducedMotion: false
respectReducedMotion: false,
announcements: {
visit: 'Navigated to: {title}',
url: 'New page at {url}'
}
};

options: Options;
Expand All @@ -51,11 +74,25 @@ export default class SwupA11yPlugin extends Plugin {

constructor(options: Partial<Options> = {}) {
super();

// Merge deprecated announcement templates into new structure
options.announcements = {
...this.defaults.announcements,
visit: options.announcementTemplate ?? this.defaults.announcements.visit,
url: options.urlTemplate ?? this.defaults.announcements.url,
...options.announcements,
};

// Merge default options with user defined options
this.options = { ...this.defaults, ...options };

// Create live region for announcing new page content
this.liveRegion = new OnDemandLiveRegion();
}

mount() {
// Prepare new hooks
this.swup.hooks.create('content:announce');
this.swup.hooks.create('content:focus');

// Prepare visit by adding a11y settings to visit object
Expand All @@ -65,6 +102,9 @@ export default class SwupA11yPlugin extends Plugin {
this.on('visit:start', this.markAsBusy);
this.on('visit:end', this.unmarkAsBusy);

// Prepare announcement by reading new page heading
this.on('content:replace', this.prepareAnnouncement);

// Announce new page and focus container after content is replaced
this.on('content:replace', this.handleNewPageContent);

Expand All @@ -87,55 +127,70 @@ export default class SwupA11yPlugin extends Plugin {

prepareVisit(visit: Visit) {
visit.a11y = {
announce: undefined,
focus: this.options.contentSelector
};
}

handleNewPageContent() {
nextTick().then(() => {
this.announcePageName();
this.focusPageContent();
});
}

announcePageName() {
const { contentSelector, headingSelector, urlTemplate, announcementTemplate } =
this.options;
prepareAnnouncement(visit: Visit) {
// Allow customizing announcement before this hook
if (typeof visit.a11y.announce !== 'undefined') return;

// Default: announce new /path/of/page.html
let pageName: string = urlTemplate.replace('{url}', window.location.pathname);
const { contentSelector, headingSelector, announcements } = this.options;
const { href, url, pathname: path } = Location.fromUrl(window.location.href);
const lang = document.documentElement.lang || '*';

// Check for title tag
if (document.title) {
pageName = document.title;
}
// @ts-expect-error: indexing is messy
const templates: Announcements = announcements[lang] || announcements['*'] || announcements;
if (typeof templates !== 'object') return;

// Look for first heading in content container
const content = document.querySelector(contentSelector);
if (content) {
const headings = content.querySelectorAll(headingSelector);
if (headings && headings.length) {
const [heading] = headings;
pageName = heading.getAttribute('aria-label') || heading.textContent || pageName;
}
}
const heading = document.querySelector(`${contentSelector} ${headingSelector}`);
// Get page title from aria attribute or text content
let title = heading?.getAttribute('aria-label') || heading?.textContent;
// Fall back to document title, then url if no title was found
title = title || document.title || this.parseTemplate(templates.url, { href, url, path });
// Replace {variables} in template
const announcement = this.parseTemplate(templates.visit, { title, href, url, path });

visit.a11y.announce = announcement;
}

parseTemplate(str: string, replacements: Record<string, string>): string {
return Object.keys(replacements).reduce((str, key) => {
return str.replace(`{${key}}`, replacements[key] || '');
}, str || '');
}

const announcement = announcementTemplate.replace('{title}', pageName.trim());
this.liveRegion.say(announcement);
handleNewPageContent() {
// We can't `await` nextTick() here because it would block ViewTransition callbacks
// Apparently, during ViewTransition updates there is no microtask queue
nextTick().then(async () => {
this.swup.hooks.call('content:announce', undefined, (visit) => {
this.announcePageName(visit);
});
this.swup.hooks.call('content:focus', undefined, (visit) => {
this.focusPageContent(visit);
});
});
}

async focusPageContent() {
await this.swup.hooks.call('content:focus', undefined, (visit) => {
if (!visit.a11y.focus) return;
announcePageName(visit: Visit) {
if (visit.a11y.announce) {
this.liveRegion.say(visit.a11y.announce);
}
}

const content = document.querySelector<HTMLElement>(visit.a11y.focus);
if (content instanceof HTMLElement) {
if (this.needsTabindex(content)) {
content.setAttribute('tabindex', '-1');
}
content.focus({ preventScroll: true });
async focusPageContent(visit: Visit) {
if (!visit.a11y.focus) return;

const content = document.querySelector<HTMLElement>(visit.a11y.focus);
if (content instanceof HTMLElement) {
if (this.needsTabindex(content)) {
content.setAttribute('tabindex', '-1');
}
});
content.focus({ preventScroll: true });
}
}

disableTransitionAnimations(visit: Visit) {
Expand Down

0 comments on commit 86ab83a

Please sign in to comment.