Skip to content

Commit ff288ce

Browse files
committed
fix: make toasts accessible
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
1 parent fbacc41 commit ff288ce

2 files changed

Lines changed: 79 additions & 1 deletion

File tree

lib/toast.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,44 @@ import { t } from './utils/l10n.js'
99

1010
import '../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')

styles/toast.scss

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,17 @@
6969
margin-left: 12px;
7070
}
7171

72-
&:hover, &:focus, &:active {
72+
&:hover, &:focus-visible, &:active {
7373
cursor: pointer;
7474
opacity: 1;
7575
}
76+
77+
// Visible focus indicator (WCAG 2.4.11 Focus Appearance)
78+
&:focus-visible {
79+
outline: 2px solid var(--color-primary-element, Highlight);
80+
outline-offset: 2px;
81+
border-radius: var(--border-radius);
82+
}
7683
}
7784

7885
&.toastify-top {

0 commit comments

Comments
 (0)