From db0412c2396c6dec4bf85aca5e22ab97db53e02b Mon Sep 17 00:00:00 2001 From: Ashish Myles Date: Tue, 23 May 2023 15:33:23 -0400 Subject: [PATCH 1/3] [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. --- .../src/engine/semantics/accessibility.dart | 17 +- .../engine/semantics/accessibility_test.dart | 154 ++++++++++-------- 2 files changed, 99 insertions(+), 72 deletions(-) diff --git a/lib/web_ui/lib/src/engine/semantics/accessibility.dart b/lib/web_ui/lib/src/engine/semantics/accessibility.dart index 8062765f2ffff..372c8906de530 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,10 @@ void initializeAccessibilityAnnouncements() { }); } +/// Duration for which a live message will be present in the DOM for the screen +/// reader to announce it. +const Duration liveMessageDuration = Duration(milliseconds: 300); + /// Makes accessibility announcements using `aria-live` DOM elements. class AccessibilityAnnouncements { /// Creates a new instance with its own DOM elements used for announcements. @@ -119,12 +124,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..f29438f07c850 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'; @@ -20,18 +23,26 @@ void testMain() { await initializeEngine(); }); - 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 +54,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(); }); }); } From 5af78f62cc030e31162b6e69be379eba524fdfb5 Mon Sep 17 00:00:00 2001 From: Ashish Myles Date: Tue, 23 May 2023 16:47:34 -0400 Subject: [PATCH 2/3] Document rationale for length of delay before message is cleared from live region. --- lib/web_ui/lib/src/engine/semantics/accessibility.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/web_ui/lib/src/engine/semantics/accessibility.dart b/lib/web_ui/lib/src/engine/semantics/accessibility.dart index 372c8906de530..060e069501146 100644 --- a/lib/web_ui/lib/src/engine/semantics/accessibility.dart +++ b/lib/web_ui/lib/src/engine/semantics/accessibility.dart @@ -53,6 +53,8 @@ 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. const Duration liveMessageDuration = Duration(milliseconds: 300); /// Makes accessibility announcements using `aria-live` DOM elements. From ac5c7bb97a4c63e9b1ee6a67d1905d070dd613d9 Mon Sep 17 00:00:00 2001 From: Ashish Myles Date: Tue, 23 May 2023 16:58:20 -0400 Subject: [PATCH 3/3] Make [liveMessageDuration] settable to reduce delays for tests. --- lib/web_ui/lib/src/engine/semantics/accessibility.dart | 7 ++++++- lib/web_ui/test/engine/semantics/accessibility_test.dart | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/web_ui/lib/src/engine/semantics/accessibility.dart b/lib/web_ui/lib/src/engine/semantics/accessibility.dart index 060e069501146..216b7615964a5 100644 --- a/lib/web_ui/lib/src/engine/semantics/accessibility.dart +++ b/lib/web_ui/lib/src/engine/semantics/accessibility.dart @@ -55,7 +55,12 @@ void initializeAccessibilityAnnouncements() { /// reader to announce it. /// /// This was determined by trial and error with some extra buffer added. -const Duration liveMessageDuration = Duration(milliseconds: 300); +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 { diff --git a/lib/web_ui/test/engine/semantics/accessibility_test.dart b/lib/web_ui/test/engine/semantics/accessibility_test.dart index f29438f07c850..91972cd3b91d1 100644 --- a/lib/web_ui/test/engine/semantics/accessibility_test.dart +++ b/lib/web_ui/test/engine/semantics/accessibility_test.dart @@ -21,6 +21,7 @@ void main() { void testMain() { setUpAll(() async { await initializeEngine(); + setLiveMessageDurationForTest(const Duration(milliseconds: 10)); }); void expectAnnouncementElements({required bool present}) {