mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-11-07 23:16:52 +08:00
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:
@ -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>
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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')}
|
||||||
|
|||||||
Reference in New Issue
Block a user