Skip to content

Commit c339bc3

Browse files
fix(input): improve error text accessibility (#30635)
Issue number: resolves 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. --> Currently, when an error text is shown, it may not announce itself to voice assistants. This is because the way error text currently works is by always existing in the DOM, but being hidden when there is no error. When the error state changes, the error text is shown, but as far as the voice assistant can tell it's always been there and nothing has changed. ## What is the new behavior? <!-- Please describe the behavior or changes that are being added by this PR. --> With these changes, both input and textarea have been updated so they'll properly announce error text when it shows up. We had to do this with a mutation observer and state because it's important in some frameworks, like Angular, that state changes to cause a re-render. This, combined with some minor aria changes, makes it so that when a field is declared invalid, it immediately announces the invalid state instead of waiting for the user to go back to the invalid field. ## 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. --> Current dev build: ``` 8.7.4-dev.11756220757.185b8cbf ``` ## Screens [Textarea](https://ionic-framework-git-ionic-49-ionic1.vercel.app/src/components/textarea/test/validation) [Input](https://ionic-framework-git-ionic-49-ionic1.vercel.app/src/components/input/test/validation) --------- Co-authored-by: Brandy Smith <brandyscarney@users.noreply.github.com>
1 parent 49f96d7 commit c339bc3

File tree

17 files changed

+1374
-17
lines changed

17 files changed

+1374
-17
lines changed

core/src/components/input/input.tsx

Lines changed: 51 additions & 8 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,16 @@ export class Input implements ComponentInterface {
396403
};
397404
}
398405

406+
/**
407+
* Checks if the input is in an invalid state based on Ionic validation classes
408+
*/
409+
private checkInvalidState(): boolean {
410+
const hasIonTouched = this.el.classList.contains('ion-touched');
411+
const hasIonInvalid = this.el.classList.contains('ion-invalid');
412+
413+
return hasIonTouched && hasIonInvalid;
414+
}
415+
399416
connectedCallback() {
400417
const { el } = this;
401418

@@ -406,6 +423,26 @@ export class Input implements ComponentInterface {
406423
() => this.labelSlot
407424
);
408425

426+
// Watch for class changes to update validation state
427+
if (Build.isBrowser && typeof MutationObserver !== 'undefined') {
428+
this.validationObserver = new MutationObserver(() => {
429+
const newIsInvalid = this.checkInvalidState();
430+
if (this.isInvalid !== newIsInvalid) {
431+
this.isInvalid = newIsInvalid;
432+
// Force a re-render to update aria-describedby immediately
433+
forceUpdate(this);
434+
}
435+
});
436+
437+
this.validationObserver.observe(el, {
438+
attributes: true,
439+
attributeFilter: ['class'],
440+
});
441+
}
442+
443+
// Always set initial state
444+
this.isInvalid = this.checkInvalidState();
445+
409446
this.debounceChanged();
410447
if (Build.isBrowser) {
411448
document.dispatchEvent(
@@ -451,6 +488,12 @@ export class Input implements ComponentInterface {
451488
this.notchController.destroy();
452489
this.notchController = undefined;
453490
}
491+
492+
// Clean up validation observer to prevent memory leaks
493+
if (this.validationObserver) {
494+
this.validationObserver.disconnect();
495+
this.validationObserver = undefined;
496+
}
454497
}
455498

456499
/**
@@ -626,22 +669,22 @@ export class Input implements ComponentInterface {
626669
* Renders the helper text or error text values
627670
*/
628671
private renderHintText() {
629-
const { helperText, errorText, helperTextId, errorTextId } = this;
672+
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
630673

631674
return [
632-
<div id={helperTextId} class="helper-text">
633-
{helperText}
675+
<div id={helperTextId} class="helper-text" aria-live="polite">
676+
{!isInvalid ? helperText : null}
634677
</div>,
635-
<div id={errorTextId} class="error-text">
636-
{errorText}
678+
<div id={errorTextId} class="error-text" role="alert">
679+
{isInvalid ? errorText : null}
637680
</div>,
638681
];
639682
}
640683

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

644-
if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
687+
if (isInvalid && errorText) {
645688
return errorTextId;
646689
}
647690

@@ -864,7 +907,7 @@ export class Input implements ComponentInterface {
864907
onCompositionstart={this.onCompositionStart}
865908
onCompositionend={this.onCompositionEnd}
866909
aria-describedby={this.getHintTextID()}
867-
aria-invalid={this.getHintTextID() === this.errorTextId}
910+
aria-invalid={this.isInvalid ? 'true' : undefined}
868911
{...this.inheritedAttributes}
869912
/>
870913
{this.clearInput && !readonly && !disabled && (
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
<!DOCTYPE html>
2+
<html lang="en" dir="ltr">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>Input - Validation</title>
6+
<meta
7+
name="viewport"
8+
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
9+
/>
10+
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
11+
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
12+
<script src="../../../../../scripts/testing/scripts.js"></script>
13+
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
14+
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
15+
<style>
16+
.grid {
17+
display: grid;
18+
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
19+
grid-row-gap: 20px;
20+
grid-column-gap: 20px;
21+
}
22+
23+
h2 {
24+
font-size: 12px;
25+
font-weight: normal;
26+
27+
color: var(--ion-color-step-600);
28+
29+
margin-top: 10px;
30+
margin-bottom: 5px;
31+
}
32+
33+
.validation-info {
34+
margin: 20px;
35+
padding: 10px;
36+
background: var(--ion-color-light);
37+
border-radius: 4px;
38+
}
39+
</style>
40+
</head>
41+
42+
<body>
43+
<ion-app>
44+
<ion-header>
45+
<ion-toolbar>
46+
<ion-title>Input - Validation Test</ion-title>
47+
</ion-toolbar>
48+
</ion-header>
49+
50+
<ion-content class="ion-padding">
51+
<div class="validation-info">
52+
<h2>Screen Reader Testing Instructions:</h2>
53+
<ol>
54+
<li>Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)</li>
55+
<li>Tab through the form fields</li>
56+
<li>When you tab away from an empty required field, the error should be announced immediately</li>
57+
<li>The error text should be announced BEFORE the next field is announced</li>
58+
<li>Test in Chrome, Safari, and Firefox to verify consistent behavior</li>
59+
</ol>
60+
</div>
61+
62+
<div class="grid">
63+
<div>
64+
<h2>Required Email Field</h2>
65+
<ion-input
66+
id="email-input"
67+
type="email"
68+
label="Email"
69+
label-placement="floating"
70+
fill="outline"
71+
placeholder="Enter your email"
72+
helper-text="We'll never share your email"
73+
error-text="Please enter a valid email address"
74+
required
75+
></ion-input>
76+
</div>
77+
78+
<div>
79+
<h2>Required Name Field</h2>
80+
<ion-input
81+
id="name-input"
82+
type="text"
83+
label="Full Name"
84+
label-placement="floating"
85+
fill="outline"
86+
placeholder="Enter your full name"
87+
helper-text="First and last name"
88+
error-text="Name is required"
89+
required
90+
></ion-input>
91+
</div>
92+
93+
<div>
94+
<h2>Phone Number (Pattern Validation)</h2>
95+
<ion-input
96+
id="phone-input"
97+
type="tel"
98+
label="Phone"
99+
label-placement="floating"
100+
fill="outline"
101+
placeholder="(555) 555-5555"
102+
pattern="^\(\d{3}\) \d{3}-\d{4}$"
103+
helper-text="Format: (555) 555-5555"
104+
error-text="Please enter a valid phone number"
105+
required
106+
></ion-input>
107+
</div>
108+
109+
<div>
110+
<h2>Password (Min Length)</h2>
111+
<ion-input
112+
id="password-input"
113+
type="password"
114+
label="Password"
115+
label-placement="floating"
116+
fill="outline"
117+
placeholder="Enter password"
118+
minlength="8"
119+
helper-text="At least 8 characters"
120+
error-text="Password must be at least 8 characters"
121+
required
122+
></ion-input>
123+
</div>
124+
125+
<div>
126+
<h2>Age (Number Range)</h2>
127+
<ion-input
128+
id="age-input"
129+
type="number"
130+
label="Age"
131+
label-placement="floating"
132+
fill="outline"
133+
placeholder="Enter your age"
134+
min="18"
135+
max="120"
136+
helper-text="Must be 18 or older"
137+
error-text="Please enter a valid age (18-120)"
138+
required
139+
></ion-input>
140+
</div>
141+
142+
<div>
143+
<h2>Optional Field (No Validation)</h2>
144+
<ion-input
145+
id="optional-input"
146+
type="text"
147+
label="Optional Info"
148+
label-placement="floating"
149+
fill="outline"
150+
placeholder="This field is optional"
151+
helper-text="You can skip this field"
152+
></ion-input>
153+
</div>
154+
</div>
155+
156+
<div class="ion-padding">
157+
<ion-button id="submit-btn" expand="block" disabled>Submit Form</ion-button>
158+
<ion-button id="reset-btn" expand="block" fill="outline">Reset Form</ion-button>
159+
</div>
160+
</ion-content>
161+
</ion-app>
162+
163+
<script>
164+
// Simple validation logic
165+
const inputs = document.querySelectorAll('ion-input');
166+
const submitBtn = document.getElementById('submit-btn');
167+
const resetBtn = document.getElementById('reset-btn');
168+
169+
// Track which fields have been touched
170+
const touchedFields = new Set();
171+
172+
// Validation functions
173+
const validators = {
174+
'email-input': (value) => {
175+
if (!value) return false;
176+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
177+
},
178+
'name-input': (value) => {
179+
return value && value.trim().length > 0;
180+
},
181+
'phone-input': (value) => {
182+
if (!value) return false;
183+
return /^\(\d{3}\) \d{3}-\d{4}$/.test(value);
184+
},
185+
'password-input': (value) => {
186+
return value && value.length >= 8;
187+
},
188+
'age-input': (value) => {
189+
if (!value) return false;
190+
const age = parseInt(value);
191+
return age >= 18 && age <= 120;
192+
},
193+
'optional-input': () => true, // Always valid
194+
};
195+
196+
function validateField(input) {
197+
const inputId = input.id;
198+
const value = input.value;
199+
const isValid = validators[inputId] ? validators[inputId](value) : true;
200+
201+
// Only show validation state if field has been touched
202+
if (touchedFields.has(inputId)) {
203+
if (isValid) {
204+
input.classList.remove('ion-invalid');
205+
input.classList.add('ion-valid');
206+
} else {
207+
input.classList.remove('ion-valid');
208+
input.classList.add('ion-invalid');
209+
}
210+
input.classList.add('ion-touched');
211+
}
212+
213+
return isValid;
214+
}
215+
216+
function validateForm() {
217+
let allValid = true;
218+
inputs.forEach((input) => {
219+
if (input.id !== 'optional-input') {
220+
const isValid = validateField(input);
221+
if (!isValid) {
222+
allValid = false;
223+
}
224+
}
225+
});
226+
submitBtn.disabled = !allValid;
227+
return allValid;
228+
}
229+
230+
// Add event listeners
231+
inputs.forEach((input) => {
232+
// Mark as touched on blur
233+
input.addEventListener('ionBlur', (e) => {
234+
touchedFields.add(input.id);
235+
validateField(input);
236+
validateForm();
237+
238+
const isInvalid = input.classList.contains('ion-invalid');
239+
if (isInvalid) {
240+
console.log('Field marked invalid:', input.label, input.errorText);
241+
}
242+
});
243+
244+
// Validate on input
245+
input.addEventListener('ionInput', (e) => {
246+
if (touchedFields.has(input.id)) {
247+
validateField(input);
248+
validateForm();
249+
}
250+
});
251+
252+
// Also validate on focus loss via native blur
253+
input.addEventListener('focusout', (e) => {
254+
// Small delay to ensure Ionic's classes are updated
255+
setTimeout(() => {
256+
touchedFields.add(input.id);
257+
validateField(input);
258+
validateForm();
259+
}, 10);
260+
});
261+
});
262+
263+
// Reset button
264+
resetBtn.addEventListener('click', () => {
265+
inputs.forEach((input) => {
266+
input.value = '';
267+
input.classList.remove('ion-valid', 'ion-invalid', 'ion-touched');
268+
});
269+
touchedFields.clear();
270+
submitBtn.disabled = true;
271+
});
272+
273+
// Submit button
274+
submitBtn.addEventListener('click', () => {
275+
if (validateForm()) {
276+
alert('Form submitted successfully!');
277+
}
278+
});
279+
280+
// Initial setup
281+
validateForm();
282+
</script>
283+
</body>
284+
</html>

0 commit comments

Comments
 (0)