Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions lib/web_ui/lib/src/engine/semantics/accessibility.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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!;
}
Expand All @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
155 changes: 90 additions & 65 deletions lib/web_ui/test/engine/semantics/accessibility_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<void>.delayed(liveMessageDuration * 2);
initializeAccessibilityAnnouncements();
expectAnnouncementElements(present: true);
});

group('$AccessibilityAnnouncements', () {
test('Initialization and disposal', () {
// Elements should be there right after engine initialization.
expectAnnouncementElements(present: true);
Expand All @@ -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},
});
}

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

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 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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we know/care what happens if assertiveness is a negative int? Or is this not a scenario we'll run into

Copy link
Contributor Author

@marcianx marcianx May 24, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not a scenario we should run into - it would give an index out of bounds. This is a serialization of the Assertiveness enum. It's converted back to the enum using Assertiveness.values[deserializedAssertiveness]:

final Assertiveness assertiveness = Assertiveness.values[assertivenessIndex];

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, '');

// 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, '');

// 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, '');
test('Rapid-fire messages are each announced.', () async {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this behavior change if mixed assertive/polite rapid-fire messages are sent, rather than just polite?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope. Nothing fundamentally changes. They go into different live divs, each of which is handled independently. These tests ensure that multiple messages of the same type don't clobber each other in any way.

sendAnnouncementMessage(message: 'Hello');
expectMessages(polite: 'Hello');

await Future<void>.delayed(liveMessageDuration * 0.5);
sendAnnouncementMessage(message: 'There');
expectMessages(polite: 'HelloThere');

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