Skip to content

Commit 59b350e

Browse files
committed
fix(select): improve screen reader announcement timing for validation errors
1 parent 2586284 commit 59b350e

File tree

9 files changed

+468
-10
lines changed

9 files changed

+468
-10
lines changed

core/src/components/select/select.tsx

Lines changed: 74 additions & 10 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, 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';
44
import { compareOptions, createNotchController, isOptionSelected } from '@utils/forms';
55
import { focusVisibleElement, renderHiddenInput, inheritAttributes } from '@utils/helpers';
@@ -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,49 @@ 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 = this.checkInvalidState();
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()` not being
325+
* high priority enough to guarantee
326+
* the DOM is updated before the screen reader
327+
* announces the attribute change.
328+
* By using a promise, it makes sure to
329+
* announce the change before the next frame
330+
* since promises are high priority.
331+
*/
332+
Promise.resolve().then(() => {
333+
this.hintTextID = this.getHintTextID();
334+
});
335+
}
336+
});
337+
338+
this.validationObserver.observe(el, {
339+
attributes: true,
340+
attributeFilter: ['class'],
341+
});
342+
}
343+
344+
// Always set initial state
345+
this.isInvalid = this.checkInvalidState();
301346
}
302347

303348
componentWillLoad() {
304349
this.inheritedAttributes = inheritAttributes(this.el, ['aria-label']);
350+
351+
this.hintTextID = this.getHintTextID();
305352
}
306353

307354
componentDidLoad() {
@@ -328,6 +375,12 @@ export class Select implements ComponentInterface {
328375
this.notchController.destroy();
329376
this.notchController = undefined;
330377
}
378+
379+
// Clean up validation observer to prevent memory leaks.
380+
if (this.validationObserver) {
381+
this.validationObserver.disconnect();
382+
this.validationObserver = undefined;
383+
}
331384
}
332385

333386
/**
@@ -1056,8 +1109,8 @@ export class Select implements ComponentInterface {
10561109
aria-label={this.ariaLabel}
10571110
aria-haspopup="dialog"
10581111
aria-expanded={`${isExpanded}`}
1059-
aria-describedby={this.getHintTextID()}
1060-
aria-invalid={this.getHintTextID() === this.errorTextId}
1112+
aria-describedby={this.hintTextID}
1113+
aria-invalid={this.isInvalid ? 'true' : undefined}
10611114
aria-required={`${required}`}
10621115
onFocus={this.onFocus}
10631116
onBlur={this.onBlur}
@@ -1067,9 +1120,9 @@ export class Select implements ComponentInterface {
10671120
}
10681121

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

1072-
if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
1125+
if (isInvalid && errorText) {
10731126
return errorTextId;
10741127
}
10751128

@@ -1084,14 +1137,14 @@ export class Select implements ComponentInterface {
10841137
* Renders the helper text or error text values
10851138
*/
10861139
private renderHintText() {
1087-
const { helperText, errorText, helperTextId, errorTextId } = this;
1140+
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
10881141

10891142
return [
1090-
<div id={helperTextId} class="helper-text" part="supporting-text helper-text">
1091-
{helperText}
1143+
<div id={helperTextId} class="helper-text" part="supporting-text helper-text" aria-live="polite">
1144+
{!isInvalid ? helperText : null}
10921145
</div>,
1093-
<div id={errorTextId} class="error-text" part="supporting-text error-text">
1094-
{errorText}
1146+
<div id={errorTextId} class="error-text" part="supporting-text error-text" role="alert">
1147+
{isInvalid ? errorText : null}
10951148
</div>,
10961149
];
10971150
}
@@ -1115,6 +1168,17 @@ export class Select implements ComponentInterface {
11151168
return <div class="select-bottom">{this.renderHintText()}</div>;
11161169
}
11171170

1171+
/**
1172+
* Checks if the input is in an invalid state based
1173+
* on Ionic validation classes.
1174+
*/
1175+
private checkInvalidState(): boolean {
1176+
const hasIonTouched = this.el.classList.contains('ion-touched');
1177+
const hasIonInvalid = this.el.classList.contains('ion-invalid');
1178+
1179+
return hasIonTouched && hasIonInvalid;
1180+
}
1181+
11181182
render() {
11191183
const {
11201184
disabled,
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>

packages/angular/test/base/src/app/lazy/template-form/template-form.component.html

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,31 @@
7777
<p>MinLength Errors: <span id="minlength-errors">{{minLengthField.errors | json}}</span></p>
7878
</ion-label>
7979
</ion-item>
80+
81+
<!-- Test ion-select with required validation -->
82+
<ion-item>
83+
<ion-select
84+
label="Required Select"
85+
[(ngModel)]="selectValue"
86+
name="selectField"
87+
required
88+
#selectField="ngModel"
89+
id="template-select-test"
90+
errorText="This field is required"
91+
helperText="Select an option">
92+
<ion-select-option value="option1">Option 1</ion-select-option>
93+
<ion-select-option value="option2">Option 2</ion-select-option>
94+
</ion-select>
95+
</ion-item>
96+
97+
<!-- Display validation state for debugging -->
98+
<ion-item>
99+
<ion-label>
100+
<p>Select Touched: <span id="select-touched">{{selectField.touched}}</span></p>
101+
<p>Select Invalid: <span id="select-invalid">{{selectField.invalid}}</span></p>
102+
<p>Select Errors: <span id="select-errors">{{selectField.errors | json}}</span></p>
103+
</ion-label>
104+
</ion-item>
80105
</ion-list>
81106

82107
<div class="ion-padding">

packages/angular/test/base/src/app/lazy/template-form/template-form.component.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export class TemplateFormComponent {
99
inputValue = '';
1010
textareaValue = '';
1111
minLengthValue = '';
12+
selectValue = '';
1213

1314
// Track if form has been submitted
1415
submitted = false;

0 commit comments

Comments
 (0)