From 92db36489cca944caf1593dbd518a1f025a171a2 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Thu, 13 Nov 2025 10:11:32 -0800 Subject: [PATCH] fix(checkbox, toggle, radio-group): improve screen reader announcement timing for validation errors (#30714) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue number: 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? - Updated aria attributes - Added observer with an observer ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information [Checkbox preview](https://ionic-framework-git-fw-6757-ionic1.vercel.app/src/components/checkbox/test/validation/) [Toggle preview](https://ionic-framework-git-fw-6757-ionic1.vercel.app/src/components/toggle/test/validation/) [Radio Group preview](https://ionic-framework-git-fw-6757-ionic1.vercel.app/src/components/radio-group/test/validation/) ⚠️ Flakiness ⚠️ The flakiness on checkbox and toggle is because when a native input is present on the page, the browser will have the screen reader to be really fast when it comes to checking information. This speed ends up being too fast for `ion-checkbox` to be able to add the error text. This leads to the error text not being announce consistently. There's no issue when it comes to ion-input or ion-textarea because Ionic uses the native inputs so their arias are read. There's also no issue with ion-select because we don't have a native input. It's only an issue with checkbox and the others is because it has a [native input that is being hidden](https://github.com/ionic-team/ionic-framework/blob/8e884bd2cb73481acbb6eb4a4a507d6f6a8716b1/core/src/components/checkbox/checkbox.tsx#L368-L369). So the browser sees that and speeds up the screen reader. The flakiness on radio group is because when you tab out of the last radio button, the ionBlur event is emitted by the child . This event bubbles up, but the timing is still too early for the group. --- core/src/components/checkbox/checkbox.tsx | 85 +++++++- .../checkbox/test/validation/index.html | 184 +++++++++++++++++ .../components/radio-group/radio-group.tsx | 82 +++++++- .../radio-group/test/validation/index.html | 194 ++++++++++++++++++ core/src/components/select/select.tsx | 10 +- .../toggle/test/validation/index.html | 184 +++++++++++++++++ core/src/components/toggle/toggle.tsx | 84 ++++++-- core/src/utils/forms/validity.ts | 8 +- .../template-form.component.html | 76 ++++++- .../template-form/template-form.component.ts | 3 + .../standalone/app-standalone/app.routes.ts | 3 + .../home-page/home-page.component.html | 19 +- .../checkbox-validation.component.html | 53 +++++ .../checkbox-validation.component.scss | 36 ++++ .../checkbox-validation.component.ts | 61 ++++++ .../radio-group-validation.component.html | 57 +++++ .../radio-group-validation.component.scss | 36 ++++ .../radio-group-validation.component.ts | 66 ++++++ .../toggle-validation.component.html | 54 +++++ .../toggle-validation.component.scss | 36 ++++ .../toggle-validation.component.ts | 61 ++++++ 21 files changed, 1348 insertions(+), 44 deletions(-) create mode 100644 core/src/components/checkbox/test/validation/index.html create mode 100644 core/src/components/radio-group/test/validation/index.html create mode 100644 core/src/components/toggle/test/validation/index.html create mode 100644 packages/angular/test/base/src/app/standalone/validation/checkbox-validation/checkbox-validation.component.html create mode 100644 packages/angular/test/base/src/app/standalone/validation/checkbox-validation/checkbox-validation.component.scss create mode 100644 packages/angular/test/base/src/app/standalone/validation/checkbox-validation/checkbox-validation.component.ts create mode 100644 packages/angular/test/base/src/app/standalone/validation/radio-group-validation/radio-group-validation.component.html create mode 100644 packages/angular/test/base/src/app/standalone/validation/radio-group-validation/radio-group-validation.component.scss create mode 100644 packages/angular/test/base/src/app/standalone/validation/radio-group-validation/radio-group-validation.component.ts create mode 100644 packages/angular/test/base/src/app/standalone/validation/toggle-validation/toggle-validation.component.html create mode 100644 packages/angular/test/base/src/app/standalone/validation/toggle-validation/toggle-validation.component.scss create mode 100644 packages/angular/test/base/src/app/standalone/validation/toggle-validation/toggle-validation.component.ts diff --git a/core/src/components/checkbox/checkbox.tsx b/core/src/components/checkbox/checkbox.tsx index 22f3959fba..abd289c263 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,63 @@ 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(() => { + 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 */ @@ -203,10 +265,10 @@ export class Checkbox implements ComponentInterface { ev.stopPropagation(); }; - private getHintTextID(): string | undefined { - const { el, helperText, errorText, helperTextId, errorTextId } = this; + private getHintTextId(): string | undefined { + 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 +284,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 +297,11 @@ export class Checkbox implements ComponentInterface { return (
-
- {helperText} +
+ {!isInvalid ? helperText : null}
-
- {errorText} +
); @@ -274,11 +336,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 c3e1e4c0b0..e060c2c720 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; + private getHintTextId(): string | undefined { + 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 0000000000..49fdbf1ebb --- /dev/null +++ b/core/src/components/radio-group/test/validation/index.html @@ -0,0 +1,194 @@ + + + + + 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/select/select.tsx b/core/src/components/select/select.tsx index ac2fbb6d89..ed967b9f83 100644 --- a/core/src/components/select/select.tsx +++ b/core/src/components/select/select.tsx @@ -87,7 +87,7 @@ export class Select implements ComponentInterface { */ @State() isInvalid = false; - @State() private hintTextID?: string; + @State() private hintTextId?: string; /** * The text to display on the cancel button. @@ -332,7 +332,7 @@ export class Select implements ComponentInterface { * a higher priority. */ Promise.resolve().then(() => { - this.hintTextID = this.getHintTextID(); + this.hintTextId = this.getHintTextId(); }); } }); @@ -350,7 +350,7 @@ export class Select implements ComponentInterface { componentWillLoad() { this.inheritedAttributes = inheritAttributes(this.el, ['aria-label']); - this.hintTextID = this.getHintTextID(); + this.hintTextId = this.getHintTextId(); } componentDidLoad() { @@ -1111,7 +1111,7 @@ export class Select implements ComponentInterface { aria-label={this.ariaLabel} aria-haspopup="dialog" aria-expanded={`${isExpanded}`} - aria-describedby={this.hintTextID} + aria-describedby={this.hintTextId} aria-invalid={this.isInvalid ? 'true' : undefined} aria-required={`${required}`} onFocus={this.onFocus} @@ -1121,7 +1121,7 @@ export class Select implements ComponentInterface { ); } - private getHintTextID(): string | undefined { + private getHintTextId(): string | undefined { const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this; if (isInvalid && errorText) { 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 0000000000..54932edeb0 --- /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 18f4b26115..b46635c8a7 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() { @@ -335,10 +393,10 @@ export class Toggle implements ComponentInterface { return this.el.textContent !== ''; } - private getHintTextID(): string | undefined { - const { el, helperText, errorText, helperTextId, errorTextId } = this; + private getHintTextId(): string | undefined { + 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 705e104e80..d26e1a6cc4 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 93f6284957..007743f905 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 f0eece0ba3..7ac9c61918 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,19 +121,34 @@ Validation Tests + + + Checkbox Validation Test + + Input Validation Test + + + Radio Group Validation Test + + + + + Select Validation Test + + Textarea Validation Test - + - Select 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 0000000000..86a8425e0c --- /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 0000000000..d8b2a267e5 --- /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 0000000000..48bf8a935f --- /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 0000000000..b7c90ba1ce --- /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 0000000000..add228ccab --- /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 0000000000..aa4ee109a5 --- /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 0000000000..1bf6ac67e7 --- /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 0000000000..add228ccab --- /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 0000000000..d756ac150b --- /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!'); + } + } +}