Skip to content

fix(input): improve error text accessibility #30635

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 41 additions & 3 deletions core/src/components/input/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,15 @@ export class Input implements ComponentInterface {
*/
@State() hasFocus = false;

/**
* Track validation state for proper aria-live announcements
*/
@State() isInvalid = false;

@Element() el!: HTMLIonInputElement;

private validationObserver?: MutationObserver;

/**
* The color to use from your application's color palette.
* Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`.
Expand Down Expand Up @@ -396,6 +403,13 @@ export class Input implements ComponentInterface {
};
}

/**
* Checks if the input is in an invalid state based on validation classes
*/
private checkValidationState(): boolean {
return this.el.classList.contains('ion-touched') && this.el.classList.contains('ion-invalid');
}

connectedCallback() {
const { el } = this;

Expand All @@ -406,6 +420,24 @@ export class Input implements ComponentInterface {
() => this.labelSlot
);

// Watch for class changes to update validation state
if (Build.isBrowser && typeof MutationObserver !== 'undefined') {
this.validationObserver = new MutationObserver(() => {
const newIsInvalid = this.checkValidationState();
if (this.isInvalid !== newIsInvalid) {
this.isInvalid = newIsInvalid;
}
});

this.validationObserver.observe(el, {
attributes: true,
attributeFilter: ['class'],
});
}

// Always set initial state
this.isInvalid = this.checkValidationState();

this.debounceChanged();
if (Build.isBrowser) {
document.dispatchEvent(
Expand Down Expand Up @@ -451,6 +483,12 @@ export class Input implements ComponentInterface {
this.notchController.destroy();
this.notchController = undefined;
}

// Clean up validation observer to prevent memory leaks
if (this.validationObserver) {
this.validationObserver.disconnect();
this.validationObserver = undefined;
}
}

/**
Expand Down Expand Up @@ -639,9 +677,9 @@ export class Input implements ComponentInterface {
}

private getHintTextID(): string | undefined {
const { el, helperText, errorText, helperTextId, errorTextId } = this;
const { isInvalid, helperText, errorText, helperTextId, errorTextId } = this;

if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
if (isInvalid && errorText) {
return errorTextId;
}

Expand Down Expand Up @@ -864,7 +902,7 @@ export class Input implements ComponentInterface {
onCompositionstart={this.onCompositionStart}
onCompositionend={this.onCompositionEnd}
aria-describedby={this.getHintTextID()}
aria-invalid={this.getHintTextID() === this.errorTextId}
aria-invalid={this.isInvalid ? 'true' : undefined}
{...this.inheritedAttributes}
/>
{this.clearInput && !readonly && !disabled && (
Expand Down
86 changes: 86 additions & 0 deletions core/src/components/input/test/input.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,89 @@ describe('input: clear icon', () => {
expect(icon.getAttribute('icon')).toBe('foo');
});
});

// Regression tests for screen reader accessibility of error messages
describe('input: error text accessibility', () => {
it('should have error text element with proper structure', async () => {
const page = await newSpecPage({
components: [Input],
html: `<ion-input label="Input" error-text="This field is required"></ion-input>`,
});

const errorTextEl = page.body.querySelector('ion-input .error-text');
expect(errorTextEl).not.toBe(null);
expect(errorTextEl!.getAttribute('id')).toContain('error-text');
expect(errorTextEl!.textContent).toBe('This field is required');
});

it('should set aria-invalid when input is invalid', async () => {
const page = await newSpecPage({
components: [Input],
html: `<ion-input label="Input" error-text="Required field" class="ion-touched ion-invalid"></ion-input>`,
});

const nativeInput = page.body.querySelector('ion-input input')!;

// Should be invalid because of the classes
expect(nativeInput.getAttribute('aria-invalid')).toBe('true');
});

it('should set aria-describedby to error text when invalid', async () => {
const page = await newSpecPage({
components: [Input],
html: `<ion-input label="Input" error-text="Required field" class="ion-touched ion-invalid"></ion-input>`,
});

const nativeInput = page.body.querySelector('ion-input input')!;
const errorTextEl = page.body.querySelector('ion-input .error-text')!;

// Verify aria-describedby points to error text
const errorId = errorTextEl.getAttribute('id');
expect(nativeInput.getAttribute('aria-describedby')).toBe(errorId);
});

it('should set aria-describedby to helper text when valid', async () => {
const page = await newSpecPage({
components: [Input],
html: `<ion-input label="Input" helper-text="Enter a value" error-text="Required field"></ion-input>`,
});

const nativeInput = page.body.querySelector('ion-input input')!;
const helperTextEl = page.body.querySelector('ion-input .helper-text')!;

// When not invalid, should point to helper text
const helperId = helperTextEl.getAttribute('id');
expect(nativeInput.getAttribute('aria-describedby')).toBe(helperId);
expect(nativeInput.getAttribute('aria-invalid')).toBeNull();
});

it('should have helper text element with proper structure', async () => {
const page = await newSpecPage({
components: [Input],
html: `<ion-input label="Input" helper-text="Enter a valid value"></ion-input>`,
});

const helperTextEl = page.body.querySelector('ion-input .helper-text');
expect(helperTextEl).not.toBe(null);
expect(helperTextEl!.getAttribute('id')).toContain('helper-text');
expect(helperTextEl!.textContent).toBe('Enter a valid value');
});

it('should maintain error text content when error text changes dynamically', async () => {
const page = await newSpecPage({
components: [Input],
html: `<ion-input label="Input"></ion-input>`,
});

const input = page.body.querySelector('ion-input')!;

// Add error text dynamically
input.setAttribute('error-text', 'Invalid email format');
await page.waitForChanges();

const errorTextEl = page.body.querySelector('ion-input .error-text');
expect(errorTextEl).not.toBe(null);
expect(errorTextEl!.getAttribute('id')).toContain('error-text');
expect(errorTextEl!.textContent).toBe('Invalid email format');
});
});
Loading
Loading