diff --git a/core/src/components/checkbox/checkbox.tsx b/core/src/components/checkbox/checkbox.tsx index 22f3959fba7..5af4fa7a9d4 100644 --- a/core/src/components/checkbox/checkbox.tsx +++ b/core/src/components/checkbox/checkbox.tsx @@ -1,5 +1,6 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; -import { Component, Element, Event, Host, Method, Prop, h } from '@stencil/core'; +import { Build, Component, Element, Event, Host, Method, Prop, State, h } from '@stencil/core'; +import { checkInvalidState } from '@utils/forms'; import type { Attributes } from '@utils/helpers'; import { inheritAriaAttributes, renderHiddenInput } from '@utils/helpers'; import { createColorClasses, hostContext } from '@utils/theme'; @@ -35,6 +36,7 @@ export class Checkbox implements ComponentInterface { private helperTextId = `${this.inputId}-helper-text`; private errorTextId = `${this.inputId}-error-text`; private inheritedAttributes: Attributes = {}; + private validationObserver?: MutationObserver; @Element() el!: HTMLIonCheckboxElement; @@ -120,6 +122,13 @@ export class Checkbox implements ComponentInterface { */ @Prop() required = false; + /** + * Track validation state for proper aria-live announcements. + */ + @State() isInvalid = false; + + @State() private hintTextID?: string; + /** * Emitted when the checked property has changed as a result of a user action such as a click. * @@ -137,10 +146,64 @@ export class Checkbox implements ComponentInterface { */ @Event() ionBlur!: EventEmitter; + connectedCallback() { + const { el } = this; + + // Watch for class changes to update validation state. + if (Build.isBrowser && typeof MutationObserver !== 'undefined') { + this.validationObserver = new MutationObserver(() => { + const newIsInvalid = checkInvalidState(el); + if (this.isInvalid !== newIsInvalid) { + this.isInvalid = newIsInvalid; + /** + * Screen readers tend to announce changes + * to `aria-describedby` when the attribute + * is changed during a blur event for a + * native form control. + * However, the announcement can be spotty + * when using a non-native form control + * and `forceUpdate()`. + * This is due to `forceUpdate()` internally + * rescheduling the DOM update to a lower + * priority queue regardless if it's called + * inside a Promise or not, thus causing + * the screen reader to potentially miss the + * change. + * By using a State variable inside a Promise, + * it guarantees a re-render immediately at + * a higher priority. + */ + Promise.resolve().then(() => { + console.log('updating hint text id'); + this.hintTextID = this.getHintTextID(); + }); + } + }); + + this.validationObserver.observe(el, { + attributes: true, + attributeFilter: ['class'], + }); + } + + // Always set initial state + this.isInvalid = checkInvalidState(el); + } + componentWillLoad() { this.inheritedAttributes = { ...inheritAriaAttributes(this.el), }; + + this.hintTextID = this.getHintTextID(); + } + + disconnectedCallback() { + // Clean up validation observer to prevent memory leaks. + if (this.validationObserver) { + this.validationObserver.disconnect(); + this.validationObserver = undefined; + } } /** @internal */ @@ -204,9 +267,9 @@ export class Checkbox implements ComponentInterface { }; private getHintTextID(): string | undefined { - const { el, helperText, errorText, helperTextId, errorTextId } = this; + const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this; - if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) { + if (isInvalid && errorText) { return errorTextId; } @@ -222,7 +285,7 @@ export class Checkbox implements ComponentInterface { * This element should only be rendered if hint text is set. */ private renderHintText() { - const { helperText, errorText, helperTextId, errorTextId } = this; + const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this; /** * undefined and empty string values should @@ -235,11 +298,11 @@ export class Checkbox implements ComponentInterface { return (
-
- {helperText} +
+ {!isInvalid ? helperText : null}
-
- {errorText} +
); @@ -274,11 +337,12 @@ export class Checkbox implements ComponentInterface { + + + + Checkbox - Validation + + + + + + + + + + + + + + Checkbox - 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 Field

+ I agree to the terms and conditions +
+ +
+

Optional Field (No Validation)

+ Optional Checkbox +
+
+ +
+ Submit Form + Reset Form +
+
+
+ + + + diff --git a/core/src/components/radio-group/radio-group.tsx b/core/src/components/radio-group/radio-group.tsx index c3e1e4c0b0e..26c187453a6 100644 --- a/core/src/components/radio-group/radio-group.tsx +++ b/core/src/components/radio-group/radio-group.tsx @@ -1,5 +1,6 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; -import { Component, Element, Event, Host, Listen, Method, Prop, Watch, h } from '@stencil/core'; +import { Build, Component, Element, Event, Host, Listen, Method, Prop, State, Watch, h } from '@stencil/core'; +import { checkInvalidState } from '@utils/forms'; import { renderHiddenInput } from '@utils/helpers'; import { getIonMode } from '../../global/ionic-global'; @@ -19,9 +20,17 @@ export class RadioGroup implements ComponentInterface { private errorTextId = `${this.inputId}-error-text`; private labelId = `${this.inputId}-lbl`; private label?: HTMLIonLabelElement | null; + private validationObserver?: MutationObserver; @Element() el!: HTMLElement; + /** + * Track validation state for proper aria-live announcements. + */ + @State() isInvalid = false; + + @State() private hintTextID?: string; + /** * If `true`, the radios can be deselected. */ @@ -121,6 +130,57 @@ export class RadioGroup implements ComponentInterface { this.labelId = label.id = this.name + '-lbl'; } } + + // Watch for class changes to update validation state. + if (Build.isBrowser && typeof MutationObserver !== 'undefined') { + this.validationObserver = new MutationObserver(() => { + const newIsInvalid = checkInvalidState(this.el); + if (this.isInvalid !== newIsInvalid) { + this.isInvalid = newIsInvalid; + /** + * Screen readers tend to announce changes + * to `aria-describedby` when the attribute + * is changed during a blur event for a + * native form control. + * However, the announcement can be spotty + * when using a non-native form control + * and `forceUpdate()`. + * This is due to `forceUpdate()` internally + * rescheduling the DOM update to a lower + * priority queue regardless if it's called + * inside a Promise or not, thus causing + * the screen reader to potentially miss the + * change. + * By using a State variable inside a Promise, + * it guarantees a re-render immediately at + * a higher priority. + */ + Promise.resolve().then(() => { + this.hintTextID = this.getHintTextID(); + }); + } + }); + + this.validationObserver.observe(this.el, { + attributes: true, + attributeFilter: ['class'], + }); + } + + // Always set initial state + this.isInvalid = checkInvalidState(this.el); + } + + componentWillLoad() { + this.hintTextID = this.getHintTextID(); + } + + disconnectedCallback() { + // Clean up validation observer to prevent memory leaks. + if (this.validationObserver) { + this.validationObserver.disconnect(); + this.validationObserver = undefined; + } } private getRadios(): HTMLIonRadioElement[] { @@ -244,7 +304,7 @@ export class RadioGroup 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; const hasHintText = !!helperText || !!errorText; if (!hasHintText) { @@ -253,20 +313,20 @@ export class RadioGroup implements ComponentInterface { return (
-
- {helperText} +
+ {!isInvalid ? helperText : null}
-
- {errorText} +
); } private getHintTextID(): string | undefined { - const { el, helperText, errorText, helperTextId, errorTextId } = this; + const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this; - if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) { + if (isInvalid && errorText) { return errorTextId; } @@ -287,8 +347,8 @@ export class RadioGroup implements ComponentInterface { diff --git a/core/src/components/radio-group/test/validation/index.html b/core/src/components/radio-group/test/validation/index.html new file mode 100644 index 00000000000..29e05fb5505 --- /dev/null +++ b/core/src/components/radio-group/test/validation/index.html @@ -0,0 +1,195 @@ + + + + + Radio Group - Validation + + + + + + + + + + + + + + Radio Group - 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 Field

+ + Grapes
+ Strawberries +
+
+ +
+

Optional Field (No Validation)

+ + Cucumbers
+ Tomatoes +
+
+
+ +
+ Submit Form + Reset Form +
+
+
+ + + + diff --git a/core/src/components/toggle/test/validation/index.html b/core/src/components/toggle/test/validation/index.html new file mode 100644 index 00000000000..54932edeb08 --- /dev/null +++ b/core/src/components/toggle/test/validation/index.html @@ -0,0 +1,184 @@ + + + + + Toggle - Validation + + + + + + + + + + + + + + Toggle - 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 Field

+ Tap to turn on +
+ +
+

Optional Field (No Validation)

+ Optional Toggle +
+
+ +
+ Submit Form + Reset Form +
+
+
+ + + + diff --git a/core/src/components/toggle/toggle.tsx b/core/src/components/toggle/toggle.tsx index 18f4b26115c..b6b8f349d0b 100644 --- a/core/src/components/toggle/toggle.tsx +++ b/core/src/components/toggle/toggle.tsx @@ -1,5 +1,6 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; -import { Component, Element, Event, Host, Prop, State, Watch, h } from '@stencil/core'; +import { Build, Component, Element, Event, Host, Prop, State, Watch, h } from '@stencil/core'; +import { checkInvalidState } from '@utils/forms'; import { renderHiddenInput, inheritAriaAttributes } from '@utils/helpers'; import type { Attributes } from '@utils/helpers'; import { hapticSelection } from '@utils/native/haptic'; @@ -44,11 +45,19 @@ export class Toggle implements ComponentInterface { private inheritedAttributes: Attributes = {}; private toggleTrack?: HTMLElement; private didLoad = false; + private validationObserver?: MutationObserver; @Element() el!: HTMLIonToggleElement; @State() activated = false; + /** + * Track validation state for proper aria-live announcements. + */ + @State() isInvalid = false; + + @State() private hintTextID?: string; + /** * The color to use from your application's color palette. * Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. @@ -168,15 +177,56 @@ export class Toggle implements ComponentInterface { } async connectedCallback() { + const { didLoad, el } = this; + /** * If we have not yet rendered * ion-toggle, then toggleTrack is not defined. * But if we are moving ion-toggle via appendChild, * then toggleTrack will be defined. */ - if (this.didLoad) { + if (didLoad) { this.setupGesture(); } + + // Watch for class changes to update validation state. + if (Build.isBrowser && typeof MutationObserver !== 'undefined') { + this.validationObserver = new MutationObserver(() => { + const newIsInvalid = checkInvalidState(el); + if (this.isInvalid !== newIsInvalid) { + this.isInvalid = newIsInvalid; + /** + * Screen readers tend to announce changes + * to `aria-describedby` when the attribute + * is changed during a blur event for a + * native form control. + * However, the announcement can be spotty + * when using a non-native form control + * and `forceUpdate()`. + * This is due to `forceUpdate()` internally + * rescheduling the DOM update to a lower + * priority queue regardless if it's called + * inside a Promise or not, thus causing + * the screen reader to potentially miss the + * change. + * By using a State variable inside a Promise, + * it guarantees a re-render immediately at + * a higher priority. + */ + Promise.resolve().then(() => { + this.hintTextID = this.getHintTextID(); + }); + } + }); + + this.validationObserver.observe(el, { + attributes: true, + attributeFilter: ['class'], + }); + } + + // Always set initial state + this.isInvalid = checkInvalidState(el); } componentDidLoad() { @@ -207,12 +257,20 @@ export class Toggle implements ComponentInterface { this.gesture.destroy(); this.gesture = undefined; } + + // Clean up validation observer to prevent memory leaks. + if (this.validationObserver) { + this.validationObserver.disconnect(); + this.validationObserver = undefined; + } } componentWillLoad() { this.inheritedAttributes = { ...inheritAriaAttributes(this.el), }; + + this.hintTextID = this.getHintTextID(); } private onStart() { @@ -336,9 +394,9 @@ export class Toggle implements ComponentInterface { } private getHintTextID(): string | undefined { - const { el, helperText, errorText, helperTextId, errorTextId } = this; + const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this; - if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) { + if (isInvalid && errorText) { return errorTextId; } @@ -354,7 +412,7 @@ export class Toggle implements ComponentInterface { * This element should only be rendered if hint text is set. */ private renderHintText() { - const { helperText, errorText, helperTextId, errorTextId } = this; + const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this; /** * undefined and empty string values should @@ -367,11 +425,11 @@ export class Toggle implements ComponentInterface { return (
-
- {helperText} +
+ {!isInvalid ? helperText : null}
-
- {errorText} +
); @@ -385,7 +443,6 @@ export class Toggle implements ComponentInterface { color, disabled, el, - errorTextId, hasLabel, inheritedAttributes, inputId, @@ -405,12 +462,13 @@ export class Toggle implements ComponentInterface { Option 2 - + @@ -102,6 +102,80 @@

Select Errors: {{selectField.errors | json}}

+ + + + + I agree to the terms and conditions + + + + + + +

Checkbox Touched: {{checkboxField.touched}}

+

Checkbox Invalid: {{checkboxField.invalid}}

+

Checkbox Errors: {{checkboxField.errors | json}}

+
+
+ + + + + Tap to turn on + + + + + + +

Toggle Touched: {{toggleField.touched}}

+

Toggle Invalid: {{toggleField.invalid}}

+

Toggle Errors: {{toggleField.errors | json}}

+
+
+ + + + + Grapes
+ Strawberries +
+
+ + + + +

Radio Group Touched: {{radioGroupField.touched}}

+

Radio Group Invalid: {{radioGroupField.invalid}}

+

Radio Group Errors: {{radioGroupField.errors | json}}

+
+
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 index 705e104e808..d26e1a6cc4a 100644 --- 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 @@ -10,6 +10,9 @@ export class TemplateFormComponent { textareaValue = ''; minLengthValue = ''; selectValue = ''; + checkboxValue = false; + toggleValue = false; + radioGroupValue = ''; // Track if form has been submitted 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 93f6284957f..007743f905f 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 @@ -48,6 +48,9 @@ export const routes: Routes = [ { 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: 'select-validation', loadComponent: () => import('../validation/select-validation/select-validation.component').then(c => c.SelectValidationComponent) }, + { path: 'checkbox-validation', loadComponent: () => import('../validation/checkbox-validation/checkbox-validation.component').then(c => c.CheckboxValidationComponent) }, + { path: 'toggle-validation', loadComponent: () => import('../validation/toggle-validation/toggle-validation.component').then(c => c.ToggleValidationComponent) }, + { path: 'radio-group-validation', loadComponent: () => import('../validation/radio-group-validation/radio-group-validation.component').then(c => c.RadioGroupValidationComponent) }, { path: '**', redirectTo: 'input-validation' } ] }, 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 f0eece0ba33..7ac9c619180 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 @@ -121,14 +121,19 @@ Validation Tests + + + Checkbox Validation Test + + Input Validation Test - + - Textarea Validation Test + Radio Group Validation Test @@ -136,6 +141,16 @@ Select Validation Test + + + Textarea Validation Test + + + + + Toggle Validation Test + + diff --git a/packages/angular/test/base/src/app/standalone/validation/checkbox-validation/checkbox-validation.component.html b/packages/angular/test/base/src/app/standalone/validation/checkbox-validation/checkbox-validation.component.html new file mode 100644 index 00000000000..86a8425e0cb --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/validation/checkbox-validation/checkbox-validation.component.html @@ -0,0 +1,53 @@ + + + Checkbox - 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 Field

+ + {{ fieldMetadata.terms.label }} + +
+ +
+

Optional Field (No Validation)

+ + {{ fieldMetadata.optional.label }} + +
+
+
+ +
+ Submit Form + Reset Form +
+
diff --git a/packages/angular/test/base/src/app/standalone/validation/checkbox-validation/checkbox-validation.component.scss b/packages/angular/test/base/src/app/standalone/validation/checkbox-validation/checkbox-validation.component.scss new file mode 100644 index 00000000000..d8b2a267e5a --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/validation/checkbox-validation/checkbox-validation.component.scss @@ -0,0 +1,36 @@ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + grid-row-gap: 50px; + grid-column-gap: 50px; +} + +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/checkbox-validation/checkbox-validation.component.ts b/packages/angular/test/base/src/app/standalone/validation/checkbox-validation/checkbox-validation.component.ts new file mode 100644 index 00000000000..48bf8a935f1 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/validation/checkbox-validation/checkbox-validation.component.ts @@ -0,0 +1,61 @@ +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; +import { + FormBuilder, + ReactiveFormsModule, + Validators +} from '@angular/forms'; +import { + IonButton, + IonContent, + IonHeader, + IonCheckbox, + IonTitle, + IonToolbar +} from '@ionic/angular/standalone'; + +@Component({ + selector: 'app-checkbox-validation', + templateUrl: './checkbox-validation.component.html', + styleUrls: ['./checkbox-validation.component.scss'], + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + IonCheckbox, + IonButton, + IonHeader, + IonToolbar, + IonTitle, + IonContent + ] +}) +export class CheckboxValidationComponent { + // Field metadata for labels and error messages + fieldMetadata = { + terms: { + label: 'I agree to the terms and conditions', + helperText: "You must agree to continue", + errorText: 'This field is required' + }, + optional: { + label: 'Optional Checkbox', + helperText: 'You can skip this field', + errorText: '' + } + }; + + form = this.fb.group({ + terms: [false, Validators.requiredTrue], + optional: [false] + }); + + 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/radio-group-validation/radio-group-validation.component.html b/packages/angular/test/base/src/app/standalone/validation/radio-group-validation/radio-group-validation.component.html new file mode 100644 index 00000000000..b7c90ba1ce6 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/validation/radio-group-validation/radio-group-validation.component.html @@ -0,0 +1,57 @@ + + + Radio Group - 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 Field

+ + {{ fieldMetadata.fruits.firstRadio }}
+ {{ fieldMetadata.fruits.secondRadio }} +
+
+ +
+

Optional Field (No Validation)

+ + {{ fieldMetadata.optional.firstRadio }}
+ {{ fieldMetadata.optional.secondRadio }} +
+
+
+
+ +
+ Submit Form + Reset Form +
+
diff --git a/packages/angular/test/base/src/app/standalone/validation/radio-group-validation/radio-group-validation.component.scss b/packages/angular/test/base/src/app/standalone/validation/radio-group-validation/radio-group-validation.component.scss new file mode 100644 index 00000000000..add228ccab1 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/validation/radio-group-validation/radio-group-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/radio-group-validation/radio-group-validation.component.ts b/packages/angular/test/base/src/app/standalone/validation/radio-group-validation/radio-group-validation.component.ts new file mode 100644 index 00000000000..aa4ee109a50 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/validation/radio-group-validation/radio-group-validation.component.ts @@ -0,0 +1,66 @@ +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; +import { + FormBuilder, + ReactiveFormsModule, + Validators +} from '@angular/forms'; +import { + IonButton, + IonContent, + IonHeader, + IonRadioGroup, + IonRadio, + IonTitle, + IonToolbar +} from '@ionic/angular/standalone'; + +@Component({ + selector: 'app-radio-group-validation', + templateUrl: './radio-group-validation.component.html', + styleUrls: ['./radio-group-validation.component.scss'], + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + IonRadioGroup, + IonRadio, + IonButton, + IonHeader, + IonToolbar, + IonTitle, + IonContent + ] +}) +export class RadioGroupValidationComponent { + // Field metadata for labels and error messages + fieldMetadata = { + fruits: { + helperText: "You must select one to continue", + errorText: 'This field is required', + firstRadio: "Grapes", + secondRadio: "Strawberries" + }, + optional: { + label: 'Optional Radio', + helperText: 'You can skip this field', + errorText: '', + firstRadio: "Option A", + secondRadio: "Option B" + } + }; + + form = this.fb.group({ + fruits: ['', Validators.required], + 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/toggle-validation/toggle-validation.component.html b/packages/angular/test/base/src/app/standalone/validation/toggle-validation/toggle-validation.component.html new file mode 100644 index 00000000000..1bf6ac67e75 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/validation/toggle-validation/toggle-validation.component.html @@ -0,0 +1,54 @@ + + + Toggle - 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 Field

+ + {{ fieldMetadata.on.label }} + +
+ +
+

Optional Field (No Validation)

+ + {{ fieldMetadata.optional.label }} + + +
+
+
+ +
+ Submit Form + Reset Form +
+
diff --git a/packages/angular/test/base/src/app/standalone/validation/toggle-validation/toggle-validation.component.scss b/packages/angular/test/base/src/app/standalone/validation/toggle-validation/toggle-validation.component.scss new file mode 100644 index 00000000000..add228ccab1 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/validation/toggle-validation/toggle-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/toggle-validation/toggle-validation.component.ts b/packages/angular/test/base/src/app/standalone/validation/toggle-validation/toggle-validation.component.ts new file mode 100644 index 00000000000..d756ac150be --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/validation/toggle-validation/toggle-validation.component.ts @@ -0,0 +1,61 @@ +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; +import { + FormBuilder, + ReactiveFormsModule, + Validators +} from '@angular/forms'; +import { + IonButton, + IonContent, + IonHeader, + IonToggle, + IonTitle, + IonToolbar +} from '@ionic/angular/standalone'; + +@Component({ + selector: 'app-toggle-validation', + templateUrl: './toggle-validation.component.html', + styleUrls: ['./toggle-validation.component.scss'], + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + IonToggle, + IonButton, + IonHeader, + IonToolbar, + IonTitle, + IonContent + ] +}) +export class ToggleValidationComponent { + // Field metadata for labels and error messages + fieldMetadata = { + on: { + label: 'Tap to turn on', + helperText: "You must turn on to continue", + errorText: 'This field is required' + }, + optional: { + label: 'Optional Toggle', + helperText: 'You can skip this field', + errorText: '' + } + }; + + form = this.fb.group({ + on: [false, Validators.requiredTrue], + optional: [false] + }); + + constructor(private fb: FormBuilder) {} + + // Submit form + onSubmit(): void { + if (this.form.valid) { + alert('Form submitted successfully!'); + } + } +}