[web] Update a11y announcements to append divs instead of setting content. (flutter/engine#42258)

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 https://github.com/flutter/flutter/issues/127335.
This commit is contained in:
Ashish Myles 2023-05-24 16:08:21 -04:00 committed by GitHub
parent 5fbc30881d
commit a96b23fa7e
2 changed files with 105 additions and 70 deletions

View File

@ -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) {

View File

@ -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));
});
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<void>.delayed(liveMessageDuration * 2);
initializeAccessibilityAnnouncements();
expectAnnouncementElements(present: true);
});
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,
);
}
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(<dynamic, dynamic>{
'data': <dynamic, dynamic>{'message': message},
});
}
test('Default value of aria-live is polite when assertiveness is not specified', () {
resetAccessibilityAnnouncements();
const Map<dynamic, dynamic> testInput = <dynamic, dynamic>{'data': <dynamic, dynamic>{'message': 'polite message'}};
accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(testInput));
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, 'polite message');
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, '');
void sendAnnouncementMessage({required String message, int? assertiveness}) {
accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(<dynamic, dynamic>{
'data': <dynamic, dynamic>{
'message': message,
'assertiveness': assertiveness,
},
}));
}
void expectMessages({String polite = '', String assertive = ''}) {
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, polite);
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, assertive);
}
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<void>.delayed(liveMessageDuration);
expectNoMessages();
});
test('aria-live is assertive when assertiveness is set to 1', () {
resetAccessibilityAnnouncements();
const Map<dynamic, dynamic> testInput = <dynamic, dynamic>{'data': <dynamic, dynamic>{'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<void>.delayed(liveMessageDuration);
expectNoMessages();
});
test('aria-live is polite when assertiveness is null', () {
resetAccessibilityAnnouncements();
const Map<dynamic, dynamic> testInput = <dynamic, dynamic>{'data': <dynamic, dynamic>{'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<void>.delayed(liveMessageDuration);
expectNoMessages();
});
test('aria-live is polite when assertiveness is set to 0', () {
resetAccessibilityAnnouncements();
const Map<dynamic, dynamic> testInput = <dynamic, dynamic>{'data': <dynamic, dynamic>{'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<void>.delayed(liveMessageDuration);
expectNoMessages();
});
test('The same message announced twice is altered to convince the screen reader to read it again.', () {
resetAccessibilityAnnouncements();
const Map<dynamic, dynamic> testInput = <dynamic, dynamic>{'data': <dynamic, dynamic>{'message': 'Hello'}};
accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(testInput));
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');
// The DOM value gains a "." to make the message look updated.
const Map<dynamic, dynamic> testInput2 = <dynamic, dynamic>{'data': <dynamic, dynamic>{'message': 'Hello'}};
accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(testInput2));
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, 'Hello.');
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, '');
await Future<void>.delayed(liveMessageDuration * 0.5);
sendAnnouncementMessage(message: 'There');
expectMessages(polite: 'HelloThere');
// Now the "." is removed because the message without it will also look updated.
const Map<dynamic, dynamic> testInput3 = <dynamic, dynamic>{'data': <dynamic, dynamic>{'message': 'Hello'}};
accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(testInput3));
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, 'Hello');
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, '');
await Future<void>.delayed(liveMessageDuration * 0.6);
expectMessages(polite: 'There');
await Future<void>.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<void>.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<void>.delayed(liveMessageDuration);
expectNoMessages();
});
});
}