diff --git a/lib/web_ui/lib/src/engine/semantics/accessibility.dart b/lib/web_ui/lib/src/engine/semantics/accessibility.dart index 8062765f2ffff..216b7615964a5 100644 --- a/lib/web_ui/lib/src/engine/semantics/accessibility.dart +++ b/lib/web_ui/lib/src/engine/semantics/accessibility.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; import 'dart:typed_data'; import '../../engine.dart' show registerHotRestartListener; @@ -23,7 +24,7 @@ enum Assertiveness { AccessibilityAnnouncements get accessibilityAnnouncements { assert( _accessibilityAnnouncements != null, - 'AccessibilityAnnouncements not initialized. Call initializeAccessibilityAnnouncements() to innitialize it.', + 'AccessibilityAnnouncements not initialized. Call initializeAccessibilityAnnouncements() to initialize it.', ); return _accessibilityAnnouncements!; } @@ -50,6 +51,17 @@ void initializeAccessibilityAnnouncements() { }); } +/// Duration for which a live message will be present in the DOM for the screen +/// reader to announce it. +/// +/// This was determined by trial and error with some extra buffer added. +Duration liveMessageDuration = const Duration(milliseconds: 300); + +/// Sets [liveMessageDuration] to reduce the delay in tests. +void setLiveMessageDurationForTest(Duration duration) { + liveMessageDuration = duration; +} + /// Makes accessibility announcements using `aria-live` DOM elements. class AccessibilityAnnouncements { /// Creates a new instance with its own DOM elements used for announcements. @@ -119,12 +131,10 @@ class AccessibilityAnnouncements { assert(!_isDisposed); final DomHTMLElement ariaLiveElement = ariaLiveElementFor(assertiveness); - // If the last announced message is the same as the new message, some - // screen readers, such as Narrator, will not read the same message - // again. In this case, add an artifical "." at the end of the message - // string to force the text of the message to look different. - final String suffix = ariaLiveElement.innerText == message ? '.' : ''; - ariaLiveElement.text = '$message$suffix'; + final DomElement messageElement = createDomElement('div'); + messageElement.text = message; + ariaLiveElement.append(messageElement); + Timer(liveMessageDuration, () => messageElement.remove()); } static DomHTMLLabelElement _createElement(Assertiveness assertiveness) { diff --git a/lib/web_ui/test/engine/semantics/accessibility_test.dart b/lib/web_ui/test/engine/semantics/accessibility_test.dart index 99176120a5b37..91972cd3b91d1 100644 --- a/lib/web_ui/test/engine/semantics/accessibility_test.dart +++ b/lib/web_ui/test/engine/semantics/accessibility_test.dart @@ -2,6 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; +import 'dart:typed_data'; + import 'package:test/bootstrap/browser.dart'; import 'package:test/test.dart'; import 'package:ui/src/engine/dom.dart'; @@ -18,20 +21,29 @@ void main() { void testMain() { setUpAll(() async { await initializeEngine(); + setLiveMessageDurationForTest(const Duration(milliseconds: 10)); }); - group('$AccessibilityAnnouncements', () { - void expectAnnouncementElements({required bool present}) { - expect( - domDocument.getElementById('ftl-announcement-polite'), - present ? isNotNull : isNull, - ); - expect( - domDocument.getElementById('ftl-announcement-assertive'), - present ? isNotNull : isNull, - ); - } + void expectAnnouncementElements({required bool present}) { + expect( + domDocument.getElementById('ftl-announcement-polite'), + present ? isNotNull : isNull, + ); + expect( + domDocument.getElementById('ftl-announcement-assertive'), + present ? isNotNull : isNull, + ); + } + + tearDown(() async { + // Completely reset accessibility announcements for subsequent tests. + accessibilityAnnouncements.dispose(); + await Future.delayed(liveMessageDuration * 2); + initializeAccessibilityAnnouncements(); + expectAnnouncementElements(present: true); + }); + group('$AccessibilityAnnouncements', () { test('Initialization and disposal', () { // Elements should be there right after engine initialization. expectAnnouncementElements(present: true); @@ -43,76 +55,89 @@ void testMain() { expectAnnouncementElements(present: true); }); - void resetAccessibilityAnnouncements() { - accessibilityAnnouncements.dispose(); - initializeAccessibilityAnnouncements(); - expectAnnouncementElements(present: true); + ByteData? encodeMessageOnly({required String message}) { + return codec.encodeMessage({ + 'data': {'message': message}, + }); + } + + void sendAnnouncementMessage({required String message, int? assertiveness}) { + accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage({ + 'data': { + 'message': message, + 'assertiveness': assertiveness, + }, + })); + } + + void expectMessages({String polite = '', String assertive = ''}) { + expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, polite); + expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, assertive); } - test('Default value of aria-live is polite when assertiveness is not specified', () { - resetAccessibilityAnnouncements(); - const Map testInput = {'data': {'message': 'polite message'}}; - accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(testInput)); - expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, 'polite message'); - expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, ''); + void expectNoMessages() => expectMessages(); + + test('Default value of aria-live is polite when assertiveness is not specified', () async { + accessibilityAnnouncements.handleMessage(codec, encodeMessageOnly(message: 'polite message')); + expectMessages(polite: 'polite message'); + + await Future.delayed(liveMessageDuration); + expectNoMessages(); }); - test('aria-live is assertive when assertiveness is set to 1', () { - resetAccessibilityAnnouncements(); - const Map testInput = {'data': {'message': 'assertive message', 'assertiveness': 1}}; - accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(testInput)); - expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, ''); - expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, 'assertive message'); + test('aria-live is assertive when assertiveness is set to 1', () async { + sendAnnouncementMessage(message: 'assertive message', assertiveness: 1); + expectMessages(assertive: 'assertive message'); + + await Future.delayed(liveMessageDuration); + expectNoMessages(); }); - test('aria-live is polite when assertiveness is null', () { - resetAccessibilityAnnouncements(); - const Map testInput = {'data': {'message': 'polite message', 'assertiveness': null}}; - accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(testInput)); - expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, 'polite message'); - expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, ''); + test('aria-live is polite when assertiveness is null', () async { + sendAnnouncementMessage(message: 'polite message'); + expectMessages(polite: 'polite message'); + + await Future.delayed(liveMessageDuration); + expectNoMessages(); }); - test('aria-live is polite when assertiveness is set to 0', () { - resetAccessibilityAnnouncements(); - const Map testInput = {'data': {'message': 'polite message', 'assertiveness': 0}}; - accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(testInput)); - expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, 'polite message'); - expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, ''); + test('aria-live is polite when assertiveness is set to 0', () async { + sendAnnouncementMessage(message: 'polite message', assertiveness: 0); + expectMessages(polite: 'polite message'); + + await Future.delayed(liveMessageDuration); + expectNoMessages(); }); - test('The same message announced twice is altered to convince the screen reader to read it again.', () { - resetAccessibilityAnnouncements(); - const Map testInput = {'data': {'message': 'Hello'}}; - accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(testInput)); - expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, 'Hello'); - expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, ''); - - // The DOM value gains a "." to make the message look updated. - const Map testInput2 = {'data': {'message': 'Hello'}}; - accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(testInput2)); - expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, 'Hello.'); - expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, ''); - - // Now the "." is removed because the message without it will also look updated. - const Map testInput3 = {'data': {'message': 'Hello'}}; - accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(testInput3)); - expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, 'Hello'); - expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, ''); + test('Rapid-fire messages are each announced.', () async { + sendAnnouncementMessage(message: 'Hello'); + expectMessages(polite: 'Hello'); + + await Future.delayed(liveMessageDuration * 0.5); + sendAnnouncementMessage(message: 'There'); + expectMessages(polite: 'HelloThere'); + + await Future.delayed(liveMessageDuration * 0.6); + expectMessages(polite: 'There'); + + await Future.delayed(liveMessageDuration * 0.5); + expectNoMessages(); }); - test('announce() polite', () { - resetAccessibilityAnnouncements(); + test('announce() polite', () async { accessibilityAnnouncements.announce('polite message', Assertiveness.polite); - expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, 'polite message'); - expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, ''); + expectMessages(polite: 'polite message'); + + await Future.delayed(liveMessageDuration); + expectNoMessages(); }); - test('announce() assertive', () { - resetAccessibilityAnnouncements(); + test('announce() assertive', () async { accessibilityAnnouncements.announce('assertive message', Assertiveness.assertive); - expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, ''); - expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, 'assertive message'); + expectMessages(assertive: 'assertive message'); + + await Future.delayed(liveMessageDuration); + expectNoMessages(); }); }); }