fix(checkbox, select): improve error text accessibility

This commit is contained in:
Maria Hutt
2025-10-07 15:30:47 -07:00
parent 7bb9535f60
commit eaa64fdb73
4 changed files with 443 additions and 16 deletions

View File

@ -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<void>;
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 (
<div class="checkbox-bottom">
<div id={helperTextId} class="helper-text" part="supporting-text helper-text">
{helperText}
<div id={helperTextId} class="helper-text" part="supporting-text helper-text" aria-live="polite">
{!isInvalid ? helperText : null}
</div>
<div id={errorTextId} class="error-text" part="supporting-text error-text">
{errorText}
<div id={errorTextId} class="error-text" part="supporting-text error-text" role="alert">
{isInvalid ? errorText : null}
</div>
</div>
);
}
/**
* 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, {