Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[web] use a permanent live region for a11y announcements #38015

Merged
merged 4 commits into from
Dec 3, 2022
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
2 changes: 2 additions & 0 deletions lib/web_ui/lib/src/engine/initialization.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import 'package:ui/src/engine/profiler.dart';
import 'package:ui/src/engine/raw_keyboard.dart';
import 'package:ui/src/engine/renderer.dart';
import 'package:ui/src/engine/safe_browser_api.dart';
import 'package:ui/src/engine/semantics/accessibility.dart';
import 'package:ui/src/engine/window.dart';
import 'package:ui/ui.dart' as ui;

Expand Down Expand Up @@ -240,6 +241,7 @@ Future<void> initializeEngineUi() async {
}
_initializationState = DebugEngineInitializationState.initializingUi;

initializeAccessibilityAnnouncements();
RawKeyboard.initialize(onMacOs: operatingSystem == OperatingSystem.macOs);
MouseCursor.initialize();
ensureFlutterViewEmbedderInitialized();
Expand Down
141 changes: 88 additions & 53 deletions lib/web_ui/lib/src/engine/semantics/accessibility.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// 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 @@ -21,84 +20,120 @@ enum Assertiveness {
}

/// Singleton for accessing accessibility announcements from the platform.
final AccessibilityAnnouncements accessibilityAnnouncements =
AccessibilityAnnouncements.instance;
AccessibilityAnnouncements get accessibilityAnnouncements {
assert(
_accessibilityAnnouncements != null,
'AccessibilityAnnouncements not initialized. Call initializeAccessibilityAnnouncements() to innitialize it.',
);
return _accessibilityAnnouncements!;
}
AccessibilityAnnouncements? _accessibilityAnnouncements;

/// Attaches accessibility announcements coming from the 'flutter/accessibility'
/// channel as temporary elements to the DOM.
/// Initializes the [accessibilityAnnouncements] singleton.
///
/// It is an error to attempt to initialize the singleton more than once. Call
/// [AccessibilityAnnouncements.dispose] prior to calling this function again.
void initializeAccessibilityAnnouncements() {
assert(
_accessibilityAnnouncements == null,
'AccessibilityAnnouncements is already initialized. This is likely a bug in '
'Flutter Web engine initialization. Please file an issue at '
'https://github.com/flutter/flutter/issues/new/choose',
);
_accessibilityAnnouncements = AccessibilityAnnouncements();
registerHotRestartListener(() {
accessibilityAnnouncements.dispose();
});
}

/// Makes accessibility announcements using `aria-live` DOM elements.
class AccessibilityAnnouncements {
AccessibilityAnnouncements._() {
registerHotRestartListener(() {
_removeElementTimer?.cancel();
});
/// Creates a new instance with its own DOM elements used for announcements.
factory AccessibilityAnnouncements() {
final DomHTMLElement politeElement = _createElement(Assertiveness.polite);
final DomHTMLElement assertiveElement = _createElement(Assertiveness.assertive);
domDocument.body!.append(politeElement);
domDocument.body!.append(assertiveElement);
return AccessibilityAnnouncements._(politeElement, assertiveElement);
}

/// Initializes the [AccessibilityAnnouncements] singleton if it is not
/// already initialized.
static AccessibilityAnnouncements get instance {
return _instance ??= AccessibilityAnnouncements._();
}
AccessibilityAnnouncements._(this._politeElement, this._assertiveElement);

static AccessibilityAnnouncements? _instance;
/// A live region element with `aria-live` set to "polite", used to announce
/// accouncements politely.
final DomHTMLElement _politeElement;

/// Timer that times when the accessibility element should be removed from the
/// DOM.
///
/// The element is added to the DOM temporarily for announcing the
/// message to the assistive technology.
Timer? _removeElementTimer;
/// A live region element with `aria-live` set to "assertive", used to announce
/// accouncements assertively.
final DomHTMLElement _assertiveElement;

/// The duration the accessibility announcements stay on the DOM.
///
/// It is removed after this time expired.
Duration durationA11yMessageIsOnDom = const Duration(seconds: 5);
/// Looks up the element used to announce messages of the given [assertiveness].
DomHTMLElement ariaLiveElementFor(Assertiveness assertiveness) {
assert(!_isDisposed);
switch (assertiveness) {
case Assertiveness.polite: return _politeElement;
case Assertiveness.assertive: return _assertiveElement;
}
}

/// Element which is used to communicate the message from the
/// 'flutter/accessibility' to the assistive technologies.
///
/// This element gets attached to the DOM temporarily. It gets removed
/// after a duration. See [durationA11yMessageIsOnDom].
///
/// This element has aria-live attribute.
///
/// It also has id 'accessibility-element' for testing purposes.
DomHTMLElement? _element;
bool _isDisposed = false;

DomHTMLElement get _domElement => _element ??= _createElement();
/// Disposes of the resources used by this object.
///
/// This object's methods must not be called after calling this method.
void dispose() {
assert(!_isDisposed);
_isDisposed = true;
_politeElement.remove();
_assertiveElement.remove();
_accessibilityAnnouncements = null;
}

/// Decodes the message coming from the 'flutter/accessibility' channel.
/// Makes an accessibity announcement from a message sent by the framework
/// over the 'flutter/accessibility' channel.
///
/// The encoded message is passed as [data], and will be decoded using [codec].
void handleMessage(StandardMessageCodec codec, ByteData? data) {
final Map<dynamic, dynamic> inputMap =
codec.decodeMessage(data) as Map<dynamic, dynamic>;
assert(!_isDisposed);
final Map<dynamic, dynamic> inputMap = codec.decodeMessage(data) as Map<dynamic, dynamic>;
final Map<dynamic, dynamic> dataMap = inputMap.readDynamicJson('data');
final String? message = dataMap.tryString('message');
if (message != null && message.isNotEmpty) {
/// The default value for politeness is `polite`.
final int ariaLivePolitenessIndex = dataMap.tryInt('assertiveness') ?? 0;
final Assertiveness ariaLivePoliteness = Assertiveness.values[ariaLivePolitenessIndex];
_initLiveRegion(message, ariaLivePoliteness);
_removeElementTimer = Timer(durationA11yMessageIsOnDom, () {
_element!.remove();
});
/// The default value for assertiveness is `polite`.
final int assertivenessIndex = dataMap.tryInt('assertiveness') ?? 0;
final Assertiveness assertiveness = Assertiveness.values[assertivenessIndex];
announce(message, assertiveness);
}
}

void _initLiveRegion(String message, Assertiveness ariaLivePoliteness) {
final String assertiveLevel = (ariaLivePoliteness == Assertiveness.assertive) ? 'assertive' : 'polite';
_domElement.setAttribute('aria-live', assertiveLevel);
_domElement.text = message;
domDocument.body!.append(_domElement);
/// Makes an accessibility announcement using an `aria-live` element.
///
/// [message] is the text of the announcement.
///
/// [assertiveness] controls how interruptive the announcement is.
void announce(String message, Assertiveness assertiveness) {
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';
}

DomHTMLLabelElement _createElement() {
static DomHTMLLabelElement _createElement(Assertiveness assertiveness) {
final String ariaLiveValue = (assertiveness == Assertiveness.assertive) ? 'assertive' : 'polite';
final DomHTMLLabelElement liveRegion = createDomHTMLLabelElement();
liveRegion.setAttribute('id', 'accessibility-element');
liveRegion.setAttribute('id', 'ftl-announcement-$ariaLiveValue');
liveRegion.style
..position = 'fixed'
..overflow = 'hidden'
..transform = 'translate(-99999px, -99999px)'
..width = '1px'
..height = '1px';
liveRegion.setAttribute('aria-live', ariaLiveValue);
return liveRegion;
}
}
127 changes: 77 additions & 50 deletions lib/web_ui/test/engine/semantics/accessibility_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,90 +2,117 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async' show Future;

import 'package:test/bootstrap/browser.dart';
import 'package:test/test.dart';
import 'package:ui/src/engine/dom.dart';
import 'package:ui/src/engine/initialization.dart';
import 'package:ui/src/engine/semantics.dart';
import 'package:ui/src/engine/services.dart';

const StandardMessageCodec codec = StandardMessageCodec();
const String testMessage = 'This is an tooltip.';
const Map<dynamic, dynamic> testInput = <dynamic, dynamic>{
'data': <dynamic, dynamic>{'message': testMessage}
};

void main() {
internalBootstrapBrowserTest(() => testMain);
}

void testMain() {
late AccessibilityAnnouncements accessibilityAnnouncements;
setUpAll(() async {
await initializeEngine();
});

group('$AccessibilityAnnouncements', () {
setUp(() {
accessibilityAnnouncements = AccessibilityAnnouncements.instance;
});
void expectAnnouncementElements({required bool present}) {
expect(
domDocument.getElementById('ftl-announcement-polite'),
present ? isNotNull : isNull,
);
expect(
domDocument.getElementById('ftl-announcement-assertive'),
present ? isNotNull : isNull,
);
}

test(
'Creates element when handling a message and removes '
'is after a delay', () {
// Set the a11y announcement's duration on DOM to half seconds.
accessibilityAnnouncements.durationA11yMessageIsOnDom =
const Duration(milliseconds: 500);
test('Initialization and disposal', () {
// Elements should be there right after engine initialization.
expectAnnouncementElements(present: true);

// Initially there is no accessibility-element
expect(domDocument.getElementById('accessibility-element'), isNull);
accessibilityAnnouncements.dispose();
expectAnnouncementElements(present: false);

accessibilityAnnouncements.handleMessage(codec,
codec.encodeMessage(testInput));
expect(
domDocument.getElementById('accessibility-element'),
isNotNull,
);
final DomHTMLLabelElement input =
domDocument.getElementById('accessibility-element')! as DomHTMLLabelElement;
expect(input.getAttribute('aria-live'), equals('polite'));
expect(input.text, testMessage);

// The element should have been removed after the duration.
Future<void>.delayed(
accessibilityAnnouncements.durationA11yMessageIsOnDom,
() =>
expect(domDocument.getElementById('accessibility-element'), isNull));
initializeAccessibilityAnnouncements();
expectAnnouncementElements(present: true);
});

void resetAccessibilityAnnouncements() {
accessibilityAnnouncements.dispose();
initializeAccessibilityAnnouncements();
expectAnnouncementElements(present: true);
}

test('Default value of aria-live is polite when assertiveness is not specified', () {
const Map<dynamic, dynamic> testInput = <dynamic, dynamic>{'data': <dynamic, dynamic>{'message': 'message'}};
resetAccessibilityAnnouncements();
const Map<dynamic, dynamic> testInput = <dynamic, dynamic>{'data': <dynamic, dynamic>{'message': 'polite message'}};
accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(testInput));
final DomHTMLLabelElement input = domDocument.getElementById('accessibility-element')! as DomHTMLLabelElement;

expect(input.getAttribute('aria-live'), equals('polite'));
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, 'polite message');
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, '');
});

test('aria-live is assertive when assertiveness is set to 1', () {
const Map<dynamic, dynamic> testInput = <dynamic, dynamic>{'data': <dynamic, dynamic>{'message': 'message', 'assertiveness': 1}};
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));
final DomHTMLLabelElement input = domDocument.getElementById('accessibility-element')! as DomHTMLLabelElement;

expect(input.getAttribute('aria-live'), equals('assertive'));
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, '');
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, 'assertive message');
});

test('aria-live is polite when assertiveness is null', () {
const Map<dynamic, dynamic> testInput = <dynamic, dynamic>{'data': <dynamic, dynamic>{'message': 'message', 'assertiveness': null}};
resetAccessibilityAnnouncements();
const Map<dynamic, dynamic> testInput = <dynamic, dynamic>{'data': <dynamic, dynamic>{'message': 'polite message', 'assertiveness': null}};
accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(testInput));
final DomHTMLLabelElement input = domDocument.getElementById('accessibility-element')! as DomHTMLLabelElement;

expect(input.getAttribute('aria-live'), equals('polite'));
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', () {
const Map<dynamic, dynamic> testInput = <dynamic, dynamic>{'data': <dynamic, dynamic>{'message': 'message', 'assertiveness': 0}};
resetAccessibilityAnnouncements();
const Map<dynamic, dynamic> testInput = <dynamic, dynamic>{'data': <dynamic, dynamic>{'message': 'polite message', 'assertiveness': 0}};
accessibilityAnnouncements.handleMessage(codec, codec.encodeMessage(testInput));
final DomHTMLLabelElement input = domDocument.getElementById('accessibility-element')! as DomHTMLLabelElement;
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, 'polite message');
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, '');
});

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('announce() polite', () {
resetAccessibilityAnnouncements();
accessibilityAnnouncements.announce('polite message', Assertiveness.polite);
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, 'polite message');
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, '');
});

expect(input.getAttribute('aria-live'), equals('polite'));
test('announce() assertive', () {
resetAccessibilityAnnouncements();
accessibilityAnnouncements.announce('assertive message', Assertiveness.assertive);
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.polite).text, '');
expect(accessibilityAnnouncements.ariaLiveElementFor(Assertiveness.assertive).text, 'assertive message');
});
});
}