mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-11-08 23:58:13 +08:00
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.
This commit is contained in:
@ -33,6 +33,8 @@ import { getCounterText } from './input.utils';
|
|||||||
export class Input implements ComponentInterface {
|
export class Input implements ComponentInterface {
|
||||||
private nativeInput?: HTMLInputElement;
|
private nativeInput?: HTMLInputElement;
|
||||||
private inputId = `ion-input-${inputIds++}`;
|
private inputId = `ion-input-${inputIds++}`;
|
||||||
|
private helperTextId = `${this.inputId}-helper-text`;
|
||||||
|
private errorTextId = `${this.inputId}-error-text`;
|
||||||
private inheritedAttributes: Attributes = {};
|
private inheritedAttributes: Attributes = {};
|
||||||
private isComposing = false;
|
private isComposing = false;
|
||||||
private slotMutationController?: SlotMutationController;
|
private slotMutationController?: SlotMutationController;
|
||||||
@ -573,9 +575,30 @@ export class Input implements ComponentInterface {
|
|||||||
* Renders the helper text or error text values
|
* Renders the helper text or error text values
|
||||||
*/
|
*/
|
||||||
private renderHintText() {
|
private renderHintText() {
|
||||||
const { helperText, errorText } = this;
|
const { helperText, errorText, helperTextId, errorTextId } = this;
|
||||||
|
|
||||||
return [<div class="helper-text">{helperText}</div>, <div class="error-text">{errorText}</div>];
|
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 undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderCounter() {
|
private renderCounter() {
|
||||||
@ -777,6 +800,8 @@ export class Input implements ComponentInterface {
|
|||||||
onKeyDown={this.onKeydown}
|
onKeyDown={this.onKeydown}
|
||||||
onCompositionstart={this.onCompositionStart}
|
onCompositionstart={this.onCompositionStart}
|
||||||
onCompositionend={this.onCompositionEnd}
|
onCompositionend={this.onCompositionEnd}
|
||||||
|
aria-describedby={this.getHintTextID()}
|
||||||
|
aria-invalid={this.getHintTextID() === this.errorTextId}
|
||||||
{...this.inheritedAttributes}
|
{...this.inheritedAttributes}
|
||||||
/>
|
/>
|
||||||
{this.clearInput && !readonly && !disabled && (
|
{this.clearInput && !readonly && !disabled && (
|
||||||
|
|||||||
@ -68,6 +68,19 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
|
|||||||
await expect(helperText).toHaveText('my helper');
|
await expect(helperText).toHaveText('my helper');
|
||||||
await expect(errorText).toBeHidden();
|
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 }) => {
|
test('error text should be visible when input is invalid', async ({ page }) => {
|
||||||
await page.setContent(
|
await page.setContent(
|
||||||
`<ion-input class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
|
`<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
|
|||||||
const errorText = page.locator('ion-input .error-text');
|
const errorText = page.locator('ion-input .error-text');
|
||||||
await expect(errorText).toHaveScreenshot(screenshot(`input-error-custom-color`));
|
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('input: hint text rendering', () => {
|
||||||
test.describe('regular inputs', () => {
|
test.describe('regular inputs', () => {
|
||||||
|
|||||||
@ -27,6 +27,19 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
|
|||||||
await expect(helperText).toHaveText('my helper');
|
await expect(helperText).toHaveText('my helper');
|
||||||
await expect(errorText).toBeHidden();
|
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 }) => {
|
test('error text should be visible when textarea is invalid', async ({ page }) => {
|
||||||
await page.setContent(
|
await page.setContent(
|
||||||
`<ion-textarea class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my textarea"></ion-textarea>`,
|
`<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
|
|||||||
const errorText = page.locator('ion-textarea .error-text');
|
const errorText = page.locator('ion-textarea .error-text');
|
||||||
await expect(errorText).toHaveScreenshot(screenshot(`textarea-error-custom-color`));
|
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('textarea: hint text rendering', () => {
|
||||||
test.describe('regular textareas', () => {
|
test.describe('regular textareas', () => {
|
||||||
|
|||||||
@ -45,6 +45,8 @@ import type { TextareaChangeEventDetail, TextareaInputEventDetail } from './text
|
|||||||
export class Textarea implements ComponentInterface {
|
export class Textarea implements ComponentInterface {
|
||||||
private nativeInput?: HTMLTextAreaElement;
|
private nativeInput?: HTMLTextAreaElement;
|
||||||
private inputId = `ion-textarea-${textareaIds++}`;
|
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
|
* `true` if the textarea was cleared as a result of the user typing
|
||||||
* with `clearOnEdit` enabled.
|
* with `clearOnEdit` enabled.
|
||||||
@ -576,9 +578,30 @@ export class Textarea implements ComponentInterface {
|
|||||||
* Renders the helper text or error text values
|
* Renders the helper text or error text values
|
||||||
*/
|
*/
|
||||||
private renderHintText() {
|
private renderHintText() {
|
||||||
const { helperText, errorText } = this;
|
const { helperText, errorText, helperTextId, errorTextId } = this;
|
||||||
|
|
||||||
return [<div class="helper-text">{helperText}</div>, <div class="error-text">{errorText}</div>];
|
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 undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderCounter() {
|
private renderCounter() {
|
||||||
@ -703,6 +726,8 @@ export class Textarea implements ComponentInterface {
|
|||||||
onBlur={this.onBlur}
|
onBlur={this.onBlur}
|
||||||
onFocus={this.onFocus}
|
onFocus={this.onFocus}
|
||||||
onKeyDown={this.onKeyDown}
|
onKeyDown={this.onKeyDown}
|
||||||
|
aria-describedby={this.getHintTextID()}
|
||||||
|
aria-invalid={this.getHintTextID() === this.errorTextId}
|
||||||
{...this.inheritedAttributes}
|
{...this.inheritedAttributes}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
|
|||||||
Reference in New Issue
Block a user