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-
1713type 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 */
2416const 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 */
4438export 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 */
5147export 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