From 5a7314553a8def87bd19275640c92dd72a6ef1a4 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Fri, 25 Oct 2024 12:07:09 -0700 Subject: [PATCH] fix(input, textarea): ensure screen readers announce helper and error text when focused (#29958) Issue number: internal --------- ## What is the current behavior? Screen readers do not announce helper and error text when user is focused on the input or textarea. This does not align with the accessibility guidelines. ## What is the new behavior? - The appropriate `aria` tags are added to the native input and textarea in order to associate them to the helper and error texts. - `aria-describedBy` will only be added to the native element based on helper or error text. If helper text exists then the helper text ID will be used. If the error text exists and the component has the `ion-touched ion-invalid` classes, then the error text ID will be used. - `aria-invalid` will only be added if the error text exists and the component has the `ion-touched ion-invalid` classes. - Added tests. ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information How to test: 1. Navigate to the [input page](https://ionic-framework-lio43tje7-ionic1.vercel.app/src/components/input/test/bottom-content) on the `main` branch 2. Turn on the screen reader of your choice 3. Notice that the screen reader does not announce the helper or error text when the input is focused 4. Navigate to the [input page](https://ionic-framework-git-rou-11274-ionic1.vercel.app/src/components/input/test/bottom-content) on the `ROU-11274` branch 5. Turn on the screen reader of your choice 6. Verify that the screen reader announces the helper or error text when the input is focused on 7. Navigate to the [textarea page](https://ionic-framework-lio43tje7-ionic1.vercel.app/src/components/textarea/test/bottom-content) on the `main` branch 8. Repeat steps 2-3 9. Navigate to the [textarea page](https://ionic-framework-git-rou-11274-ionic1.vercel.app/src/components/textarea/test/bottom-content) on the `ROU-11274` branch 10. Repeat steps 5-6 Known Webkit issues: This fix will not work on macOS [16](https://bugs.webkit.org/show_bug.cgi?id=254081) and [17](https://bugs.webkit.org/show_bug.cgi?id=262895) as VoiceOver will not read any text using `aria-describedby`. Works fine on macOS 18. --- core/src/components/input/input.tsx | 29 +++++++++- .../input/test/bottom-content/input.e2e.ts | 55 +++++++++++++++++++ .../test/bottom-content/textarea.e2e.ts | 55 +++++++++++++++++++ core/src/components/textarea/textarea.tsx | 29 +++++++++- 4 files changed, 164 insertions(+), 4 deletions(-) diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index f2430eabf32..2a9b9ec34e7 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -33,6 +33,8 @@ import { getCounterText } from './input.utils'; export class Input implements ComponentInterface { private nativeInput?: HTMLInputElement; private inputId = `ion-input-${inputIds++}`; + private helperTextId = `${this.inputId}-helper-text`; + private errorTextId = `${this.inputId}-error-text`; private inheritedAttributes: Attributes = {}; private isComposing = false; private slotMutationController?: SlotMutationController; @@ -573,9 +575,30 @@ export class Input implements ComponentInterface { * Renders the helper text or error text values */ private renderHintText() { - const { helperText, errorText } = this; + const { helperText, errorText, helperTextId, errorTextId } = this; + + return [ +
+ {helperText} +
, +
+ {errorText} +
, + ]; + } + + private getHintTextID(): string | undefined { + const { el, helperText, errorText, helperTextId, errorTextId } = this; + + if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) { + return errorTextId; + } + + if (helperText) { + return helperTextId; + } - return [
{helperText}
,
{errorText}
]; + return undefined; } private renderCounter() { @@ -777,6 +800,8 @@ export class Input implements ComponentInterface { onKeyDown={this.onKeydown} onCompositionstart={this.onCompositionStart} onCompositionend={this.onCompositionEnd} + aria-describedby={this.getHintTextID()} + aria-invalid={this.getHintTextID() === this.errorTextId} {...this.inheritedAttributes} /> {this.clearInput && !readonly && !disabled && ( diff --git a/core/src/components/input/test/bottom-content/input.e2e.ts b/core/src/components/input/test/bottom-content/input.e2e.ts index 9f09ffd78a4..5de0ca79e19 100644 --- a/core/src/components/input/test/bottom-content/input.e2e.ts +++ b/core/src/components/input/test/bottom-content/input.e2e.ts @@ -68,6 +68,19 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co await expect(helperText).toHaveText('my helper'); await expect(errorText).toBeHidden(); }); + test('input should have an aria-describedby attribute when helper text is present', async ({ page }) => { + await page.setContent( + ``, + config + ); + + const input = page.locator('ion-input input'); + const helperText = page.locator('ion-input .helper-text'); + const helperTextId = await helperText.getAttribute('id'); + const ariaDescribedBy = await input.getAttribute('aria-describedby'); + + expect(ariaDescribedBy).toBe(helperTextId); + }); test('error text should be visible when input is invalid', async ({ page }) => { await page.setContent( ``, @@ -96,6 +109,48 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co const errorText = page.locator('ion-input .error-text'); await expect(errorText).toHaveScreenshot(screenshot(`input-error-custom-color`)); }); + test('input should have an aria-describedby attribute when error text is present', async ({ page }) => { + await page.setContent( + ``, + config + ); + + const input = page.locator('ion-input input'); + const errorText = page.locator('ion-input .error-text'); + const errorTextId = await errorText.getAttribute('id'); + const ariaDescribedBy = await input.getAttribute('aria-describedby'); + + expect(ariaDescribedBy).toBe(errorTextId); + }); + test('input should have aria-invalid attribute when input is invalid', async ({ page }) => { + await page.setContent( + ``, + config + ); + + const input = page.locator('ion-input input'); + + await expect(input).toHaveAttribute('aria-invalid'); + }); + test('input should not have aria-invalid attribute when input is valid', async ({ page }) => { + await page.setContent( + ``, + config + ); + + const input = page.locator('ion-input input'); + + await expect(input).not.toHaveAttribute('aria-invalid'); + }); + test('input should not have aria-describedby attribute when no hint or error text is present', async ({ + page, + }) => { + await page.setContent(``, config); + + const input = page.locator('ion-input input'); + + await expect(input).not.toHaveAttribute('aria-describedby'); + }); }); test.describe('input: hint text rendering', () => { test.describe('regular inputs', () => { diff --git a/core/src/components/textarea/test/bottom-content/textarea.e2e.ts b/core/src/components/textarea/test/bottom-content/textarea.e2e.ts index e6c60e1ce0d..79fcfc4cfac 100644 --- a/core/src/components/textarea/test/bottom-content/textarea.e2e.ts +++ b/core/src/components/textarea/test/bottom-content/textarea.e2e.ts @@ -27,6 +27,19 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co await expect(helperText).toHaveText('my helper'); await expect(errorText).toBeHidden(); }); + test('textarea should have an aria-describedby attribute when helper text is present', async ({ page }) => { + await page.setContent( + ``, + config + ); + + const textarea = page.locator('ion-textarea textarea'); + const helperText = page.locator('ion-textarea .helper-text'); + const helperTextId = await helperText.getAttribute('id'); + const ariaDescribedBy = await textarea.getAttribute('aria-describedby'); + + expect(ariaDescribedBy).toBe(helperTextId); + }); test('error text should be visible when textarea is invalid', async ({ page }) => { await page.setContent( ``, @@ -55,6 +68,48 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co const errorText = page.locator('ion-textarea .error-text'); await expect(errorText).toHaveScreenshot(screenshot(`textarea-error-custom-color`)); }); + test('textarea should have an aria-describedby attribute when error text is present', async ({ page }) => { + await page.setContent( + ``, + config + ); + + const textarea = page.locator('ion-textarea textarea'); + const errorText = page.locator('ion-textarea .error-text'); + const errorTextId = await errorText.getAttribute('id'); + const ariaDescribedBy = await textarea.getAttribute('aria-describedby'); + + expect(ariaDescribedBy).toBe(errorTextId); + }); + test('textarea should have aria-invalid attribute when input is invalid', async ({ page }) => { + await page.setContent( + ``, + config + ); + + const textarea = page.locator('ion-textarea textarea'); + + await expect(textarea).toHaveAttribute('aria-invalid'); + }); + test('textarea should not have aria-invalid attribute when input is valid', async ({ page }) => { + await page.setContent( + ``, + config + ); + + const textarea = page.locator('ion-textarea textarea'); + + await expect(textarea).not.toHaveAttribute('aria-invalid'); + }); + test('textarea should not have aria-describedby attribute when no hint or error text is present', async ({ + page, + }) => { + await page.setContent(``, config); + + const textarea = page.locator('ion-textarea textarea'); + + await expect(textarea).not.toHaveAttribute('aria-describedby'); + }); }); test.describe('textarea: hint text rendering', () => { test.describe('regular textareas', () => { diff --git a/core/src/components/textarea/textarea.tsx b/core/src/components/textarea/textarea.tsx index 09d66928cfb..3349f0c1a85 100644 --- a/core/src/components/textarea/textarea.tsx +++ b/core/src/components/textarea/textarea.tsx @@ -45,6 +45,8 @@ import type { TextareaChangeEventDetail, TextareaInputEventDetail } from './text export class Textarea implements ComponentInterface { private nativeInput?: HTMLTextAreaElement; private inputId = `ion-textarea-${textareaIds++}`; + private helperTextId = `${this.inputId}-helper-text`; + private errorTextId = `${this.inputId}-error-text`; /** * `true` if the textarea was cleared as a result of the user typing * with `clearOnEdit` enabled. @@ -576,9 +578,30 @@ export class Textarea implements ComponentInterface { * Renders the helper text or error text values */ private renderHintText() { - const { helperText, errorText } = this; + const { helperText, errorText, helperTextId, errorTextId } = this; + + return [ +
+ {helperText} +
, +
+ {errorText} +
, + ]; + } + + private getHintTextID(): string | undefined { + const { el, helperText, errorText, helperTextId, errorTextId } = this; + + if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) { + return errorTextId; + } + + if (helperText) { + return helperTextId; + } - return [
{helperText}
,
{errorText}
]; + return undefined; } private renderCounter() { @@ -703,6 +726,8 @@ export class Textarea implements ComponentInterface { onBlur={this.onBlur} onFocus={this.onFocus} onKeyDown={this.onKeyDown} + aria-describedby={this.getHintTextID()} + aria-invalid={this.getHintTextID() === this.errorTextId} {...this.inheritedAttributes} > {value}