Skip to content
Merged
38 changes: 32 additions & 6 deletions core/src/components/toast/test/a11y/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,47 @@
<main>
<h1 style="background-color: white">Toast - a11y</h1>

<button id="polite" onclick="presentToast({ message: 'This is a toast message' })">Present Toast</button>
<button
id="assertive"
onclick="presentToast({ message: 'This is an assertive toast message', htmlAttributes: { 'aria-live': 'assertive' } })"
<ion-button id="inline-toast-trigger">Present Inline Toast</ion-button>
<ion-toast
id="inline-toast"
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
</button>
Present Controller Toast
</ion-button>

<ion-button onclick="updateContent()">Update Inner Content</ion-button>
</main>
</ion-app>
<script>
const inlineToast = document.querySelector('#inline-toast');
inlineToast.buttons = ['Ok'];

const presentToast = async (opts) => {
const toast = await toastController.create(opts);

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>
</body>
</html>
12 changes: 4 additions & 8 deletions core/src/components/toast/test/a11y/toast.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,10 @@ test.describe('toast: a11y', () => {
test.skip(testInfo.project.metadata.rtl === true, 'This test does not check LTR vs RTL layouts');
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 politeButton = page.locator('#polite');
await politeButton.click();

await page.click('#inline-toast-trigger');
await ionToastDidPresent.next();

/**
Expand All @@ -23,12 +21,10 @@ test.describe('toast: a11y', () => {
const results = await new AxeBuilder({ page }).disableRules('color-contrast').analyze();
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 politeButton = page.locator('#assertive');
await politeButton.click();

await page.click('#controller-toast-trigger');
await ionToastDidPresent.next();

/**
Expand Down
49 changes: 48 additions & 1 deletion core/src/components/toast/test/toast.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { newSpecPage } from '@stencil/core/testing';
import { Toast } from '../toast';
import { config } from '../../../global/config';

describe('alert: custom html', () => {
describe('toast: custom html', () => {
it('should not allow for custom html by default', async () => {
const page = await newSpecPage({
components: [Toast],
Expand Down Expand Up @@ -41,3 +41,50 @@ describe('alert: custom html', () => {
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);
});
});
97 changes: 81 additions & 16 deletions core/src/components/toast/toast.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
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 { getIonMode } from '../../global/ionic-global';
Expand Down Expand Up @@ -55,6 +55,13 @@ export class Toast implements ComponentInterface, OverlayInterface {

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;

/**
Expand Down Expand Up @@ -268,6 +275,14 @@ export class Toast implements ComponentInterface, OverlayInterface {
this.position
);
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;

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

if (dismissed) {
this.delegateController.removeViewFromDom();
this.revealContentToScreenReader = false;
}

return dismissed;
Expand Down Expand Up @@ -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;
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 (
<div class="toast-message" part="message">
<div key={key} aria-hidden={ariaHidden} class="toast-message" part="message">
{message}
</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() {
const { layout, el } = this;
const { layout, el, revealContentToScreenReader, header, message } = this;
const allButtons = this.getButtons();
const startButtons = allButtons.filter((b) => b.side === 'start');
const endButtons = allButtons.filter((b) => b.side !== 'start');
Expand All @@ -431,7 +473,6 @@ export class Toast implements ComponentInterface, OverlayInterface {
[`toast-${this.position}`]: true,
[`toast-layout-${layout}`]: true,
};
const role = allButtons.length > 0 ? 'dialog' : 'status';

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

return (
<Host
aria-live="polite"
aria-atomic="true"
role={role}
tabindex="-1"
{...(this.htmlAttributes as any)}
style={{
Expand All @@ -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>
)}

<div class="toast-content">
{this.header !== undefined && (
<div class="toast-header" part="header">
{this.header}
</div>
)}
{this.message !== undefined && this.renderToastMessage()}
{/*
This creates a live region where screen readers
only announce the header and the message. Elements
such as icons and buttons should not be announced.
aria-live and aria-atomic here are redundant, but we
add them to maximize browser compatibility.

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>

{this.renderButtons(endButtons, 'end')}
Expand Down