Skip to content

Commit 5760856

Browse files
committed
fix(input): fixing input validation accessibility for template-driven validation
1 parent 1baf39e commit 5760856

File tree

8 files changed

+407
-17
lines changed

8 files changed

+407
-17
lines changed

core/src/components/input/input.tsx

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,18 @@ export class Input implements ComponentInterface {
407407
* Checks if the input is in an invalid state based on validation classes
408408
*/
409409
private checkValidationState(): boolean {
410-
return this.el.classList.contains('ion-touched') && this.el.classList.contains('ion-invalid');
410+
// Check for both Ionic and Angular validation classes on the element itself
411+
// Angular applies ng-touched/ng-invalid directly to the host element with ngModel
412+
const hasIonTouched = this.el.classList.contains('ion-touched');
413+
const hasIonInvalid = this.el.classList.contains('ion-invalid');
414+
const hasNgTouched = this.el.classList.contains('ng-touched');
415+
const hasNgInvalid = this.el.classList.contains('ng-invalid');
416+
417+
// Return true if we have both touched and invalid states from either framework
418+
const isTouched = hasIonTouched || hasNgTouched;
419+
const isInvalid = hasIonInvalid || hasNgInvalid;
420+
421+
return isTouched && isInvalid;
411422
}
412423

413424
connectedCallback() {
@@ -680,15 +691,25 @@ export class Input implements ComponentInterface {
680691
* Renders the helper text or error text values
681692
*/
682693
private renderHintText() {
683-
const { helperText, errorText, helperTextId, errorTextId } = this;
694+
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
684695

685696
return [
686-
<div id={helperTextId} class="helper-text">
687-
{helperText}
688-
</div>,
689-
<div id={errorTextId} class="error-text">
690-
{errorText}
691-
</div>,
697+
helperText && !isInvalid && (
698+
<div id={helperTextId} class="helper-text" aria-live="polite">
699+
{helperText}
700+
</div>
701+
),
702+
errorText && isInvalid && (
703+
<div
704+
id={errorTextId}
705+
class="error-text"
706+
aria-live="assertive"
707+
aria-atomic="true"
708+
role="alert"
709+
>
710+
{errorText}
711+
</div>
712+
),
692713
];
693714
}
694715

core/src/components/textarea/textarea.tsx

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,18 @@ export class Textarea implements ComponentInterface {
339339
* Checks if the textarea is in an invalid state based on validation classes
340340
*/
341341
private checkValidationState(): boolean {
342-
return this.el.classList.contains('ion-touched') && this.el.classList.contains('ion-invalid');
342+
// Check for both Ionic and Angular validation classes on the element itself
343+
// Angular applies ng-touched/ng-invalid directly to the host element with ngModel
344+
const hasIonTouched = this.el.classList.contains('ion-touched');
345+
const hasIonInvalid = this.el.classList.contains('ion-invalid');
346+
const hasNgTouched = this.el.classList.contains('ng-touched');
347+
const hasNgInvalid = this.el.classList.contains('ng-invalid');
348+
349+
// Return true if we have both touched and invalid states from either framework
350+
const isTouched = hasIonTouched || hasNgTouched;
351+
const isInvalid = hasIonInvalid || hasNgInvalid;
352+
353+
return isTouched && isInvalid;
343354
}
344355

345356
connectedCallback() {
@@ -683,15 +694,25 @@ export class Textarea implements ComponentInterface {
683694
* Renders the helper text or error text values
684695
*/
685696
private renderHintText() {
686-
const { helperText, errorText, helperTextId, errorTextId } = this;
697+
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
687698

688699
return [
689-
<div id={helperTextId} class="helper-text">
690-
{helperText}
691-
</div>,
692-
<div id={errorTextId} class="error-text">
693-
{errorText}
694-
</div>,
700+
helperText && !isInvalid && (
701+
<div id={helperTextId} class="helper-text" aria-live="polite">
702+
{helperText}
703+
</div>
704+
),
705+
errorText && isInvalid && (
706+
<div
707+
id={errorTextId}
708+
class="error-text"
709+
aria-live="assertive"
710+
aria-atomic="true"
711+
role="alert"
712+
>
713+
{errorText}
714+
</div>
715+
),
695716
];
696717
}
697718

packages/angular/test/base/src/app/lazy/app-lazy/app.module.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { AlertComponent } from '../alert/alert.component';
2828
import { AccordionComponent } from '../accordion/accordion.component';
2929
import { AccordionModalComponent } from '../accordion/accordion-modal/accordion-modal.component';
3030
import { TabsBasicComponent } from '../tabs-basic/tabs-basic.component';
31+
import { TemplateFormComponent } from '../template-form/template-form.component';
3132

3233
@NgModule({
3334
declarations: [
@@ -53,7 +54,8 @@ import { TabsBasicComponent } from '../tabs-basic/tabs-basic.component';
5354
AlertComponent,
5455
AccordionComponent,
5556
AccordionModalComponent,
56-
TabsBasicComponent
57+
TabsBasicComponent,
58+
TemplateFormComponent
5759
],
5860
imports: [
5961
CommonModule,

packages/angular/test/base/src/app/lazy/app-lazy/app.routes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { NavigationPage3Component } from '../navigation-page3/navigation-page3.c
1919
import { AlertComponent } from '../alert/alert.component';
2020
import { AccordionComponent } from '../accordion/accordion.component';
2121
import { TabsBasicComponent } from '../tabs-basic/tabs-basic.component';
22+
import { TemplateFormComponent } from '../template-form/template-form.component';
2223

2324
export const routes: Routes = [
2425
{
@@ -33,6 +34,7 @@ export const routes: Routes = [
3334
{ path: 'textarea', loadChildren: () => import('../textarea/textarea.module').then(m => m.TextareaModule) },
3435
{ path: 'searchbar', loadChildren: () => import('../searchbar/searchbar.module').then(m => m.SearchbarModule) },
3536
{ path: 'form', component: FormComponent },
37+
{ path: 'template-form', component: TemplateFormComponent },
3638
{ path: 'modals', component: ModalComponent },
3739
{ path: 'modal-inline', loadChildren: () => import('../modal-inline').then(m => m.ModalInlineModule) },
3840
{ path: 'view-child', component: ViewChildComponent },

packages/angular/test/base/src/app/lazy/home-page/home-page.component.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@
2525
Form Test
2626
</ion-label>
2727
</ion-item>
28+
<ion-item routerLink="/lazy/template-form">
29+
<ion-label>
30+
Template-Driven Form Test
31+
</ion-label>
32+
</ion-item>
2833
<ion-item routerLink="/lazy/modals">
2934
<ion-label>
3035
Modals Test
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<ion-header>
2+
<ion-toolbar>
3+
<ion-title>Template-Driven Form Validation Test</ion-title>
4+
</ion-toolbar>
5+
</ion-header>
6+
7+
<ion-content>
8+
<form #templateForm="ngForm" (ngSubmit)="onSubmit(templateForm)">
9+
<ion-list>
10+
<!-- Test ion-input with required validation -->
11+
<ion-item>
12+
<ion-input
13+
label="Required Input"
14+
[(ngModel)]="inputValue"
15+
name="inputField"
16+
required
17+
#inputField="ngModel"
18+
id="template-input-test"
19+
errorText="This field is required"
20+
helperText="Enter some text">
21+
</ion-input>
22+
</ion-item>
23+
24+
<!-- Display validation state for debugging -->
25+
<ion-item>
26+
<ion-label>
27+
<p>Input Touched: <span id="input-touched">{{inputField.touched}}</span></p>
28+
<p>Input Invalid: <span id="input-invalid">{{inputField.invalid}}</span></p>
29+
<p>Input Errors: <span id="input-errors">{{inputField.errors | json}}</span></p>
30+
</ion-label>
31+
</ion-item>
32+
33+
<!-- Test ion-textarea with required validation -->
34+
<ion-item>
35+
<ion-textarea
36+
label="Required Textarea"
37+
[(ngModel)]="textareaValue"
38+
name="textareaField"
39+
required
40+
#textareaField="ngModel"
41+
id="template-textarea-test"
42+
errorText="This field is required"
43+
helperText="Enter some text"
44+
rows="4">
45+
</ion-textarea>
46+
</ion-item>
47+
48+
<!-- Display validation state for debugging -->
49+
<ion-item>
50+
<ion-label>
51+
<p>Textarea Touched: <span id="textarea-touched">{{textareaField.touched}}</span></p>
52+
<p>Textarea Invalid: <span id="textarea-invalid">{{textareaField.invalid}}</span></p>
53+
<p>Textarea Errors: <span id="textarea-errors">{{textareaField.errors | json}}</span></p>
54+
</ion-label>
55+
</ion-item>
56+
57+
<!-- Additional test with minlength validation -->
58+
<ion-item>
59+
<ion-input
60+
label="Min Length Input (3 chars)"
61+
[(ngModel)]="minLengthValue"
62+
name="minLengthField"
63+
required
64+
minlength="3"
65+
#minLengthField="ngModel"
66+
id="template-minlength-test"
67+
errorText="Minimum 3 characters required"
68+
helperText="Enter at least 3 characters">
69+
</ion-input>
70+
</ion-item>
71+
72+
<!-- Display validation state for minlength field -->
73+
<ion-item>
74+
<ion-label>
75+
<p>MinLength Touched: <span id="minlength-touched">{{minLengthField.touched}}</span></p>
76+
<p>MinLength Invalid: <span id="minlength-invalid">{{minLengthField.invalid}}</span></p>
77+
<p>MinLength Errors: <span id="minlength-errors">{{minLengthField.errors | json}}</span></p>
78+
</ion-label>
79+
</ion-item>
80+
</ion-list>
81+
82+
<div class="ion-padding">
83+
<p>Form Valid: <span id="form-valid">{{templateForm.valid}}</span></p>
84+
<p>Form Submitted: <span id="form-submitted">{{submitted}}</span></p>
85+
86+
<ion-button type="submit" id="submit-button" [disabled]="!templateForm.valid">
87+
Submit Form
88+
</ion-button>
89+
90+
<ion-button type="button" id="reset-button" (click)="resetForm(templateForm)">
91+
Reset Form
92+
</ion-button>
93+
94+
<ion-button type="button" id="touch-all-button" (click)="templateForm.form.markAllAsTouched()">
95+
Mark All as Touched
96+
</ion-button>
97+
</div>
98+
99+
<div class="ion-padding">
100+
<h3>Form Values:</h3>
101+
<pre id="form-values">{{templateForm.value | json}}</pre>
102+
</div>
103+
</form>
104+
105+
<div class="ion-padding">
106+
<h3>Instructions to reproduce issue:</h3>
107+
<ol>
108+
<li>Click in the "Required Input" field</li>
109+
<li>Click outside without entering text</li>
110+
<li>The field should show as touched and invalid</li>
111+
<li>The error text should appear below the input</li>
112+
<li>For screen readers, the validation state should be announced</li>
113+
</ol>
114+
<p><strong>Note:</strong> With template-driven forms, Angular applies validation classes to the wrapper element, not directly to ion-input/ion-textarea.</p>
115+
</div>
116+
</ion-content>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Component } from '@angular/core';
2+
3+
@Component({
4+
selector: 'app-template-form',
5+
templateUrl: './template-form.component.html',
6+
standalone: false
7+
})
8+
export class TemplateFormComponent {
9+
inputValue = '';
10+
textareaValue = '';
11+
minLengthValue = '';
12+
13+
// Track if form has been submitted
14+
submitted = false;
15+
16+
onSubmit(form: any) {
17+
this.submitted = true;
18+
console.log('Form submitted:', form.value);
19+
console.log('Form valid:', form.valid);
20+
}
21+
22+
resetForm(form: any) {
23+
form.reset();
24+
this.submitted = false;
25+
}
26+
}

0 commit comments

Comments
 (0)