@@ -9,6 +9,44 @@ import { t } from './utils/l10n.js'
99
1010import '../styles/toast.scss'
1111
12+ /**
13+ * Persistent aria-live regions that exist in the DOM before any toast is shown.
14+ * Screen readers require the live region to already be present when content is added;
15+ * injecting an element that already carries aria-live is unreliable (NVDA, JAWS).
16+ */
17+ let _politeRegion : HTMLElement | null = null
18+ let _assertiveRegion : HTMLElement | null = null
19+
20+ const VISUALLY_HIDDEN_STYLE = 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0'
21+
22+ function getOrCreateLiveRegion ( level : 'polite' | 'assertive' ) : HTMLElement {
23+ if ( level === 'assertive' && _assertiveRegion ) return _assertiveRegion
24+ if ( level === 'polite' && _politeRegion ) return _politeRegion
25+
26+ const region = document . createElement ( 'div' )
27+ region . setAttribute ( 'aria-live' , level )
28+ region . setAttribute ( 'aria-atomic' , 'true' )
29+ region . setAttribute ( 'aria-relevant' , 'additions text' )
30+ region . style . cssText = VISUALLY_HIDDEN_STYLE
31+ document . body . appendChild ( region )
32+
33+ if ( level === 'assertive' ) {
34+ _assertiveRegion = region
35+ } else {
36+ _politeRegion = region
37+ }
38+ return region
39+ }
40+
41+ function announceToLiveRegion ( text : string , level : 'polite' | 'assertive' ) : void {
42+ const region = getOrCreateLiveRegion ( level )
43+ // Clear first so the same message announced twice is re-read
44+ region . textContent = ''
45+ setTimeout ( ( ) => {
46+ region . textContent = text
47+ } , 50 )
48+ }
49+
1250/**
1351 * Enum of available Toast types
1452 */
@@ -136,6 +174,24 @@ export function showMessage(data: string | Node, options?: ToastOptions): Toast
136174 ariaLive = ToastAriaLive . ASSERTIVE
137175 }
138176
177+ // Announce to a persistent live region – more reliable than aria-live on
178+ // dynamically injected elements (NVDA+Firefox, older JAWS).
179+ // Prefix the message with a translated type label so sighted users' color
180+ // cue (the border) is also conveyed to screen readers (WCAG 1.4.1).
181+ if ( ariaLive !== ToastAriaLive . OFF ) {
182+ const typeLabels : Partial < Record < ToastType , string > > = {
183+ [ ToastType . ERROR ] : t ( 'Error' ) ,
184+ [ ToastType . WARNING ] : t ( 'Warning' ) ,
185+ [ ToastType . INFO ] : t ( 'Info' ) ,
186+ [ ToastType . SUCCESS ] : t ( 'Success' ) ,
187+ }
188+ const typePrefix = options . type && typeLabels [ options . type ] ? typeLabels [ options . type ] + ': ' : ''
189+ const announceText = typeof data === 'string'
190+ ? data
191+ : ( data as Element ) . textContent ?? ''
192+ announceToLiveRegion ( typePrefix + announceText , ariaLive === ToastAriaLive . ASSERTIVE ? 'assertive' : 'polite' )
193+ }
194+
139195 const toast = Toastify ( {
140196 [ ! isNode ? 'text' : 'node' ] : data ,
141197 duration : options . timeout ,
@@ -153,6 +209,19 @@ export function showMessage(data: string | Node, options?: ToastOptions): Toast
153209
154210 toast . showToast ( )
155211
212+ // Fix accessibility of the rendered toast element
213+ const toastEl = toast . toastElement as HTMLElement | null
214+ if ( toastEl ) {
215+ // role="alert" or role="status" is required – toastify only sets aria-live
216+ toastEl . setAttribute ( 'role' , ariaLive === ToastAriaLive . ASSERTIVE ? 'alert' : 'status' )
217+
218+ // The close button is icon-only; give it an accessible name
219+ const closeBtn = toastEl . querySelector < HTMLButtonElement > ( '.toast-close' )
220+ if ( closeBtn ) {
221+ closeBtn . setAttribute ( 'aria-label' , t ( 'Close' ) )
222+ }
223+ }
224+
156225 return toast
157226}
158227
@@ -208,6 +277,8 @@ export function showLoading(text: string, options?: ToastOptions): Toast {
208277 const loader = document . createElement ( 'span' )
209278 loader . innerHTML = LoaderSvg
210279 loader . classList . add ( 'toast-loader' )
280+ // The spinner is decorative – the toast text already conveys the loading state
281+ loader . setAttribute ( 'aria-hidden' , 'true' )
211282
212283 // Generate loader layout
213284 const loaderContent = document . createElement ( 'span' )
0 commit comments