From cde3f14c2a7fdd567bf4447c9c68af644f5c3357 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Mon, 3 Nov 2025 11:57:28 -0800 Subject: [PATCH] fix(radio-group): improve error text accessibility --- .../components/radio-group/radio-group.tsx | 76 ++++++- .../radio-group/test/validation/index.html | 195 ++++++++++++++++++ core/src/utils/forms/validity.ts | 3 +- 3 files changed, 263 insertions(+), 11 deletions(-) create mode 100644 core/src/components/radio-group/test/validation/index.html diff --git a/core/src/components/radio-group/radio-group.tsx b/core/src/components/radio-group/radio-group.tsx index c3e1e4c0b0..b9a059acd5 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,53 @@ 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); + } + + disconnectedCallback() { + // Clean up validation observer to prevent memory leaks. + if (this.validationObserver) { + this.validationObserver.disconnect(); + this.validationObserver = undefined; + } } private getRadios(): HTMLIonRadioElement[] { @@ -244,7 +300,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 +309,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 +343,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..a700e28896 --- /dev/null +++ b/core/src/components/radio-group/test/validation/index.html @@ -0,0 +1,195 @@ + + + + + Radrio 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/utils/forms/validity.ts b/core/src/utils/forms/validity.ts index 45e8f3e231..5c2adabe67 100644 --- a/core/src/utils/forms/validity.ts +++ b/core/src/utils/forms/validity.ts @@ -3,7 +3,8 @@ type FormElement = | HTMLIonTextareaElement | HTMLIonSelectElement | HTMLIonCheckboxElement - | HTMLIonToggleElement; + | HTMLIonToggleElement + | HTMLElement; /** * Checks if the form element is in an invalid state based on