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; });