Skip to content

Commit 92db364

Browse files
authored
fix(checkbox, toggle, radio-group): improve screen reader announcement timing for validation errors (#30714)
Issue number: internal --------- <!-- Please do not submit updates to dependencies unless it fixes an issue. --> <!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. --> ## What is the current behavior? <!-- Please describe the current behavior that you are modifying. --> Currently, when an error text is shown, it may not announce itself to voice assistants. This is because the way error text currently works is by always existing in the DOM, but being hidden when there is no error. When the error state changes, the error text is shown, but as far as the voice assistant can tell it's always been there and nothing has changed. ## What is the new behavior? <!-- Please describe the behavior or changes that are being added by this PR. --> - Updated aria attributes - Added observer with an observer ## Does this introduce a breaking change? - [ ] Yes - [x] No <!-- If this introduces a breaking change: 1. Describe the impact and migration path for existing applications below. 2. Update the BREAKING.md file with the breaking change. 3. Add "BREAKING CHANGE: [...]" to the commit description when merging. See https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer for more information. --> ## Other information <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. --> [Checkbox preview](https://ionic-framework-git-fw-6757-ionic1.vercel.app/src/components/checkbox/test/validation/) [Toggle preview](https://ionic-framework-git-fw-6757-ionic1.vercel.app/src/components/toggle/test/validation/) [Radio Group preview](https://ionic-framework-git-fw-6757-ionic1.vercel.app/src/components/radio-group/test/validation/) ⚠️ Flakiness ⚠️ The flakiness on checkbox and toggle is because when a native input is present on the page, the browser will have the screen reader to be really fast when it comes to checking information. This speed ends up being too fast for `ion-checkbox` to be able to add the error text. This leads to the error text not being announce consistently. There's no issue when it comes to ion-input or ion-textarea because Ionic uses the native inputs so their arias are read. There's also no issue with ion-select because we don't have a native input. It's only an issue with checkbox and the others is because it has a [native input that is being hidden](https://github.com/ionic-team/ionic-framework/blob/8e884bd2cb73481acbb6eb4a4a507d6f6a8716b1/core/src/components/checkbox/checkbox.tsx#L368-L369). So the browser sees that and speeds up the screen reader. The flakiness on radio group is because when you tab out of the last radio button, the ionBlur event is emitted by the child <ion-radio>. This event bubbles up, but the timing is still too early for the group.
1 parent c37e2a5 commit 92db364

File tree

21 files changed

+1348
-44
lines changed

21 files changed

+1348
-44
lines changed

core/src/components/checkbox/checkbox.tsx

Lines changed: 74 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ComponentInterface, EventEmitter } from '@stencil/core';
2-
import { Component, Element, Event, Host, Method, Prop, h } from '@stencil/core';
2+
import { Build, Component, Element, Event, Host, Method, Prop, State, h } from '@stencil/core';
3+
import { checkInvalidState } from '@utils/forms';
34
import type { Attributes } from '@utils/helpers';
45
import { inheritAriaAttributes, renderHiddenInput } from '@utils/helpers';
56
import { createColorClasses, hostContext } from '@utils/theme';
@@ -35,6 +36,7 @@ export class Checkbox implements ComponentInterface {
3536
private helperTextId = `${this.inputId}-helper-text`;
3637
private errorTextId = `${this.inputId}-error-text`;
3738
private inheritedAttributes: Attributes = {};
39+
private validationObserver?: MutationObserver;
3840

3941
@Element() el!: HTMLIonCheckboxElement;
4042

@@ -120,6 +122,13 @@ export class Checkbox implements ComponentInterface {
120122
*/
121123
@Prop() required = false;
122124

125+
/**
126+
* Track validation state for proper aria-live announcements.
127+
*/
128+
@State() isInvalid = false;
129+
130+
@State() private hintTextId?: string;
131+
123132
/**
124133
* Emitted when the checked property has changed as a result of a user action such as a click.
125134
*
@@ -137,10 +146,63 @@ export class Checkbox implements ComponentInterface {
137146
*/
138147
@Event() ionBlur!: EventEmitter<void>;
139148

149+
connectedCallback() {
150+
const { el } = this;
151+
152+
// Watch for class changes to update validation state.
153+
if (Build.isBrowser && typeof MutationObserver !== 'undefined') {
154+
this.validationObserver = new MutationObserver(() => {
155+
const newIsInvalid = checkInvalidState(el);
156+
if (this.isInvalid !== newIsInvalid) {
157+
this.isInvalid = newIsInvalid;
158+
/**
159+
* Screen readers tend to announce changes
160+
* to `aria-describedby` when the attribute
161+
* is changed during a blur event for a
162+
* native form control.
163+
* However, the announcement can be spotty
164+
* when using a non-native form control
165+
* and `forceUpdate()`.
166+
* This is due to `forceUpdate()` internally
167+
* rescheduling the DOM update to a lower
168+
* priority queue regardless if it's called
169+
* inside a Promise or not, thus causing
170+
* the screen reader to potentially miss the
171+
* change.
172+
* By using a State variable inside a Promise,
173+
* it guarantees a re-render immediately at
174+
* a higher priority.
175+
*/
176+
Promise.resolve().then(() => {
177+
this.hintTextId = this.getHintTextId();
178+
});
179+
}
180+
});
181+
182+
this.validationObserver.observe(el, {
183+
attributes: true,
184+
attributeFilter: ['class'],
185+
});
186+
}
187+
188+
// Always set initial state
189+
this.isInvalid = checkInvalidState(el);
190+
}
191+
140192
componentWillLoad() {
141193
this.inheritedAttributes = {
142194
...inheritAriaAttributes(this.el),
143195
};
196+
197+
this.hintTextId = this.getHintTextId();
198+
}
199+
200+
disconnectedCallback() {
201+
// Clean up validation observer to prevent memory leaks.
202+
if (this.validationObserver) {
203+
this.validationObserver.disconnect();
204+
this.validationObserver = undefined;
205+
}
144206
}
145207

146208
/** @internal */
@@ -203,10 +265,10 @@ export class Checkbox implements ComponentInterface {
203265
ev.stopPropagation();
204266
};
205267

206-
private getHintTextID(): string | undefined {
207-
const { el, helperText, errorText, helperTextId, errorTextId } = this;
268+
private getHintTextId(): string | undefined {
269+
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
208270

209-
if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
271+
if (isInvalid && errorText) {
210272
return errorTextId;
211273
}
212274

@@ -222,7 +284,7 @@ export class Checkbox implements ComponentInterface {
222284
* This element should only be rendered if hint text is set.
223285
*/
224286
private renderHintText() {
225-
const { helperText, errorText, helperTextId, errorTextId } = this;
287+
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
226288

227289
/**
228290
* undefined and empty string values should
@@ -235,11 +297,11 @@ export class Checkbox implements ComponentInterface {
235297

236298
return (
237299
<div class="checkbox-bottom">
238-
<div id={helperTextId} class="helper-text" part="supporting-text helper-text">
239-
{helperText}
300+
<div id={helperTextId} class="helper-text" part="supporting-text helper-text" aria-live="polite">
301+
{!isInvalid ? helperText : null}
240302
</div>
241-
<div id={errorTextId} class="error-text" part="supporting-text error-text">
242-
{errorText}
303+
<div id={errorTextId} class="error-text" part="supporting-text error-text" role="alert">
304+
{isInvalid ? errorText : null}
243305
</div>
244306
</div>
245307
);
@@ -274,11 +336,12 @@ export class Checkbox implements ComponentInterface {
274336
<Host
275337
role="checkbox"
276338
aria-checked={indeterminate ? 'mixed' : `${checked}`}
277-
aria-describedby={this.getHintTextID()}
278-
aria-invalid={this.getHintTextID() === this.errorTextId}
339+
aria-describedby={this.hintTextId}
340+
aria-invalid={this.isInvalid ? 'true' : undefined}
279341
aria-labelledby={hasLabelContent ? this.inputLabelId : null}
280342
aria-label={inheritedAttributes['aria-label'] || null}
281343
aria-disabled={disabled ? 'true' : null}
344+
aria-required={required ? 'true' : undefined}
282345
tabindex={disabled ? undefined : 0}
283346
onKeyDown={this.onKeyDown}
284347
onFocus={this.onFocus}
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.innerText, 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>

0 commit comments

Comments
 (0)