From 59b350edb3d3efe8b877bea08b0c500b9219f83b Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Thu, 9 Oct 2025 17:05:59 -0700 Subject: [PATCH] fix(select): improve screen reader announcement timing for validation errors --- core/src/components/select/select.tsx | 84 +++++++- .../select/test/validation/index.html | 200 ++++++++++++++++++ .../template-form.component.html | 25 +++ .../template-form/template-form.component.ts | 1 + .../standalone/app-standalone/app.routes.ts | 1 + .../home-page/home-page.component.html | 5 + .../select-validation.component.html | 63 ++++++ .../select-validation.component.scss | 36 ++++ .../select-validation.component.ts | 63 ++++++ 9 files changed, 468 insertions(+), 10 deletions(-) create mode 100644 core/src/components/select/test/validation/index.html create mode 100644 packages/angular/test/base/src/app/standalone/validation/select-validation/select-validation.component.html create mode 100644 packages/angular/test/base/src/app/standalone/validation/select-validation/select-validation.component.scss create mode 100644 packages/angular/test/base/src/app/standalone/validation/select-validation/select-validation.component.ts diff --git a/core/src/components/select/select.tsx b/core/src/components/select/select.tsx index 7df8049d13a..1ed90a1ce4a 100644 --- a/core/src/components/select/select.tsx +++ b/core/src/components/select/select.tsx @@ -1,5 +1,5 @@ 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 { focusVisibleElement, renderHiddenInput, inheritAttributes } from '@utils/helpers'; @@ -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,49 @@ 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 = this.checkInvalidState(); + 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()` not being + * high priority enough to guarantee + * the DOM is updated before the screen reader + * announces the attribute change. + * By using a promise, it makes sure to + * announce the change before the next frame + * since promises are high priority. + */ + Promise.resolve().then(() => { + this.hintTextID = this.getHintTextID(); + }); + } + }); + + this.validationObserver.observe(el, { + attributes: true, + attributeFilter: ['class'], + }); + } + + // Always set initial state + this.isInvalid = this.checkInvalidState(); } componentWillLoad() { this.inheritedAttributes = inheritAttributes(this.el, ['aria-label']); + + this.hintTextID = this.getHintTextID(); } componentDidLoad() { @@ -328,6 +375,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 +1109,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 +1120,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 +1137,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} + , ]; } @@ -1115,6 +1168,17 @@ export class Select implements ComponentInterface { return
{this.renderHintText()}
; } + /** + * 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; + } + render() { const { disabled, 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/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!'); + } + } +}