Skip to content

Commit 5a73145

Browse files
authored
fix(input, textarea): ensure screen readers announce helper and error text when focused (#29958)
Issue number: internal --------- <!-- 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. --> 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? <!-- Please describe the behavior or changes that are being added by this PR. --> - 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 <!-- If this introduces a breaking change: 1. Describe the impact and migration path for existing applications below. 2. Update the BREAKING.md file with the breaking change. 3. Add "BREAKING CHANGE: [...]" to the commit description when merging. See https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer for more information. --> ## Other information <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. --> 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.
1 parent 322d7c9 commit 5a73145

File tree

4 files changed

+164
-4
lines changed

4 files changed

+164
-4
lines changed

core/src/components/input/input.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ import { getCounterText } from './input.utils';
3333
export class Input implements ComponentInterface {
3434
private nativeInput?: HTMLInputElement;
3535
private inputId = `ion-input-${inputIds++}`;
36+
private helperTextId = `${this.inputId}-helper-text`;
37+
private errorTextId = `${this.inputId}-error-text`;
3638
private inheritedAttributes: Attributes = {};
3739
private isComposing = false;
3840
private slotMutationController?: SlotMutationController;
@@ -573,9 +575,30 @@ export class Input implements ComponentInterface {
573575
* Renders the helper text or error text values
574576
*/
575577
private renderHintText() {
576-
const { helperText, errorText } = this;
578+
const { helperText, errorText, helperTextId, errorTextId } = this;
579+
580+
return [
581+
<div id={helperTextId} class="helper-text">
582+
{helperText}
583+
</div>,
584+
<div id={errorTextId} class="error-text">
585+
{errorText}
586+
</div>,
587+
];
588+
}
589+
590+
private getHintTextID(): string | undefined {
591+
const { el, helperText, errorText, helperTextId, errorTextId } = this;
592+
593+
if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
594+
return errorTextId;
595+
}
596+
597+
if (helperText) {
598+
return helperTextId;
599+
}
577600

578-
return [<div class="helper-text">{helperText}</div>, <div class="error-text">{errorText}</div>];
601+
return undefined;
579602
}
580603

581604
private renderCounter() {
@@ -777,6 +800,8 @@ export class Input implements ComponentInterface {
777800
onKeyDown={this.onKeydown}
778801
onCompositionstart={this.onCompositionStart}
779802
onCompositionend={this.onCompositionEnd}
803+
aria-describedby={this.getHintTextID()}
804+
aria-invalid={this.getHintTextID() === this.errorTextId}
780805
{...this.inheritedAttributes}
781806
/>
782807
{this.clearInput && !readonly && !disabled && (

core/src/components/input/test/bottom-content/input.e2e.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,19 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
6868
await expect(helperText).toHaveText('my helper');
6969
await expect(errorText).toBeHidden();
7070
});
71+
test('input should have an aria-describedby attribute when helper text is present', async ({ page }) => {
72+
await page.setContent(
73+
`<ion-input helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
74+
config
75+
);
76+
77+
const input = page.locator('ion-input input');
78+
const helperText = page.locator('ion-input .helper-text');
79+
const helperTextId = await helperText.getAttribute('id');
80+
const ariaDescribedBy = await input.getAttribute('aria-describedby');
81+
82+
expect(ariaDescribedBy).toBe(helperTextId);
83+
});
7184
test('error text should be visible when input is invalid', async ({ page }) => {
7285
await page.setContent(
7386
`<ion-input class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
@@ -96,6 +109,48 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
96109
const errorText = page.locator('ion-input .error-text');
97110
await expect(errorText).toHaveScreenshot(screenshot(`input-error-custom-color`));
98111
});
112+
test('input should have an aria-describedby attribute when error text is present', async ({ page }) => {
113+
await page.setContent(
114+
`<ion-input class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
115+
config
116+
);
117+
118+
const input = page.locator('ion-input input');
119+
const errorText = page.locator('ion-input .error-text');
120+
const errorTextId = await errorText.getAttribute('id');
121+
const ariaDescribedBy = await input.getAttribute('aria-describedby');
122+
123+
expect(ariaDescribedBy).toBe(errorTextId);
124+
});
125+
test('input should have aria-invalid attribute when input is invalid', async ({ page }) => {
126+
await page.setContent(
127+
`<ion-input class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
128+
config
129+
);
130+
131+
const input = page.locator('ion-input input');
132+
133+
await expect(input).toHaveAttribute('aria-invalid');
134+
});
135+
test('input should not have aria-invalid attribute when input is valid', async ({ page }) => {
136+
await page.setContent(
137+
`<ion-input helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
138+
config
139+
);
140+
141+
const input = page.locator('ion-input input');
142+
143+
await expect(input).not.toHaveAttribute('aria-invalid');
144+
});
145+
test('input should not have aria-describedby attribute when no hint or error text is present', async ({
146+
page,
147+
}) => {
148+
await page.setContent(`<ion-input label="my input"></ion-input>`, config);
149+
150+
const input = page.locator('ion-input input');
151+
152+
await expect(input).not.toHaveAttribute('aria-describedby');
153+
});
99154
});
100155
test.describe('input: hint text rendering', () => {
101156
test.describe('regular inputs', () => {

core/src/components/textarea/test/bottom-content/textarea.e2e.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,19 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
2727
await expect(helperText).toHaveText('my helper');
2828
await expect(errorText).toBeHidden();
2929
});
30+
test('textarea should have an aria-describedby attribute when helper text is present', async ({ page }) => {
31+
await page.setContent(
32+
`<ion-textarea helper-text="my helper" error-text="my error" label="my textarea"></ion-textarea>`,
33+
config
34+
);
35+
36+
const textarea = page.locator('ion-textarea textarea');
37+
const helperText = page.locator('ion-textarea .helper-text');
38+
const helperTextId = await helperText.getAttribute('id');
39+
const ariaDescribedBy = await textarea.getAttribute('aria-describedby');
40+
41+
expect(ariaDescribedBy).toBe(helperTextId);
42+
});
3043
test('error text should be visible when textarea is invalid', async ({ page }) => {
3144
await page.setContent(
3245
`<ion-textarea class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my textarea"></ion-textarea>`,
@@ -55,6 +68,48 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
5568
const errorText = page.locator('ion-textarea .error-text');
5669
await expect(errorText).toHaveScreenshot(screenshot(`textarea-error-custom-color`));
5770
});
71+
test('textarea should have an aria-describedby attribute when error text is present', async ({ page }) => {
72+
await page.setContent(
73+
`<ion-textarea class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my textarea"></ion-textarea>`,
74+
config
75+
);
76+
77+
const textarea = page.locator('ion-textarea textarea');
78+
const errorText = page.locator('ion-textarea .error-text');
79+
const errorTextId = await errorText.getAttribute('id');
80+
const ariaDescribedBy = await textarea.getAttribute('aria-describedby');
81+
82+
expect(ariaDescribedBy).toBe(errorTextId);
83+
});
84+
test('textarea should have aria-invalid attribute when input is invalid', async ({ page }) => {
85+
await page.setContent(
86+
`<ion-textarea class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my textarea"></ion-textarea>`,
87+
config
88+
);
89+
90+
const textarea = page.locator('ion-textarea textarea');
91+
92+
await expect(textarea).toHaveAttribute('aria-invalid');
93+
});
94+
test('textarea should not have aria-invalid attribute when input is valid', async ({ page }) => {
95+
await page.setContent(
96+
`<ion-textarea helper-text="my helper" error-text="my error" label="my textarea"></ion-textarea>`,
97+
config
98+
);
99+
100+
const textarea = page.locator('ion-textarea textarea');
101+
102+
await expect(textarea).not.toHaveAttribute('aria-invalid');
103+
});
104+
test('textarea should not have aria-describedby attribute when no hint or error text is present', async ({
105+
page,
106+
}) => {
107+
await page.setContent(`<ion-textarea label="my textarea"></ion-textarea>`, config);
108+
109+
const textarea = page.locator('ion-textarea textarea');
110+
111+
await expect(textarea).not.toHaveAttribute('aria-describedby');
112+
});
58113
});
59114
test.describe('textarea: hint text rendering', () => {
60115
test.describe('regular textareas', () => {

core/src/components/textarea/textarea.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ import type { TextareaChangeEventDetail, TextareaInputEventDetail } from './text
4545
export class Textarea implements ComponentInterface {
4646
private nativeInput?: HTMLTextAreaElement;
4747
private inputId = `ion-textarea-${textareaIds++}`;
48+
private helperTextId = `${this.inputId}-helper-text`;
49+
private errorTextId = `${this.inputId}-error-text`;
4850
/**
4951
* `true` if the textarea was cleared as a result of the user typing
5052
* with `clearOnEdit` enabled.
@@ -576,9 +578,30 @@ export class Textarea implements ComponentInterface {
576578
* Renders the helper text or error text values
577579
*/
578580
private renderHintText() {
579-
const { helperText, errorText } = this;
581+
const { helperText, errorText, helperTextId, errorTextId } = this;
582+
583+
return [
584+
<div id={helperTextId} class="helper-text">
585+
{helperText}
586+
</div>,
587+
<div id={errorTextId} class="error-text">
588+
{errorText}
589+
</div>,
590+
];
591+
}
592+
593+
private getHintTextID(): string | undefined {
594+
const { el, helperText, errorText, helperTextId, errorTextId } = this;
595+
596+
if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
597+
return errorTextId;
598+
}
599+
600+
if (helperText) {
601+
return helperTextId;
602+
}
580603

581-
return [<div class="helper-text">{helperText}</div>, <div class="error-text">{errorText}</div>];
604+
return undefined;
582605
}
583606

584607
private renderCounter() {
@@ -703,6 +726,8 @@ export class Textarea implements ComponentInterface {
703726
onBlur={this.onBlur}
704727
onFocus={this.onFocus}
705728
onKeyDown={this.onKeyDown}
729+
aria-describedby={this.getHintTextID()}
730+
aria-invalid={this.getHintTextID() === this.errorTextId}
706731
{...this.inheritedAttributes}
707732
>
708733
{value}

0 commit comments

Comments
 (0)