Skip to content

Commit eaa64fd

Browse files
committed
fix(checkbox, select): improve error text accessibility
1 parent 7bb9535 commit eaa64fd

File tree

4 files changed

+443
-16
lines changed

4 files changed

+443
-16
lines changed

core/src/components/checkbox/checkbox.tsx

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ComponentInterface, EventEmitter } from '@stencil/core';
2-
import { Component, Element, Event, Host, Method, Prop, h } from '@stencil/core';
2+
import { Component, Element, Event, Host, Method, Prop, State, h, forceUpdate } from '@stencil/core';
33
import type { Attributes } from '@utils/helpers';
44
import { inheritAriaAttributes, renderHiddenInput } from '@utils/helpers';
55
import { createColorClasses, hostContext } from '@utils/theme';
@@ -121,6 +121,11 @@ export class Checkbox implements ComponentInterface {
121121
*/
122122
@Prop() required = false;
123123

124+
/**
125+
* Track validation state for proper aria-live announcements.
126+
*/
127+
@State() isInvalid = false;
128+
124129
/**
125130
* Emitted when the checked property has changed as a result of a user action such as a click.
126131
*
@@ -138,6 +143,11 @@ export class Checkbox implements ComponentInterface {
138143
*/
139144
@Event() ionBlur!: EventEmitter<void>;
140145

146+
connectedCallback() {
147+
// Always set initial state.
148+
this.isInvalid = this.checkInvalidState();
149+
}
150+
141151
componentWillLoad() {
142152
this.inheritedAttributes = {
143153
...inheritAriaAttributes(this.el),
@@ -179,6 +189,13 @@ export class Checkbox implements ComponentInterface {
179189
};
180190

181191
private onBlur = () => {
192+
const newIsInvalid = this.checkInvalidState();
193+
if (this.isInvalid !== newIsInvalid) {
194+
this.isInvalid = newIsInvalid;
195+
// Force a re-render to update aria-describedby immediately.
196+
forceUpdate(this);
197+
}
198+
182199
this.ionBlur.emit();
183200
};
184201

@@ -208,9 +225,9 @@ export class Checkbox implements ComponentInterface {
208225
};
209226

210227
private getHintTextID(): string | undefined {
211-
const { el, helperText, errorText, helperTextId, errorTextId } = this;
228+
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
212229

213-
if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
230+
if (isInvalid && errorText) {
214231
return errorTextId;
215232
}
216233

@@ -226,7 +243,7 @@ export class Checkbox implements ComponentInterface {
226243
* This element should only be rendered if hint text is set.
227244
*/
228245
private renderHintText() {
229-
const { helperText, errorText, helperTextId, errorTextId } = this;
246+
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
230247

231248
/**
232249
* undefined and empty string values should
@@ -239,16 +256,26 @@ export class Checkbox implements ComponentInterface {
239256

240257
return (
241258
<div class="checkbox-bottom">
242-
<div id={helperTextId} class="helper-text" part="supporting-text helper-text">
243-
{helperText}
259+
<div id={helperTextId} class="helper-text" part="supporting-text helper-text" aria-live="polite">
260+
{!isInvalid ? helperText : null}
244261
</div>
245-
<div id={errorTextId} class="error-text" part="supporting-text error-text">
246-
{errorText}
262+
<div id={errorTextId} class="error-text" part="supporting-text error-text" role="alert">
263+
{isInvalid ? errorText : null}
247264
</div>
248265
</div>
249266
);
250267
}
251268

269+
/**
270+
* Checks if the input is in an invalid state based on Ionic validation classes
271+
*/
272+
private checkInvalidState(): boolean {
273+
const hasIonTouched = this.el.classList.contains('ion-touched');
274+
const hasIonInvalid = this.el.classList.contains('ion-invalid');
275+
276+
return hasIonTouched && hasIonInvalid;
277+
}
278+
252279
render() {
253280
const {
254281
color,
@@ -279,10 +306,11 @@ export class Checkbox implements ComponentInterface {
279306
role="checkbox"
280307
aria-checked={indeterminate ? 'mixed' : `${checked}`}
281308
aria-describedby={this.getHintTextID()}
282-
aria-invalid={this.getHintTextID() === this.errorTextId}
309+
aria-invalid={this.isInvalid ? 'true' : undefined}
283310
aria-labelledby={hasLabelContent ? this.inputLabelId : null}
284311
aria-label={inheritedAttributes['aria-label'] || null}
285312
aria-disabled={disabled ? 'true' : null}
313+
aria-required={required ? 'true' : undefined}
286314
tabindex={disabled ? undefined : 0}
287315
onKeyDown={this.onKeyDown}
288316
class={createColorClasses(color, {
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
<!DOCTYPE html>
2+
<html lang="en" dir="ltr">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>Checkbox - Validation</title>
6+
<meta
7+
name="viewport"
8+
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
9+
/>
10+
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
11+
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
12+
<script src="../../../../../scripts/testing/scripts.js"></script>
13+
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
14+
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
15+
<style>
16+
.grid {
17+
display: grid;
18+
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
19+
grid-row-gap: 30px;
20+
grid-column-gap: 30px;
21+
}
22+
23+
h2 {
24+
font-size: 12px;
25+
font-weight: normal;
26+
27+
color: var(--ion-color-step-600);
28+
29+
margin-top: 10px;
30+
margin-bottom: 5px;
31+
}
32+
33+
.validation-info {
34+
margin: 20px;
35+
padding: 10px;
36+
background: var(--ion-color-light);
37+
border-radius: 4px;
38+
}
39+
</style>
40+
</head>
41+
42+
<body>
43+
<ion-app>
44+
<ion-header>
45+
<ion-toolbar>
46+
<ion-title>Checkbox - Validation Test</ion-title>
47+
</ion-toolbar>
48+
</ion-header>
49+
50+
<ion-content class="ion-padding">
51+
<div class="validation-info">
52+
<h2>Screen Reader Testing Instructions:</h2>
53+
<ol>
54+
<li>Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)</li>
55+
<li>Tab through the form fields</li>
56+
<li>When you tab away from an empty required field, the error should be announced immediately</li>
57+
<li>The error text should be announced BEFORE the next field is announced</li>
58+
<li>Test in Chrome, Safari, and Firefox to verify consistent behavior</li>
59+
</ol>
60+
</div>
61+
62+
<div class="grid">
63+
<div>
64+
<h2>Required Field</h2>
65+
<ion-checkbox
66+
id="terms-checkbox"
67+
helper-text="You must agree to continue"
68+
error-text="Please accept the terms and conditions"
69+
required
70+
>I agree to the terms and conditions</ion-checkbox
71+
>
72+
</div>
73+
74+
<div>
75+
<h2>Optional Field (No Validation)</h2>
76+
<ion-checkbox id="optional-checkbox" helper-text="You can skip this field">Optional Checkbox</ion-checkbox>
77+
</div>
78+
</div>
79+
80+
<div class="ion-padding">
81+
<ion-button id="submit-btn" expand="block" disabled>Submit Form</ion-button>
82+
<ion-button id="reset-btn" expand="block" fill="outline">Reset Form</ion-button>
83+
</div>
84+
</ion-content>
85+
</ion-app>
86+
87+
<script>
88+
// Simple validation logic
89+
const checkboxes = document.querySelectorAll('ion-checkbox');
90+
const submitBtn = document.getElementById('submit-btn');
91+
const resetBtn = document.getElementById('reset-btn');
92+
93+
// Track which fields have been touched
94+
const touchedFields = new Set();
95+
96+
// Validation functions
97+
const validators = {
98+
'terms-checkbox': (checked) => {
99+
return checked === true;
100+
},
101+
'optional-checkbox': () => true, // Always valid
102+
};
103+
104+
function validateField(checkbox) {
105+
const checkboxId = checkbox.id;
106+
const checked = checkbox.checked;
107+
const isValid = validators[checkboxId] ? validators[checkboxId](checked) : true;
108+
109+
// Only show validation state if field has been touched
110+
if (touchedFields.has(checkboxId)) {
111+
if (isValid) {
112+
checkbox.classList.remove('ion-invalid');
113+
checkbox.classList.add('ion-valid');
114+
} else {
115+
checkbox.classList.remove('ion-valid');
116+
checkbox.classList.add('ion-invalid');
117+
}
118+
checkbox.classList.add('ion-touched');
119+
}
120+
121+
return isValid;
122+
}
123+
124+
function validateForm() {
125+
let allValid = true;
126+
checkboxes.forEach((checkbox) => {
127+
if (checkbox.id !== 'optional-checkbox') {
128+
const isValid = validateField(checkbox);
129+
if (!isValid) {
130+
allValid = false;
131+
}
132+
}
133+
});
134+
submitBtn.disabled = !allValid;
135+
return allValid;
136+
}
137+
138+
// Add event listeners
139+
checkboxes.forEach((checkbox) => {
140+
// Mark as touched on blur
141+
checkbox.addEventListener('ionBlur', (e) => {
142+
console.log('Blur event on:', checkbox.id);
143+
touchedFields.add(checkbox.id);
144+
validateField(checkbox);
145+
validateForm();
146+
147+
const isInvalid = checkbox.classList.contains('ion-invalid');
148+
if (isInvalid) {
149+
console.log('Field marked invalid:', checkbox.label, checkbox.errorText);
150+
}
151+
});
152+
153+
// Validate on change
154+
checkbox.addEventListener('ionChange', (e) => {
155+
console.log('Change event on:', checkbox.id);
156+
if (touchedFields.has(checkbox.id)) {
157+
validateField(checkbox);
158+
validateForm();
159+
}
160+
});
161+
});
162+
163+
// Reset button
164+
resetBtn.addEventListener('click', () => {
165+
checkboxes.forEach((checkbox) => {
166+
checkbox.checked = false;
167+
checkbox.classList.remove('ion-valid', 'ion-invalid', 'ion-touched');
168+
});
169+
touchedFields.clear();
170+
submitBtn.disabled = true;
171+
});
172+
173+
// Submit button
174+
submitBtn.addEventListener('click', () => {
175+
if (validateForm()) {
176+
alert('Form submitted successfully!');
177+
}
178+
});
179+
180+
// Initial setup
181+
validateForm();
182+
</script>
183+
</body>
184+
</html>

core/src/components/select/select.tsx

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ export class Select implements ComponentInterface {
8181
*/
8282
@State() hasFocus = false;
8383

84+
/**
85+
* Track validation state for proper aria-live announcements.
86+
*/
87+
@State() isInvalid = false;
88+
8489
/**
8590
* The text to display on the cancel button.
8691
*/
@@ -298,6 +303,9 @@ export class Select implements ComponentInterface {
298303
*/
299304
forceUpdate(this);
300305
});
306+
307+
// Always set initial state.
308+
this.isInvalid = this.checkInvalidState();
301309
}
302310

303311
componentWillLoad() {
@@ -868,8 +876,15 @@ export class Select implements ComponentInterface {
868876
};
869877

870878
private onBlur = () => {
879+
const newIsInvalid = this.checkInvalidState();
871880
this.hasFocus = false;
872881

882+
if (this.isInvalid !== newIsInvalid) {
883+
this.isInvalid = newIsInvalid;
884+
// Force a re-render to update aria-describedby immediately.
885+
forceUpdate(this);
886+
}
887+
873888
this.ionBlur.emit();
874889
};
875890

@@ -1067,9 +1082,9 @@ export class Select implements ComponentInterface {
10671082
}
10681083

10691084
private getHintTextID(): string | undefined {
1070-
const { el, helperText, errorText, helperTextId, errorTextId } = this;
1085+
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
10711086

1072-
if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
1087+
if (isInvalid && errorText) {
10731088
return errorTextId;
10741089
}
10751090

@@ -1084,14 +1099,14 @@ export class Select implements ComponentInterface {
10841099
* Renders the helper text or error text values
10851100
*/
10861101
private renderHintText() {
1087-
const { helperText, errorText, helperTextId, errorTextId } = this;
1102+
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
10881103

10891104
return [
1090-
<div id={helperTextId} class="helper-text" part="supporting-text helper-text">
1091-
{helperText}
1105+
<div id={helperTextId} class="helper-text" part="supporting-text helper-text" aria-live="polite">
1106+
{isInvalid ? helperText : null}
10921107
</div>,
1093-
<div id={errorTextId} class="error-text" part="supporting-text error-text">
1094-
{errorText}
1108+
<div id={errorTextId} class="error-text" part="supporting-text error-text" role="alert">
1109+
{isInvalid ? errorText : null}
10951110
</div>,
10961111
];
10971112
}
@@ -1115,6 +1130,16 @@ export class Select implements ComponentInterface {
11151130
return <div class="select-bottom">{this.renderHintText()}</div>;
11161131
}
11171132

1133+
/**
1134+
* Checks if the input is in an invalid state based on Ionic validation classes
1135+
*/
1136+
private checkInvalidState(): boolean {
1137+
const hasIonTouched = this.el.classList.contains('ion-touched');
1138+
const hasIonInvalid = this.el.classList.contains('ion-invalid');
1139+
1140+
return hasIonTouched && hasIonInvalid;
1141+
}
1142+
11181143
render() {
11191144
const {
11201145
disabled,

0 commit comments

Comments
 (0)