Skip to content

Commit cde3f14

Browse files
committed
fix(radio-group): improve error text accessibility
1 parent f508821 commit cde3f14

File tree

3 files changed

+263
-11
lines changed

3 files changed

+263
-11
lines changed

core/src/components/radio-group/radio-group.tsx

Lines changed: 66 additions & 10 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, Listen, Method, Prop, Watch, h } from '@stencil/core';
2+
import { Build, Component, Element, Event, Host, Listen, Method, Prop, State, Watch, h } from '@stencil/core';
3+
import { checkInvalidState } from '@utils/forms';
34
import { renderHiddenInput } from '@utils/helpers';
45

56
import { getIonMode } from '../../global/ionic-global';
@@ -19,9 +20,17 @@ export class RadioGroup implements ComponentInterface {
1920
private errorTextId = `${this.inputId}-error-text`;
2021
private labelId = `${this.inputId}-lbl`;
2122
private label?: HTMLIonLabelElement | null;
23+
private validationObserver?: MutationObserver;
2224

2325
@Element() el!: HTMLElement;
2426

27+
/**
28+
* Track validation state for proper aria-live announcements.
29+
*/
30+
@State() isInvalid = false;
31+
32+
@State() private hintTextID?: string;
33+
2534
/**
2635
* If `true`, the radios can be deselected.
2736
*/
@@ -121,6 +130,53 @@ export class RadioGroup implements ComponentInterface {
121130
this.labelId = label.id = this.name + '-lbl';
122131
}
123132
}
133+
134+
// Watch for class changes to update validation state.
135+
if (Build.isBrowser && typeof MutationObserver !== 'undefined') {
136+
this.validationObserver = new MutationObserver(() => {
137+
const newIsInvalid = checkInvalidState(this.el);
138+
if (this.isInvalid !== newIsInvalid) {
139+
this.isInvalid = newIsInvalid;
140+
/**
141+
* Screen readers tend to announce changes
142+
* to `aria-describedby` when the attribute
143+
* is changed during a blur event for a
144+
* native form control.
145+
* However, the announcement can be spotty
146+
* when using a non-native form control
147+
* and `forceUpdate()`.
148+
* This is due to `forceUpdate()` internally
149+
* rescheduling the DOM update to a lower
150+
* priority queue regardless if it's called
151+
* inside a Promise or not, thus causing
152+
* the screen reader to potentially miss the
153+
* change.
154+
* By using a State variable inside a Promise,
155+
* it guarantees a re-render immediately at
156+
* a higher priority.
157+
*/
158+
Promise.resolve().then(() => {
159+
this.hintTextID = this.getHintTextID();
160+
});
161+
}
162+
});
163+
164+
this.validationObserver.observe(this.el, {
165+
attributes: true,
166+
attributeFilter: ['class'],
167+
});
168+
}
169+
170+
// Always set initial state
171+
this.isInvalid = checkInvalidState(this.el);
172+
}
173+
174+
disconnectedCallback() {
175+
// Clean up validation observer to prevent memory leaks.
176+
if (this.validationObserver) {
177+
this.validationObserver.disconnect();
178+
this.validationObserver = undefined;
179+
}
124180
}
125181

126182
private getRadios(): HTMLIonRadioElement[] {
@@ -244,7 +300,7 @@ export class RadioGroup implements ComponentInterface {
244300
* Renders the helper text or error text values
245301
*/
246302
private renderHintText() {
247-
const { helperText, errorText, helperTextId, errorTextId } = this;
303+
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
248304

249305
const hasHintText = !!helperText || !!errorText;
250306
if (!hasHintText) {
@@ -253,20 +309,20 @@ export class RadioGroup implements ComponentInterface {
253309

254310
return (
255311
<div class="radio-group-top">
256-
<div id={helperTextId} class="helper-text">
257-
{helperText}
312+
<div id={helperTextId} class="helper-text" aria-live="polite">
313+
{!isInvalid ? helperText : null}
258314
</div>
259-
<div id={errorTextId} class="error-text">
260-
{errorText}
315+
<div id={errorTextId} class="error-text" role="alert">
316+
{isInvalid ? errorText : null}
261317
</div>
262318
</div>
263319
);
264320
}
265321

266322
private getHintTextID(): string | undefined {
267-
const { el, helperText, errorText, helperTextId, errorTextId } = this;
323+
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
268324

269-
if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
325+
if (isInvalid && errorText) {
270326
return errorTextId;
271327
}
272328

@@ -287,8 +343,8 @@ export class RadioGroup implements ComponentInterface {
287343
<Host
288344
role="radiogroup"
289345
aria-labelledby={label ? labelId : null}
290-
aria-describedby={this.getHintTextID()}
291-
aria-invalid={this.getHintTextID() === this.errorTextId}
346+
aria-describedby={this.hintTextID}
347+
aria-invalid={this.isInvalid ? 'true' : undefined}
292348
onClick={this.onClick}
293349
class={mode}
294350
>
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
<!DOCTYPE html>
2+
<html lang="en" dir="ltr">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>Radrio Group - 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>Radio Group - 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-radio-group
66+
id="fruits-radio-group"
67+
helper-text="You must select one to continue"
68+
error-text="Please select a fruit"
69+
allow-empty-selection="true"
70+
required
71+
>
72+
<ion-radio value="grapes">Grapes</ion-radio><br />
73+
<ion-radio value="strawberries">Strawberries</ion-radio>
74+
</ion-radio-group>
75+
</div>
76+
77+
<div>
78+
<h2>Optional Field (No Validation)</h2>
79+
<ion-radio-group
80+
id="optional-radio-group"
81+
helper-text="You can skip this field"
82+
allow-empty-selection="true"
83+
required
84+
>
85+
<ion-radio value="cucumbers">Cucumbers</ion-radio><br />
86+
<ion-radio value="tomatoes">Tomatoes</ion-radio>
87+
</ion-radio-group>
88+
</div>
89+
</div>
90+
91+
<div class="ion-padding">
92+
<ion-button id="submit-btn" expand="block" disabled>Submit Form</ion-button>
93+
<ion-button id="reset-btn" expand="block" fill="outline">Reset Form</ion-button>
94+
</div>
95+
</ion-content>
96+
</ion-app>
97+
98+
<script>
99+
// Simple validation logic
100+
const radioGroups = document.querySelectorAll('ion-radio-group');
101+
const submitBtn = document.getElementById('submit-btn');
102+
const resetBtn = document.getElementById('reset-btn');
103+
104+
// Track which fields have been touched
105+
const touchedFields = new Set();
106+
107+
// Validation functions
108+
const validators = {
109+
'fruits-radio-group': (value) => {
110+
return value !== undefined;
111+
},
112+
'optional-checkbox': () => true, // Always valid
113+
};
114+
115+
function validateField(radioGroup) {
116+
const radioGroupId = radioGroup.id;
117+
const value = radioGroup.value;
118+
const isValid = validators[radioGroupId] ? validators[radioGroupId](value) : true;
119+
120+
// Only show validation state if field has been touched
121+
if (touchedFields.has(radioGroupId)) {
122+
if (isValid) {
123+
radioGroup.classList.remove('ion-invalid');
124+
radioGroup.classList.add('ion-valid');
125+
} else {
126+
radioGroup.classList.remove('ion-valid');
127+
radioGroup.classList.add('ion-invalid');
128+
}
129+
radioGroup.classList.add('ion-touched');
130+
}
131+
132+
return isValid;
133+
}
134+
135+
function validateForm() {
136+
let allValid = true;
137+
radioGroups.forEach((radioGroup) => {
138+
if (radioGroup.id !== 'optional-radio-group') {
139+
const isValid = validateField(radioGroup);
140+
if (!isValid) {
141+
allValid = false;
142+
}
143+
}
144+
});
145+
submitBtn.disabled = !allValid;
146+
return allValid;
147+
}
148+
149+
// Add event listeners
150+
radioGroups.forEach((radioGroup) => {
151+
// Mark as touched on blur
152+
radioGroup.addEventListener('ionBlur', (e) => {
153+
console.log('Blur event on:', radioGroup.id);
154+
touchedFields.add(radioGroup.id);
155+
validateField(radioGroup);
156+
validateForm();
157+
158+
const isInvalid = radioGroup.classList.contains('ion-invalid');
159+
if (isInvalid) {
160+
console.log('Field marked invalid:', radioGroup.label, radioGroup.errorText);
161+
}
162+
});
163+
164+
// Validate on change
165+
radioGroup.addEventListener('ionChange', (e) => {
166+
console.log('Change event on:', radioGroup.id);
167+
if (touchedFields.has(radioGroup.id)) {
168+
validateField(radioGroup);
169+
validateForm();
170+
}
171+
});
172+
});
173+
174+
// Reset button
175+
resetBtn.addEventListener('click', () => {
176+
radioGroups.forEach((radioGroup) => {
177+
radioGroup.value = '';
178+
radioGroup.classList.remove('ion-valid', 'ion-invalid', 'ion-touched');
179+
});
180+
touchedFields.clear();
181+
submitBtn.disabled = true;
182+
});
183+
184+
// Submit button
185+
submitBtn.addEventListener('click', () => {
186+
if (validateForm()) {
187+
alert('Form submitted successfully!');
188+
}
189+
});
190+
191+
// Initial setup
192+
validateForm();
193+
</script>
194+
</body>
195+
</html>

core/src/utils/forms/validity.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ type FormElement =
33
| HTMLIonTextareaElement
44
| HTMLIonSelectElement
55
| HTMLIonCheckboxElement
6-
| HTMLIonToggleElement;
6+
| HTMLIonToggleElement
7+
| HTMLElement;
78

89
/**
910
* Checks if the form element is in an invalid state based on

0 commit comments

Comments
 (0)