diff --git a/lib/web_ui/lib/src/engine/semantics/accessibility.dart b/lib/web_ui/lib/src/engine/semantics/accessibility.dart
index 9f95393eb2397..8062765f2ffff 100644
--- a/lib/web_ui/lib/src/engine/semantics/accessibility.dart
+++ b/lib/web_ui/lib/src/engine/semantics/accessibility.dart
@@ -29,6 +29,10 @@ AccessibilityAnnouncements get accessibilityAnnouncements {
}
AccessibilityAnnouncements? _accessibilityAnnouncements;
+void debugOverrideAccessibilityAnnouncements(AccessibilityAnnouncements override) {
+ _accessibilityAnnouncements = override;
+}
+
/// Initializes the [accessibilityAnnouncements] singleton.
///
/// It is an error to attempt to initialize the singleton more than once. Call
diff --git a/lib/web_ui/lib/src/engine/semantics/live_region.dart b/lib/web_ui/lib/src/engine/semantics/live_region.dart
index 6f51f6ce55290..2d17cff0a15b7 100644
--- a/lib/web_ui/lib/src/engine/semantics/live_region.dart
+++ b/lib/web_ui/lib/src/engine/semantics/live_region.dart
@@ -2,41 +2,37 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
-import '../dom.dart';
+import 'accessibility.dart';
import 'semantics.dart';
/// Manages semantics configurations that represent live regions.
///
-/// "aria-live" attribute is added to communicate the live region to the
-/// assistive technology.
+/// Assistive technologies treat "aria-live" attribute differently. To keep
+/// the behavior consistent, [accessibilityAnnouncements.announce] is used.
///
-/// The usage of "aria-live" is browser-dependent.
-///
-/// VoiceOver only supports "aria-live" with "polite" politeness setting. When
-/// the inner html content is changed. It doesn't read the "aria-label".
-///
-/// When there is an aria-live attribute added, assistive technologies read the
+/// When there is an update to [LiveRegion], assistive technologies read the
/// label of the element. See [LabelAndValue]. If there is no label provided
-/// no content will be read, therefore DOM is cleaned.
+/// no content will be read.
class LiveRegion extends RoleManager {
LiveRegion(SemanticsObject semanticsObject)
: super(Role.labelAndValue, semanticsObject);
+ String? _lastAnnouncement;
+
@override
void update() {
- if (semanticsObject.hasLabel) {
- semanticsObject.element.setAttribute('aria-live', 'polite');
- } else {
- _cleanupDom();
+ // Avoid announcing the same message over and over.
+ if (_lastAnnouncement != semanticsObject.label) {
+ _lastAnnouncement = semanticsObject.label;
+ if (semanticsObject.hasLabel) {
+ accessibilityAnnouncements.announce(
+ _lastAnnouncement! , Assertiveness.polite
+ );
+ }
}
}
- void _cleanupDom() {
- semanticsObject.element.removeAttribute('aria-live');
- }
-
@override
void dispose() {
- _cleanupDom();
}
}
diff --git a/lib/web_ui/test/engine/semantics/semantics_test.dart b/lib/web_ui/test/engine/semantics/semantics_test.dart
index c5683b9afaed0..3de8054bc9844 100644
--- a/lib/web_ui/test/engine/semantics/semantics_test.dart
+++ b/lib/web_ui/test/engine/semantics/semantics_test.dart
@@ -1743,12 +1743,43 @@ void _testImage() {
});
}
+class MockAccessibilityAnnouncements implements AccessibilityAnnouncements {
+ int announceInvoked = 0;
+
+ @override
+ void announce(String message, Assertiveness assertiveness) {
+ announceInvoked += 1;
+ }
+
+ @override
+ DomHTMLElement ariaLiveElementFor(Assertiveness assertiveness) {
+ throw UnsupportedError(
+ 'ariaLiveElementFor is not supported in MockAccessibilityAnnouncements');
+ }
+
+ @override
+ void dispose() {
+ throw UnsupportedError(
+ 'dispose is not supported in MockAccessibilityAnnouncements!');
+ }
+
+ @override
+ void handleMessage(StandardMessageCodec codec, ByteData? data) {
+ throw UnsupportedError(
+ 'handleMessage is not supported in MockAccessibilityAnnouncements!');
+ }
+}
+
void _testLiveRegion() {
- test('renders a live region if there is a label', () async {
+ test('announces the label after an update', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
+ final MockAccessibilityAnnouncements mockAccessibilityAnnouncements =
+ MockAccessibilityAnnouncements();
+ debugOverrideAccessibilityAnnouncements(mockAccessibilityAnnouncements);
+
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
@@ -1758,19 +1789,20 @@ void _testLiveRegion() {
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
semantics().updateSemantics(builder.build());
-
- expectSemanticsTree('''
-
-''');
+ expect(mockAccessibilityAnnouncements.announceInvoked, 1);
semantics().semanticsEnabled = false;
});
- test('does not render a live region if there is no label', () async {
+ test('does not announce anything if there is no label', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
+ final MockAccessibilityAnnouncements mockAccessibilityAnnouncements =
+ MockAccessibilityAnnouncements();
+ debugOverrideAccessibilityAnnouncements(mockAccessibilityAnnouncements);
+
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
@@ -1779,10 +1811,41 @@ void _testLiveRegion() {
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
semantics().updateSemantics(builder.build());
+ expect(mockAccessibilityAnnouncements.announceInvoked, 0);
- expectSemanticsTree('''
-
-''');
+ semantics().semanticsEnabled = false;
+ });
+
+ test('does not announce the same label over and over', () async {
+ semantics()
+ ..debugOverrideTimestampFunction(() => _testTime)
+ ..semanticsEnabled = true;
+
+ final MockAccessibilityAnnouncements mockAccessibilityAnnouncements =
+ MockAccessibilityAnnouncements();
+ debugOverrideAccessibilityAnnouncements(mockAccessibilityAnnouncements);
+
+ ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
+ updateNode(
+ builder,
+ label: 'This is a snackbar',
+ flags: 0 | ui.SemanticsFlag.isLiveRegion.index,
+ transform: Matrix4.identity().toFloat64(),
+ rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
+ );
+ semantics().updateSemantics(builder.build());
+ expect(mockAccessibilityAnnouncements.announceInvoked, 1);
+
+ builder = ui.SemanticsUpdateBuilder();
+ updateNode(
+ builder,
+ label: 'This is a snackbar',
+ flags: 0 | ui.SemanticsFlag.isLiveRegion.index,
+ transform: Matrix4.identity().toFloat64(),
+ rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
+ );
+ semantics().updateSemantics(builder.build());
+ expect(mockAccessibilityAnnouncements.announceInvoked, 1);
semantics().semanticsEnabled = false;
});