Skip to content

Commit b3a27a5

Browse files
committed
Reimplement LiveAnnouncer using vanilla DOM
1 parent 512927e commit b3a27a5

File tree

2 files changed

+85
-91
lines changed

2 files changed

+85
-91
lines changed

packages/@react-aria/live-announcer/package.json

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,7 @@
1717
"url": "https://github.com/adobe/react-spectrum"
1818
},
1919
"dependencies": {
20-
"@babel/runtime": "^7.6.2",
21-
"@react-aria/utils": "^3.12.0",
22-
"@react-aria/visually-hidden": "^3.2.8"
23-
},
24-
"peerDependencies": {
25-
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0",
26-
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
20+
"@babel/runtime": "^7.6.2"
2721
},
2822
"publishConfig": {
2923
"access": "public"

packages/@react-aria/live-announcer/src/LiveAnnouncer.tsx

Lines changed: 84 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,12 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import React, {Fragment, ReactNode, RefObject, useImperativeHandle, useState} from 'react';
14-
import ReactDOM from 'react-dom';
15-
import {VisuallyHidden} from '@react-aria/visually-hidden';
16-
1713
type Assertiveness = 'assertive' | 'polite';
18-
interface Announcer {
19-
announce(message: string, assertiveness: Assertiveness, timeout: number): void,
20-
clear(assertiveness: Assertiveness): void
21-
}
2214

2315
/* Inspired by https://github.com/AlmeroSteyn/react-aria-live */
2416
const LIVEREGION_TIMEOUT_DELAY = 7000;
2517

26-
let liveRegionAnnouncer = React.createRef<Announcer>();
27-
let node: HTMLElement = null;
28-
let messageId = 0;
18+
let liveAnnouncer: LiveAnnouncer = null;
2919

3020
/**
3121
* Announces the message using screen reader technology.
@@ -35,108 +25,118 @@ export function announce(
3525
assertiveness: Assertiveness = 'assertive',
3626
timeout = LIVEREGION_TIMEOUT_DELAY
3727
) {
38-
ensureInstance(announcer => announcer.announce(message, assertiveness, timeout));
28+
if (!liveAnnouncer) {
29+
liveAnnouncer = new LiveAnnouncer();
30+
}
31+
32+
liveAnnouncer.announce(message, assertiveness, timeout);
3933
}
4034

4135
/**
4236
* Stops all queued announcements.
4337
*/
4438
export function clearAnnouncer(assertiveness: Assertiveness) {
45-
ensureInstance(announcer => announcer.clear(assertiveness));
39+
if (liveAnnouncer) {
40+
liveAnnouncer.clear(assertiveness);
41+
}
4642
}
4743

4844
/**
4945
* Removes the announcer from the DOM.
5046
*/
5147
export function destroyAnnouncer() {
52-
if (liveRegionAnnouncer.current) {
53-
ReactDOM.unmountComponentAtNode(node);
54-
document.body.removeChild(node);
55-
node = null;
48+
if (liveAnnouncer) {
49+
liveAnnouncer.destroy();
50+
liveAnnouncer = null;
5651
}
5752
}
5853

59-
/**
60-
* Ensures we only have one instance of the announcer so that we don't have elements competing.
61-
*/
62-
function ensureInstance(callback: (announcer: Announcer) => void) {
63-
if (!liveRegionAnnouncer.current) {
64-
node = document.createElement('div');
65-
node.dataset.liveAnnouncer = 'true';
66-
document.body.prepend(node);
67-
ReactDOM.render(
68-
<LiveRegionAnnouncer ref={liveRegionAnnouncer} />,
69-
node,
70-
() => callback(liveRegionAnnouncer.current)
71-
);
72-
} else {
73-
callback(liveRegionAnnouncer.current);
54+
// LiveAnnouncer is implemented using vanilla DOM, not React. That's because as of React 18
55+
// ReactDOM.render is deprecated, and the replacement, ReactDOM.createRoot is moved into a
56+
// subpath import `react-dom/client`. That makes it hard for us to support multiple React versions.
57+
// As a global API, we can't use portals without introducing a breaking API change. LiveAnnouncer
58+
// is simple enough to implement without React, so that's what we do here.
59+
// See this discussion for more details: https://github.com/reactwg/react-18/discussions/125#discussioncomment-2382638
60+
class LiveAnnouncer {
61+
node: HTMLElement;
62+
assertiveLog: HTMLElement;
63+
politeLog: HTMLElement;
64+
65+
constructor() {
66+
this.node = document.createElement('div');
67+
this.node.dataset.liveAnnouncer = 'true';
68+
// copied from VisuallyHidden
69+
Object.assign(this.node.style, {
70+
border: 0,
71+
clip: 'rect(0 0 0 0)',
72+
clipPath: 'inset(50%)',
73+
height: 1,
74+
margin: '0 -1px -1px 0',
75+
overflow: 'hidden',
76+
padding: 0,
77+
position: 'absolute',
78+
width: 1,
79+
whiteSpace: 'nowrap'
80+
});
81+
82+
this.assertiveLog = this.createLog('assertive');
83+
this.node.appendChild(this.assertiveLog);
84+
85+
this.politeLog = this.createLog('polite');
86+
this.node.appendChild(this.politeLog);
87+
88+
document.body.prepend(this.node);
7489
}
75-
}
7690

77-
const LiveRegionAnnouncer = React.forwardRef((_, ref: RefObject<Announcer>) => {
78-
let [assertiveMessages, setAssertiveMessages] = useState([]);
79-
let [politeMessages, setPoliteMessages] = useState([]);
91+
createLog(ariaLive: string) {
92+
let node = document.createElement('div');
93+
node.setAttribute('role', 'log');
94+
node.setAttribute('aria-live', ariaLive);
95+
node.setAttribute('aria-relevant', 'additions');
96+
return node;
97+
}
8098

81-
let clear = (assertiveness: Assertiveness) => {
82-
if (!assertiveness || assertiveness === 'assertive') {
83-
setAssertiveMessages([]);
99+
destroy() {
100+
if (!this.node) {
101+
return;
84102
}
85103

86-
if (!assertiveness || assertiveness === 'polite') {
87-
setPoliteMessages([]);
104+
document.body.removeChild(this.node);
105+
this.node = null;
106+
}
107+
108+
announce(message: string, assertiveness = 'assertive', timeout = LIVEREGION_TIMEOUT_DELAY) {
109+
if (!this.node) {
110+
return;
88111
}
89-
};
90112

91-
let announce = (message: string, assertiveness = 'assertive', timeout = LIVEREGION_TIMEOUT_DELAY) => {
92-
let id = messageId++;
113+
let node = document.createElement('div');
114+
node.textContent = message;
93115

94116
if (assertiveness === 'assertive') {
95-
setAssertiveMessages(messages => [...messages, {id, text: message}]);
117+
this.assertiveLog.appendChild(node);
96118
} else {
97-
setPoliteMessages(messages => [...messages, {id, text: message}]);
119+
this.politeLog.appendChild(node);
98120
}
99121

100122
if (message !== '') {
101123
setTimeout(() => {
102-
if (assertiveness === 'assertive') {
103-
setAssertiveMessages(messages => messages.filter(message => message.id !== id));
104-
} else {
105-
setPoliteMessages(messages => messages.filter(message => message.id !== id));
106-
}
124+
node.remove();
107125
}, timeout);
108126
}
109-
};
110-
111-
useImperativeHandle(ref, () => ({
112-
announce,
113-
clear
114-
}));
115-
116-
return (
117-
<Fragment>
118-
<MessageBlock aria-live="assertive">
119-
{assertiveMessages.map(message => <div key={message.id}>{message.text}</div>)}
120-
</MessageBlock>
121-
<MessageBlock aria-live="polite">
122-
{politeMessages.map(message => <div key={message.id}>{message.text}</div>)}
123-
</MessageBlock>
124-
</Fragment>
125-
);
126-
});
127-
128-
interface MessageBlockProps {
129-
children: ReactNode,
130-
'aria-live': Assertiveness
131-
}
132-
133-
function MessageBlock({children, 'aria-live': ariaLive}: MessageBlockProps) {
134-
return (
135-
<VisuallyHidden
136-
role="log"
137-
aria-live={ariaLive}
138-
aria-relevant="additions">
139-
{children}
140-
</VisuallyHidden>
141-
);
127+
}
128+
129+
clear(assertiveness: Assertiveness) {
130+
if (!this.node) {
131+
return;
132+
}
133+
134+
if (!assertiveness || assertiveness === 'assertive') {
135+
this.assertiveLog.innerHTML = '';
136+
}
137+
138+
if (!assertiveness || assertiveness === 'polite') {
139+
this.politeLog.innerHTML = '';
140+
}
141+
}
142142
}

0 commit comments

Comments
 (0)