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

Move focus starting point on anchor scroll #44

Closed
wants to merge 13 commits into from
65 changes: 65 additions & 0 deletions src/announcements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Location } from 'swup';
import { AnnouncementTranslations, Options } from './index.js';
import { createElement, parseTemplate } from './util.js';

export class Announcer {
id: string = 'swup-announcer';
style: string = `position:absolute;top:0;left:0;clip:rect(0 0 0 0);clip-path:inset(50%);overflow:hidden;white-space:nowrap;word-wrap:normal;width:1px;height:1px;`;
region: Element;

constructor() {
this.region = this.getRegion() ?? this.createRegion();
}

getRegion(): HTMLElement | null {
return document.getElementById(this.id);
}

createRegion(): Element {
const liveRegion = createElement(
`<p aria-live="assertive" aria-atomic="true" id="${this.id}" style="${this.style}"></p>`
);
document.body.appendChild(liveRegion);
return liveRegion;
}

announce(message: string, delay: number = 0) {
setTimeout(() => {
// // Fix screen readers not announcing the same message twice
if (this.region.textContent === message) {
message = `${message}.`;
}
// Clear before announcing
this.region.textContent = '';
this.region.textContent = message;
}, delay);
}
}

type PageAnnouncementOptions = Pick<Options, 'headingSelector' | 'announcements'>;

export function getPageAnnouncement({
headingSelector,
announcements
}: PageAnnouncementOptions): string | undefined {
const lang = document.documentElement.lang || '*';
const { href, url, pathname: path } = Location.fromUrl(window.location.href);

const templates = (announcements as AnnouncementTranslations)[lang] || announcements;
if (typeof templates !== 'object') return;

// Look for first heading on page
const headingEl = document.querySelector(headingSelector);
if (!headingEl) {
console.warn(`SwupA11yPlugin: No main heading (${headingSelector}) found on new page`);
}

// Get page heading from aria attribute or text content
const heading = headingEl?.getAttribute('aria-label') || headingEl?.textContent;

// Fall back to document title, then url if no title was found
const title = heading || document.title || parseTemplate(templates.url, { href, url, path });

// Replace {variables} in template
return parseTemplate(templates.visit, { title, href, url, path });
}
35 changes: 0 additions & 35 deletions src/announcer.ts

This file was deleted.

40 changes: 40 additions & 0 deletions src/focus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export function getAutofocusElement(): HTMLElement | undefined {
const focusEl = document.querySelector<HTMLElement>('body [autofocus]');
if (focusEl && !focusEl.closest('inert, [aria-disabled], [aria-hidden="true"]')) {
return focusEl;
}
}

export function focusAutofocusElement(): boolean {
const autofocusEl = getAutofocusElement();
if (!autofocusEl) return false;

if (autofocusEl !== document.activeElement) {
// Only focus if not already focused
// No preventScroll flag here, as probably intended with autofocus
autofocusEl.focus();
}
return true;
}

export function focusElement(elementOrSelector: string | HTMLElement) {
let el: HTMLElement | null;
if (typeof elementOrSelector === 'string') {
el = document.querySelector<HTMLElement>(elementOrSelector);
} else {
el = elementOrSelector;
}

if (!(el instanceof HTMLElement)) return;

// Set and restore tabindex to allow focusing non-focusable elements
const tabindex = el.getAttribute('tabindex');
el.setAttribute('tabindex', '-1');
el.focus({ preventScroll: true });
if (tabindex !== null) {
el.setAttribute('tabindex', tabindex);
} else {
// Removing the tabindex will reset screen reader position, so we'll keep it
// el.removeAttribute('tabindex');
}
}
86 changes: 21 additions & 65 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Location, Visit, nextTick } from 'swup';
import { HookHandler, Visit } from 'swup';
import Plugin from '@swup/plugin';

import 'focus-options-polyfill';

import Announcer from './announcer.js';
import { getAutofocusElement, parseTemplate } from './util.js';
import { Announcer, getPageAnnouncement } from './announcements.js';
import { focusAutofocusElement, focusElement } from './focus.js';

export interface VisitA11y {
/** How to announce the new content after it inserted */
Expand All @@ -31,19 +31,19 @@ declare module 'swup' {
}

/** Templates for announcements of the new page content. */
type Announcements = {
export 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 = {
export type AnnouncementTranslations = {
[lang: string]: Announcements;
};

type Options = {
export type Options = {
/** The selector for finding headings inside the main content area. */
headingSelector: string;
/** Whether to skip animations for users that prefer reduced motion. */
Expand Down Expand Up @@ -103,6 +103,9 @@ export default class SwupA11yPlugin extends Plugin {
// Announce new page title after visit completes
this.on('visit:end', this.announceContent);

// Move focus start point when clicking on-page anchors
this.on('scroll:anchor', this.handleAnchorScroll);

// Disable transition and scroll animations if user prefers reduced motion
if (this.options.respectReducedMotion) {
this.before('visit:start', this.disableTransitionAnimations);
Expand Down Expand Up @@ -142,7 +145,7 @@ export default class SwupA11yPlugin extends Plugin {
this.swup.hooks.callSync('content:announce', visit, undefined, (visit) => {
// Allow customizing announcement before this hook
if (typeof visit.a11y.announce === 'undefined') {
visit.a11y.announce = this.getPageTitle();
visit.a11y.announce = this.getPageAnnouncement();
}

// Announcement disabled for this visit?
Expand All @@ -159,78 +162,31 @@ export default class SwupA11yPlugin extends Plugin {
if (!visit.a11y.focus) return;

// Found and focused [autofocus] element? Return early
if (this.focusAutofocusElement() === true) return;
if (this.options.autofocus && focusAutofocusElement() === true) return;

// Otherwise, find and focus actual content container
this.focusContentElement(visit.a11y.focus);
focusElement(visit.a11y.focus);
});
}

getPageTitle(): string | undefined {
const { headingSelector, announcements } = this.options;
const { href, url, pathname: path } = Location.fromUrl(window.location.href);
const lang = document.documentElement.lang || '*';

const templates: Announcements =
(announcements as AnnouncementTranslations)[lang] || announcements;
if (typeof templates !== 'object') return;

// Look for first heading on page
const headingEl = document.querySelector(headingSelector);
if (!headingEl) {
console.warn(
`SwupA11yPlugin: No main heading (${headingSelector}) found in incoming document`
);
}

// Get page heading from aria attribute or text content
const heading = headingEl?.getAttribute('aria-label') || headingEl?.textContent;

// Fall back to document title, then url if no title was found
const title =
heading || document.title || parseTemplate(templates.url, { href, url, path });

// Replace {variables} in template
const announcement = parseTemplate(templates.visit, { title, href, url, path });

return announcement;
}

focusContentElement(selector: string) {
const el = document.querySelector<HTMLElement>(selector);
if (!(el instanceof HTMLElement)) return;

// Set and restore tabindex to allow focusing non-focusable elements
const tabindex = el.getAttribute('tabindex');
el.setAttribute('tabindex', '-1');
el.focus({ preventScroll: true });
if (tabindex !== null) {
el.setAttribute('tabindex', tabindex);
} else {
el.removeAttribute('tabindex');
}
}

focusAutofocusElement(): boolean {
if (!this.options.autofocus) return false;

const autofocusEl = getAutofocusElement();
if (autofocusEl) {
if (autofocusEl !== document.activeElement) {
autofocusEl.focus(); // no preventScroll flag here, as probably intended
}
return true;
handleAnchorScroll: HookHandler<'scroll:anchor'> = (visit, { hash }) => {
const anchor = this.swup.getAnchorElement(hash);
if (anchor instanceof HTMLElement) {
focusElement(anchor);
}
};

return false;
getPageAnnouncement(): string | undefined {
const { headingSelector, announcements } = this.options;
return getPageAnnouncement({ headingSelector, announcements });
}

disableTransitionAnimations(visit: Visit) {
visit.animation.animate = visit.animation.animate && this.shouldAnimate();
}

disableScrollAnimations(visit: Visit) {
// @ts-ignore: animate property is not defined unless Scroll Plugin installed
// @ts-expect-error: animate property is not defined unless Scroll Plugin installed
visit.scroll.animate = visit.scroll.animate && this.shouldAnimate();
}

Expand Down
7 changes: 0 additions & 7 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,6 @@ export function createElement(html: string): Element {
return template.content.children[0];
}

export function getAutofocusElement(): HTMLElement | undefined {
const focusEl = document.querySelector<HTMLElement>('body [autofocus]');
if (focusEl && !focusEl.closest('inert, [aria-disabled], [aria-hidden="true"]')) {
return focusEl;
}
}

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