diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx
index 69281c5e899..d1beb1ff2ec 100644
--- a/core/src/components/input/input.tsx
+++ b/core/src/components/input/input.tsx
@@ -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"`.
@@ -396,6 +403,24 @@ export class Input implements ComponentInterface {
};
}
+ /**
+ * Checks if the input is in an invalid state based on validation classes
+ */
+ private checkValidationState(): boolean {
+ // Check for both Ionic and Angular validation classes on the element itself
+ // Angular applies ng-touched/ng-invalid directly to the host element with ngModel
+ const hasIonTouched = this.el.classList.contains('ion-touched');
+ const hasIonInvalid = this.el.classList.contains('ion-invalid');
+ const hasNgTouched = this.el.classList.contains('ng-touched');
+ const hasNgInvalid = this.el.classList.contains('ng-invalid');
+
+ // Return true if we have both touched and invalid states from either framework
+ const isTouched = hasIonTouched || hasNgTouched;
+ const isInvalid = hasIonInvalid || hasNgInvalid;
+
+ return isTouched && isInvalid;
+ }
+
connectedCallback() {
const { el } = this;
@@ -406,6 +431,26 @@ 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;
+ // Force a re-render to update aria-describedby immediately
+ forceUpdate(this);
+ }
+ });
+
+ this.validationObserver.observe(el, {
+ attributes: true,
+ attributeFilter: ['class'],
+ });
+ }
+
+ // Always set initial state
+ this.isInvalid = this.checkValidationState();
+
this.debounceChanged();
if (Build.isBrowser) {
document.dispatchEvent(
@@ -451,6 +496,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;
+ }
}
/**
@@ -549,6 +600,20 @@ export class Input implements ComponentInterface {
this.didInputClearOnEdit = false;
this.ionBlur.emit(ev);
+
+ /**
+ * Check validation state after blur to handle framework-managed classes.
+ * Frameworks like Angular update classes asynchronously, often using
+ * requestAnimationFrame or promises. Using setTimeout ensures we check
+ * after all microtasks and animation frames have completed.
+ */
+ setTimeout(() => {
+ const newIsInvalid = this.checkValidationState();
+ if (this.isInvalid !== newIsInvalid) {
+ this.isInvalid = newIsInvalid;
+ forceUpdate(this);
+ }
+ }, 100);
};
private onFocus = (ev: FocusEvent) => {
@@ -626,22 +691,33 @@ export class Input implements ComponentInterface {
* Renders the helper text or error text values
*/
private renderHintText() {
- const { helperText, errorText, helperTextId, errorTextId } = this;
+ const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
return [
-
- {helperText}
-
,
-
- {errorText}
-
,
+ helperText && (
+
+ {helperText}
+
+ ),
+ errorText && (
+
+ {errorText}
+
+ ),
];
}
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;
}
@@ -864,7 +940,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 && (
diff --git a/core/src/components/input/test/input.spec.ts b/core/src/components/input/test/input.spec.ts
index af9faac9f3c..4275325d23a 100644
--- a/core/src/components/input/test/input.spec.ts
+++ b/core/src/components/input/test/input.spec.ts
@@ -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: ``,
+ });
+
+ 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: ``,
+ });
+
+ 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: ``,
+ });
+
+ 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: ``,
+ });
+
+ 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: ``,
+ });
+
+ 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: ``,
+ });
+
+ 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');
+ });
+});
diff --git a/core/src/components/input/test/validation/index.html b/core/src/components/input/test/validation/index.html
new file mode 100644
index 00000000000..8fffb0f8080
--- /dev/null
+++ b/core/src/components/input/test/validation/index.html
@@ -0,0 +1,299 @@
+
+
+
+
+ Input - Validation
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Input - Validation Test
+
+
+
+
+
+
Screen Reader Testing Instructions:
+
+ - Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)
+ - Tab through the form fields
+ - When you tab away from an empty required field, the error should be announced immediately
+ - The error text should be announced BEFORE the next field is announced
+ - Test in Chrome, Safari, and Firefox to verify consistent behavior
+
+
+
+
+
+
Required Email Field
+
+
+
+
+
Required Name Field
+
+
+
+
+
Phone Number (Pattern Validation)
+
+
+
+
+
Password (Min Length)
+
+
+
+
+
Age (Number Range)
+
+
+
+
+
Optional Field (No Validation)
+
+
+
+
+
+ Submit Form
+ Reset Form
+
+
+
+
+
+
+
+
+
+
diff --git a/core/src/components/textarea/test/textarea.spec.ts b/core/src/components/textarea/test/textarea.spec.ts
index f1611a3e291..75f376719d9 100644
--- a/core/src/components/textarea/test/textarea.spec.ts
+++ b/core/src/components/textarea/test/textarea.spec.ts
@@ -85,3 +85,89 @@ describe('textarea: label rendering', () => {
expect(labelText.textContent).toBe('Label Prop Text');
});
});
+
+// Accessibility tests for error text announcements to screen readers
+describe('textarea: error text accessibility', () => {
+ it('should have error text element with proper structure', async () => {
+ const page = await newSpecPage({
+ components: [Textarea],
+ html: ``,
+ });
+
+ const errorTextEl = page.body.querySelector('ion-textarea .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 textarea is invalid', async () => {
+ const page = await newSpecPage({
+ components: [Textarea],
+ html: ``,
+ });
+
+ const nativeTextarea = page.body.querySelector('ion-textarea textarea')!;
+
+ // Should be invalid because of the classes
+ expect(nativeTextarea.getAttribute('aria-invalid')).toBe('true');
+ });
+
+ it('should set aria-describedby to error text when invalid', async () => {
+ const page = await newSpecPage({
+ components: [Textarea],
+ html: ``,
+ });
+
+ const nativeTextarea = page.body.querySelector('ion-textarea textarea')!;
+ const errorTextEl = page.body.querySelector('ion-textarea .error-text')!;
+
+ // Verify aria-describedby points to error text
+ const errorId = errorTextEl.getAttribute('id');
+ expect(nativeTextarea.getAttribute('aria-describedby')).toBe(errorId);
+ });
+
+ it('should set aria-describedby to helper text when valid', async () => {
+ const page = await newSpecPage({
+ components: [Textarea],
+ html: ``,
+ });
+
+ const nativeTextarea = page.body.querySelector('ion-textarea textarea')!;
+ const helperTextEl = page.body.querySelector('ion-textarea .helper-text')!;
+
+ // When not invalid, should point to helper text
+ const helperId = helperTextEl.getAttribute('id');
+ expect(nativeTextarea.getAttribute('aria-describedby')).toBe(helperId);
+ expect(nativeTextarea.getAttribute('aria-invalid')).toBeNull();
+ });
+
+ it('should have helper text element with proper structure', async () => {
+ const page = await newSpecPage({
+ components: [Textarea],
+ html: ``,
+ });
+
+ const helperTextEl = page.body.querySelector('ion-textarea .helper-text');
+ expect(helperTextEl).not.toBe(null);
+ expect(helperTextEl!.getAttribute('id')).toContain('helper-text');
+ expect(helperTextEl!.textContent).toBe('Enter your comments');
+ });
+
+ it('should maintain error text content when error text changes dynamically', async () => {
+ const page = await newSpecPage({
+ components: [Textarea],
+ html: ``,
+ });
+
+ const textarea = page.body.querySelector('ion-textarea')!;
+
+ // Add error text dynamically
+ textarea.setAttribute('error-text', 'Invalid content');
+ await page.waitForChanges();
+
+ const errorTextEl = page.body.querySelector('ion-textarea .error-text');
+ expect(errorTextEl).not.toBe(null);
+ expect(errorTextEl!.getAttribute('id')).toContain('error-text');
+ expect(errorTextEl!.textContent).toBe('Invalid content');
+ });
+});
diff --git a/core/src/components/textarea/test/validation/index.html b/core/src/components/textarea/test/validation/index.html
new file mode 100644
index 00000000000..0c12b879f75
--- /dev/null
+++ b/core/src/components/textarea/test/validation/index.html
@@ -0,0 +1,300 @@
+
+
+
+
+ Textarea - Validation
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Textarea - Validation Test
+
+
+
+
+
+
Screen Reader Testing Instructions:
+
+ - Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)
+ - Tab through the form fields
+ - When you tab away from an empty required field, the error should be announced immediately
+ - The error text should be announced BEFORE the next field is announced
+ - Test in Chrome, Safari, and Firefox to verify consistent behavior
+
+
+
+
+
+
Required Description (Min Length)
+
+
+
+
+
Required Comments
+
+
+
+
+
Bio (Max Length)
+
+
+
+
+
Address (Pattern Validation)
+
+
+
+
+
Review (Min/Max Length)
+
+
+
+
+
Optional Notes
+
+
+
+
+
+ Submit Form
+ Reset Form
+
+
+
+
+
+
+
+
+
+
diff --git a/core/src/components/textarea/textarea.tsx b/core/src/components/textarea/textarea.tsx
index 64ff00c9225..627338b191f 100644
--- a/core/src/components/textarea/textarea.tsx
+++ b/core/src/components/textarea/textarea.tsx
@@ -81,6 +81,13 @@ export class Textarea implements ComponentInterface {
*/
@State() hasFocus = false;
+ /**
+ * Track validation state for proper aria-live announcements
+ */
+ @State() isInvalid = false;
+
+ 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"`.
@@ -328,6 +335,24 @@ export class Textarea implements ComponentInterface {
}
}
+ /**
+ * Checks if the textarea is in an invalid state based on validation classes
+ */
+ private checkValidationState(): boolean {
+ // Check for both Ionic and Angular validation classes on the element itself
+ // Angular applies ng-touched/ng-invalid directly to the host element with ngModel
+ const hasIonTouched = this.el.classList.contains('ion-touched');
+ const hasIonInvalid = this.el.classList.contains('ion-invalid');
+ const hasNgTouched = this.el.classList.contains('ng-touched');
+ const hasNgInvalid = this.el.classList.contains('ng-invalid');
+
+ // Return true if we have both touched and invalid states from either framework
+ const isTouched = hasIonTouched || hasNgTouched;
+ const isInvalid = hasIonInvalid || hasNgInvalid;
+
+ return isTouched && isInvalid;
+ }
+
connectedCallback() {
const { el } = this;
this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => forceUpdate(this));
@@ -336,6 +361,27 @@ export class Textarea implements ComponentInterface {
() => this.notchSpacerEl,
() => 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;
+ // Force a re-render to update aria-describedby immediately
+ forceUpdate(this);
+ }
+ });
+
+ this.validationObserver.observe(el, {
+ attributes: true,
+ attributeFilter: ['class'],
+ });
+ }
+
+ // Always set initial state
+ this.isInvalid = this.checkValidationState();
+
this.debounceChanged();
if (Build.isBrowser) {
document.dispatchEvent(
@@ -364,6 +410,12 @@ export class Textarea 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;
+ }
}
componentWillLoad() {
@@ -533,6 +585,20 @@ export class Textarea implements ComponentInterface {
}
this.didTextareaClearOnEdit = false;
this.ionBlur.emit(ev);
+
+ /**
+ * Check validation state after blur to handle framework-managed classes.
+ * Frameworks like Angular update classes asynchronously, often using
+ * requestAnimationFrame or promises. Using setTimeout ensures we check
+ * after all microtasks and animation frames have completed.
+ */
+ setTimeout(() => {
+ const newIsInvalid = this.checkValidationState();
+ if (this.isInvalid !== newIsInvalid) {
+ this.isInvalid = newIsInvalid;
+ forceUpdate(this);
+ }
+ }, 100);
};
private onKeyDown = (ev: KeyboardEvent) => {
@@ -628,22 +694,33 @@ export class Textarea implements ComponentInterface {
* Renders the helper text or error text values
*/
private renderHintText() {
- const { helperText, errorText, helperTextId, errorTextId } = this;
+ const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
return [
-
- {helperText}
-
,
-
- {errorText}
-
,
+ helperText && (
+
+ {helperText}
+
+ ),
+ errorText && (
+
+ {errorText}
+
+ ),
];
}
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;
}
@@ -777,7 +854,7 @@ export class Textarea implements ComponentInterface {
onFocus={this.onFocus}
onKeyDown={this.onKeyDown}
aria-describedby={this.getHintTextID()}
- aria-invalid={this.getHintTextID() === this.errorTextId}
+ aria-invalid={this.isInvalid ? 'true' : undefined}
{...this.inheritedAttributes}
>
{value}
diff --git a/packages/angular/test/base/src/app/lazy/app-lazy/app.module.ts b/packages/angular/test/base/src/app/lazy/app-lazy/app.module.ts
index caf27670d2d..ac0ebd501fb 100644
--- a/packages/angular/test/base/src/app/lazy/app-lazy/app.module.ts
+++ b/packages/angular/test/base/src/app/lazy/app-lazy/app.module.ts
@@ -28,6 +28,7 @@ import { AlertComponent } from '../alert/alert.component';
import { AccordionComponent } from '../accordion/accordion.component';
import { AccordionModalComponent } from '../accordion/accordion-modal/accordion-modal.component';
import { TabsBasicComponent } from '../tabs-basic/tabs-basic.component';
+import { TemplateFormComponent } from '../template-form/template-form.component';
@NgModule({
declarations: [
@@ -53,7 +54,8 @@ import { TabsBasicComponent } from '../tabs-basic/tabs-basic.component';
AlertComponent,
AccordionComponent,
AccordionModalComponent,
- TabsBasicComponent
+ TabsBasicComponent,
+ TemplateFormComponent
],
imports: [
CommonModule,
diff --git a/packages/angular/test/base/src/app/lazy/app-lazy/app.routes.ts b/packages/angular/test/base/src/app/lazy/app-lazy/app.routes.ts
index 0e15ea2867d..1a46992f92c 100644
--- a/packages/angular/test/base/src/app/lazy/app-lazy/app.routes.ts
+++ b/packages/angular/test/base/src/app/lazy/app-lazy/app.routes.ts
@@ -19,6 +19,7 @@ import { NavigationPage3Component } from '../navigation-page3/navigation-page3.c
import { AlertComponent } from '../alert/alert.component';
import { AccordionComponent } from '../accordion/accordion.component';
import { TabsBasicComponent } from '../tabs-basic/tabs-basic.component';
+import { TemplateFormComponent } from '../template-form/template-form.component';
export const routes: Routes = [
{
@@ -33,6 +34,7 @@ export const routes: Routes = [
{ path: 'textarea', loadChildren: () => import('../textarea/textarea.module').then(m => m.TextareaModule) },
{ path: 'searchbar', loadChildren: () => import('../searchbar/searchbar.module').then(m => m.SearchbarModule) },
{ path: 'form', component: FormComponent },
+ { path: 'template-form', component: TemplateFormComponent },
{ path: 'modals', component: ModalComponent },
{ path: 'modal-inline', loadChildren: () => import('../modal-inline').then(m => m.ModalInlineModule) },
{ path: 'view-child', component: ViewChildComponent },
diff --git a/packages/angular/test/base/src/app/lazy/home-page/home-page.component.html b/packages/angular/test/base/src/app/lazy/home-page/home-page.component.html
index 80418148c5e..136a0119d34 100644
--- a/packages/angular/test/base/src/app/lazy/home-page/home-page.component.html
+++ b/packages/angular/test/base/src/app/lazy/home-page/home-page.component.html
@@ -25,6 +25,11 @@
Form Test
+
+
+ Template-Driven Form Test
+
+
Modals Test
diff --git a/packages/angular/test/base/src/app/lazy/template-form/template-form.component.html b/packages/angular/test/base/src/app/lazy/template-form/template-form.component.html
new file mode 100644
index 00000000000..d33aa4ae1e5
--- /dev/null
+++ b/packages/angular/test/base/src/app/lazy/template-form/template-form.component.html
@@ -0,0 +1,116 @@
+
+
+ Template-Driven Form Validation Test
+
+
+
+
+
+
+
+
Instructions to reproduce issue:
+
+ - Click in the "Required Input" field
+ - Click outside without entering text
+ - The field should show as touched and invalid
+ - The error text should appear below the input
+ - For screen readers, the validation state should be announced
+
+
Note: With template-driven forms, Angular applies validation classes to the wrapper element, not directly to ion-input/ion-textarea.
+
+
diff --git a/packages/angular/test/base/src/app/lazy/template-form/template-form.component.ts b/packages/angular/test/base/src/app/lazy/template-form/template-form.component.ts
new file mode 100644
index 00000000000..1ecdaa5e5d0
--- /dev/null
+++ b/packages/angular/test/base/src/app/lazy/template-form/template-form.component.ts
@@ -0,0 +1,26 @@
+import { Component } from '@angular/core';
+
+@Component({
+ selector: 'app-template-form',
+ templateUrl: './template-form.component.html',
+ standalone: false
+})
+export class TemplateFormComponent {
+ inputValue = '';
+ textareaValue = '';
+ minLengthValue = '';
+
+ // Track if form has been submitted
+ submitted = false;
+
+ onSubmit(form: any) {
+ this.submitted = true;
+ console.log('Form submitted:', form.value);
+ console.log('Form valid:', form.valid);
+ }
+
+ resetForm(form: any) {
+ form.reset();
+ this.submitted = false;
+ }
+}
diff --git a/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts b/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts
index ae6ee66193c..fafb69c62ad 100644
--- a/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts
+++ b/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts
@@ -40,6 +40,14 @@ export const routes: Routes = [
]
},
{ path: 'tabs-basic', loadComponent: () => import('../tabs-basic/tabs-basic.component').then(c => c.TabsBasicComponent) },
+ {
+ path: 'validation',
+ children: [
+ { path: 'input-validation', loadComponent: () => import('../validation/input-validation/input-validation.component').then(c => c.InputValidationComponent) },
+ { path: 'textarea-validation', loadComponent: () => import('../validation/textarea-validation/textarea-validation.component').then(c => c.TextareaValidationComponent) },
+ { path: '**', redirectTo: 'input-validation' }
+ ]
+ },
{
path: 'value-accessors',
children: [
diff --git a/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html b/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html
index 163e438d42c..7900bdfb64e 100644
--- a/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html
+++ b/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html
@@ -107,6 +107,22 @@
+
+
+ Validation Tests
+
+
+
+ Input Validation Test
+
+
+
+
+ Textarea Validation Test
+
+
+
+
Value Accessors
diff --git a/packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.html b/packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.html
new file mode 100644
index 00000000000..bf7fed90f32
--- /dev/null
+++ b/packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.html
@@ -0,0 +1,170 @@
+
+
+ Input - Validation Test
+
+
+
+
+
+
Screen Reader Testing Instructions:
+
+ - Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)
+ - Tab through the form fields
+ - When you tab away from an empty required field, the error should be announced immediately
+ - The error text should be announced BEFORE the next field is announced
+ - Test in Chrome, Safari, and Firefox to verify consistent behavior
+
+
+
+
+
+
+ Submit Form
+ Reset Form
+
+
+
+
+
diff --git a/packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.scss b/packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.scss
new file mode 100644
index 00000000000..abf7b3d12d7
--- /dev/null
+++ b/packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.scss
@@ -0,0 +1,44 @@
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
+ grid-row-gap: 20px;
+ grid-column-gap: 20px;
+}
+
+h2 {
+ font-size: 12px;
+ font-weight: normal;
+ color: var(--ion-color-step-600);
+ margin-top: 10px;
+ margin-bottom: 5px;
+}
+
+.aria-live-region {
+ position: absolute;
+ left: -10000px;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+}
+
+.validation-info {
+ margin: 20px;
+ padding: 10px;
+ background: var(--ion-color-light);
+ border-radius: 4px;
+}
+
+.validation-info h2 {
+ font-size: 14px;
+ font-weight: 600;
+ margin-bottom: 10px;
+}
+
+.validation-info ol {
+ margin: 0;
+ padding-left: 20px;
+}
+
+.validation-info li {
+ margin-bottom: 5px;
+}
diff --git a/packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.ts b/packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.ts
new file mode 100644
index 00000000000..07893fa93d5
--- /dev/null
+++ b/packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.ts
@@ -0,0 +1,196 @@
+import { CommonModule } from '@angular/common';
+import { Component, ElementRef, ViewChild } from '@angular/core';
+import {
+ FormBuilder,
+ ReactiveFormsModule,
+ Validators
+} from '@angular/forms';
+import {
+ IonButton,
+ IonContent,
+ IonHeader,
+ IonInput,
+ IonTitle,
+ IonToolbar
+} from '@ionic/angular/standalone';
+
+@Component({
+ selector: 'app-input-validation',
+ templateUrl: './input-validation.component.html',
+ styleUrls: ['./input-validation.component.scss'],
+ standalone: true,
+ imports: [
+ CommonModule,
+ ReactiveFormsModule,
+ IonInput,
+ IonButton,
+ IonHeader,
+ IonToolbar,
+ IonTitle,
+ IonContent
+ ]
+})
+export class InputValidationComponent {
+ @ViewChild('debugRegion', { static: true }) debugRegion?: ElementRef;
+
+ // Track which fields have been touched (using Set like vanilla test)
+ touchedFields = new Set();
+
+ // Field metadata for labels and error messages
+ fieldMetadata = {
+ email: {
+ label: 'Email',
+ helperText: "We'll never share your email",
+ errorText: 'Please enter a valid email address'
+ },
+ name: {
+ label: 'Full Name',
+ helperText: 'First and last name',
+ errorText: 'Name is required'
+ },
+ phone: {
+ label: 'Phone',
+ helperText: 'Format: (555) 555-5555',
+ errorText: 'Please enter a valid phone number'
+ },
+ password: {
+ label: 'Password',
+ helperText: 'At least 8 characters',
+ errorText: 'Password must be at least 8 characters'
+ },
+ age: {
+ label: 'Age',
+ helperText: 'Must be 18 or older',
+ errorText: 'Please enter a valid age (18-120)'
+ },
+ optional: {
+ label: 'Optional Info',
+ helperText: 'You can skip this field',
+ errorText: ''
+ }
+ };
+
+ form = this.fb.group({
+ email: ['', [Validators.required, Validators.email]],
+ name: ['', Validators.required],
+ phone: ['', [Validators.required, Validators.pattern(/^\(\d{3}\) \d{3}-\d{4}$/)]],
+ password: ['', [Validators.required, Validators.minLength(8)]],
+ age: ['', [Validators.required, Validators.min(18), Validators.max(120)]],
+ optional: ['']
+ });
+
+ constructor(private fb: FormBuilder) {}
+
+ // Check if a field has been touched
+ isTouched(fieldName: string): boolean {
+ return this.touchedFields.has(fieldName);
+ }
+
+ // Check if a field is invalid
+ isInvalid(fieldName: string): boolean {
+ const control = this.form.get(fieldName);
+ return !!(control && control.invalid && this.isTouched(fieldName));
+ }
+
+ // Check if a field is valid
+ isValid(fieldName: string): boolean {
+ const control = this.form.get(fieldName);
+ return !!(control && control.valid && this.isTouched(fieldName));
+ }
+
+ // Mark a field as touched
+ markTouched(fieldName: string): void {
+ this.touchedFields.add(fieldName);
+ }
+
+ // Handle blur event
+ onIonBlur(fieldName: string, inputElement: IonInput): void {
+ this.markTouched(fieldName);
+ this.updateValidationClasses(fieldName, inputElement);
+
+ // Update aria-live region if invalid
+ if (this.isInvalid(fieldName) && this.debugRegion) {
+ const metadata = this.fieldMetadata[fieldName as keyof typeof this.fieldMetadata];
+ this.debugRegion.nativeElement.textContent =
+ `Field ${metadata.label} is invalid: ${metadata.errorText}`;
+ console.log('Field marked invalid:', metadata.label, metadata.errorText);
+ }
+ }
+
+ // Handle input event
+ onIonInput(fieldName: string, inputElement: IonInput): void {
+ if (this.isTouched(fieldName)) {
+ this.updateValidationClasses(fieldName, inputElement);
+ }
+ }
+
+ // Handle focusout event (with timeout to match vanilla test)
+ onFocusOut(fieldName: string, inputElement: IonInput): void {
+ setTimeout(() => {
+ this.markTouched(fieldName);
+ this.updateValidationClasses(fieldName, inputElement);
+ }, 10);
+ }
+
+ // Update validation classes on the input element
+ private updateValidationClasses(fieldName: string, inputElement: IonInput): void {
+ // Access the native element through the Angular component
+ const element = (inputElement as any).el || (inputElement as any).nativeElement;
+
+ // Ensure we have a valid element with classList
+ if (!element || !element.classList) {
+ console.warn('Could not access native element for validation classes');
+ return;
+ }
+
+ if (this.isTouched(fieldName)) {
+ // Add ion-touched class
+ element.classList.add('ion-touched');
+
+ // Update ion-valid/ion-invalid classes
+ if (this.isInvalid(fieldName)) {
+ element.classList.remove('ion-valid');
+ element.classList.add('ion-invalid');
+ } else if (this.isValid(fieldName)) {
+ element.classList.remove('ion-invalid');
+ element.classList.add('ion-valid');
+ }
+ }
+ }
+
+ // Check if form is valid (excluding optional field)
+ isFormValid(): boolean {
+ const requiredFields = ['email', 'name', 'phone', 'password', 'age'];
+ return requiredFields.every(field => {
+ const control = this.form.get(field);
+ return control && control.valid;
+ });
+ }
+
+ // Submit form
+ onSubmit(): void {
+ if (this.isFormValid()) {
+ alert('Form submitted successfully!');
+ }
+ }
+
+ // Reset form
+ onReset(): void {
+ // Reset form values
+ this.form.reset();
+
+ // Clear touched fields
+ this.touchedFields.clear();
+
+ // Remove validation classes from all inputs
+ const inputs = document.querySelectorAll('ion-input');
+ inputs.forEach(input => {
+ input.classList.remove('ion-valid', 'ion-invalid', 'ion-touched');
+ });
+
+ // Clear aria-live region
+ if (this.debugRegion) {
+ this.debugRegion.nativeElement.textContent = '';
+ }
+ }
+}
diff --git a/packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.html b/packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.html
new file mode 100644
index 00000000000..61a41d9b593
--- /dev/null
+++ b/packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.html
@@ -0,0 +1,172 @@
+
+
+ Textarea - Validation Test
+
+
+
+
+
+
Screen Reader Testing Instructions:
+
+ - Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)
+ - Tab through the form fields
+ - When you tab away from an empty required field, the error should be announced immediately
+ - The error text should be announced BEFORE the next field is announced
+ - Test in Chrome, Safari, and Firefox to verify consistent behavior
+
+
+
+
+
+
+ Submit Form
+ Reset Form
+
+
+
+
+
diff --git a/packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.scss b/packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.scss
new file mode 100644
index 00000000000..8c0400b3756
--- /dev/null
+++ b/packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.scss
@@ -0,0 +1,44 @@
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ grid-row-gap: 20px;
+ grid-column-gap: 20px;
+}
+
+h2 {
+ font-size: 12px;
+ font-weight: normal;
+ color: var(--ion-color-step-600);
+ margin-top: 10px;
+ margin-bottom: 5px;
+}
+
+.aria-live-region {
+ position: absolute;
+ left: -10000px;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+}
+
+.validation-info {
+ margin: 20px;
+ padding: 10px;
+ background: var(--ion-color-light);
+ border-radius: 4px;
+}
+
+.validation-info h2 {
+ font-size: 14px;
+ font-weight: 600;
+ margin-bottom: 10px;
+}
+
+.validation-info ol {
+ margin: 0;
+ padding-left: 20px;
+}
+
+.validation-info li {
+ margin-bottom: 5px;
+}
diff --git a/packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.ts b/packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.ts
new file mode 100644
index 00000000000..3de365abf84
--- /dev/null
+++ b/packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.ts
@@ -0,0 +1,216 @@
+import { CommonModule } from '@angular/common';
+import { Component, ElementRef, ViewChild } from '@angular/core';
+import {
+ AbstractControl,
+ FormBuilder,
+ ReactiveFormsModule,
+ ValidationErrors,
+ Validators
+} from '@angular/forms';
+import {
+ IonButton,
+ IonContent,
+ IonHeader,
+ IonTextarea,
+ IonTitle,
+ IonToolbar
+} from '@ionic/angular/standalone';
+
+// Custom validator for address (must be at least 10 chars and contain a digit)
+function addressValidator(control: AbstractControl): ValidationErrors | null {
+ const value = control.value;
+ if (!value || value.length < 10) {
+ return { invalidAddress: true };
+ }
+ // Check if it contains at least one number (for street/zip)
+ return /\d/.test(value) ? null : { invalidAddress: true };
+}
+
+@Component({
+ selector: 'app-textarea-validation',
+ templateUrl: './textarea-validation.component.html',
+ styleUrls: ['./textarea-validation.component.scss'],
+ standalone: true,
+ imports: [
+ CommonModule,
+ ReactiveFormsModule,
+ IonTextarea,
+ IonButton,
+ IonHeader,
+ IonToolbar,
+ IonTitle,
+ IonContent
+ ]
+})
+export class TextareaValidationComponent {
+ @ViewChild('debugRegion', { static: true }) debugRegion?: ElementRef;
+
+ // Track which fields have been touched (using Set like vanilla test)
+ touchedFields = new Set();
+
+ // Field metadata for labels and error messages
+ fieldMetadata = {
+ description: {
+ label: 'Description',
+ helperText: 'At least 20 characters',
+ errorText: 'Description must be at least 20 characters',
+ rows: 4
+ },
+ comments: {
+ label: 'Comments',
+ helperText: 'Please provide your feedback',
+ errorText: 'Comments are required',
+ rows: 4
+ },
+ bio: {
+ label: 'Bio',
+ helperText: 'Maximum 200 characters',
+ errorText: 'Bio is required',
+ rows: 4,
+ counter: true
+ },
+ address: {
+ label: 'Address',
+ helperText: 'Include street, city, state, and zip',
+ errorText: 'Please enter a complete address',
+ rows: 3
+ },
+ review: {
+ label: 'Product Review',
+ helperText: 'Between 50-500 characters',
+ errorText: 'Review must be between 50-500 characters',
+ rows: 5,
+ counter: true
+ },
+ notes: {
+ label: 'Additional Notes',
+ helperText: 'This field is optional',
+ errorText: '',
+ rows: 3
+ }
+ };
+
+ form = this.fb.group({
+ description: ['', [Validators.required, Validators.minLength(20)]],
+ comments: ['', Validators.required],
+ bio: ['', [Validators.required, Validators.maxLength(200)]],
+ address: ['', [Validators.required, addressValidator]],
+ review: ['', [Validators.required, Validators.minLength(50), Validators.maxLength(500)]],
+ notes: ['']
+ });
+
+ constructor(private fb: FormBuilder) {}
+
+ // Check if a field has been touched
+ isTouched(fieldName: string): boolean {
+ return this.touchedFields.has(fieldName);
+ }
+
+ // Check if a field is invalid
+ isInvalid(fieldName: string): boolean {
+ const control = this.form.get(fieldName);
+ return !!(control && control.invalid && this.isTouched(fieldName));
+ }
+
+ // Check if a field is valid
+ isValid(fieldName: string): boolean {
+ const control = this.form.get(fieldName);
+ return !!(control && control.valid && this.isTouched(fieldName));
+ }
+
+ // Mark a field as touched
+ markTouched(fieldName: string): void {
+ this.touchedFields.add(fieldName);
+ }
+
+ // Handle blur event
+ onIonBlur(fieldName: string, textareaElement: IonTextarea): void {
+ this.markTouched(fieldName);
+ this.updateValidationClasses(fieldName, textareaElement);
+
+ // Update aria-live region if invalid
+ if (this.isInvalid(fieldName) && this.debugRegion) {
+ const metadata = this.fieldMetadata[fieldName as keyof typeof this.fieldMetadata];
+ this.debugRegion.nativeElement.textContent =
+ `Field ${metadata.label} is invalid: ${metadata.errorText}`;
+ console.log('Field marked invalid:', metadata.label, metadata.errorText);
+ }
+ }
+
+ // Handle input event
+ onIonInput(fieldName: string, textareaElement: IonTextarea): void {
+ if (this.isTouched(fieldName)) {
+ this.updateValidationClasses(fieldName, textareaElement);
+ }
+ }
+
+ // Handle focusout event (with timeout to match vanilla test)
+ onFocusOut(fieldName: string, textareaElement: IonTextarea): void {
+ setTimeout(() => {
+ this.markTouched(fieldName);
+ this.updateValidationClasses(fieldName, textareaElement);
+ }, 10);
+ }
+
+ // Update validation classes on the textarea element
+ private updateValidationClasses(fieldName: string, textareaElement: IonTextarea): void {
+ // Access the native element through the Angular component
+ const element = (textareaElement as any).el || (textareaElement as any).nativeElement;
+
+ // Ensure we have a valid element with classList
+ if (!element || !element.classList) {
+ console.warn('Could not access native element for validation classes');
+ return;
+ }
+
+ if (this.isTouched(fieldName)) {
+ // Add ion-touched class
+ element.classList.add('ion-touched');
+
+ // Update ion-valid/ion-invalid classes
+ if (this.isInvalid(fieldName)) {
+ element.classList.remove('ion-valid');
+ element.classList.add('ion-invalid');
+ } else if (this.isValid(fieldName)) {
+ element.classList.remove('ion-invalid');
+ element.classList.add('ion-valid');
+ }
+ }
+ }
+
+ // Check if form is valid (excluding optional field)
+ isFormValid(): boolean {
+ const requiredFields = ['description', 'comments', 'bio', 'address', 'review'];
+ return requiredFields.every(field => {
+ const control = this.form.get(field);
+ return control && control.valid;
+ });
+ }
+
+ // Submit form
+ onSubmit(): void {
+ if (this.isFormValid()) {
+ alert('Form submitted successfully!');
+ }
+ }
+
+ // Reset form
+ onReset(): void {
+ // Reset form values
+ this.form.reset();
+
+ // Clear touched fields
+ this.touchedFields.clear();
+
+ // Remove validation classes from all textareas
+ const textareas = document.querySelectorAll('ion-textarea');
+ textareas.forEach(textarea => {
+ textarea.classList.remove('ion-valid', 'ion-invalid', 'ion-touched');
+ });
+
+ // Clear aria-live region
+ if (this.debugRegion) {
+ this.debugRegion.nativeElement.textContent = '';
+ }
+ }
+}