Skip to content

Commit 6c1f61b

Browse files
committed
fix(input): improve validation state reactivity
1 parent 9ebada9 commit 6c1f61b

File tree

4 files changed

+76
-56
lines changed

4 files changed

+76
-56
lines changed

core/src/components/input/input.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,8 @@ export class Input implements ComponentInterface {
426426
const newIsInvalid = this.checkValidationState();
427427
if (this.isInvalid !== newIsInvalid) {
428428
this.isInvalid = newIsInvalid;
429+
// Force a re-render to update aria-describedby immediately
430+
forceUpdate(this);
429431
}
430432
});
431433

@@ -587,6 +589,20 @@ export class Input implements ComponentInterface {
587589
this.didInputClearOnEdit = false;
588590

589591
this.ionBlur.emit(ev);
592+
593+
/**
594+
* Check validation state after blur to handle framework-managed classes.
595+
* Frameworks like Angular update classes asynchronously, often using
596+
* requestAnimationFrame or promises. Using setTimeout ensures we check
597+
* after all microtasks and animation frames have completed.
598+
*/
599+
setTimeout(() => {
600+
const newIsInvalid = this.checkValidationState();
601+
if (this.isInvalid !== newIsInvalid) {
602+
this.isInvalid = newIsInvalid;
603+
forceUpdate(this);
604+
}
605+
}, 100);
590606
};
591607

592608
private onFocus = (ev: FocusEvent) => {

core/src/components/textarea/textarea.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,8 @@ export class Textarea implements ComponentInterface {
357357
const newIsInvalid = this.checkValidationState();
358358
if (this.isInvalid !== newIsInvalid) {
359359
this.isInvalid = newIsInvalid;
360+
// Force a re-render to update aria-describedby immediately
361+
forceUpdate(this);
360362
}
361363
});
362364

@@ -572,6 +574,20 @@ export class Textarea implements ComponentInterface {
572574
}
573575
this.didTextareaClearOnEdit = false;
574576
this.ionBlur.emit(ev);
577+
578+
/**
579+
* Check validation state after blur to handle framework-managed classes.
580+
* Frameworks like Angular update classes asynchronously, often using
581+
* requestAnimationFrame or promises. Using setTimeout ensures we check
582+
* after all microtasks and animation frames have completed.
583+
*/
584+
setTimeout(() => {
585+
const newIsInvalid = this.checkValidationState();
586+
if (this.isInvalid !== newIsInvalid) {
587+
this.isInvalid = newIsInvalid;
588+
forceUpdate(this);
589+
}
590+
}, 100);
575591
};
576592

577593
private onKeyDown = (ev: KeyboardEvent) => {

packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.ts

Lines changed: 21 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,19 @@
1-
import { Component, ElementRef, ViewChild } from '@angular/core';
21
import { CommonModule } from '@angular/common';
2+
import { Component, ElementRef, ViewChild } from '@angular/core';
33
import {
44
FormBuilder,
55
ReactiveFormsModule,
6-
Validators,
7-
AbstractControl,
8-
ValidationErrors
6+
Validators
97
} from '@angular/forms';
108
import {
11-
IonInput,
129
IonButton,
10+
IonContent,
1311
IonHeader,
14-
IonToolbar,
12+
IonInput,
1513
IonTitle,
16-
IonContent,
17-
IonApp,
18-
IonButtons,
19-
IonItem,
20-
IonList
14+
IonToolbar
2115
} from '@ionic/angular/standalone';
2216

23-
// Custom validator for phone pattern
24-
function phoneValidator(control: AbstractControl): ValidationErrors | null {
25-
const value = control.value;
26-
if (!value) return null;
27-
const phonePattern = /^\(\d{3}\) \d{3}-\d{4}$/;
28-
return phonePattern.test(value) ? null : { invalidPhone: true };
29-
}
30-
3117
@Component({
3218
selector: 'app-input-validation',
3319
templateUrl: './input-validation.component.html',
@@ -36,16 +22,12 @@ function phoneValidator(control: AbstractControl): ValidationErrors | null {
3622
imports: [
3723
CommonModule,
3824
ReactiveFormsModule,
39-
IonApp,
4025
IonInput,
4126
IonButton,
4227
IonHeader,
4328
IonToolbar,
4429
IonTitle,
45-
IonContent,
46-
IonButtons,
47-
IonItem,
48-
IonList
30+
IonContent
4931
]
5032
})
5133
export class InputValidationComponent {
@@ -125,11 +107,11 @@ export class InputValidationComponent {
125107
onIonBlur(fieldName: string, inputElement: IonInput): void {
126108
this.markTouched(fieldName);
127109
this.updateValidationClasses(fieldName, inputElement);
128-
110+
129111
// Update aria-live region if invalid
130112
if (this.isInvalid(fieldName) && this.debugRegion) {
131113
const metadata = this.fieldMetadata[fieldName as keyof typeof this.fieldMetadata];
132-
this.debugRegion.nativeElement.textContent =
114+
this.debugRegion.nativeElement.textContent =
133115
`Field ${metadata.label} is invalid: ${metadata.errorText}`;
134116
console.log('Field marked invalid:', metadata.label, metadata.errorText);
135117
}
@@ -152,12 +134,19 @@ export class InputValidationComponent {
152134

153135
// Update validation classes on the input element
154136
private updateValidationClasses(fieldName: string, inputElement: IonInput): void {
155-
const element = inputElement as any;
156-
137+
// Access the native element through the Angular component
138+
const element = (inputElement as any).el || (inputElement as any).nativeElement;
139+
140+
// Ensure we have a valid element with classList
141+
if (!element || !element.classList) {
142+
console.warn('Could not access native element for validation classes');
143+
return;
144+
}
145+
157146
if (this.isTouched(fieldName)) {
158147
// Add ion-touched class
159148
element.classList.add('ion-touched');
160-
149+
161150
// Update ion-valid/ion-invalid classes
162151
if (this.isInvalid(fieldName)) {
163152
element.classList.remove('ion-valid');
@@ -189,16 +178,16 @@ export class InputValidationComponent {
189178
onReset(): void {
190179
// Reset form values
191180
this.form.reset();
192-
181+
193182
// Clear touched fields
194183
this.touchedFields.clear();
195-
184+
196185
// Remove validation classes from all inputs
197186
const inputs = document.querySelectorAll('ion-input');
198187
inputs.forEach(input => {
199188
input.classList.remove('ion-valid', 'ion-invalid', 'ion-touched');
200189
});
201-
190+
202191
// Clear aria-live region
203192
if (this.debugRegion) {
204193
this.debugRegion.nativeElement.textContent = '';

packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.ts

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,19 @@
1-
import { Component, ElementRef, ViewChild } from '@angular/core';
21
import { CommonModule } from '@angular/common';
2+
import { Component, ElementRef, ViewChild } from '@angular/core';
33
import {
4+
AbstractControl,
45
FormBuilder,
56
ReactiveFormsModule,
6-
Validators,
7-
AbstractControl,
8-
ValidationErrors
7+
ValidationErrors,
8+
Validators
99
} from '@angular/forms';
1010
import {
11-
IonTextarea,
1211
IonButton,
12+
IonContent,
1313
IonHeader,
14-
IonToolbar,
14+
IonTextarea,
1515
IonTitle,
16-
IonContent,
17-
IonApp,
18-
IonButtons,
19-
IonItem,
20-
IonList
16+
IonToolbar
2117
} from '@ionic/angular/standalone';
2218

2319
// Custom validator for address (must be at least 10 chars and contain a digit)
@@ -38,16 +34,12 @@ function addressValidator(control: AbstractControl): ValidationErrors | null {
3834
imports: [
3935
CommonModule,
4036
ReactiveFormsModule,
41-
IonApp,
4237
IonTextarea,
4338
IonButton,
4439
IonHeader,
4540
IonToolbar,
4641
IonTitle,
47-
IonContent,
48-
IonButtons,
49-
IonItem,
50-
IonList
42+
IonContent
5143
]
5244
})
5345
export class TextareaValidationComponent {
@@ -135,11 +127,11 @@ export class TextareaValidationComponent {
135127
onIonBlur(fieldName: string, textareaElement: IonTextarea): void {
136128
this.markTouched(fieldName);
137129
this.updateValidationClasses(fieldName, textareaElement);
138-
130+
139131
// Update aria-live region if invalid
140132
if (this.isInvalid(fieldName) && this.debugRegion) {
141133
const metadata = this.fieldMetadata[fieldName as keyof typeof this.fieldMetadata];
142-
this.debugRegion.nativeElement.textContent =
134+
this.debugRegion.nativeElement.textContent =
143135
`Field ${metadata.label} is invalid: ${metadata.errorText}`;
144136
console.log('Field marked invalid:', metadata.label, metadata.errorText);
145137
}
@@ -162,12 +154,19 @@ export class TextareaValidationComponent {
162154

163155
// Update validation classes on the textarea element
164156
private updateValidationClasses(fieldName: string, textareaElement: IonTextarea): void {
165-
const element = textareaElement as any;
166-
157+
// Access the native element through the Angular component
158+
const element = (textareaElement as any).el || (textareaElement as any).nativeElement;
159+
160+
// Ensure we have a valid element with classList
161+
if (!element || !element.classList) {
162+
console.warn('Could not access native element for validation classes');
163+
return;
164+
}
165+
167166
if (this.isTouched(fieldName)) {
168167
// Add ion-touched class
169168
element.classList.add('ion-touched');
170-
169+
171170
// Update ion-valid/ion-invalid classes
172171
if (this.isInvalid(fieldName)) {
173172
element.classList.remove('ion-valid');
@@ -199,16 +198,16 @@ export class TextareaValidationComponent {
199198
onReset(): void {
200199
// Reset form values
201200
this.form.reset();
202-
201+
203202
// Clear touched fields
204203
this.touchedFields.clear();
205-
204+
206205
// Remove validation classes from all textareas
207206
const textareas = document.querySelectorAll('ion-textarea');
208207
textareas.forEach(textarea => {
209208
textarea.classList.remove('ion-valid', 'ion-invalid', 'ion-touched');
210209
});
211-
210+
212211
// Clear aria-live region
213212
if (this.debugRegion) {
214213
this.debugRegion.nativeElement.textContent = '';

0 commit comments

Comments
 (0)