10
10
* governing permissions and limitations under the License.
11
11
*/
12
12
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
-
17
13
type Assertiveness = 'assertive' | 'polite' ;
18
- interface Announcer {
19
- announce ( message : string , assertiveness : Assertiveness , timeout : number ) : void ,
20
- clear ( assertiveness : Assertiveness ) : void
21
- }
22
14
23
15
/* Inspired by https://github.com/AlmeroSteyn/react-aria-live */
24
16
const LIVEREGION_TIMEOUT_DELAY = 7000 ;
25
17
26
- let liveRegionAnnouncer = React . createRef < Announcer > ( ) ;
27
- let node : HTMLElement = null ;
28
- let messageId = 0 ;
18
+ let liveAnnouncer : LiveAnnouncer = null ;
29
19
30
20
/**
31
21
* Announces the message using screen reader technology.
@@ -35,108 +25,118 @@ export function announce(
35
25
assertiveness : Assertiveness = 'assertive' ,
36
26
timeout = LIVEREGION_TIMEOUT_DELAY
37
27
) {
38
- ensureInstance ( announcer => announcer . announce ( message , assertiveness , timeout ) ) ;
28
+ if ( ! liveAnnouncer ) {
29
+ liveAnnouncer = new LiveAnnouncer ( ) ;
30
+ }
31
+
32
+ liveAnnouncer . announce ( message , assertiveness , timeout ) ;
39
33
}
40
34
41
35
/**
42
36
* Stops all queued announcements.
43
37
*/
44
38
export function clearAnnouncer ( assertiveness : Assertiveness ) {
45
- ensureInstance ( announcer => announcer . clear ( assertiveness ) ) ;
39
+ if ( liveAnnouncer ) {
40
+ liveAnnouncer . clear ( assertiveness ) ;
41
+ }
46
42
}
47
43
48
44
/**
49
45
* Removes the announcer from the DOM.
50
46
*/
51
47
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 ;
56
51
}
57
52
}
58
53
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 ) ;
74
89
}
75
- }
76
90
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
+ }
80
98
81
- let clear = ( assertiveness : Assertiveness ) => {
82
- if ( ! assertiveness || assertiveness === 'assertive' ) {
83
- setAssertiveMessages ( [ ] ) ;
99
+ destroy ( ) {
100
+ if ( ! this . node ) {
101
+ return ;
84
102
}
85
103
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 ;
88
111
}
89
- } ;
90
112
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 ;
93
115
94
116
if ( assertiveness === 'assertive' ) {
95
- setAssertiveMessages ( messages => [ ... messages , { id , text : message } ] ) ;
117
+ this . assertiveLog . appendChild ( node ) ;
96
118
} else {
97
- setPoliteMessages ( messages => [ ... messages , { id , text : message } ] ) ;
119
+ this . politeLog . appendChild ( node ) ;
98
120
}
99
121
100
122
if ( message !== '' ) {
101
123
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 ( ) ;
107
125
} , timeout ) ;
108
126
}
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
+ }
142
142
}
0 commit comments