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

Commit d746877

Browse files
authored
[web] use a permanent live region for a11y announcements (#38015)
* [web] use a permanent live region for a11y announcements * alter same message to force screen reader to read it again * use period instead of exclamation mark * docs
1 parent 6d0ff37 commit d746877

File tree

3 files changed

+167
-103
lines changed

3 files changed

+167
-103
lines changed

lib/web_ui/lib/src/engine/initialization.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import 'package:ui/src/engine/profiler.dart';
1717
import 'package:ui/src/engine/raw_keyboard.dart';
1818
import 'package:ui/src/engine/renderer.dart';
1919
import 'package:ui/src/engine/safe_browser_api.dart';
20+
import 'package:ui/src/engine/semantics/accessibility.dart';
2021
import 'package:ui/src/engine/window.dart';
2122
import 'package:ui/ui.dart' as ui;
2223

@@ -240,6 +241,7 @@ Future<void> initializeEngineUi() async {
240241
}
241242
_initializationState = DebugEngineInitializationState.initializingUi;
242243

244+
initializeAccessibilityAnnouncements();
243245
RawKeyboard.initialize(onMacOs: operatingSystem == OperatingSystem.macOs);
244246
MouseCursor.initialize();
245247
ensureFlutterViewEmbedderInitialized();

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

Lines changed: 88 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
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';
65
import 'dart:typed_data';
76

87
import '../../engine.dart' show registerHotRestartListener;
@@ -21,84 +20,120 @@ enum Assertiveness {
2120
}
2221

2322
/// Singleton for accessing accessibility announcements from the platform.
24-
final AccessibilityAnnouncements accessibilityAnnouncements =
25-
AccessibilityAnnouncements.instance;
23+
AccessibilityAnnouncements get accessibilityAnnouncements {
24+
assert(
25+
_accessibilityAnnouncements != null,
26+
'AccessibilityAnnouncements not initialized. Call initializeAccessibilityAnnouncements() to innitialize it.',
27+
);
28+
return _accessibilityAnnouncements!;
29+
}
30+
AccessibilityAnnouncements? _accessibilityAnnouncements;
2631

27-
/// Attaches accessibility announcements coming from the 'flutter/accessibility'
28-
/// channel as temporary elements to the DOM.
32+
/// Initializes the [accessibilityAnnouncements] singleton.
33+
///
34+
/// It is an error to attempt to initialize the singleton more than once. Call
35+
/// [AccessibilityAnnouncements.dispose] prior to calling this function again.
36+
void initializeAccessibilityAnnouncements() {
37+
assert(
38+
_accessibilityAnnouncements == null,
39+
'AccessibilityAnnouncements is already initialized. This is likely a bug in '
40+
'Flutter Web engine initialization. Please file an issue at '
41+
'https://github.com/flutter/flutter/issues/new/choose',
42+
);
43+
_accessibilityAnnouncements = AccessibilityAnnouncements();
44+
registerHotRestartListener(() {
45+
accessibilityAnnouncements.dispose();
46+
});
47+
}
48+
49+
/// Makes accessibility announcements using `aria-live` DOM elements.
2950
class AccessibilityAnnouncements {
30-
AccessibilityAnnouncements._() {
31-
registerHotRestartListener(() {
32-
_removeElementTimer?.cancel();
33-
});
51+
/// Creates a new instance with its own DOM elements used for announcements.
52+
factory AccessibilityAnnouncements() {
53+
final DomHTMLElement politeElement = _createElement(Assertiveness.polite);
54+
final DomHTMLElement assertiveElement = _createElement(Assertiveness.assertive);
55+
domDocument.body!.append(politeElement);
56+
domDocument.body!.append(assertiveElement);
57+
return AccessibilityAnnouncements._(politeElement, assertiveElement);
3458
}
3559

36-
/// Initializes the [AccessibilityAnnouncements] singleton if it is not
37-
/// already initialized.
38-
static AccessibilityAnnouncements get instance {
39-
return _instance ??= AccessibilityAnnouncements._();
40-
}
60+
AccessibilityAnnouncements._(this._politeElement, this._assertiveElement);
4161

42-
static AccessibilityAnnouncements? _instance;
62+
/// A live region element with `aria-live` set to "polite", used to announce
63+
/// accouncements politely.
64+
final DomHTMLElement _politeElement;
4365

44-
/// Timer that times when the accessibility element should be removed from the
45-
/// DOM.
46-
///
47-
/// The element is added to the DOM temporarily for announcing the
48-
/// message to the assistive technology.
49-
Timer? _removeElementTimer;
66+
/// A live region element with `aria-live` set to "assertive", used to announce
67+
/// accouncements assertively.
68+
final DomHTMLElement _assertiveElement;
5069

51-
/// The duration the accessibility announcements stay on the DOM.
52-
///
53-
/// It is removed after this time expired.
54-
Duration durationA11yMessageIsOnDom = const Duration(seconds: 5);
70+
/// Looks up the element used to announce messages of the given [assertiveness].
71+
DomHTMLElement ariaLiveElementFor(Assertiveness assertiveness) {
72+
assert(!_isDisposed);
73+
switch (assertiveness) {
74+
case Assertiveness.polite: return _politeElement;
75+
case Assertiveness.assertive: return _assertiveElement;
76+
}
77+
}
5578

56-
/// Element which is used to communicate the message from the
57-
/// 'flutter/accessibility' to the assistive technologies.
58-
///
59-
/// This element gets attached to the DOM temporarily. It gets removed
60-
/// after a duration. See [durationA11yMessageIsOnDom].
61-
///
62-
/// This element has aria-live attribute.
63-
///
64-
/// It also has id 'accessibility-element' for testing purposes.
65-
DomHTMLElement? _element;
79+
bool _isDisposed = false;
6680

67-
DomHTMLElement get _domElement => _element ??= _createElement();
81+
/// Disposes of the resources used by this object.
82+
///
83+
/// This object's methods must not be called after calling this method.
84+
void dispose() {
85+
assert(!_isDisposed);
86+
_isDisposed = true;
87+
_politeElement.remove();
88+
_assertiveElement.remove();
89+
_accessibilityAnnouncements = null;
90+
}
6891

69-
/// Decodes the message coming from the 'flutter/accessibility' channel.
92+
/// Makes an accessibity announcement from a message sent by the framework
93+
/// over the 'flutter/accessibility' channel.
94+
///
95+
/// The encoded message is passed as [data], and will be decoded using [codec].
7096
void handleMessage(StandardMessageCodec codec, ByteData? data) {
71-
final Map<dynamic, dynamic> inputMap =
72-
codec.decodeMessage(data) as Map<dynamic, dynamic>;
97+
assert(!_isDisposed);
98+
final Map<dynamic, dynamic> inputMap = codec.decodeMessage(data) as Map<dynamic, dynamic>;
7399
final Map<dynamic, dynamic> dataMap = inputMap.readDynamicJson('data');
74100
final String? message = dataMap.tryString('message');
75101
if (message != null && message.isNotEmpty) {
76-
/// The default value for politeness is `polite`.
77-
final int ariaLivePolitenessIndex = dataMap.tryInt('assertiveness') ?? 0;
78-
final Assertiveness ariaLivePoliteness = Assertiveness.values[ariaLivePolitenessIndex];
79-
_initLiveRegion(message, ariaLivePoliteness);
80-
_removeElementTimer = Timer(durationA11yMessageIsOnDom, () {
81-
_element!.remove();
82-
});
102+
/// The default value for assertiveness is `polite`.
103+
final int assertivenessIndex = dataMap.tryInt('assertiveness') ?? 0;
104+
final Assertiveness assertiveness = Assertiveness.values[assertivenessIndex];
105+
announce(message, assertiveness);
83106
}
84107
}
85108

86-
void _initLiveRegion(String message, Assertiveness ariaLivePoliteness) {
87-
final String assertiveLevel = (ariaLivePoliteness == Assertiveness.assertive) ? 'assertive' : 'polite';
88-
_domElement.setAttribute('aria-live', assertiveLevel);
89-
_domElement.text = message;
90-
domDocument.body!.append(_domElement);
109+
/// Makes an accessibility announcement using an `aria-live` element.
110+
///
111+
/// [message] is the text of the announcement.
112+
///
113+
/// [assertiveness] controls how interruptive the announcement is.
114+
void announce(String message, Assertiveness assertiveness) {
115+
assert(!_isDisposed);
116+
final DomHTMLElement ariaLiveElement = ariaLiveElementFor(assertiveness);
117+
118+
// If the last announced message is the same as the new message, some
119+
// screen readers, such as Narrator, will not read the same message
120+
// again. In this case, add an artifical "." at the end of the message
121+
// string to force the text of the message to look different.
122+
final String suffix = ariaLiveElement.innerText == message ? '.' : '';
123+
ariaLiveElement.text = '$message$suffix';
91124
}
92125

93-
DomHTMLLabelElement _createElement() {
126+
static DomHTMLLabelElement _createElement(Assertiveness assertiveness) {
127+
final String ariaLiveValue = (assertiveness == Assertiveness.assertive) ? 'assertive' : 'polite';
94128
final DomHTMLLabelElement liveRegion = createDomHTMLLabelElement();
95-
liveRegion.setAttribute('id', 'accessibility-element');
129+
liveRegion.setAttribute('id', 'ftl-announcement-$ariaLiveValue');
96130
liveRegion.style
97131
..position = 'fixed'
98132
..overflow = 'hidden'
99133
..transform = 'translate(-99999px, -99999px)'
100134
..width = '1px'
101135
..height = '1px';
136+
liveRegion.setAttribute('aria-live', ariaLiveValue);
102137
return liveRegion;
103138
}
104139
}

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

Lines changed: 77 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,90 +2,117 @@
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' show Future;
6-
75
import 'package:test/bootstrap/browser.dart';
86
import 'package:test/test.dart';
97
import 'package:ui/src/engine/dom.dart';
8+
import 'package:ui/src/engine/initialization.dart';
109
import 'package:ui/src/engine/semantics.dart';
1110
import 'package:ui/src/engine/services.dart';
1211

1312
const StandardMessageCodec codec = StandardMessageCodec();
14-
const String testMessage = 'This is an tooltip.';
15-
const Map<dynamic, dynamic> testInput = <dynamic, dynamic>{
16-
'data': <dynamic, dynamic>{'message': testMessage}
17-
};
1813

1914
void main() {
2015
internalBootstrapBrowserTest(() => testMain);
2116
}
2217

2318
void testMain() {
24-
late AccessibilityAnnouncements accessibilityAnnouncements;
19+
setUpAll(() async {
20+
await initializeEngine();
21+
});
2522

2623
group('$AccessibilityAnnouncements', () {
27-
setUp(() {
28-
accessibilityAnnouncements = AccessibilityAnnouncements.instance;
29-
});
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+
}
3034

31-
test(
32-
'Creates element when handling a message and removes '
33-
'is after a delay', () {
34-
// Set the a11y announcement's duration on DOM to half seconds.
35-
accessibilityAnnouncements.durationA11yMessageIsOnDom =
36-
const Duration(milliseconds: 500);
35+
test('Initialization and disposal', () {
36+
// Elements should be there right after engine initialization.
37+
expectAnnouncementElements(present: true);
3738

38-
// Initially there is no accessibility-element
39-
expect(domDocument.getElementById('accessibility-element'), isNull);
39+
accessibilityAnnouncements.dispose();
40+
expectAnnouncementElements(present: false);
4041

41-
accessibilityAnnouncements.handleMessage(codec,
42-
codec.encodeMessage(testInput));
43-
expect(
44-
domDocument.getElementById('accessibility-element'),
45-
isNotNull,
46-
);
47-
final DomHTMLLabelElement input =
48-
domDocument.getElementById('accessibility-element')! as DomHTMLLabelElement;
49-
expect(input.getAttribute('aria-live'), equals('polite'));
50-
expect(input.text, testMessage);
51-
52-
// The element should have been removed after the duration.
53-
Future<void>.delayed(
54-
accessibilityAnnouncements.durationA11yMessageIsOnDom,
55-
() =>
56-
expect(domDocument.getElementById('accessibility-element'), isNull));
42+
initializeAccessibilityAnnouncements();
43+
expectAnnouncementElements(present: true);
5744
});
5845

46+
void resetAccessibilityAnnouncements() {
47+
accessibilityAnnouncements.dispose();
48+
initializeAccessibilityAnnouncements();
49+
expectAnnouncementElements(present: true);
50+
}
51+
5952
test('Default value of aria-live is polite when assertiveness is not specified', () {
60-
const Map<dynamic, dynamic> testInput = <dynamic, dynamic>{'data': <dynamic, dynamic>{'message': 'message'}};
53+
resetAccessibilityAnnouncements();
54+
const Map<dynamic, dynamic> testInput = <dynamic, dynamic>{'data': <dynamic, dynamic>{'message': 'polite message'}};
6155
accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(testInput));
62-
final DomHTMLLabelElement input = domDocument.getElementById('accessibility-element')! as DomHTMLLabelElement;
63-
64-
expect(input.getAttribute('aria-live'), equals('polite'));
56+
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, 'polite message');
57+
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, '');
6558
});
6659

67-
test('aria-live is assertive when assertiveness is set to 1', () {
68-
const Map<dynamic, dynamic> testInput = <dynamic, dynamic>{'data': <dynamic, dynamic>{'message': 'message', 'assertiveness': 1}};
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}};
6963
accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(testInput));
70-
final DomHTMLLabelElement input = domDocument.getElementById('accessibility-element')! as DomHTMLLabelElement;
71-
72-
expect(input.getAttribute('aria-live'), equals('assertive'));
64+
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, '');
65+
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, 'assertive message');
7366
});
7467

7568
test('aria-live is polite when assertiveness is null', () {
76-
const Map<dynamic, dynamic> testInput = <dynamic, dynamic>{'data': <dynamic, dynamic>{'message': 'message', 'assertiveness': null}};
69+
resetAccessibilityAnnouncements();
70+
const Map<dynamic, dynamic> testInput = <dynamic, dynamic>{'data': <dynamic, dynamic>{'message': 'polite message', 'assertiveness': null}};
7771
accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(testInput));
78-
final DomHTMLLabelElement input = domDocument.getElementById('accessibility-element')! as DomHTMLLabelElement;
79-
80-
expect(input.getAttribute('aria-live'), equals('polite'));
72+
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, 'polite message');
73+
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, '');
8174
});
8275

8376
test('aria-live is polite when assertiveness is set to 0', () {
84-
const Map<dynamic, dynamic> testInput = <dynamic, dynamic>{'data': <dynamic, dynamic>{'message': 'message', 'assertiveness': 0}};
77+
resetAccessibilityAnnouncements();
78+
const Map<dynamic, dynamic> testInput = <dynamic, dynamic>{'data': <dynamic, dynamic>{'message': 'polite message', 'assertiveness': 0}};
8579
accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(testInput));
86-
final DomHTMLLabelElement input = domDocument.getElementById('accessibility-element')! as DomHTMLLabelElement;
80+
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, 'polite message');
81+
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, '');
82+
});
83+
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, '');
102+
});
103+
104+
test('announce() polite', () {
105+
resetAccessibilityAnnouncements();
106+
accessibilityAnnouncements.announce('polite message', Assertiveness.polite);
107+
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, 'polite message');
108+
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, '');
109+
});
87110

88-
expect(input.getAttribute('aria-live'), equals('polite'));
111+
test('announce() assertive', () {
112+
resetAccessibilityAnnouncements();
113+
accessibilityAnnouncements.announce('assertive message', Assertiveness.assertive);
114+
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, '');
115+
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, 'assertive message');
89116
});
90117
});
91118
}

0 commit comments

Comments
 (0)