/* * 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 = ''; } } }