From 76c8b94e2a818e1b824701b788d5ed8b6e554d42 Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Tue, 18 Apr 2023 10:57:30 -0400 Subject: [PATCH] fix(toast): screen readers announce content (#27198) Issue URL: resolves #25866 --------- Docs PR: https://github.com/ionic-team/ionic-docs/pull/2914 ## What is the current behavior? NVDA is not announcing toasts on present. ## What is the new behavior? - 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 ## Other information Dev build: 7.0.3-dev.11681482468.19d7784f --- .../src/components/toast/test/a11y/index.html | 38 ++++++-- .../components/toast/test/a11y/toast.e2e.ts | 12 +-- core/src/components/toast/test/toast.spec.ts | 49 +++++++++- core/src/components/toast/toast.tsx | 97 ++++++++++++++++--- 4 files changed, 165 insertions(+), 31 deletions(-) diff --git a/core/src/components/toast/test/a11y/index.html b/core/src/components/toast/test/a11y/index.html index 9e8b0c6351..b2eb88a7ae 100644 --- a/core/src/components/toast/test/a11y/index.html +++ b/core/src/components/toast/test/a11y/index.html @@ -18,21 +18,47 @@

Toast - a11y

- - + Present Controller Toast + + + Update Inner Content
diff --git a/core/src/components/toast/test/a11y/toast.e2e.ts b/core/src/components/toast/test/a11y/toast.e2e.ts index 9b98496ef2..a9ccf76cba 100644 --- a/core/src/components/toast/test/a11y/toast.e2e.ts +++ b/core/src/components/toast/test/a11y/toast.e2e.ts @@ -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(); /** @@ -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(); /** diff --git a/core/src/components/toast/test/toast.spec.ts b/core/src/components/toast/test/toast.spec.ts index 1f25432e54..711b08e549 100644 --- a/core/src/components/toast/test/toast.spec.ts +++ b/core/src/components/toast/test/toast.spec.ts @@ -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], @@ -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: ``, + }); + + 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: ` + + + + `, + }); + + 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); + }); +}); diff --git a/core/src/components/toast/toast.tsx b/core/src/components/toast/toast.tsx index 64dc0060ea..9c2f01ce9d 100644 --- a/core/src/components/toast/toast.tsx +++ b/core/src/components/toast/toast.tsx @@ -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'; @@ -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; /** @@ -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) { @@ -303,6 +318,7 @@ export class Toast implements ComponentInterface, OverlayInterface { if (dismissed) { this.delegateController.removeViewFromDom(); + this.revealContentToScreenReader = false; } 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; if (customHTMLEnabled) { - return
; + return ( +
+ ); } return ( -
+
{message}
); } + /** + * 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 ( +
+ {this.header} +
+ ); + } + 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'); @@ -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 @@ -446,9 +487,6 @@ export class Toast implements ComponentInterface, OverlayInterface { return (