Skip to content

Commit ec83531

Browse files
committed
fix(input): add aria-live attributes to error text
1 parent e5ed8a1 commit ec83531

File tree

4 files changed

+225
-10
lines changed

4 files changed

+225
-10
lines changed

core/src/components/input/input.tsx

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,15 @@ export class Input implements ComponentInterface {
7979
*/
8080
@State() hasFocus = false;
8181

82+
/**
83+
* Track validation state for proper aria-live announcements
84+
*/
85+
@State() isInvalid = false;
86+
8287
@Element() el!: HTMLIonInputElement;
8388

89+
private validationObserver?: MutationObserver;
90+
8491
/**
8592
* The color to use from your application's color palette.
8693
* Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`.
@@ -396,6 +403,13 @@ export class Input implements ComponentInterface {
396403
};
397404
}
398405

406+
/**
407+
* Checks if the input is in an invalid state based on validation classes
408+
*/
409+
private checkValidationState(): boolean {
410+
return this.el.classList.contains('ion-touched') && this.el.classList.contains('ion-invalid');
411+
}
412+
399413
connectedCallback() {
400414
const { el } = this;
401415

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

423+
// Watch for class changes to update validation state
424+
if (Build.isBrowser) {
425+
this.validationObserver = new MutationObserver(() => {
426+
const newIsInvalid = this.checkValidationState();
427+
if (this.isInvalid !== newIsInvalid) {
428+
this.isInvalid = newIsInvalid;
429+
}
430+
});
431+
432+
this.validationObserver.observe(el, {
433+
attributes: true,
434+
attributeFilter: ['class'],
435+
});
436+
437+
// Set initial state
438+
this.isInvalid = this.checkValidationState();
439+
}
440+
409441
this.debounceChanged();
410442
if (Build.isBrowser) {
411443
document.dispatchEvent(
@@ -451,6 +483,12 @@ export class Input implements ComponentInterface {
451483
this.notchController.destroy();
452484
this.notchController = undefined;
453485
}
486+
487+
// Clean up validation observer to prevent memory leaks
488+
if (this.validationObserver) {
489+
this.validationObserver.disconnect();
490+
this.validationObserver = undefined;
491+
}
454492
}
455493

456494
/**
@@ -626,22 +664,28 @@ export class Input implements ComponentInterface {
626664
* Renders the helper text or error text values
627665
*/
628666
private renderHintText() {
629-
const { helperText, errorText, helperTextId, errorTextId } = this;
667+
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
630668

631669
return [
632670
<div id={helperTextId} class="helper-text">
633671
{helperText}
634672
</div>,
635-
<div id={errorTextId} class="error-text">
636-
{errorText}
673+
<div
674+
id={errorTextId}
675+
class="error-text"
676+
role={isInvalid && errorText ? 'alert' : undefined}
677+
aria-live={isInvalid && errorText ? 'polite' : 'off'}
678+
aria-atomic="true"
679+
>
680+
{isInvalid && errorText ? errorText : ''}
637681
</div>,
638682
];
639683
}
640684

641685
private getHintTextID(): string | undefined {
642-
const { el, helperText, errorText, helperTextId, errorTextId } = this;
686+
const { isInvalid, helperText, errorText, helperTextId, errorTextId } = this;
643687

644-
if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
688+
if (isInvalid && errorText) {
645689
return errorTextId;
646690
}
647691

core/src/components/input/test/input.spec.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,66 @@ describe('input: clear icon', () => {
133133
expect(icon.getAttribute('icon')).toBe('foo');
134134
});
135135
});
136+
137+
// Regression tests for screen reader accessibility of error messages
138+
describe('input: error text accessibility', () => {
139+
it('should have error text element with proper structure', async () => {
140+
const page = await newSpecPage({
141+
components: [Input],
142+
html: `<ion-input label="Input" error-text="This field is required"></ion-input>`,
143+
});
144+
145+
const errorTextEl = page.body.querySelector('ion-input .error-text');
146+
expect(errorTextEl).not.toBe(null);
147+
148+
// Error text element should always exist and have aria-atomic
149+
expect(errorTextEl!.getAttribute('aria-atomic')).toBe('true');
150+
expect(errorTextEl!.getAttribute('id')).toContain('error-text');
151+
});
152+
153+
it('should maintain error text structure when error text changes dynamically', async () => {
154+
const page = await newSpecPage({
155+
components: [Input],
156+
html: `<ion-input label="Input"></ion-input>`,
157+
});
158+
159+
const input = page.body.querySelector('ion-input')!;
160+
161+
// Add error text dynamically
162+
input.setAttribute('error-text', 'Invalid email format');
163+
await page.waitForChanges();
164+
165+
const errorTextEl = page.body.querySelector('ion-input .error-text');
166+
expect(errorTextEl).not.toBe(null);
167+
expect(errorTextEl!.getAttribute('aria-atomic')).toBe('true');
168+
expect(errorTextEl!.getAttribute('id')).toContain('error-text');
169+
});
170+
171+
it('should have proper aria-describedby reference structure', async () => {
172+
const page = await newSpecPage({
173+
components: [Input],
174+
html: `<ion-input label="Input" error-text="Required field"></ion-input>`,
175+
});
176+
177+
const errorTextEl = page.body.querySelector('ion-input .error-text')!;
178+
179+
// Verify the error text element has an ID
180+
const errorId = errorTextEl.getAttribute('id');
181+
expect(errorId).toContain('error-text');
182+
183+
// Note: aria-describedby is dynamically set based on validation state
184+
// The actual connection happens when the input becomes invalid
185+
});
186+
187+
it('should have helper text element with proper structure', async () => {
188+
const page = await newSpecPage({
189+
components: [Input],
190+
html: `<ion-input label="Input" helper-text="Enter a valid value"></ion-input>`,
191+
});
192+
193+
const helperTextEl = page.body.querySelector('ion-input .helper-text');
194+
expect(helperTextEl).not.toBe(null);
195+
expect(helperTextEl!.getAttribute('id')).toContain('helper-text');
196+
expect(helperTextEl!.textContent).toBe('Enter a valid value');
197+
});
198+
});

core/src/components/textarea/test/textarea.spec.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,66 @@ describe('textarea: label rendering', () => {
8585
expect(labelText.textContent).toBe('Label Prop Text');
8686
});
8787
});
88+
89+
// Accessibility tests for error text announcements to screen readers
90+
describe('textarea: error text accessibility', () => {
91+
it('should have error text element with proper structure', async () => {
92+
const page = await newSpecPage({
93+
components: [Textarea],
94+
html: `<ion-textarea label="Textarea" error-text="This field is required"></ion-textarea>`,
95+
});
96+
97+
const errorTextEl = page.body.querySelector('ion-textarea .error-text');
98+
expect(errorTextEl).not.toBe(null);
99+
100+
// Error text element should always exist and have aria-atomic
101+
expect(errorTextEl!.getAttribute('aria-atomic')).toBe('true');
102+
expect(errorTextEl!.getAttribute('id')).toContain('error-text');
103+
});
104+
105+
it('should maintain error text structure when error text changes dynamically', async () => {
106+
const page = await newSpecPage({
107+
components: [Textarea],
108+
html: `<ion-textarea label="Textarea"></ion-textarea>`,
109+
});
110+
111+
const textarea = page.body.querySelector('ion-textarea')!;
112+
113+
// Add error text dynamically
114+
textarea.setAttribute('error-text', 'Invalid content');
115+
await page.waitForChanges();
116+
117+
const errorTextEl = page.body.querySelector('ion-textarea .error-text');
118+
expect(errorTextEl).not.toBe(null);
119+
expect(errorTextEl!.getAttribute('aria-atomic')).toBe('true');
120+
expect(errorTextEl!.getAttribute('id')).toContain('error-text');
121+
});
122+
123+
it('should have proper aria-describedby reference structure', async () => {
124+
const page = await newSpecPage({
125+
components: [Textarea],
126+
html: `<ion-textarea label="Textarea" error-text="Required field"></ion-textarea>`,
127+
});
128+
129+
const errorTextEl = page.body.querySelector('ion-textarea .error-text')!;
130+
131+
// Verify the error text element has an ID
132+
const errorId = errorTextEl.getAttribute('id');
133+
expect(errorId).toContain('error-text');
134+
135+
// Note: aria-describedby is dynamically set based on validation state
136+
// The actual connection happens when the textarea becomes invalid
137+
});
138+
139+
it('should have helper text element with proper structure', async () => {
140+
const page = await newSpecPage({
141+
components: [Textarea],
142+
html: `<ion-textarea label="Textarea" helper-text="Enter your comments"></ion-textarea>`,
143+
});
144+
145+
const helperTextEl = page.body.querySelector('ion-textarea .helper-text');
146+
expect(helperTextEl).not.toBe(null);
147+
expect(helperTextEl!.getAttribute('id')).toContain('helper-text');
148+
expect(helperTextEl!.textContent).toBe('Enter your comments');
149+
});
150+
});

core/src/components/textarea/textarea.tsx

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,13 @@ export class Textarea implements ComponentInterface {
8181
*/
8282
@State() hasFocus = false;
8383

84+
/**
85+
* Track validation state for proper aria-live announcements
86+
*/
87+
@State() isInvalid = false;
88+
89+
private validationObserver?: MutationObserver;
90+
8491
/**
8592
* The color to use from your application's color palette.
8693
* Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`.
@@ -328,6 +335,13 @@ export class Textarea implements ComponentInterface {
328335
}
329336
}
330337

338+
/**
339+
* Checks if the textarea is in an invalid state based on validation classes
340+
*/
341+
private checkValidationState(): boolean {
342+
return this.el.classList.contains('ion-touched') && this.el.classList.contains('ion-invalid');
343+
}
344+
331345
connectedCallback() {
332346
const { el } = this;
333347
this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => forceUpdate(this));
@@ -336,6 +350,25 @@ export class Textarea implements ComponentInterface {
336350
() => this.notchSpacerEl,
337351
() => this.labelSlot
338352
);
353+
354+
// Watch for class changes to update validation state
355+
if (Build.isBrowser) {
356+
this.validationObserver = new MutationObserver(() => {
357+
const newIsInvalid = this.checkValidationState();
358+
if (this.isInvalid !== newIsInvalid) {
359+
this.isInvalid = newIsInvalid;
360+
}
361+
});
362+
363+
this.validationObserver.observe(el, {
364+
attributes: true,
365+
attributeFilter: ['class'],
366+
});
367+
368+
// Set initial state
369+
this.isInvalid = this.checkValidationState();
370+
}
371+
339372
this.debounceChanged();
340373
if (Build.isBrowser) {
341374
document.dispatchEvent(
@@ -364,6 +397,12 @@ export class Textarea implements ComponentInterface {
364397
this.notchController.destroy();
365398
this.notchController = undefined;
366399
}
400+
401+
// Clean up validation observer to prevent memory leaks
402+
if (this.validationObserver) {
403+
this.validationObserver.disconnect();
404+
this.validationObserver = undefined;
405+
}
367406
}
368407

369408
componentWillLoad() {
@@ -628,22 +667,28 @@ export class Textarea implements ComponentInterface {
628667
* Renders the helper text or error text values
629668
*/
630669
private renderHintText() {
631-
const { helperText, errorText, helperTextId, errorTextId } = this;
670+
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
632671

633672
return [
634673
<div id={helperTextId} class="helper-text">
635674
{helperText}
636675
</div>,
637-
<div id={errorTextId} class="error-text">
638-
{errorText}
676+
<div
677+
id={errorTextId}
678+
class="error-text"
679+
role={isInvalid && errorText ? 'alert' : undefined}
680+
aria-live={isInvalid && errorText ? 'polite' : 'off'}
681+
aria-atomic="true"
682+
>
683+
{isInvalid && errorText ? errorText : ''}
639684
</div>,
640685
];
641686
}
642687

643688
private getHintTextID(): string | undefined {
644-
const { el, helperText, errorText, helperTextId, errorTextId } = this;
689+
const { isInvalid, helperText, errorText, helperTextId, errorTextId } = this;
645690

646-
if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
691+
if (isInvalid && errorText) {
647692
return errorTextId;
648693
}
649694

0 commit comments

Comments
 (0)