diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index 69281c5e899..829f2e34df6 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,16 @@ export class Input implements ComponentInterface { }; } + /** + * Checks if the input 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; @@ -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.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 +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 [ -
- {helperText} +
+ {!isInvalid ? helperText : null}
, -
- {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 +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 && ( 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..556ba15545f --- /dev/null +++ b/core/src/components/input/test/validation/index.html @@ -0,0 +1,292 @@ + + + + + Input - Validation + + + + + + + + + + + + + + Input - Validation Test + + + + +
+

Screen Reader Testing Instructions:

+
    +
  1. Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)
  2. +
  3. Tab through the form fields
  4. +
  5. When you tab away from an empty required field, the error should be announced immediately
  6. +
  7. The error text should be announced BEFORE the next field is announced
  8. +
  9. Test in Chrome, Safari, and Firefox to verify consistent behavior
  10. +
+
+ +
+
+

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/validation/index.html b/core/src/components/textarea/test/validation/index.html new file mode 100644 index 00000000000..3c832e7979e --- /dev/null +++ b/core/src/components/textarea/test/validation/index.html @@ -0,0 +1,293 @@ + + + + + Textarea - Validation + + + + + + + + + + + + + + Textarea - Validation Test + + + + +
+

Screen Reader Testing Instructions:

+
    +
  1. Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)
  2. +
  3. Tab through the form fields
  4. +
  5. When you tab away from an empty required field, the error should be announced immediately
  6. +
  7. The error text should be announced BEFORE the next field is announced
  8. +
  9. Test in Chrome, Safari, and Firefox to verify consistent behavior
  10. +
+
+ +
+
+

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..83c1b91c2e4 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,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 [ -
- {helperText} +
+ {!isInvalid ? helperText : null}
, -
- {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 +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} 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 + + + + +
+ + + + + + + + + + +

Input Touched: {{inputField.touched}}

+

Input Invalid: {{inputField.invalid}}

+

Input Errors: {{inputField.errors | json}}

+
+
+ + + + + + + + + + +

Textarea Touched: {{textareaField.touched}}

+

Textarea Invalid: {{textareaField.invalid}}

+

Textarea Errors: {{textareaField.errors | json}}

+
+
+ + + + + + + + + + +

MinLength Touched: {{minLengthField.touched}}

+

MinLength Invalid: {{minLengthField.invalid}}

+

MinLength Errors: {{minLengthField.errors | json}}

+
+
+
+ +
+

Form Valid: {{templateForm.valid}}

+

Form Submitted: {{submitted}}

+ + + Submit Form + + + + Reset Form + + + + Mark All as Touched + +
+ +
+

Form Values:

+
{{templateForm.value | json}}
+
+
+ +
+

Instructions to reproduce issue:

+
    +
  1. Click in the "Required Input" field
  2. +
  3. Click outside without entering text
  4. +
  5. The field should show as touched and invalid
  6. +
  7. The error text should appear below the input
  8. +
  9. For screen readers, the validation state should be announced
  10. +
+

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..d3c085b084b --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.html @@ -0,0 +1,131 @@ + + + Input - Validation Test + + + + +
+

Screen Reader Testing Instructions:

+
    +
  1. Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)
  2. +
  3. Tab through the form fields
  4. +
  5. When you tab away from an empty required field, the error should be announced immediately
  6. +
  7. The error text should be announced BEFORE the next field is announced
  8. +
  9. Test in Chrome, Safari, and Firefox to verify consistent behavior
  10. +
+
+ +
+
+
+

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/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..add228ccab1 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.scss @@ -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; +} 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..f7d67754cd9 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.ts @@ -0,0 +1,125 @@ +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 { + // 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 is invalid + isInvalid(fieldName: string): boolean { + const control = this.form.get(fieldName); + return !!(control && control.invalid && control.touched); + } + + // Check if a field is valid + isValid(fieldName: string): boolean { + const control = this.form.get(fieldName); + return !!(control && control.valid && control.touched); + } + + + // 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'); + }); + } +} 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..8aa8f506b61 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.html @@ -0,0 +1,133 @@ + + + Textarea - Validation Test + + + + +
+

Screen Reader Testing Instructions:

+
    +
  1. Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)
  2. +
  3. Tab through the form fields
  4. +
  5. When you tab away from an empty required field, the error should be announced immediately
  6. +
  7. The error text should be announced BEFORE the next field is announced
  8. +
  9. Test in Chrome, Safari, and Firefox to verify consistent behavior
  10. +
+
+ +
+
+
+

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/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..3756ddefcd0 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.ts @@ -0,0 +1,144 @@ +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 { + // 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 is invalid + isInvalid(fieldName: string): boolean { + const control = this.form.get(fieldName); + return !!(control && control.invalid && control.touched); + } + + // Check if a field is valid + isValid(fieldName: string): boolean { + const control = this.form.get(fieldName); + return !!(control && control.valid && control.touched); + } + + // 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'); + }); + } +}