Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit db0412c

Browse files
committed
[web] Update a11y announcements to append divs instead of setting content.
This also removes the appended divs after a short time so that screen readers don't navigate to it, especially when users are entering the DOM to enable accessiblity. Fixes #127335.
1 parent 052e28b commit db0412c

File tree

2 files changed

+99
-72
lines changed

2 files changed

+99
-72
lines changed

lib/web_ui/lib/src/engine/semantics/accessibility.dart

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5+
import 'dart:async';
56
import 'dart:typed_data';
67

78
import '../../engine.dart' show registerHotRestartListener;
@@ -23,7 +24,7 @@ enum Assertiveness {
2324
AccessibilityAnnouncements get accessibilityAnnouncements {
2425
assert(
2526
_accessibilityAnnouncements != null,
26-
'AccessibilityAnnouncements not initialized. Call initializeAccessibilityAnnouncements() to innitialize it.',
27+
'AccessibilityAnnouncements not initialized. Call initializeAccessibilityAnnouncements() to initialize it.',
2728
);
2829
return _accessibilityAnnouncements!;
2930
}
@@ -50,6 +51,10 @@ void initializeAccessibilityAnnouncements() {
5051
});
5152
}
5253

54+
/// Duration for which a live message will be present in the DOM for the screen
55+
/// reader to announce it.
56+
const Duration liveMessageDuration = Duration(milliseconds: 300);
57+
5358
/// Makes accessibility announcements using `aria-live` DOM elements.
5459
class AccessibilityAnnouncements {
5560
/// Creates a new instance with its own DOM elements used for announcements.
@@ -119,12 +124,10 @@ class AccessibilityAnnouncements {
119124
assert(!_isDisposed);
120125
final DomHTMLElement ariaLiveElement = ariaLiveElementFor(assertiveness);
121126

122-
// If the last announced message is the same as the new message, some
123-
// screen readers, such as Narrator, will not read the same message
124-
// again. In this case, add an artifical "." at the end of the message
125-
// string to force the text of the message to look different.
126-
final String suffix = ariaLiveElement.innerText == message ? '.' : '';
127-
ariaLiveElement.text = '$message$suffix';
127+
final DomElement messageElement = createDomElement('div');
128+
messageElement.text = message;
129+
ariaLiveElement.append(messageElement);
130+
Timer(liveMessageDuration, () => messageElement.remove());
128131
}
129132

130133
static DomHTMLLabelElement _createElement(Assertiveness assertiveness) {

lib/web_ui/test/engine/semantics/accessibility_test.dart

Lines changed: 89 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5+
import 'dart:async';
6+
import 'dart:typed_data';
7+
58
import 'package:test/bootstrap/browser.dart';
69
import 'package:test/test.dart';
710
import 'package:ui/src/engine/dom.dart';
@@ -20,18 +23,26 @@ void testMain() {
2023
await initializeEngine();
2124
});
2225

23-
group('$AccessibilityAnnouncements', () {
24-
void expectAnnouncementElements({required bool present}) {
25-
expect(
26-
domDocument.getElementById('ftl-announcement-polite'),
27-
present ? isNotNull : isNull,
28-
);
29-
expect(
30-
domDocument.getElementById('ftl-announcement-assertive'),
31-
present ? isNotNull : isNull,
32-
);
33-
}
26+
void expectAnnouncementElements({required bool present}) {
27+
expect(
28+
domDocument.getElementById('ftl-announcement-polite'),
29+
present ? isNotNull : isNull,
30+
);
31+
expect(
32+
domDocument.getElementById('ftl-announcement-assertive'),
33+
present ? isNotNull : isNull,
34+
);
35+
}
36+
37+
tearDown(() async {
38+
// Completely reset accessibility announcements for subsequent tests.
39+
accessibilityAnnouncements.dispose();
40+
await Future<void>.delayed(liveMessageDuration * 2);
41+
initializeAccessibilityAnnouncements();
42+
expectAnnouncementElements(present: true);
43+
});
3444

45+
group('$AccessibilityAnnouncements', () {
3546
test('Initialization and disposal', () {
3647
// Elements should be there right after engine initialization.
3748
expectAnnouncementElements(present: true);
@@ -43,76 +54,89 @@ void testMain() {
4354
expectAnnouncementElements(present: true);
4455
});
4556

46-
void resetAccessibilityAnnouncements() {
47-
accessibilityAnnouncements.dispose();
48-
initializeAccessibilityAnnouncements();
49-
expectAnnouncementElements(present: true);
57+
ByteData? encodeMessageOnly({required String message}) {
58+
return codec.encodeMessage(<dynamic, dynamic>{
59+
'data': <dynamic, dynamic>{'message': message},
60+
});
61+
}
62+
63+
void sendAnnouncementMessage({required String message, int? assertiveness}) {
64+
accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(<dynamic, dynamic>{
65+
'data': <dynamic, dynamic>{
66+
'message': message,
67+
'assertiveness': assertiveness,
68+
},
69+
}));
70+
}
71+
72+
void expectMessages({String polite = '', String assertive = ''}) {
73+
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, polite);
74+
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, assertive);
5075
}
5176

52-
test('Default value of aria-live is polite when assertiveness is not specified', () {
53-
resetAccessibilityAnnouncements();
54-
const Map<dynamic, dynamic> testInput = <dynamic, dynamic>{'data': <dynamic, dynamic>{'message': 'polite message'}};
55-
accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(testInput));
56-
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, 'polite message');
57-
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, '');
77+
void expectNoMessages() => expectMessages();
78+
79+
test('Default value of aria-live is polite when assertiveness is not specified', () async {
80+
accessibilityAnnouncements.handleMessage(codec, encodeMessageOnly(message: 'polite message'));
81+
expectMessages(polite: 'polite message');
82+
83+
await Future<void>.delayed(liveMessageDuration);
84+
expectNoMessages();
5885
});
5986

60-
test('aria-live is assertive when assertiveness is set to 1', () {
61-
resetAccessibilityAnnouncements();
62-
const Map<dynamic, dynamic> testInput = <dynamic, dynamic>{'data': <dynamic, dynamic>{'message': 'assertive message', 'assertiveness': 1}};
63-
accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(testInput));
64-
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, '');
65-
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, 'assertive message');
87+
test('aria-live is assertive when assertiveness is set to 1', () async {
88+
sendAnnouncementMessage(message: 'assertive message', assertiveness: 1);
89+
expectMessages(assertive: 'assertive message');
90+
91+
await Future<void>.delayed(liveMessageDuration);
92+
expectNoMessages();
6693
});
6794

68-
test('aria-live is polite when assertiveness is null', () {
69-
resetAccessibilityAnnouncements();
70-
const Map<dynamic, dynamic> testInput = <dynamic, dynamic>{'data': <dynamic, dynamic>{'message': 'polite message', 'assertiveness': null}};
71-
accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(testInput));
72-
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, 'polite message');
73-
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, '');
95+
test('aria-live is polite when assertiveness is null', () async {
96+
sendAnnouncementMessage(message: 'polite message');
97+
expectMessages(polite: 'polite message');
98+
99+
await Future<void>.delayed(liveMessageDuration);
100+
expectNoMessages();
74101
});
75102

76-
test('aria-live is polite when assertiveness is set to 0', () {
77-
resetAccessibilityAnnouncements();
78-
const Map<dynamic, dynamic> testInput = <dynamic, dynamic>{'data': <dynamic, dynamic>{'message': 'polite message', 'assertiveness': 0}};
79-
accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(testInput));
80-
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, 'polite message');
81-
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, '');
103+
test('aria-live is polite when assertiveness is set to 0', () async {
104+
sendAnnouncementMessage(message: 'polite message', assertiveness: 0);
105+
expectMessages(polite: 'polite message');
106+
107+
await Future<void>.delayed(liveMessageDuration);
108+
expectNoMessages();
82109
});
83110

84-
test('The same message announced twice is altered to convince the screen reader to read it again.', () {
85-
resetAccessibilityAnnouncements();
86-
const Map<dynamic, dynamic> testInput = <dynamic, dynamic>{'data': <dynamic, dynamic>{'message': 'Hello'}};
87-
accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(testInput));
88-
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, 'Hello');
89-
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, '');
90-
91-
// The DOM value gains a "." to make the message look updated.
92-
const Map<dynamic, dynamic> testInput2 = <dynamic, dynamic>{'data': <dynamic, dynamic>{'message': 'Hello'}};
93-
accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(testInput2));
94-
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, 'Hello.');
95-
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, '');
96-
97-
// Now the "." is removed because the message without it will also look updated.
98-
const Map<dynamic, dynamic> testInput3 = <dynamic, dynamic>{'data': <dynamic, dynamic>{'message': 'Hello'}};
99-
accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(testInput3));
100-
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, 'Hello');
101-
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, '');
111+
test('Rapid-fire messages are each announced.', () async {
112+
sendAnnouncementMessage(message: 'Hello');
113+
expectMessages(polite: 'Hello');
114+
115+
await Future<void>.delayed(liveMessageDuration * 0.5);
116+
sendAnnouncementMessage(message: 'There');
117+
expectMessages(polite: 'HelloThere');
118+
119+
await Future<void>.delayed(liveMessageDuration * 0.6);
120+
expectMessages(polite: 'There');
121+
122+
await Future<void>.delayed(liveMessageDuration * 0.5);
123+
expectNoMessages();
102124
});
103125

104-
test('announce() polite', () {
105-
resetAccessibilityAnnouncements();
126+
test('announce() polite', () async {
106127
accessibilityAnnouncements.announce('polite message', Assertiveness.polite);
107-
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, 'polite message');
108-
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, '');
128+
expectMessages(polite: 'polite message');
129+
130+
await Future<void>.delayed(liveMessageDuration);
131+
expectNoMessages();
109132
});
110133

111-
test('announce() assertive', () {
112-
resetAccessibilityAnnouncements();
134+
test('announce() assertive', () async {
113135
accessibilityAnnouncements.announce('assertive message', Assertiveness.assertive);
114-
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, '');
115-
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, 'assertive message');
136+
expectMessages(assertive: 'assertive message');
137+
138+
await Future<void>.delayed(liveMessageDuration);
139+
expectNoMessages();
116140
});
117141
});
118142
}

0 commit comments

Comments
 (0)