From eaa64fdb7320fb50112d5ecc8c4b74425f921d0f Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Tue, 7 Oct 2025 15:30:47 -0700 Subject: [PATCH] fix(checkbox, select): improve error text accessibility --- core/src/components/checkbox/checkbox.tsx | 46 ++++- .../checkbox/test/validation/index.html | 184 +++++++++++++++++ core/src/components/select/select.tsx | 39 +++- .../select/test/validation/index.html | 190 ++++++++++++++++++ 4 files changed, 443 insertions(+), 16 deletions(-) create mode 100644 core/src/components/checkbox/test/validation/index.html create mode 100644 core/src/components/select/test/validation/index.html diff --git a/core/src/components/checkbox/checkbox.tsx b/core/src/components/checkbox/checkbox.tsx index 00a9de5c7c..53d1bcd4a0 100644 --- a/core/src/components/checkbox/checkbox.tsx +++ b/core/src/components/checkbox/checkbox.tsx @@ -1,5 +1,5 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; -import { Component, Element, Event, Host, Method, Prop, h } from '@stencil/core'; +import { Component, Element, Event, Host, Method, Prop, State, h, forceUpdate } from '@stencil/core'; import type { Attributes } from '@utils/helpers'; import { inheritAriaAttributes, renderHiddenInput } from '@utils/helpers'; import { createColorClasses, hostContext } from '@utils/theme'; @@ -121,6 +121,11 @@ export class Checkbox implements ComponentInterface { */ @Prop() required = false; + /** + * Track validation state for proper aria-live announcements. + */ + @State() isInvalid = false; + /** * Emitted when the checked property has changed as a result of a user action such as a click. * @@ -138,6 +143,11 @@ export class Checkbox implements ComponentInterface { */ @Event() ionBlur!: EventEmitter; + connectedCallback() { + // Always set initial state. + this.isInvalid = this.checkInvalidState(); + } + componentWillLoad() { this.inheritedAttributes = { ...inheritAriaAttributes(this.el), @@ -179,6 +189,13 @@ export class Checkbox implements ComponentInterface { }; private onBlur = () => { + const newIsInvalid = this.checkInvalidState(); + if (this.isInvalid !== newIsInvalid) { + this.isInvalid = newIsInvalid; + // Force a re-render to update aria-describedby immediately. + forceUpdate(this); + } + this.ionBlur.emit(); }; @@ -208,9 +225,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; } @@ -226,7 +243,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 @@ -239,16 +256,26 @@ export class Checkbox implements ComponentInterface { return (
-
- {helperText} +
+ {!isInvalid ? helperText : null}
-
- {errorText} +
); } + /** + * 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 { color, @@ -279,10 +306,11 @@ export class Checkbox implements ComponentInterface { role="checkbox" aria-checked={indeterminate ? 'mixed' : `${checked}`} aria-describedby={this.getHintTextID()} - aria-invalid={this.getHintTextID() === this.errorTextId} + aria-invalid={this.isInvalid ? 'true' : undefined} aria-labelledby={hasLabelContent ? this.inputLabelId : null} aria-label={inheritedAttributes['aria-label'] || null} aria-disabled={disabled ? 'true' : null} + aria-required={required ? 'true' : undefined} tabindex={disabled ? undefined : 0} onKeyDown={this.onKeyDown} class={createColorClasses(color, { diff --git a/core/src/components/checkbox/test/validation/index.html b/core/src/components/checkbox/test/validation/index.html new file mode 100644 index 0000000000..8686186c7d --- /dev/null +++ b/core/src/components/checkbox/test/validation/index.html @@ -0,0 +1,184 @@ + + + + + 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/select/select.tsx b/core/src/components/select/select.tsx index 7df8049d13..19f69fae77 100644 --- a/core/src/components/select/select.tsx +++ b/core/src/components/select/select.tsx @@ -81,6 +81,11 @@ export class Select implements ComponentInterface { */ @State() hasFocus = false; + /** + * Track validation state for proper aria-live announcements. + */ + @State() isInvalid = false; + /** * The text to display on the cancel button. */ @@ -298,6 +303,9 @@ export class Select implements ComponentInterface { */ forceUpdate(this); }); + + // Always set initial state. + this.isInvalid = this.checkInvalidState(); } componentWillLoad() { @@ -868,8 +876,15 @@ export class Select implements ComponentInterface { }; private onBlur = () => { + const newIsInvalid = this.checkInvalidState(); this.hasFocus = false; + if (this.isInvalid !== newIsInvalid) { + this.isInvalid = newIsInvalid; + // Force a re-render to update aria-describedby immediately. + forceUpdate(this); + } + this.ionBlur.emit(); }; @@ -1067,9 +1082,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 +1099,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 +1130,16 @@ 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 0000000000..f955e0067b --- /dev/null +++ b/core/src/components/select/test/validation/index.html @@ -0,0 +1,190 @@ + + + + + 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)

+ Optional Checkbox +
+
+ +
+ Submit Form + Reset Form +
+
+
+ + + +