174 lines
5.3 KiB
Plaintext
174 lines
5.3 KiB
Plaintext
/*
|
|
* Copyright 2020 Adobe. All rights reserved.
|
|
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License. You may obtain a copy
|
|
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software distributed under
|
|
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
|
* OF ANY KIND, either express or implied. See the License for the specific language
|
|
* governing permissions and limitations under the License.
|
|
*/
|
|
|
|
type Assertiveness = 'assertive' | 'polite';
|
|
|
|
/* Inspired by https://github.com/AlmeroSteyn/react-aria-live */
|
|
const LIVEREGION_TIMEOUT_DELAY = 7000;
|
|
|
|
let liveAnnouncer: LiveAnnouncer | null = null;
|
|
|
|
type Message = string | {'aria-labelledby': string};
|
|
|
|
/**
|
|
* Announces the message using screen reader technology.
|
|
*/
|
|
export function announce(
|
|
message: Message,
|
|
assertiveness: Assertiveness = 'assertive',
|
|
timeout: number = LIVEREGION_TIMEOUT_DELAY
|
|
): void {
|
|
if (!liveAnnouncer) {
|
|
liveAnnouncer = new LiveAnnouncer();
|
|
// wait for the live announcer regions to be added to the dom, then announce
|
|
// otherwise Safari won't announce the message if it's added too quickly
|
|
// found most times less than 100ms were not consistent when announcing with Safari
|
|
|
|
// IS_REACT_ACT_ENVIRONMENT is used by React 18. Previous versions checked for the `jest` global.
|
|
// https://github.com/reactwg/react-18/discussions/102
|
|
// if we're in a test environment, announce without waiting
|
|
// @ts-ignore
|
|
if (!(typeof IS_REACT_ACT_ENVIRONMENT === 'boolean' ? IS_REACT_ACT_ENVIRONMENT : typeof jest !== 'undefined')) {
|
|
setTimeout(() => {
|
|
if (liveAnnouncer?.isAttached()) {
|
|
liveAnnouncer?.announce(message, assertiveness, timeout);
|
|
}
|
|
}, 100);
|
|
} else {
|
|
liveAnnouncer.announce(message, assertiveness, timeout);
|
|
}
|
|
} else {
|
|
liveAnnouncer.announce(message, assertiveness, timeout);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stops all queued announcements.
|
|
*/
|
|
export function clearAnnouncer(assertiveness: Assertiveness): void {
|
|
if (liveAnnouncer) {
|
|
liveAnnouncer.clear(assertiveness);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes the announcer from the DOM.
|
|
*/
|
|
export function destroyAnnouncer(): void {
|
|
if (liveAnnouncer) {
|
|
liveAnnouncer.destroy();
|
|
liveAnnouncer = null;
|
|
}
|
|
}
|
|
|
|
// LiveAnnouncer is implemented using vanilla DOM, not React. That's because as of React 18
|
|
// ReactDOM.render is deprecated, and the replacement, ReactDOM.createRoot is moved into a
|
|
// subpath import `react-dom/client`. That makes it hard for us to support multiple React versions.
|
|
// As a global API, we can't use portals without introducing a breaking API change. LiveAnnouncer
|
|
// is simple enough to implement without React, so that's what we do here.
|
|
// See this discussion for more details: https://github.com/reactwg/react-18/discussions/125#discussioncomment-2382638
|
|
class LiveAnnouncer {
|
|
node: HTMLElement | null = null;
|
|
assertiveLog: HTMLElement | null = null;
|
|
politeLog: HTMLElement | null = null;
|
|
|
|
constructor() {
|
|
if (typeof document !== 'undefined') {
|
|
this.node = document.createElement('div');
|
|
this.node.dataset.liveAnnouncer = 'true';
|
|
// copied from VisuallyHidden
|
|
Object.assign(this.node.style, {
|
|
border: 0,
|
|
clip: 'rect(0 0 0 0)',
|
|
clipPath: 'inset(50%)',
|
|
height: '1px',
|
|
margin: '-1px',
|
|
overflow: 'hidden',
|
|
padding: 0,
|
|
position: 'absolute',
|
|
width: '1px',
|
|
whiteSpace: 'nowrap'
|
|
});
|
|
|
|
this.assertiveLog = this.createLog('assertive');
|
|
this.node.appendChild(this.assertiveLog);
|
|
|
|
this.politeLog = this.createLog('polite');
|
|
this.node.appendChild(this.politeLog);
|
|
|
|
document.body.prepend(this.node);
|
|
}
|
|
}
|
|
|
|
isAttached() {
|
|
return this.node?.isConnected;
|
|
}
|
|
|
|
createLog(ariaLive: string) {
|
|
let node = document.createElement('div');
|
|
node.setAttribute('role', 'log');
|
|
node.setAttribute('aria-live', ariaLive);
|
|
node.setAttribute('aria-relevant', 'additions');
|
|
return node;
|
|
}
|
|
|
|
destroy() {
|
|
if (!this.node) {
|
|
return;
|
|
}
|
|
|
|
document.body.removeChild(this.node);
|
|
this.node = null;
|
|
}
|
|
|
|
announce(message: Message, assertiveness = 'assertive', timeout = LIVEREGION_TIMEOUT_DELAY) {
|
|
if (!this.node) {
|
|
return;
|
|
}
|
|
|
|
let node = document.createElement('div');
|
|
if (typeof message === 'object') {
|
|
// To read an aria-labelledby, the element must have an appropriate role, such as img.
|
|
node.setAttribute('role', 'img');
|
|
node.setAttribute('aria-labelledby', message['aria-labelledby']);
|
|
} else {
|
|
node.textContent = message;
|
|
}
|
|
|
|
if (assertiveness === 'assertive') {
|
|
this.assertiveLog?.appendChild(node);
|
|
} else {
|
|
this.politeLog?.appendChild(node);
|
|
}
|
|
|
|
if (message !== '') {
|
|
setTimeout(() => {
|
|
node.remove();
|
|
}, timeout);
|
|
}
|
|
}
|
|
|
|
clear(assertiveness: Assertiveness) {
|
|
if (!this.node) {
|
|
return;
|
|
}
|
|
|
|
if ((!assertiveness || assertiveness === 'assertive') && this.assertiveLog) {
|
|
this.assertiveLog.innerHTML = '';
|
|
}
|
|
|
|
if ((!assertiveness || assertiveness === 'polite') && this.politeLog) {
|
|
this.politeLog.innerHTML = '';
|
|
}
|
|
}
|
|
}
|