From b3a27a5d5cc7fb45ab67b0b0b95661e3f4565104 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 6 May 2022 13:22:28 -0700 Subject: [PATCH] Reimplement LiveAnnouncer using vanilla DOM --- .../@react-aria/live-announcer/package.json | 8 +- .../live-announcer/src/LiveAnnouncer.tsx | 168 +++++++++--------- 2 files changed, 85 insertions(+), 91 deletions(-) diff --git a/packages/@react-aria/live-announcer/package.json b/packages/@react-aria/live-announcer/package.json index d4b5d502379..07ab3bb7bc1 100644 --- a/packages/@react-aria/live-announcer/package.json +++ b/packages/@react-aria/live-announcer/package.json @@ -17,13 +17,7 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { - "@babel/runtime": "^7.6.2", - "@react-aria/utils": "^3.12.0", - "@react-aria/visually-hidden": "^3.2.8" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" + "@babel/runtime": "^7.6.2" }, "publishConfig": { "access": "public" diff --git a/packages/@react-aria/live-announcer/src/LiveAnnouncer.tsx b/packages/@react-aria/live-announcer/src/LiveAnnouncer.tsx index 4980ec20814..37bc82aca16 100644 --- a/packages/@react-aria/live-announcer/src/LiveAnnouncer.tsx +++ b/packages/@react-aria/live-announcer/src/LiveAnnouncer.tsx @@ -10,22 +10,12 @@ * governing permissions and limitations under the License. */ -import React, {Fragment, ReactNode, RefObject, useImperativeHandle, useState} from 'react'; -import ReactDOM from 'react-dom'; -import {VisuallyHidden} from '@react-aria/visually-hidden'; - type Assertiveness = 'assertive' | 'polite'; -interface Announcer { - announce(message: string, assertiveness: Assertiveness, timeout: number): void, - clear(assertiveness: Assertiveness): void -} /* Inspired by https://github.com/AlmeroSteyn/react-aria-live */ const LIVEREGION_TIMEOUT_DELAY = 7000; -let liveRegionAnnouncer = React.createRef(); -let node: HTMLElement = null; -let messageId = 0; +let liveAnnouncer: LiveAnnouncer = null; /** * Announces the message using screen reader technology. @@ -35,108 +25,118 @@ export function announce( assertiveness: Assertiveness = 'assertive', timeout = LIVEREGION_TIMEOUT_DELAY ) { - ensureInstance(announcer => announcer.announce(message, assertiveness, timeout)); + if (!liveAnnouncer) { + liveAnnouncer = new LiveAnnouncer(); + } + + liveAnnouncer.announce(message, assertiveness, timeout); } /** * Stops all queued announcements. */ export function clearAnnouncer(assertiveness: Assertiveness) { - ensureInstance(announcer => announcer.clear(assertiveness)); + if (liveAnnouncer) { + liveAnnouncer.clear(assertiveness); + } } /** * Removes the announcer from the DOM. */ export function destroyAnnouncer() { - if (liveRegionAnnouncer.current) { - ReactDOM.unmountComponentAtNode(node); - document.body.removeChild(node); - node = null; + if (liveAnnouncer) { + liveAnnouncer.destroy(); + liveAnnouncer = null; } } -/** - * Ensures we only have one instance of the announcer so that we don't have elements competing. - */ -function ensureInstance(callback: (announcer: Announcer) => void) { - if (!liveRegionAnnouncer.current) { - node = document.createElement('div'); - node.dataset.liveAnnouncer = 'true'; - document.body.prepend(node); - ReactDOM.render( - , - node, - () => callback(liveRegionAnnouncer.current) - ); - } else { - callback(liveRegionAnnouncer.current); +// 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; + assertiveLog: HTMLElement; + politeLog: HTMLElement; + + constructor() { + 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: 1, + margin: '0 -1px -1px 0', + overflow: 'hidden', + padding: 0, + position: 'absolute', + width: 1, + 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); } -} -const LiveRegionAnnouncer = React.forwardRef((_, ref: RefObject) => { - let [assertiveMessages, setAssertiveMessages] = useState([]); - let [politeMessages, setPoliteMessages] = useState([]); + createLog(ariaLive: string) { + let node = document.createElement('div'); + node.setAttribute('role', 'log'); + node.setAttribute('aria-live', ariaLive); + node.setAttribute('aria-relevant', 'additions'); + return node; + } - let clear = (assertiveness: Assertiveness) => { - if (!assertiveness || assertiveness === 'assertive') { - setAssertiveMessages([]); + destroy() { + if (!this.node) { + return; } - if (!assertiveness || assertiveness === 'polite') { - setPoliteMessages([]); + document.body.removeChild(this.node); + this.node = null; + } + + announce(message: string, assertiveness = 'assertive', timeout = LIVEREGION_TIMEOUT_DELAY) { + if (!this.node) { + return; } - }; - let announce = (message: string, assertiveness = 'assertive', timeout = LIVEREGION_TIMEOUT_DELAY) => { - let id = messageId++; + let node = document.createElement('div'); + node.textContent = message; if (assertiveness === 'assertive') { - setAssertiveMessages(messages => [...messages, {id, text: message}]); + this.assertiveLog.appendChild(node); } else { - setPoliteMessages(messages => [...messages, {id, text: message}]); + this.politeLog.appendChild(node); } if (message !== '') { setTimeout(() => { - if (assertiveness === 'assertive') { - setAssertiveMessages(messages => messages.filter(message => message.id !== id)); - } else { - setPoliteMessages(messages => messages.filter(message => message.id !== id)); - } + node.remove(); }, timeout); } - }; - - useImperativeHandle(ref, () => ({ - announce, - clear - })); - - return ( - - - {assertiveMessages.map(message =>
{message.text}
)} -
- - {politeMessages.map(message =>
{message.text}
)} -
-
- ); -}); - -interface MessageBlockProps { - children: ReactNode, - 'aria-live': Assertiveness - } - -function MessageBlock({children, 'aria-live': ariaLive}: MessageBlockProps) { - return ( - - {children} - - ); + } + + clear(assertiveness: Assertiveness) { + if (!this.node) { + return; + } + + if (!assertiveness || assertiveness === 'assertive') { + this.assertiveLog.innerHTML = ''; + } + + if (!assertiveness || assertiveness === 'polite') { + this.politeLog.innerHTML = ''; + } + } }