Skip to content

Commit 03303d7

Browse files
authored
fix(select): improve screen reader announcement timing for validation errors (#30723)
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 We had to do this with a mutation observer and state because it's important in some frameworks, like Angular, that state changes to cause a re-render. This, combined with some minor aria changes, makes it so that when a field is declared invalid, it immediately announces the invalid state instead of waiting for the user to go back to the invalid field. ## 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. --> [Preview](https://ionic-framework-git-fw-6797-ionic1.vercel.app/src/components/select/test/validation/)
1 parent 18e1d3e commit 03303d7

File tree

13 files changed

+482
-37
lines changed

13 files changed

+482
-37
lines changed

core/src/components/input/input.tsx

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
h,
1515
} from '@stencil/core';
1616
import type { NotchController } from '@utils/forms';
17-
import { createNotchController } from '@utils/forms';
17+
import { createNotchController, checkInvalidState } from '@utils/forms';
1818
import type { Attributes } from '@utils/helpers';
1919
import { inheritAriaAttributes, debounceEvent, inheritAttributes, componentOnReady } from '@utils/helpers';
2020
import { createSlotMutationController } from '@utils/slot-mutation-controller';
@@ -403,16 +403,6 @@ export class Input implements ComponentInterface {
403403
};
404404
}
405405

406-
/**
407-
* Checks if the input is in an invalid state based on Ionic validation classes
408-
*/
409-
private checkInvalidState(): boolean {
410-
const hasIonTouched = this.el.classList.contains('ion-touched');
411-
const hasIonInvalid = this.el.classList.contains('ion-invalid');
412-
413-
return hasIonTouched && hasIonInvalid;
414-
}
415-
416406
connectedCallback() {
417407
const { el } = this;
418408

@@ -426,7 +416,7 @@ export class Input implements ComponentInterface {
426416
// Watch for class changes to update validation state
427417
if (Build.isBrowser && typeof MutationObserver !== 'undefined') {
428418
this.validationObserver = new MutationObserver(() => {
429-
const newIsInvalid = this.checkInvalidState();
419+
const newIsInvalid = checkInvalidState(el);
430420
if (this.isInvalid !== newIsInvalid) {
431421
this.isInvalid = newIsInvalid;
432422
// Force a re-render to update aria-describedby immediately
@@ -441,7 +431,7 @@ export class Input implements ComponentInterface {
441431
}
442432

443433
// Always set initial state
444-
this.isInvalid = this.checkInvalidState();
434+
this.isInvalid = checkInvalidState(el);
445435

446436
this.debounceChanged();
447437
if (Build.isBrowser) {

core/src/components/select/select.tsx

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ComponentInterface, EventEmitter } from '@stencil/core';
2-
import { Component, Element, Event, Host, Method, Prop, State, Watch, h, forceUpdate } from '@stencil/core';
2+
import { Build, Component, Element, Event, Host, Method, Prop, State, Watch, h, forceUpdate } from '@stencil/core';
33
import type { NotchController } from '@utils/forms';
4-
import { compareOptions, createNotchController, isOptionSelected } from '@utils/forms';
4+
import { compareOptions, createNotchController, isOptionSelected, checkInvalidState } from '@utils/forms';
55
import { focusVisibleElement, renderHiddenInput, inheritAttributes } from '@utils/helpers';
66
import type { Attributes } from '@utils/helpers';
77
import { printIonWarning } from '@utils/logging';
@@ -64,6 +64,7 @@ export class Select implements ComponentInterface {
6464
private inheritedAttributes: Attributes = {};
6565
private nativeWrapperEl: HTMLElement | undefined;
6666
private notchSpacerEl: HTMLElement | undefined;
67+
private validationObserver?: MutationObserver;
6768

6869
private notchController?: NotchController;
6970

@@ -81,6 +82,13 @@ export class Select implements ComponentInterface {
8182
*/
8283
@State() hasFocus = false;
8384

85+
/**
86+
* Track validation state for proper aria-live announcements.
87+
*/
88+
@State() isInvalid = false;
89+
90+
@State() private hintTextID?: string;
91+
8492
/**
8593
* The text to display on the cancel button.
8694
*/
@@ -298,10 +306,51 @@ export class Select implements ComponentInterface {
298306
*/
299307
forceUpdate(this);
300308
});
309+
310+
// Watch for class changes to update validation state.
311+
if (Build.isBrowser && typeof MutationObserver !== 'undefined') {
312+
this.validationObserver = new MutationObserver(() => {
313+
const newIsInvalid = checkInvalidState(this.el);
314+
if (this.isInvalid !== newIsInvalid) {
315+
this.isInvalid = newIsInvalid;
316+
/**
317+
* Screen readers tend to announce changes
318+
* to `aria-describedby` when the attribute
319+
* is changed during a blur event for a
320+
* native form control.
321+
* However, the announcement can be spotty
322+
* when using a non-native form control
323+
* and `forceUpdate()`.
324+
* This is due to `forceUpdate()` internally
325+
* rescheduling the DOM update to a lower
326+
* priority queue regardless if it's called
327+
* inside a Promise or not, thus causing
328+
* the screen reader to potentially miss the
329+
* change.
330+
* By using a State variable inside a Promise,
331+
* it guarantees a re-render immediately at
332+
* a higher priority.
333+
*/
334+
Promise.resolve().then(() => {
335+
this.hintTextID = this.getHintTextID();
336+
});
337+
}
338+
});
339+
340+
this.validationObserver.observe(el, {
341+
attributes: true,
342+
attributeFilter: ['class'],
343+
});
344+
}
345+
346+
// Always set initial state
347+
this.isInvalid = checkInvalidState(this.el);
301348
}
302349

303350
componentWillLoad() {
304351
this.inheritedAttributes = inheritAttributes(this.el, ['aria-label']);
352+
353+
this.hintTextID = this.getHintTextID();
305354
}
306355

307356
componentDidLoad() {
@@ -328,6 +377,12 @@ export class Select implements ComponentInterface {
328377
this.notchController.destroy();
329378
this.notchController = undefined;
330379
}
380+
381+
// Clean up validation observer to prevent memory leaks.
382+
if (this.validationObserver) {
383+
this.validationObserver.disconnect();
384+
this.validationObserver = undefined;
385+
}
331386
}
332387

333388
/**
@@ -1056,8 +1111,8 @@ export class Select implements ComponentInterface {
10561111
aria-label={this.ariaLabel}
10571112
aria-haspopup="dialog"
10581113
aria-expanded={`${isExpanded}`}
1059-
aria-describedby={this.getHintTextID()}
1060-
aria-invalid={this.getHintTextID() === this.errorTextId}
1114+
aria-describedby={this.hintTextID}
1115+
aria-invalid={this.isInvalid ? 'true' : undefined}
10611116
aria-required={`${required}`}
10621117
onFocus={this.onFocus}
10631118
onBlur={this.onBlur}
@@ -1067,9 +1122,9 @@ export class Select implements ComponentInterface {
10671122
}
10681123

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

1072-
if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
1127+
if (isInvalid && errorText) {
10731128
return errorTextId;
10741129
}
10751130

@@ -1084,14 +1139,14 @@ export class Select implements ComponentInterface {
10841139
* Renders the helper text or error text values
10851140
*/
10861141
private renderHintText() {
1087-
const { helperText, errorText, helperTextId, errorTextId } = this;
1142+
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
10881143

10891144
return [
1090-
<div id={helperTextId} class="helper-text" part="supporting-text helper-text">
1091-
{helperText}
1145+
<div id={helperTextId} class="helper-text" part="supporting-text helper-text" aria-live="polite">
1146+
{!isInvalid ? helperText : null}
10921147
</div>,
1093-
<div id={errorTextId} class="error-text" part="supporting-text error-text">
1094-
{errorText}
1148+
<div id={errorTextId} class="error-text" part="supporting-text error-text" role="alert">
1149+
{isInvalid ? errorText : null}
10951150
</div>,
10961151
];
10971152
}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
<!DOCTYPE html>
2+
<html lang="en" dir="ltr">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>Select - 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>Select - 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-select
66+
id="fruits-select"
67+
label="Fruits"
68+
placeholder="Select one"
69+
interface="alert"
70+
helper-text="You must select an option to continue"
71+
error-text="This field is required"
72+
required
73+
>
74+
<ion-select-option value="apples">Apples</ion-select-option>
75+
<ion-select-option value="oranges">Oranges</ion-select-option>
76+
<ion-select-option value="pears">Pears</ion-select-option>
77+
</ion-select>
78+
</div>
79+
80+
<div>
81+
<h2>Optional Field (No Validation)</h2>
82+
<ion-select
83+
id="optional-select"
84+
label="Colors"
85+
placeholder="Select one"
86+
interface="alert"
87+
helper-text="You can skip this field"
88+
>
89+
<ion-select-option value="red">Red</ion-select-option>
90+
<ion-select-option value="blue">Blue</ion-select-option>
91+
<ion-select-option value="green">Green</ion-select-option>
92+
</ion-select>
93+
</div>
94+
</div>
95+
96+
<div class="ion-padding">
97+
<ion-button id="submit-btn" expand="block" disabled>Submit Form</ion-button>
98+
<ion-button id="reset-btn" expand="block" fill="outline">Reset Form</ion-button>
99+
</div>
100+
</ion-content>
101+
</ion-app>
102+
103+
<script>
104+
// Simple validation logic
105+
const selects = document.querySelectorAll('ion-select');
106+
const submitBtn = document.getElementById('submit-btn');
107+
const resetBtn = document.getElementById('reset-btn');
108+
109+
// Track which fields have been touched
110+
const touchedFields = new Set();
111+
112+
// Validation functions
113+
const validators = {
114+
'fruits-select': (value) => {
115+
return value !== '' && value !== undefined;
116+
},
117+
'optional-select': () => true, // Always valid
118+
};
119+
120+
function validateField(select) {
121+
const selectId = select.id;
122+
const value = select.value;
123+
const isValid = validators[selectId] ? validators[selectId](value) : true;
124+
125+
// Only show validation state if field has been touched
126+
if (touchedFields.has(selectId)) {
127+
if (isValid) {
128+
select.classList.remove('ion-invalid');
129+
select.classList.add('ion-valid');
130+
} else {
131+
select.classList.remove('ion-valid');
132+
select.classList.add('ion-invalid');
133+
}
134+
select.classList.add('ion-touched');
135+
}
136+
137+
return isValid;
138+
}
139+
140+
function validateForm() {
141+
let allValid = true;
142+
selects.forEach((select) => {
143+
if (select.id !== 'optional-select') {
144+
const isValid = validateField(select);
145+
if (!isValid) {
146+
allValid = false;
147+
}
148+
}
149+
});
150+
submitBtn.disabled = !allValid;
151+
return allValid;
152+
}
153+
154+
// Add event listeners
155+
selects.forEach((select) => {
156+
// Mark as touched on blur
157+
select.addEventListener('ionBlur', (e) => {
158+
console.log('Blur event on:', select.id);
159+
touchedFields.add(select.id);
160+
validateField(select);
161+
validateForm();
162+
163+
const isInvalid = select.classList.contains('ion-invalid');
164+
if (isInvalid) {
165+
console.log('Field marked invalid:', select.label, select.errorText);
166+
}
167+
});
168+
169+
// Validate on change
170+
select.addEventListener('ionChange', (e) => {
171+
console.log('Change event on:', select.id);
172+
if (touchedFields.has(select.id)) {
173+
validateField(select);
174+
validateForm();
175+
}
176+
});
177+
});
178+
179+
// Reset button
180+
resetBtn.addEventListener('click', () => {
181+
selects.forEach((select) => {
182+
select.value = '';
183+
select.classList.remove('ion-valid', 'ion-invalid', 'ion-touched');
184+
});
185+
touchedFields.clear();
186+
submitBtn.disabled = true;
187+
});
188+
189+
// Submit button
190+
submitBtn.addEventListener('click', () => {
191+
if (validateForm()) {
192+
alert('Form submitted successfully!');
193+
}
194+
});
195+
196+
// Initial setup
197+
validateForm();
198+
</script>
199+
</body>
200+
</html>

0 commit comments

Comments
 (0)