diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index ccb80120ca8..19c5a9d406f 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -14,7 +14,7 @@ import { h, } from '@stencil/core'; import type { NotchController } from '@utils/forms'; -import { createNotchController } from '@utils/forms'; +import { createNotchController, checkInvalidState } from '@utils/forms'; import type { Attributes } from '@utils/helpers'; import { inheritAriaAttributes, debounceEvent, inheritAttributes, componentOnReady } from '@utils/helpers'; import { createSlotMutationController } from '@utils/slot-mutation-controller'; @@ -403,16 +403,6 @@ 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; @@ -426,7 +416,7 @@ export class Input implements ComponentInterface { // Watch for class changes to update validation state if (Build.isBrowser && typeof MutationObserver !== 'undefined') { this.validationObserver = new MutationObserver(() => { - const newIsInvalid = this.checkInvalidState(); + const newIsInvalid = checkInvalidState(el); if (this.isInvalid !== newIsInvalid) { this.isInvalid = newIsInvalid; // Force a re-render to update aria-describedby immediately @@ -441,7 +431,7 @@ export class Input implements ComponentInterface { } // Always set initial state - this.isInvalid = this.checkInvalidState(); + this.isInvalid = checkInvalidState(el); this.debounceChanged(); if (Build.isBrowser) { diff --git a/core/src/components/select/select.tsx b/core/src/components/select/select.tsx index 7df8049d13a..ac2fbb6d89f 100644 --- a/core/src/components/select/select.tsx +++ b/core/src/components/select/select.tsx @@ -1,7 +1,7 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; -import { Component, Element, Event, Host, Method, Prop, State, Watch, h, forceUpdate } from '@stencil/core'; +import { Build, Component, Element, Event, Host, Method, Prop, State, Watch, h, forceUpdate } from '@stencil/core'; import type { NotchController } from '@utils/forms'; -import { compareOptions, createNotchController, isOptionSelected } from '@utils/forms'; +import { compareOptions, createNotchController, isOptionSelected, checkInvalidState } from '@utils/forms'; import { focusVisibleElement, renderHiddenInput, inheritAttributes } from '@utils/helpers'; import type { Attributes } from '@utils/helpers'; import { printIonWarning } from '@utils/logging'; @@ -64,6 +64,7 @@ export class Select implements ComponentInterface { private inheritedAttributes: Attributes = {}; private nativeWrapperEl: HTMLElement | undefined; private notchSpacerEl: HTMLElement | undefined; + private validationObserver?: MutationObserver; private notchController?: NotchController; @@ -81,6 +82,13 @@ export class Select implements ComponentInterface { */ @State() hasFocus = false; + /** + * Track validation state for proper aria-live announcements. + */ + @State() isInvalid = false; + + @State() private hintTextID?: string; + /** * The text to display on the cancel button. */ @@ -298,10 +306,51 @@ export class Select implements ComponentInterface { */ forceUpdate(this); }); + + // 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(el, { + attributes: true, + attributeFilter: ['class'], + }); + } + + // Always set initial state + this.isInvalid = checkInvalidState(this.el); } componentWillLoad() { this.inheritedAttributes = inheritAttributes(this.el, ['aria-label']); + + this.hintTextID = this.getHintTextID(); } componentDidLoad() { @@ -328,6 +377,12 @@ export class Select 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; + } } /** @@ -1056,8 +1111,8 @@ export class Select implements ComponentInterface { aria-label={this.ariaLabel} aria-haspopup="dialog" aria-expanded={`${isExpanded}`} - aria-describedby={this.getHintTextID()} - aria-invalid={this.getHintTextID() === this.errorTextId} + aria-describedby={this.hintTextID} + aria-invalid={this.isInvalid ? 'true' : undefined} aria-required={`${required}`} onFocus={this.onFocus} onBlur={this.onBlur} @@ -1067,9 +1122,9 @@ export class Select 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; } @@ -1084,14 +1139,14 @@ export class Select 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} + , ]; } diff --git a/core/src/components/select/test/validation/index.html b/core/src/components/select/test/validation/index.html new file mode 100644 index 00000000000..74d0586bd77 --- /dev/null +++ b/core/src/components/select/test/validation/index.html @@ -0,0 +1,200 @@ + + + + + Select - Validation + + + + + + + + + + + + + + Select - 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

+ + Apples + Oranges + Pears + +
+ +
+

Optional Field (No Validation)

+ + Red + Blue + Green + +
+
+ +
+ Submit Form + Reset Form +
+
+
+ + + + diff --git a/core/src/components/textarea/textarea.tsx b/core/src/components/textarea/textarea.tsx index 83c1b91c2e4..89646f6a247 100644 --- a/core/src/components/textarea/textarea.tsx +++ b/core/src/components/textarea/textarea.tsx @@ -15,7 +15,7 @@ import { writeTask, } from '@stencil/core'; import type { NotchController } from '@utils/forms'; -import { createNotchController } from '@utils/forms'; +import { createNotchController, checkInvalidState } from '@utils/forms'; import type { Attributes } from '@utils/helpers'; import { inheritAriaAttributes, debounceEvent, inheritAttributes, componentOnReady } from '@utils/helpers'; import { createSlotMutationController } from '@utils/slot-mutation-controller'; @@ -335,16 +335,6 @@ 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)); @@ -357,7 +347,7 @@ export class Textarea implements ComponentInterface { // Watch for class changes to update validation state if (Build.isBrowser && typeof MutationObserver !== 'undefined') { this.validationObserver = new MutationObserver(() => { - const newIsInvalid = this.checkValidationState(); + const newIsInvalid = checkInvalidState(this.el); if (this.isInvalid !== newIsInvalid) { this.isInvalid = newIsInvalid; // Force a re-render to update aria-describedby immediately @@ -372,7 +362,7 @@ export class Textarea implements ComponentInterface { } // Always set initial state - this.isInvalid = this.checkValidationState(); + this.isInvalid = checkInvalidState(this.el); this.debounceChanged(); if (Build.isBrowser) { diff --git a/core/src/utils/forms/index.ts b/core/src/utils/forms/index.ts index d24bddfaa77..682811ed643 100644 --- a/core/src/utils/forms/index.ts +++ b/core/src/utils/forms/index.ts @@ -1,2 +1,3 @@ export * from './notch-controller'; export * from './compare-with-utils'; +export * from './validity'; diff --git a/core/src/utils/forms/validity.ts b/core/src/utils/forms/validity.ts new file mode 100644 index 00000000000..995dc637b10 --- /dev/null +++ b/core/src/utils/forms/validity.ts @@ -0,0 +1,15 @@ +type FormElement = HTMLIonInputElement | HTMLIonTextareaElement | HTMLIonSelectElement; + +/** + * Checks if the form element is in an invalid state based on + * Ionic validation classes. + * + * @param el The form element to check. + * @returns `true` if the element is invalid, `false` otherwise. + */ +export const checkInvalidState = (el: FormElement): boolean => { + const hasIonTouched = el.classList.contains('ion-touched'); + const hasIonInvalid = el.classList.contains('ion-invalid'); + + return hasIonTouched && hasIonInvalid; +}; 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 index d33aa4ae1e5..ccd902f6b95 100644 --- 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 @@ -77,6 +77,31 @@

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

+ + + + + Option 1 + Option 2 + + + + + + +

Select Touched: {{selectField.touched}}

+

Select Invalid: {{selectField.invalid}}

+

Select Errors: {{selectField.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 1ecdaa5e5d0..705e104e808 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 @@ -9,6 +9,7 @@ export class TemplateFormComponent { inputValue = ''; textareaValue = ''; minLengthValue = ''; + selectValue = ''; // 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 ed9628ae7c9..93f6284957f 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 @@ -47,6 +47,7 @@ export const routes: Routes = [ 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: 'select-validation', loadComponent: () => import('../validation/select-validation/select-validation.component').then(c => c.SelectValidationComponent) }, { 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 fd6ae409a3b..f0eece0ba33 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 @@ -131,6 +131,11 @@ Textarea Validation Test + + + Select Validation Test + + diff --git a/packages/angular/test/base/src/app/standalone/validation/select-validation/select-validation.component.html b/packages/angular/test/base/src/app/standalone/validation/select-validation/select-validation.component.html new file mode 100644 index 00000000000..15993edc803 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/validation/select-validation/select-validation.component.html @@ -0,0 +1,63 @@ + + + Select - 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

+ + Apples + Oranges + Pears + +
+ +
+

Optional Field (No Validation)

+ + Red + Blue + Green + +
+
+
+ +
+ Submit Form + Reset Form +
+
diff --git a/packages/angular/test/base/src/app/standalone/validation/select-validation/select-validation.component.scss b/packages/angular/test/base/src/app/standalone/validation/select-validation/select-validation.component.scss new file mode 100644 index 00000000000..add228ccab1 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/validation/select-validation/select-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/select-validation/select-validation.component.ts b/packages/angular/test/base/src/app/standalone/validation/select-validation/select-validation.component.ts new file mode 100644 index 00000000000..1ae4a239ef4 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/validation/select-validation/select-validation.component.ts @@ -0,0 +1,63 @@ +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; +import { + FormBuilder, + ReactiveFormsModule, + Validators +} from '@angular/forms'; +import { + IonButton, + IonContent, + IonHeader, + IonSelect, + IonSelectOption, + IonTitle, + IonToolbar +} from '@ionic/angular/standalone'; + +@Component({ + selector: 'app-select-validation', + templateUrl: './select-validation.component.html', + styleUrls: ['./select-validation.component.scss'], + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + IonSelect, + IonSelectOption, + IonButton, + IonHeader, + IonToolbar, + IonTitle, + IonContent + ] +}) +export class SelectValidationComponent { + // Field metadata for labels and error messages + fieldMetadata = { + fruits: { + label: 'Fruits', + helperText: "Select an option", + errorText: 'This field is required' + }, + optional: { + label: 'Colors', + helperText: 'You can skip this field', + errorText: '' + } + }; + + form = this.fb.group({ + fruits: ['', Validators.required], + optional: [''] + }); + + constructor(private fb: FormBuilder) {} + + // Submit form + onSubmit(): void { + if (this.form.valid) { + alert('Form submitted successfully!'); + } + } +}