fix(toast): screen readers announce content (#27198)

Issue URL: resolves #25866

---------

Docs PR: https://github.com/ionic-team/ionic-docs/pull/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
This commit is contained in:
Liam DeBeasi
2023-04-18 10:57:30 -04:00
committed by GitHub
parent 742d4295dd
commit 76c8b94e2a
4 changed files with 165 additions and 31 deletions

View File

@ -18,21 +18,47 @@
<main> <main>
<h1 style="background-color: white">Toast - a11y</h1> <h1 style="background-color: white">Toast - a11y</h1>
<button id="polite" onclick="presentToast({ message: 'This is a toast message' })">Present Toast</button> <ion-button id="inline-toast-trigger">Present Inline Toast</ion-button>
<button <ion-toast
id="assertive" id="inline-toast"
onclick="presentToast({ message: 'This is an assertive toast message', htmlAttributes: { 'aria-live': 'assertive' } })" trigger="inline-toast-trigger"
icon="person"
header="Inline Toast Header"
message="Inline Toast Message"
></ion-toast>
<ion-button
id="controller-toast-trigger"
onclick="presentToast({ icon: 'person', header: 'Controller Toast Header', message: 'Controller Toast Message', buttons: ['Ok'] })"
> >
Present Assertive Toast Present Controller Toast
</button> </ion-button>
<ion-button onclick="updateContent()">Update Inner Content</ion-button>
</main> </main>
</ion-app> </ion-app>
<script> <script>
const inlineToast = document.querySelector('#inline-toast');
inlineToast.buttons = ['Ok'];
const presentToast = async (opts) => { const presentToast = async (opts) => {
const toast = await toastController.create(opts); const toast = await toastController.create(opts);
await toast.present(); await toast.present();
}; };
const updateContent = () => {
const toasts = document.querySelectorAll('ion-toast');
/**
* Note: Multiple updates to the props
* may cause screen readers like NVDA to announce
* the entire content multiple times.
*/
toasts.forEach((toast) => {
toast.header = 'Updated Header';
toast.message = 'Updated Message';
});
};
</script> </script>
</body> </body>
</html> </html>

View File

@ -7,12 +7,10 @@ test.describe('toast: a11y', () => {
test.skip(testInfo.project.metadata.rtl === true, 'This test does not check LTR vs RTL layouts'); test.skip(testInfo.project.metadata.rtl === true, 'This test does not check LTR vs RTL layouts');
await page.goto(`/src/components/toast/test/a11y`); await page.goto(`/src/components/toast/test/a11y`);
}); });
test('should not have any axe violations with polite toasts', async ({ page }) => { test('should not have any axe violations with inline toasts', async ({ page }) => {
const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent'); const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent');
const politeButton = page.locator('#polite'); await page.click('#inline-toast-trigger');
await politeButton.click();
await ionToastDidPresent.next(); await ionToastDidPresent.next();
/** /**
@ -23,12 +21,10 @@ test.describe('toast: a11y', () => {
const results = await new AxeBuilder({ page }).disableRules('color-contrast').analyze(); const results = await new AxeBuilder({ page }).disableRules('color-contrast').analyze();
expect(results.violations).toEqual([]); expect(results.violations).toEqual([]);
}); });
test('should not have any axe violations with assertive toasts', async ({ page }) => { test('should not have any axe violations with controller toasts', async ({ page }) => {
const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent'); const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent');
const politeButton = page.locator('#assertive'); await page.click('#controller-toast-trigger');
await politeButton.click();
await ionToastDidPresent.next(); await ionToastDidPresent.next();
/** /**

View File

@ -2,7 +2,7 @@ import { newSpecPage } from '@stencil/core/testing';
import { Toast } from '../toast'; import { Toast } from '../toast';
import { config } from '../../../global/config'; import { config } from '../../../global/config';
describe('alert: custom html', () => { describe('toast: custom html', () => {
it('should not allow for custom html by default', async () => { it('should not allow for custom html by default', async () => {
const page = await newSpecPage({ const page = await newSpecPage({
components: [Toast], components: [Toast],
@ -41,3 +41,50 @@ describe('alert: custom html', () => {
expect(content.querySelector('button.custom-html')).toBe(null); expect(content.querySelector('button.custom-html')).toBe(null);
}); });
}); });
/**
* These tests check if the aria-hidden attributes are being
* removed on present. Without this functionality, screen readers
* would not announce toast content correctly.
*/
describe('toast: a11y smoke test', () => {
it('should have aria-hidden content when dismissed', async () => {
const page = await newSpecPage({
components: [Toast],
html: `<ion-toast message="Message" header="Header"></ion-toast>`,
});
const toast = page.body.querySelector('ion-toast');
const header = toast.shadowRoot.querySelector('.toast-header');
const message = toast.shadowRoot.querySelector('.toast-message');
expect(header.getAttribute('aria-hidden')).toBe('true');
expect(message.getAttribute('aria-hidden')).toBe('true');
});
it('should not have aria-hidden content when presented', async () => {
const page = await newSpecPage({
components: [Toast],
html: `
<ion-app>
<ion-toast animated="false" message="Message" header="Header"></ion-toast>
</ion-app>
`,
});
const toast = page.body.querySelector('ion-toast');
/**
* Wait for present method to resolve
* and for state change to take effect.
*/
await toast.present();
await page.waitForChanges();
const header = toast.shadowRoot.querySelector('.toast-header');
const message = toast.shadowRoot.querySelector('.toast-message');
expect(header.getAttribute('aria-hidden')).toBe(null);
expect(message.getAttribute('aria-hidden')).toBe(null);
});
});

View File

@ -1,5 +1,5 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core'; import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Watch, Component, Element, Event, h, Host, Method, Prop } from '@stencil/core'; import { Watch, Component, Element, Event, h, Host, Method, Prop, State } from '@stencil/core';
import { config } from '../../global/config'; import { config } from '../../global/config';
import { getIonMode } from '../../global/ionic-global'; import { getIonMode } from '../../global/ionic-global';
@ -55,6 +55,13 @@ export class Toast implements ComponentInterface, OverlayInterface {
presented = false; presented = false;
/**
* When `true`, content inside of .toast-content
* will have aria-hidden elements removed causing
* screen readers to announce the remaining content.
*/
@State() revealContentToScreenReader = false;
@Element() el!: HTMLIonToastElement; @Element() el!: HTMLIonToastElement;
/** /**
@ -268,6 +275,14 @@ export class Toast implements ComponentInterface, OverlayInterface {
this.position this.position
); );
await this.currentTransition; await this.currentTransition;
/**
* Content is revealed to screen readers after
* the transition to avoid jank since this
* state updates will cause a re-render.
*/
this.revealContentToScreenReader = true;
this.currentTransition = undefined; this.currentTransition = undefined;
if (this.duration > 0) { if (this.duration > 0) {
@ -303,6 +318,7 @@ export class Toast implements ComponentInterface, OverlayInterface {
if (dismissed) { if (dismissed) {
this.delegateController.removeViewFromDom(); this.delegateController.removeViewFromDom();
this.revealContentToScreenReader = false;
} }
return dismissed; return dismissed;
@ -407,21 +423,47 @@ export class Toast implements ComponentInterface, OverlayInterface {
); );
} }
private renderToastMessage() { /**
* Render the `message` property.
* @param key - A key to give the element a stable identity. This is used to improve compatibility with screen readers.
* @param ariaHidden - If "true" then content will be hidden from screen readers.
*/
private renderToastMessage(key: string, ariaHidden: 'true' | null = null) {
const { customHTMLEnabled, message } = this; const { customHTMLEnabled, message } = this;
if (customHTMLEnabled) { if (customHTMLEnabled) {
return <div class="toast-message" part="message" innerHTML={sanitizeDOMString(message)}></div>; return (
<div
key={key}
aria-hidden={ariaHidden}
class="toast-message"
part="message"
innerHTML={sanitizeDOMString(message)}
></div>
);
} }
return ( return (
<div class="toast-message" part="message"> <div key={key} aria-hidden={ariaHidden} class="toast-message" part="message">
{message} {message}
</div> </div>
); );
} }
/**
* Render the `header` property.
* @param key - A key to give the element a stable identity. This is used to improve compatibility with screen readers.
* @param ariaHidden - If "true" then content will be hidden from screen readers.
*/
private renderHeader(key: string, ariaHidden: 'true' | null = null) {
return (
<div key={key} class="toast-header" aria-hidden={ariaHidden} part="header">
{this.header}
</div>
);
}
render() { render() {
const { layout, el } = this; const { layout, el, revealContentToScreenReader, header, message } = this;
const allButtons = this.getButtons(); const allButtons = this.getButtons();
const startButtons = allButtons.filter((b) => b.side === 'start'); const startButtons = allButtons.filter((b) => b.side === 'start');
const endButtons = allButtons.filter((b) => b.side !== 'start'); const endButtons = allButtons.filter((b) => b.side !== 'start');
@ -431,7 +473,6 @@ export class Toast implements ComponentInterface, OverlayInterface {
[`toast-${this.position}`]: true, [`toast-${this.position}`]: true,
[`toast-layout-${layout}`]: true, [`toast-layout-${layout}`]: true,
}; };
const role = allButtons.length > 0 ? 'dialog' : 'status';
/** /**
* Stacked buttons are only meant to be * Stacked buttons are only meant to be
@ -446,9 +487,6 @@ export class Toast implements ComponentInterface, OverlayInterface {
return ( return (
<Host <Host
aria-live="polite"
aria-atomic="true"
role={role}
tabindex="-1" tabindex="-1"
{...(this.htmlAttributes as any)} {...(this.htmlAttributes as any)}
style={{ style={{
@ -470,13 +508,40 @@ export class Toast implements ComponentInterface, OverlayInterface {
<ion-icon class="toast-icon" part="icon" icon={this.icon} lazy={false} aria-hidden="true"></ion-icon> <ion-icon class="toast-icon" part="icon" icon={this.icon} lazy={false} aria-hidden="true"></ion-icon>
)} )}
<div class="toast-content"> {/*
{this.header !== undefined && ( This creates a live region where screen readers
<div class="toast-header" part="header"> only announce the header and the message. Elements
{this.header} such as icons and buttons should not be announced.
</div> aria-live and aria-atomic here are redundant, but we
)} add them to maximize browser compatibility.
{this.message !== undefined && this.renderToastMessage()}
Toasts are meant to be subtle notifications that do
not interrupt the user which is why this has
a "status" role and a "polite" presentation.
*/}
<div class="toast-content" role="status" aria-atomic="true" aria-live="polite">
{/*
This logic below is done to improve consistency
across platforms when showing and updating live regions.
TalkBack and VoiceOver announce the live region content
when the toast is shown, but NVDA does not. As a result,
we need to trigger a DOM update so NVDA detects changes and
announces an update to the live region. We do this after
the toast is fully visible to avoid jank during the presenting
animation.
The "key" attribute is used here to force Stencil to render
new nodes and not re-use nodes. Otherwise, NVDA would not
detect any changes to the live region.
The "old" content is hidden using aria-hidden otherwise
VoiceOver will announce the toast content twice when presenting.
*/}
{!revealContentToScreenReader && header !== undefined && this.renderHeader('oldHeader', 'true')}
{!revealContentToScreenReader && message !== undefined && this.renderToastMessage('oldMessage', 'true')}
{revealContentToScreenReader && header !== undefined && this.renderHeader('header')}
{revealContentToScreenReader && message !== undefined && this.renderToastMessage('header')}
</div> </div>
{this.renderButtons(endButtons, 'end')} {this.renderButtons(endButtons, 'end')}