Skip to content

Commit

Permalink
fix(input, textarea): ensure screen readers announce helper and error…
Browse files Browse the repository at this point in the history
… 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.
  • Loading branch information
thetaPC authored Oct 25, 2024
1 parent 322d7c9 commit 5a73145
Show file tree
Hide file tree
Showing 4 changed files with 164 additions and 4 deletions.
29 changes: 27 additions & 2 deletions core/src/components/input/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 [
<div id={helperTextId} class="helper-text">
{helperText}
</div>,
<div id={errorTextId} class="error-text">
{errorText}
</div>,
];
}

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 [<div class="helper-text">{helperText}</div>, <div class="error-text">{errorText}</div>];
return undefined;
}

private renderCounter() {
Expand Down Expand Up @@ -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 && (
Expand Down
55 changes: 55 additions & 0 deletions core/src/components/input/test/bottom-content/input.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`<ion-input helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
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(
`<ion-input class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
Expand Down Expand Up @@ -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(
`<ion-input class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
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(
`<ion-input class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
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(
`<ion-input helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
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(`<ion-input label="my input"></ion-input>`, 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', () => {
Expand Down
55 changes: 55 additions & 0 deletions core/src/components/textarea/test/bottom-content/textarea.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`<ion-textarea helper-text="my helper" error-text="my error" label="my textarea"></ion-textarea>`,
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(
`<ion-textarea class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my textarea"></ion-textarea>`,
Expand Down Expand Up @@ -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(
`<ion-textarea class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my textarea"></ion-textarea>`,
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(
`<ion-textarea class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my textarea"></ion-textarea>`,
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(
`<ion-textarea helper-text="my helper" error-text="my error" label="my textarea"></ion-textarea>`,
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(`<ion-textarea label="my textarea"></ion-textarea>`, 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', () => {
Expand Down
29 changes: 27 additions & 2 deletions core/src/components/textarea/textarea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 [
<div id={helperTextId} class="helper-text">
{helperText}
</div>,
<div id={errorTextId} class="error-text">
{errorText}
</div>,
];
}

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 [<div class="helper-text">{helperText}</div>, <div class="error-text">{errorText}</div>];
return undefined;
}

private renderCounter() {
Expand Down Expand Up @@ -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}
Expand Down

0 comments on commit 5a73145

Please sign in to comment.