From c339bc36827b62ef871325869a9a5db9b17ac785 Mon Sep 17 00:00:00 2001 From: Shane Date: Thu, 4 Sep 2025 08:11:59 -0700 Subject: [PATCH] fix(input): improve error text accessibility (#30635) Issue number: resolves internal --------- ## What is the current behavior? 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? 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 ## Other information 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 --- core/src/components/input/input.tsx | 59 +++- .../input/test/validation/index.html | 284 +++++++++++++++++ .../textarea/test/validation/index.html | 285 ++++++++++++++++++ core/src/components/textarea/textarea.tsx | 60 +++- .../base/src/app/lazy/app-lazy/app.module.ts | 4 +- .../base/src/app/lazy/app-lazy/app.routes.ts | 2 + .../lazy/home-page/home-page.component.html | 5 + .../template-form.component.html | 116 +++++++ .../template-form/template-form.component.ts | 26 ++ .../standalone/app-standalone/app.routes.ts | 8 + .../home-page/home-page.component.html | 16 + .../input-validation.component.html | 131 ++++++++ .../input-validation.component.scss | 36 +++ .../input-validation.component.ts | 85 ++++++ .../textarea-validation.component.html | 133 ++++++++ .../textarea-validation.component.scss | 36 +++ .../textarea-validation.component.ts | 105 +++++++ 17 files changed, 1374 insertions(+), 17 deletions(-) create mode 100644 core/src/components/input/test/validation/index.html create mode 100644 core/src/components/textarea/test/validation/index.html create mode 100644 packages/angular/test/base/src/app/lazy/template-form/template-form.component.html create mode 100644 packages/angular/test/base/src/app/lazy/template-form/template-form.component.ts create mode 100644 packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.html create mode 100644 packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.scss create mode 100644 packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.ts create mode 100644 packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.html create mode 100644 packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.scss create mode 100644 packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.ts diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index 69281c5e89..ccb80120ca 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 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 [ -
- {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 0000000000..2a6ad89e13 --- /dev/null +++ b/core/src/components/input/test/validation/index.html @@ -0,0 +1,284 @@ + + + + + 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 0000000000..6f977a7d91 --- /dev/null +++ b/core/src/components/textarea/test/validation/index.html @@ -0,0 +1,285 @@ + + + + + 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 64ff00c922..83c1b91c2e 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 caf27670d2..ac0ebd501f 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 0e15ea2867..1a46992f92 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 80418148c5..136a0119d3 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 0000000000..d33aa4ae1e --- /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 0000000000..1ecdaa5e5d --- /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 ae6ee66193..fafb69c62a 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 163e438d42..7900bdfb64 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 0000000000..b91f127c18 --- /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 0000000000..add228ccab --- /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 0000000000..aee73b0735 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.ts @@ -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!'); + } + } +} 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 0000000000..d32a9dd862 --- /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 0000000000..6462ef79f6 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.scss @@ -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; +} 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 0000000000..a942bac78d --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.ts @@ -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!'); + } + } +}