Skip to content

Commit f508821

Browse files
committed
fix(toggle): improve error text accessibility
1 parent 8e46414 commit f508821

File tree

4 files changed

+259
-14
lines changed

4 files changed

+259
-14
lines changed

core/src/components/checkbox/checkbox.tsx

Lines changed: 1 addition & 1 deletion
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, State, h, Build } from '@stencil/core';
2+
import { Build, Component, Element, Event, Host, Method, Prop, State, h } from '@stencil/core';
33
import { checkInvalidState } from '@utils/forms';
44
import type { Attributes } from '@utils/helpers';
55
import { inheritAriaAttributes, renderHiddenInput } from '@utils/helpers';
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>Toggle - 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>Toggle - 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-toggle
66+
id="terms-toggle"
67+
helper-text="You must turn on to continue"
68+
error-text="Please turn on this toggle"
69+
required
70+
>Tap to turn on</ion-toggle
71+
>
72+
</div>
73+
74+
<div>
75+
<h2>Optional Field (No Validation)</h2>
76+
<ion-toggle id="optional-toggle" helper-text="You can skip this field">Optional Toggle</ion-toggle>
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 toggles = document.querySelectorAll('ion-toggle');
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-toggle': (checked) => {
99+
return checked === true;
100+
},
101+
'optional-toggle': () => true, // Always valid
102+
};
103+
104+
function validateField(toggle) {
105+
const toggleId = toggle.id;
106+
const checked = toggle.checked;
107+
const isValid = validators[toggleId] ? validators[toggleId](checked) : true;
108+
109+
// Only show validation state if field has been touched
110+
if (touchedFields.has(toggleId)) {
111+
if (isValid) {
112+
toggle.classList.remove('ion-invalid');
113+
toggle.classList.add('ion-valid');
114+
} else {
115+
toggle.classList.remove('ion-valid');
116+
toggle.classList.add('ion-invalid');
117+
}
118+
toggle.classList.add('ion-touched');
119+
}
120+
121+
return isValid;
122+
}
123+
124+
function validateForm() {
125+
let allValid = true;
126+
toggles.forEach((toggle) => {
127+
if (toggle.id !== 'optional-toggle') {
128+
const isValid = validateField(toggle);
129+
if (!isValid) {
130+
allValid = false;
131+
}
132+
}
133+
});
134+
submitBtn.disabled = !allValid;
135+
return allValid;
136+
}
137+
138+
// Add event listeners
139+
toggles.forEach((toggle) => {
140+
// Mark as touched on blur
141+
toggle.addEventListener('ionBlur', (e) => {
142+
console.log('Blur event on:', toggle.id);
143+
touchedFields.add(toggle.id);
144+
validateField(toggle);
145+
validateForm();
146+
147+
const isInvalid = toggle.classList.contains('ion-invalid');
148+
if (isInvalid) {
149+
console.log('Field marked invalid:', toggle.label, toggle.errorText);
150+
}
151+
});
152+
153+
// Validate on change
154+
toggle.addEventListener('ionChange', (e) => {
155+
console.log('Change event on:', toggle.id);
156+
if (touchedFields.has(toggle.id)) {
157+
validateField(toggle);
158+
validateForm();
159+
}
160+
});
161+
});
162+
163+
// Reset button
164+
resetBtn.addEventListener('click', () => {
165+
toggles.forEach((toggle) => {
166+
toggle.checked = false;
167+
toggle.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/toggle/toggle.tsx

Lines changed: 68 additions & 12 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, Prop, State, Watch, h } from '@stencil/core';
2+
import { Build, Component, Element, Event, Host, Prop, State, Watch, h } from '@stencil/core';
3+
import { checkInvalidState } from '@utils/forms';
34
import { renderHiddenInput, inheritAriaAttributes } from '@utils/helpers';
45
import type { Attributes } from '@utils/helpers';
56
import { hapticSelection } from '@utils/native/haptic';
@@ -44,11 +45,19 @@ export class Toggle implements ComponentInterface {
4445
private inheritedAttributes: Attributes = {};
4546
private toggleTrack?: HTMLElement;
4647
private didLoad = false;
48+
private validationObserver?: MutationObserver;
4749

4850
@Element() el!: HTMLIonToggleElement;
4951

5052
@State() activated = false;
5153

54+
/**
55+
* Track validation state for proper aria-live announcements.
56+
*/
57+
@State() isInvalid = false;
58+
59+
@State() private hintTextID?: string;
60+
5261
/**
5362
* The color to use from your application's color palette.
5463
* Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`.
@@ -168,15 +177,56 @@ export class Toggle implements ComponentInterface {
168177
}
169178

170179
async connectedCallback() {
180+
const { didLoad, el } = this;
181+
171182
/**
172183
* If we have not yet rendered
173184
* ion-toggle, then toggleTrack is not defined.
174185
* But if we are moving ion-toggle via appendChild,
175186
* then toggleTrack will be defined.
176187
*/
177-
if (this.didLoad) {
188+
if (didLoad) {
178189
this.setupGesture();
179190
}
191+
192+
// Watch for class changes to update validation state.
193+
if (Build.isBrowser && typeof MutationObserver !== 'undefined') {
194+
this.validationObserver = new MutationObserver(() => {
195+
const newIsInvalid = checkInvalidState(el);
196+
if (this.isInvalid !== newIsInvalid) {
197+
this.isInvalid = newIsInvalid;
198+
/**
199+
* Screen readers tend to announce changes
200+
* to `aria-describedby` when the attribute
201+
* is changed during a blur event for a
202+
* native form control.
203+
* However, the announcement can be spotty
204+
* when using a non-native form control
205+
* and `forceUpdate()`.
206+
* This is due to `forceUpdate()` internally
207+
* rescheduling the DOM update to a lower
208+
* priority queue regardless if it's called
209+
* inside a Promise or not, thus causing
210+
* the screen reader to potentially miss the
211+
* change.
212+
* By using a State variable inside a Promise,
213+
* it guarantees a re-render immediately at
214+
* a higher priority.
215+
*/
216+
Promise.resolve().then(() => {
217+
this.hintTextID = this.getHintTextID();
218+
});
219+
}
220+
});
221+
222+
this.validationObserver.observe(el, {
223+
attributes: true,
224+
attributeFilter: ['class'],
225+
});
226+
}
227+
228+
// Always set initial state
229+
this.isInvalid = checkInvalidState(el);
180230
}
181231

182232
componentDidLoad() {
@@ -207,6 +257,12 @@ export class Toggle implements ComponentInterface {
207257
this.gesture.destroy();
208258
this.gesture = undefined;
209259
}
260+
261+
// Clean up validation observer to prevent memory leaks.
262+
if (this.validationObserver) {
263+
this.validationObserver.disconnect();
264+
this.validationObserver = undefined;
265+
}
210266
}
211267

212268
componentWillLoad() {
@@ -336,9 +392,9 @@ export class Toggle implements ComponentInterface {
336392
}
337393

338394
private getHintTextID(): string | undefined {
339-
const { el, helperText, errorText, helperTextId, errorTextId } = this;
395+
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
340396

341-
if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
397+
if (isInvalid && errorText) {
342398
return errorTextId;
343399
}
344400

@@ -354,7 +410,7 @@ export class Toggle implements ComponentInterface {
354410
* This element should only be rendered if hint text is set.
355411
*/
356412
private renderHintText() {
357-
const { helperText, errorText, helperTextId, errorTextId } = this;
413+
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
358414

359415
/**
360416
* undefined and empty string values should
@@ -367,11 +423,11 @@ export class Toggle implements ComponentInterface {
367423

368424
return (
369425
<div class="toggle-bottom">
370-
<div id={helperTextId} class="helper-text" part="supporting-text helper-text">
371-
{helperText}
426+
<div id={helperTextId} class="helper-text" part="supporting-text helper-text" aria-live="polite">
427+
{!isInvalid ? helperText : null}
372428
</div>
373-
<div id={errorTextId} class="error-text" part="supporting-text error-text">
374-
{errorText}
429+
<div id={errorTextId} class="error-text" part="supporting-text error-text" role="alert">
430+
{isInvalid ? errorText : null}
375431
</div>
376432
</div>
377433
);
@@ -385,7 +441,6 @@ export class Toggle implements ComponentInterface {
385441
color,
386442
disabled,
387443
el,
388-
errorTextId,
389444
hasLabel,
390445
inheritedAttributes,
391446
inputId,
@@ -405,12 +460,13 @@ export class Toggle implements ComponentInterface {
405460
<Host
406461
role="switch"
407462
aria-checked={`${checked}`}
408-
aria-describedby={this.getHintTextID()}
409-
aria-invalid={this.getHintTextID() === errorTextId}
463+
aria-describedby={this.hintTextID}
464+
aria-invalid={this.isInvalid ? 'true' : undefined}
410465
onClick={this.onClick}
411466
aria-labelledby={hasLabel ? inputLabelId : null}
412467
aria-label={inheritedAttributes['aria-label'] || null}
413468
aria-disabled={disabled ? 'true' : null}
469+
aria-required={required ? 'true' : undefined}
414470
tabindex={disabled ? undefined : 0}
415471
onKeyDown={this.onKeyDown}
416472
onFocus={this.onFocus}

core/src/utils/forms/validity.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
type FormElement = HTMLIonInputElement | HTMLIonTextareaElement | HTMLIonSelectElement | HTMLIonCheckboxElement;
1+
type FormElement =
2+
| HTMLIonInputElement
3+
| HTMLIonTextareaElement
4+
| HTMLIonSelectElement
5+
| HTMLIonCheckboxElement
6+
| HTMLIonToggleElement;
27

38
/**
49
* Checks if the form element is in an invalid state based on

0 commit comments

Comments
 (0)