Skip to content

Commit 41ea84c

Browse files
committed
fix(textarea): ensure screen readers announce helper and error text when focused
1 parent e4f970f commit 41ea84c

File tree

2 files changed

+82
-2
lines changed

2 files changed

+82
-2
lines changed

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 = `helper-text-${textareaIds++}`;
49+
private errorTextId = `error-text-${textareaIds++}`;
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)