Skip to content

Commit a27dbaf

Browse files
committed
fix(toast): screen readers announce content (#27198)
Issue URL: resolves #25866 --------- Docs PR: ionic-team/ionic-docs#2914 <!-- Please refer to our contributing documentation for any questions on submitting a pull request, or let us know here if you need any help: https://ionicframework.com/docs/building/contributing --> <!-- Some docs updates need to be made in the `ionic-docs` repo, in a separate PR. See https://github.com/ionic-team/ionic-framework/blob/main/.github/CONTRIBUTING.md#modifying-documentation for details. --> <!-- Please do not submit updates to dependencies unless it fixes an issue. --> <!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. --> ## What is the current behavior? <!-- Please describe the current behavior that you are modifying. --> <!-- Issues are required for both bug fixes and features. --> NVDA is not announcing toasts on present. ## What is the new behavior? <!-- Please describe the behavior or changes that are being added by this PR. --> - Toast has a "status" role and "polite" announcement. - We also revisited the intended behavior of toasts to better align with the Material Design v2 spec: https://m2.material.io/components/snackbars/web#accessibility ## Does this introduce a breaking change? - [ ] Yes - [x] No <!-- If this introduces a breaking change, please describe the impact and migration path for existing applications below. --> ## Other information <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. --> Dev build: 7.0.3-dev.11681482468.19d7784f
1 parent 6f3f125 commit a27dbaf

File tree

4 files changed

+165
-31
lines changed

4 files changed

+165
-31
lines changed

core/src/components/toast/test/a11y/index.html

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,47 @@
1818
<main>
1919
<h1 style="background-color: white">Toast - a11y</h1>
2020

21-
<button id="polite" onclick="presentToast({ message: 'This is a toast message' })">Present Toast</button>
22-
<button
23-
id="assertive"
24-
onclick="presentToast({ message: 'This is an assertive toast message', htmlAttributes: { 'aria-live': 'assertive' } })"
21+
<ion-button id="inline-toast-trigger">Present Inline Toast</ion-button>
22+
<ion-toast
23+
id="inline-toast"
24+
trigger="inline-toast-trigger"
25+
icon="person"
26+
header="Inline Toast Header"
27+
message="Inline Toast Message"
28+
></ion-toast>
29+
30+
<ion-button
31+
id="controller-toast-trigger"
32+
onclick="presentToast({ icon: 'person', header: 'Controller Toast Header', message: 'Controller Toast Message', buttons: ['Ok'] })"
2533
>
26-
Present Assertive Toast
27-
</button>
34+
Present Controller Toast
35+
</ion-button>
36+
37+
<ion-button onclick="updateContent()">Update Inner Content</ion-button>
2838
</main>
2939
</ion-app>
3040
<script>
41+
const inlineToast = document.querySelector('#inline-toast');
42+
inlineToast.buttons = ['Ok'];
43+
3144
const presentToast = async (opts) => {
3245
const toast = await toastController.create(opts);
3346

3447
await toast.present();
3548
};
49+
50+
const updateContent = () => {
51+
const toasts = document.querySelectorAll('ion-toast');
52+
/**
53+
* Note: Multiple updates to the props
54+
* may cause screen readers like NVDA to announce
55+
* the entire content multiple times.
56+
*/
57+
toasts.forEach((toast) => {
58+
toast.header = 'Updated Header';
59+
toast.message = 'Updated Message';
60+
});
61+
};
3662
</script>
3763
</body>
3864
</html>

core/src/components/toast/test/a11y/toast.e2e.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,10 @@ test.describe('toast: a11y', () => {
77
test.skip(testInfo.project.metadata.rtl === true, 'This test does not check LTR vs RTL layouts');
88
await page.goto(`/src/components/toast/test/a11y`);
99
});
10-
test('should not have any axe violations with polite toasts', async ({ page }) => {
10+
test('should not have any axe violations with inline toasts', async ({ page }) => {
1111
const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent');
1212

13-
const politeButton = page.locator('#polite');
14-
await politeButton.click();
15-
13+
await page.click('#inline-toast-trigger');
1614
await ionToastDidPresent.next();
1715

1816
/**
@@ -23,12 +21,10 @@ test.describe('toast: a11y', () => {
2321
const results = await new AxeBuilder({ page }).disableRules('color-contrast').analyze();
2422
expect(results.violations).toEqual([]);
2523
});
26-
test('should not have any axe violations with assertive toasts', async ({ page }) => {
24+
test('should not have any axe violations with controller toasts', async ({ page }) => {
2725
const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent');
2826

29-
const politeButton = page.locator('#assertive');
30-
await politeButton.click();
31-
27+
await page.click('#controller-toast-trigger');
3228
await ionToastDidPresent.next();
3329

3430
/**

core/src/components/toast/test/toast.spec.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { newSpecPage } from '@stencil/core/testing';
22
import { Toast } from '../toast';
33
import { config } from '../../../global/config';
44

5-
describe('alert: custom html', () => {
5+
describe('toast: custom html', () => {
66
it('should not allow for custom html by default', async () => {
77
const page = await newSpecPage({
88
components: [Toast],
@@ -41,3 +41,50 @@ describe('alert: custom html', () => {
4141
expect(content.querySelector('button.custom-html')).toBe(null);
4242
});
4343
});
44+
45+
/**
46+
* These tests check if the aria-hidden attributes are being
47+
* removed on present. Without this functionality, screen readers
48+
* would not announce toast content correctly.
49+
*/
50+
describe('toast: a11y smoke test', () => {
51+
it('should have aria-hidden content when dismissed', async () => {
52+
const page = await newSpecPage({
53+
components: [Toast],
54+
html: `<ion-toast message="Message" header="Header"></ion-toast>`,
55+
});
56+
57+
const toast = page.body.querySelector('ion-toast');
58+
const header = toast.shadowRoot.querySelector('.toast-header');
59+
const message = toast.shadowRoot.querySelector('.toast-message');
60+
61+
expect(header.getAttribute('aria-hidden')).toBe('true');
62+
expect(message.getAttribute('aria-hidden')).toBe('true');
63+
});
64+
65+
it('should not have aria-hidden content when presented', async () => {
66+
const page = await newSpecPage({
67+
components: [Toast],
68+
html: `
69+
<ion-app>
70+
<ion-toast animated="false" message="Message" header="Header"></ion-toast>
71+
</ion-app>
72+
`,
73+
});
74+
75+
const toast = page.body.querySelector('ion-toast');
76+
77+
/**
78+
* Wait for present method to resolve
79+
* and for state change to take effect.
80+
*/
81+
await toast.present();
82+
await page.waitForChanges();
83+
84+
const header = toast.shadowRoot.querySelector('.toast-header');
85+
const message = toast.shadowRoot.querySelector('.toast-message');
86+
87+
expect(header.getAttribute('aria-hidden')).toBe(null);
88+
expect(message.getAttribute('aria-hidden')).toBe(null);
89+
});
90+
});

core/src/components/toast/toast.tsx

Lines changed: 81 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ComponentInterface, EventEmitter } from '@stencil/core';
2-
import { Watch, Component, Element, Event, h, Host, Method, Prop } from '@stencil/core';
2+
import { Watch, Component, Element, Event, h, Host, Method, Prop, State } from '@stencil/core';
33

44
import { config } from '../../global/config';
55
import { getIonMode } from '../../global/ionic-global';
@@ -55,6 +55,13 @@ export class Toast implements ComponentInterface, OverlayInterface {
5555

5656
presented = false;
5757

58+
/**
59+
* When `true`, content inside of .toast-content
60+
* will have aria-hidden elements removed causing
61+
* screen readers to announce the remaining content.
62+
*/
63+
@State() revealContentToScreenReader = false;
64+
5865
@Element() el!: HTMLIonToastElement;
5966

6067
/**
@@ -268,6 +275,14 @@ export class Toast implements ComponentInterface, OverlayInterface {
268275
this.position
269276
);
270277
await this.currentTransition;
278+
279+
/**
280+
* Content is revealed to screen readers after
281+
* the transition to avoid jank since this
282+
* state updates will cause a re-render.
283+
*/
284+
this.revealContentToScreenReader = true;
285+
271286
this.currentTransition = undefined;
272287

273288
if (this.duration > 0) {
@@ -303,6 +318,7 @@ export class Toast implements ComponentInterface, OverlayInterface {
303318

304319
if (dismissed) {
305320
this.delegateController.removeViewFromDom();
321+
this.revealContentToScreenReader = false;
306322
}
307323

308324
return dismissed;
@@ -407,21 +423,47 @@ export class Toast implements ComponentInterface, OverlayInterface {
407423
);
408424
}
409425

410-
private renderToastMessage() {
426+
/**
427+
* Render the `message` property.
428+
* @param key - A key to give the element a stable identity. This is used to improve compatibility with screen readers.
429+
* @param ariaHidden - If "true" then content will be hidden from screen readers.
430+
*/
431+
private renderToastMessage(key: string, ariaHidden: 'true' | null = null) {
411432
const { customHTMLEnabled, message } = this;
412433
if (customHTMLEnabled) {
413-
return <div class="toast-message" part="message" innerHTML={sanitizeDOMString(message)}></div>;
434+
return (
435+
<div
436+
key={key}
437+
aria-hidden={ariaHidden}
438+
class="toast-message"
439+
part="message"
440+
innerHTML={sanitizeDOMString(message)}
441+
></div>
442+
);
414443
}
415444

416445
return (
417-
<div class="toast-message" part="message">
446+
<div key={key} aria-hidden={ariaHidden} class="toast-message" part="message">
418447
{message}
419448
</div>
420449
);
421450
}
422451

452+
/**
453+
* Render the `header` property.
454+
* @param key - A key to give the element a stable identity. This is used to improve compatibility with screen readers.
455+
* @param ariaHidden - If "true" then content will be hidden from screen readers.
456+
*/
457+
private renderHeader(key: string, ariaHidden: 'true' | null = null) {
458+
return (
459+
<div key={key} class="toast-header" aria-hidden={ariaHidden} part="header">
460+
{this.header}
461+
</div>
462+
);
463+
}
464+
423465
render() {
424-
const { layout, el } = this;
466+
const { layout, el, revealContentToScreenReader, header, message } = this;
425467
const allButtons = this.getButtons();
426468
const startButtons = allButtons.filter((b) => b.side === 'start');
427469
const endButtons = allButtons.filter((b) => b.side !== 'start');
@@ -431,7 +473,6 @@ export class Toast implements ComponentInterface, OverlayInterface {
431473
[`toast-${this.position}`]: true,
432474
[`toast-layout-${layout}`]: true,
433475
};
434-
const role = allButtons.length > 0 ? 'dialog' : 'status';
435476

436477
/**
437478
* Stacked buttons are only meant to be
@@ -446,9 +487,6 @@ export class Toast implements ComponentInterface, OverlayInterface {
446487

447488
return (
448489
<Host
449-
aria-live="polite"
450-
aria-atomic="true"
451-
role={role}
452490
tabindex="-1"
453491
{...(this.htmlAttributes as any)}
454492
style={{
@@ -470,13 +508,40 @@ export class Toast implements ComponentInterface, OverlayInterface {
470508
<ion-icon class="toast-icon" part="icon" icon={this.icon} lazy={false} aria-hidden="true"></ion-icon>
471509
)}
472510

473-
<div class="toast-content">
474-
{this.header !== undefined && (
475-
<div class="toast-header" part="header">
476-
{this.header}
477-
</div>
478-
)}
479-
{this.message !== undefined && this.renderToastMessage()}
511+
{/*
512+
This creates a live region where screen readers
513+
only announce the header and the message. Elements
514+
such as icons and buttons should not be announced.
515+
aria-live and aria-atomic here are redundant, but we
516+
add them to maximize browser compatibility.
517+
518+
Toasts are meant to be subtle notifications that do
519+
not interrupt the user which is why this has
520+
a "status" role and a "polite" presentation.
521+
*/}
522+
<div class="toast-content" role="status" aria-atomic="true" aria-live="polite">
523+
{/*
524+
This logic below is done to improve consistency
525+
across platforms when showing and updating live regions.
526+
527+
TalkBack and VoiceOver announce the live region content
528+
when the toast is shown, but NVDA does not. As a result,
529+
we need to trigger a DOM update so NVDA detects changes and
530+
announces an update to the live region. We do this after
531+
the toast is fully visible to avoid jank during the presenting
532+
animation.
533+
534+
The "key" attribute is used here to force Stencil to render
535+
new nodes and not re-use nodes. Otherwise, NVDA would not
536+
detect any changes to the live region.
537+
538+
The "old" content is hidden using aria-hidden otherwise
539+
VoiceOver will announce the toast content twice when presenting.
540+
*/}
541+
{!revealContentToScreenReader && header !== undefined && this.renderHeader('oldHeader', 'true')}
542+
{!revealContentToScreenReader && message !== undefined && this.renderToastMessage('oldMessage', 'true')}
543+
{revealContentToScreenReader && header !== undefined && this.renderHeader('header')}
544+
{revealContentToScreenReader && message !== undefined && this.renderToastMessage('header')}
480545
</div>
481546

482547
{this.renderButtons(endButtons, 'end')}

0 commit comments

Comments
 (0)