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>
This commit is contained in:
Shane
2025-09-04 08:11:59 -07:00
committed by GitHub
parent 49f96d7f1e
commit c339bc3682
17 changed files with 1374 additions and 17 deletions

View File

@ -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,16 @@ export class Input implements ComponentInterface {
};
}
/**
* Checks if the input is in an invalid state based on Ionic validation classes
*/
private checkInvalidState(): boolean {
const hasIonTouched = this.el.classList.contains('ion-touched');
const hasIonInvalid = this.el.classList.contains('ion-invalid');
return hasIonTouched && hasIonInvalid;
}
connectedCallback() {
const { el } = this;
@ -406,6 +423,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.checkInvalidState();
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.checkInvalidState();
this.debounceChanged();
if (Build.isBrowser) {
document.dispatchEvent(
@ -451,6 +488,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;
}
}
/**
@ -626,22 +669,22 @@ 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 [
<div id={helperTextId} class="helper-text">
{helperText}
<div id={helperTextId} class="helper-text" aria-live="polite">
{!isInvalid ? helperText : null}
</div>,
<div id={errorTextId} class="error-text">
{errorText}
<div id={errorTextId} class="error-text" role="alert">
{isInvalid ? errorText : null}
</div>,
];
}
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 +907,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 && (

View File

@ -0,0 +1,284 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Input - Validation</title>
<meta
name="viewport"
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<style>
.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;
}
.validation-info {
margin: 20px;
padding: 10px;
background: var(--ion-color-light);
border-radius: 4px;
}
</style>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Input - Validation Test</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="validation-info">
<h2>Screen Reader Testing Instructions:</h2>
<ol>
<li>Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)</li>
<li>Tab through the form fields</li>
<li>When you tab away from an empty required field, the error should be announced immediately</li>
<li>The error text should be announced BEFORE the next field is announced</li>
<li>Test in Chrome, Safari, and Firefox to verify consistent behavior</li>
</ol>
</div>
<div class="grid">
<div>
<h2>Required Email Field</h2>
<ion-input
id="email-input"
type="email"
label="Email"
label-placement="floating"
fill="outline"
placeholder="Enter your email"
helper-text="We'll never share your email"
error-text="Please enter a valid email address"
required
></ion-input>
</div>
<div>
<h2>Required Name Field</h2>
<ion-input
id="name-input"
type="text"
label="Full Name"
label-placement="floating"
fill="outline"
placeholder="Enter your full name"
helper-text="First and last name"
error-text="Name is required"
required
></ion-input>
</div>
<div>
<h2>Phone Number (Pattern Validation)</h2>
<ion-input
id="phone-input"
type="tel"
label="Phone"
label-placement="floating"
fill="outline"
placeholder="(555) 555-5555"
pattern="^\(\d{3}\) \d{3}-\d{4}$"
helper-text="Format: (555) 555-5555"
error-text="Please enter a valid phone number"
required
></ion-input>
</div>
<div>
<h2>Password (Min Length)</h2>
<ion-input
id="password-input"
type="password"
label="Password"
label-placement="floating"
fill="outline"
placeholder="Enter password"
minlength="8"
helper-text="At least 8 characters"
error-text="Password must be at least 8 characters"
required
></ion-input>
</div>
<div>
<h2>Age (Number Range)</h2>
<ion-input
id="age-input"
type="number"
label="Age"
label-placement="floating"
fill="outline"
placeholder="Enter your age"
min="18"
max="120"
helper-text="Must be 18 or older"
error-text="Please enter a valid age (18-120)"
required
></ion-input>
</div>
<div>
<h2>Optional Field (No Validation)</h2>
<ion-input
id="optional-input"
type="text"
label="Optional Info"
label-placement="floating"
fill="outline"
placeholder="This field is optional"
helper-text="You can skip this field"
></ion-input>
</div>
</div>
<div class="ion-padding">
<ion-button id="submit-btn" expand="block" disabled>Submit Form</ion-button>
<ion-button id="reset-btn" expand="block" fill="outline">Reset Form</ion-button>
</div>
</ion-content>
</ion-app>
<script>
// Simple validation logic
const inputs = document.querySelectorAll('ion-input');
const submitBtn = document.getElementById('submit-btn');
const resetBtn = document.getElementById('reset-btn');
// Track which fields have been touched
const touchedFields = new Set();
// Validation functions
const validators = {
'email-input': (value) => {
if (!value) return false;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
},
'name-input': (value) => {
return value && value.trim().length > 0;
},
'phone-input': (value) => {
if (!value) return false;
return /^\(\d{3}\) \d{3}-\d{4}$/.test(value);
},
'password-input': (value) => {
return value && value.length >= 8;
},
'age-input': (value) => {
if (!value) return false;
const age = parseInt(value);
return age >= 18 && age <= 120;
},
'optional-input': () => true, // Always valid
};
function validateField(input) {
const inputId = input.id;
const value = input.value;
const isValid = validators[inputId] ? validators[inputId](value) : true;
// Only show validation state if field has been touched
if (touchedFields.has(inputId)) {
if (isValid) {
input.classList.remove('ion-invalid');
input.classList.add('ion-valid');
} else {
input.classList.remove('ion-valid');
input.classList.add('ion-invalid');
}
input.classList.add('ion-touched');
}
return isValid;
}
function validateForm() {
let allValid = true;
inputs.forEach((input) => {
if (input.id !== 'optional-input') {
const isValid = validateField(input);
if (!isValid) {
allValid = false;
}
}
});
submitBtn.disabled = !allValid;
return allValid;
}
// Add event listeners
inputs.forEach((input) => {
// Mark as touched on blur
input.addEventListener('ionBlur', (e) => {
touchedFields.add(input.id);
validateField(input);
validateForm();
const isInvalid = input.classList.contains('ion-invalid');
if (isInvalid) {
console.log('Field marked invalid:', input.label, input.errorText);
}
});
// Validate on input
input.addEventListener('ionInput', (e) => {
if (touchedFields.has(input.id)) {
validateField(input);
validateForm();
}
});
// Also validate on focus loss via native blur
input.addEventListener('focusout', (e) => {
// Small delay to ensure Ionic's classes are updated
setTimeout(() => {
touchedFields.add(input.id);
validateField(input);
validateForm();
}, 10);
});
});
// Reset button
resetBtn.addEventListener('click', () => {
inputs.forEach((input) => {
input.value = '';
input.classList.remove('ion-valid', 'ion-invalid', 'ion-touched');
});
touchedFields.clear();
submitBtn.disabled = true;
});
// Submit button
submitBtn.addEventListener('click', () => {
if (validateForm()) {
alert('Form submitted successfully!');
}
});
// Initial setup
validateForm();
</script>
</body>
</html>

View File

@ -0,0 +1,285 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Textarea - Validation</title>
<meta
name="viewport"
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<style>
.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;
}
.validation-info {
margin: 20px;
padding: 10px;
background: var(--ion-color-light);
border-radius: 4px;
}
</style>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Textarea - Validation Test</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="validation-info">
<h2>Screen Reader Testing Instructions:</h2>
<ol>
<li>Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)</li>
<li>Tab through the form fields</li>
<li>When you tab away from an empty required field, the error should be announced immediately</li>
<li>The error text should be announced BEFORE the next field is announced</li>
<li>Test in Chrome, Safari, and Firefox to verify consistent behavior</li>
</ol>
</div>
<div class="grid">
<div>
<h2>Required Description (Min Length)</h2>
<ion-textarea
id="description-textarea"
label="Description"
label-placement="floating"
fill="outline"
placeholder="Enter a description"
rows="4"
minlength="20"
helper-text="At least 20 characters"
error-text="Description must be at least 20 characters"
required
></ion-textarea>
</div>
<div>
<h2>Required Comments</h2>
<ion-textarea
id="comments-textarea"
label="Comments"
label-placement="floating"
fill="outline"
placeholder="Enter your comments"
rows="4"
helper-text="Please provide your feedback"
error-text="Comments are required"
required
></ion-textarea>
</div>
<div>
<h2>Bio (Max Length)</h2>
<ion-textarea
id="bio-textarea"
label="Bio"
label-placement="floating"
fill="outline"
placeholder="Tell us about yourself"
rows="4"
maxlength="200"
counter="true"
helper-text="Maximum 200 characters"
error-text="Bio is required"
required
></ion-textarea>
</div>
<div>
<h2>Address (Pattern Validation)</h2>
<ion-textarea
id="address-textarea"
label="Address"
label-placement="floating"
fill="outline"
placeholder="Enter your full address"
rows="3"
helper-text="Include street, city, state, and zip"
error-text="Please enter a complete address"
required
></ion-textarea>
</div>
<div>
<h2>Review (Min/Max Length)</h2>
<ion-textarea
id="review-textarea"
label="Product Review"
label-placement="floating"
fill="outline"
placeholder="Write your review"
rows="5"
minlength="50"
maxlength="500"
counter="true"
helper-text="Between 50-500 characters"
error-text="Review must be between 50-500 characters"
required
></ion-textarea>
</div>
<div>
<h2>Optional Notes</h2>
<ion-textarea
id="notes-textarea"
label="Additional Notes"
label-placement="floating"
fill="outline"
placeholder="Any additional notes (optional)"
rows="3"
helper-text="This field is optional"
></ion-textarea>
</div>
</div>
<div class="ion-padding">
<ion-button id="submit-btn" expand="block" disabled>Submit Form</ion-button>
<ion-button id="reset-btn" expand="block" fill="outline">Reset Form</ion-button>
</div>
</ion-content>
</ion-app>
<script>
// Simple validation logic
const textareas = document.querySelectorAll('ion-textarea');
const submitBtn = document.getElementById('submit-btn');
const resetBtn = document.getElementById('reset-btn');
// Track which fields have been touched
const touchedFields = new Set();
// Validation functions
const validators = {
'description-textarea': (value) => {
return value && value.length >= 20;
},
'comments-textarea': (value) => {
return value && value.trim().length > 0;
},
'bio-textarea': (value) => {
return value && value.length > 0 && value.length <= 200;
},
'address-textarea': (value) => {
// Simple check for address - must contain at least some text with numbers
if (!value || value.length < 10) return false;
// Check if it contains at least one number (for street/zip)
return /\d/.test(value);
},
'review-textarea': (value) => {
return value && value.length >= 50 && value.length <= 500;
},
'notes-textarea': () => true, // Always valid (optional)
};
function validateField(textarea) {
const textareaId = textarea.id;
const value = textarea.value;
const isValid = validators[textareaId] ? validators[textareaId](value) : true;
// Only show validation state if field has been touched
if (touchedFields.has(textareaId)) {
if (isValid) {
textarea.classList.remove('ion-invalid');
textarea.classList.add('ion-valid');
} else {
textarea.classList.remove('ion-valid');
textarea.classList.add('ion-invalid');
}
textarea.classList.add('ion-touched');
}
return isValid;
}
function validateForm() {
let allValid = true;
textareas.forEach((textarea) => {
if (textarea.id !== 'notes-textarea') {
const isValid = validateField(textarea);
if (!isValid) {
allValid = false;
}
}
});
submitBtn.disabled = !allValid;
return allValid;
}
// Add event listeners
textareas.forEach((textarea) => {
// Mark as touched on blur
textarea.addEventListener('ionBlur', (e) => {
touchedFields.add(textarea.id);
validateField(textarea);
validateForm();
const isInvalid = textarea.classList.contains('ion-invalid');
if (isInvalid) {
console.log('Field marked invalid:', textarea.label, textarea.errorText);
}
});
// Validate on input
textarea.addEventListener('ionInput', (e) => {
if (touchedFields.has(textarea.id)) {
validateField(textarea);
validateForm();
}
});
// Also validate on focus loss via native blur
textarea.addEventListener('focusout', (e) => {
// Small delay to ensure Ionic's classes are updated
setTimeout(() => {
touchedFields.add(textarea.id);
validateField(textarea);
validateForm();
}, 10);
});
});
// Reset button
resetBtn.addEventListener('click', () => {
textareas.forEach((textarea) => {
textarea.value = '';
textarea.classList.remove('ion-valid', 'ion-invalid', 'ion-touched');
});
touchedFields.clear();
submitBtn.disabled = true;
});
// Submit button
submitBtn.addEventListener('click', () => {
if (validateForm()) {
alert('Form submitted successfully!');
}
});
// Initial setup
validateForm();
</script>
</body>
</html>

View File

@ -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,16 @@ export class Textarea implements ComponentInterface {
}
}
/**
* Checks if the textarea is in an invalid state based on Ionic validation classes
*/
private checkValidationState(): boolean {
const hasIonTouched = this.el.classList.contains('ion-touched');
const hasIonInvalid = this.el.classList.contains('ion-invalid');
return hasIonTouched && hasIonInvalid;
}
connectedCallback() {
const { el } = this;
this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => forceUpdate(this));
@ -336,6 +353,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 +402,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() {
@ -628,22 +672,22 @@ 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 [
<div id={helperTextId} class="helper-text">
{helperText}
<div id={helperTextId} class="helper-text" aria-live="polite">
{!isInvalid ? helperText : null}
</div>,
<div id={errorTextId} class="error-text">
{errorText}
<div id={errorTextId} class="error-text" role="alert">
{isInvalid ? errorText : null}
</div>,
];
}
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 +821,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}

View File

@ -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,

View File

@ -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 },

View File

@ -25,6 +25,11 @@
Form Test
</ion-label>
</ion-item>
<ion-item routerLink="/lazy/template-form">
<ion-label>
Template-Driven Form Test
</ion-label>
</ion-item>
<ion-item routerLink="/lazy/modals">
<ion-label>
Modals Test

View File

@ -0,0 +1,116 @@
<ion-header>
<ion-toolbar>
<ion-title>Template-Driven Form Validation Test</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<form #templateForm="ngForm" (ngSubmit)="onSubmit(templateForm)">
<ion-list>
<!-- Test ion-input with required validation -->
<ion-item>
<ion-input
label="Required Input"
[(ngModel)]="inputValue"
name="inputField"
required
#inputField="ngModel"
id="template-input-test"
errorText="This field is required"
helperText="Enter some text">
</ion-input>
</ion-item>
<!-- Display validation state for debugging -->
<ion-item>
<ion-label>
<p>Input Touched: <span id="input-touched">{{inputField.touched}}</span></p>
<p>Input Invalid: <span id="input-invalid">{{inputField.invalid}}</span></p>
<p>Input Errors: <span id="input-errors">{{inputField.errors | json}}</span></p>
</ion-label>
</ion-item>
<!-- Test ion-textarea with required validation -->
<ion-item>
<ion-textarea
label="Required Textarea"
[(ngModel)]="textareaValue"
name="textareaField"
required
#textareaField="ngModel"
id="template-textarea-test"
errorText="This field is required"
helperText="Enter some text"
rows="4">
</ion-textarea>
</ion-item>
<!-- Display validation state for debugging -->
<ion-item>
<ion-label>
<p>Textarea Touched: <span id="textarea-touched">{{textareaField.touched}}</span></p>
<p>Textarea Invalid: <span id="textarea-invalid">{{textareaField.invalid}}</span></p>
<p>Textarea Errors: <span id="textarea-errors">{{textareaField.errors | json}}</span></p>
</ion-label>
</ion-item>
<!-- Additional test with minlength validation -->
<ion-item>
<ion-input
label="Min Length Input (3 chars)"
[(ngModel)]="minLengthValue"
name="minLengthField"
required
minlength="3"
#minLengthField="ngModel"
id="template-minlength-test"
errorText="Minimum 3 characters required"
helperText="Enter at least 3 characters">
</ion-input>
</ion-item>
<!-- Display validation state for minlength field -->
<ion-item>
<ion-label>
<p>MinLength Touched: <span id="minlength-touched">{{minLengthField.touched}}</span></p>
<p>MinLength Invalid: <span id="minlength-invalid">{{minLengthField.invalid}}</span></p>
<p>MinLength Errors: <span id="minlength-errors">{{minLengthField.errors | json}}</span></p>
</ion-label>
</ion-item>
</ion-list>
<div class="ion-padding">
<p>Form Valid: <span id="form-valid">{{templateForm.valid}}</span></p>
<p>Form Submitted: <span id="form-submitted">{{submitted}}</span></p>
<ion-button type="submit" id="submit-button" [disabled]="!templateForm.valid">
Submit Form
</ion-button>
<ion-button type="button" id="reset-button" (click)="resetForm(templateForm)">
Reset Form
</ion-button>
<ion-button type="button" id="touch-all-button" (click)="templateForm.form.markAllAsTouched()">
Mark All as Touched
</ion-button>
</div>
<div class="ion-padding">
<h3>Form Values:</h3>
<pre id="form-values">{{templateForm.value | json}}</pre>
</div>
</form>
<div class="ion-padding">
<h3>Instructions to reproduce issue:</h3>
<ol>
<li>Click in the "Required Input" field</li>
<li>Click outside without entering text</li>
<li>The field should show as touched and invalid</li>
<li>The error text should appear below the input</li>
<li>For screen readers, the validation state should be announced</li>
</ol>
<p><strong>Note:</strong> With template-driven forms, Angular applies validation classes to the wrapper element, not directly to ion-input/ion-textarea.</p>
</div>
</ion-content>

View File

@ -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;
}
}

View File

@ -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: [

View File

@ -107,6 +107,22 @@
</ion-item>
</ion-list>
<ion-list>
<ion-list-header>
<ion-label>Validation Tests</ion-label>
</ion-list-header>
<ion-item routerLink="/standalone/validation/input-validation">
<ion-label>
Input Validation Test
</ion-label>
</ion-item>
<ion-item routerLink="/standalone/validation/textarea-validation">
<ion-label>
Textarea Validation Test
</ion-label>
</ion-item>
</ion-list>
<ion-list>
<ion-list-header>
<ion-label>Value Accessors</ion-label>

View File

@ -0,0 +1,131 @@
<ion-header>
<ion-toolbar>
<ion-title>Input - Validation Test</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="validation-info">
<h2>Screen Reader Testing Instructions:</h2>
<ol>
<li>Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)</li>
<li>Tab through the form fields</li>
<li>When you tab away from an empty required field, the error should be announced immediately</li>
<li>The error text should be announced BEFORE the next field is announced</li>
<li>Test in Chrome, Safari, and Firefox to verify consistent behavior</li>
</ol>
</div>
<form [formGroup]="form">
<div class="grid">
<div>
<h2>Required Email Field</h2>
<ion-input
#emailInput
id="email-input"
type="email"
label="Email"
labelPlacement="floating"
fill="outline"
placeholder="Enter your email"
[helperText]="fieldMetadata.email.helperText"
[errorText]="fieldMetadata.email.errorText"
formControlName="email"
required
></ion-input>
</div>
<div>
<h2>Required Name Field</h2>
<ion-input
#nameInput
id="name-input"
type="text"
label="Full Name"
labelPlacement="floating"
fill="outline"
placeholder="Enter your full name"
[helperText]="fieldMetadata.name.helperText"
[errorText]="fieldMetadata.name.errorText"
formControlName="name"
required
></ion-input>
</div>
<div>
<h2>Phone Number (Pattern Validation)</h2>
<ion-input
#phoneInput
id="phone-input"
type="tel"
label="Phone"
labelPlacement="floating"
fill="outline"
placeholder="(555) 555-5555"
pattern="^\(\d{3}\) \d{3}-\d{4}$"
[helperText]="fieldMetadata.phone.helperText"
[errorText]="fieldMetadata.phone.errorText"
formControlName="phone"
required
></ion-input>
</div>
<div>
<h2>Password (Min Length)</h2>
<ion-input
#passwordInput
id="password-input"
type="password"
label="Password"
labelPlacement="floating"
fill="outline"
placeholder="Enter password"
minlength="8"
[helperText]="fieldMetadata.password.helperText"
[errorText]="fieldMetadata.password.errorText"
formControlName="password"
required
></ion-input>
</div>
<div>
<h2>Age (Number Range)</h2>
<ion-input
#ageInput
id="age-input"
type="number"
label="Age"
labelPlacement="floating"
fill="outline"
placeholder="Enter your age"
min="18"
max="120"
[helperText]="fieldMetadata.age.helperText"
[errorText]="fieldMetadata.age.errorText"
formControlName="age"
required
></ion-input>
</div>
<div>
<h2>Optional Field (No Validation)</h2>
<ion-input
#optionalInput
id="optional-input"
type="text"
label="Optional Info"
labelPlacement="floating"
fill="outline"
placeholder="This field is optional"
[helperText]="fieldMetadata.optional.helperText"
formControlName="optional"
></ion-input>
</div>
</div>
</form>
<div class="ion-padding">
<ion-button id="submit-btn" expand="block" [disabled]="form.invalid" (click)="onSubmit()">Submit Form</ion-button>
<ion-button id="reset-btn" expand="block" fill="outline" (click)="form.reset()">Reset Form</ion-button>
</div>
</ion-content>

View File

@ -0,0 +1,36 @@
.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;
}
.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;
}

View File

@ -0,0 +1,85 @@
import { CommonModule } from '@angular/common';
import { Component } 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 {
// 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) {}
// Submit form
onSubmit(): void {
if (this.form.valid) {
alert('Form submitted successfully!');
}
}
}

View File

@ -0,0 +1,133 @@
<ion-header>
<ion-toolbar>
<ion-title>Textarea - Validation Test</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="validation-info">
<h2>Screen Reader Testing Instructions:</h2>
<ol>
<li>Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)</li>
<li>Tab through the form fields</li>
<li>When you tab away from an empty required field, the error should be announced immediately</li>
<li>The error text should be announced BEFORE the next field is announced</li>
<li>Test in Chrome, Safari, and Firefox to verify consistent behavior</li>
</ol>
</div>
<form [formGroup]="form">
<div class="grid">
<div>
<h2>Required Description (Min Length)</h2>
<ion-textarea
#descriptionTextarea
id="description-textarea"
[label]="fieldMetadata.description.label"
labelPlacement="floating"
fill="outline"
placeholder="Enter a description"
[rows]="fieldMetadata.description.rows"
minlength="20"
[helperText]="fieldMetadata.description.helperText"
[errorText]="fieldMetadata.description.errorText"
formControlName="description"
required
></ion-textarea>
</div>
<div>
<h2>Required Comments</h2>
<ion-textarea
#commentsTextarea
id="comments-textarea"
[label]="fieldMetadata.comments.label"
labelPlacement="floating"
fill="outline"
placeholder="Enter your comments"
[rows]="fieldMetadata.comments.rows"
[helperText]="fieldMetadata.comments.helperText"
[errorText]="fieldMetadata.comments.errorText"
formControlName="comments"
required
></ion-textarea>
</div>
<div>
<h2>Bio (Max Length)</h2>
<ion-textarea
#bioTextarea
id="bio-textarea"
[label]="fieldMetadata.bio.label"
labelPlacement="floating"
fill="outline"
placeholder="Tell us about yourself"
[rows]="fieldMetadata.bio.rows"
maxlength="200"
[counter]="fieldMetadata.bio.counter"
[helperText]="fieldMetadata.bio.helperText"
[errorText]="fieldMetadata.bio.errorText"
formControlName="bio"
required
></ion-textarea>
</div>
<div>
<h2>Address (Pattern Validation)</h2>
<ion-textarea
#addressTextarea
id="address-textarea"
[label]="fieldMetadata.address.label"
labelPlacement="floating"
fill="outline"
placeholder="Enter your full address"
[rows]="fieldMetadata.address.rows"
[helperText]="fieldMetadata.address.helperText"
[errorText]="fieldMetadata.address.errorText"
formControlName="address"
required
></ion-textarea>
</div>
<div>
<h2>Review (Min/Max Length)</h2>
<ion-textarea
#reviewTextarea
id="review-textarea"
[label]="fieldMetadata.review.label"
labelPlacement="floating"
fill="outline"
placeholder="Write your review"
[rows]="fieldMetadata.review.rows"
minlength="50"
maxlength="500"
[counter]="fieldMetadata.review.counter"
[helperText]="fieldMetadata.review.helperText"
[errorText]="fieldMetadata.review.errorText"
formControlName="review"
required
></ion-textarea>
</div>
<div>
<h2>Optional Notes</h2>
<ion-textarea
#notesTextarea
id="notes-textarea"
[label]="fieldMetadata.notes.label"
labelPlacement="floating"
fill="outline"
placeholder="Any additional notes (optional)"
[rows]="fieldMetadata.notes.rows"
[helperText]="fieldMetadata.notes.helperText"
formControlName="notes"
></ion-textarea>
</div>
</div>
</form>
<div class="ion-padding">
<ion-button id="submit-btn" expand="block" [disabled]="form.invalid" (click)="onSubmit()">Submit Form</ion-button>
<ion-button id="reset-btn" expand="block" fill="outline" (click)="form.reset()">Reset Form</ion-button>
</div>
</ion-content>

View File

@ -0,0 +1,36 @@
.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;
}
.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;
}

View File

@ -0,0 +1,105 @@
import { CommonModule } from '@angular/common';
import { Component } 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 {
// 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) {}
// Submit form
onSubmit(): void {
if (this.form.valid) {
alert('Form submitted successfully!');
}
}
}