diff --git a/lib/web_ui/lib/src/engine/initialization.dart b/lib/web_ui/lib/src/engine/initialization.dart index d332523896cb9..ef0ad54ab1a1b 100644 --- a/lib/web_ui/lib/src/engine/initialization.dart +++ b/lib/web_ui/lib/src/engine/initialization.dart @@ -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; @@ -240,6 +241,7 @@ Future initializeEngineUi() async { } _initializationState = DebugEngineInitializationState.initializingUi; + initializeAccessibilityAnnouncements(); RawKeyboard.initialize(onMacOs: operatingSystem == OperatingSystem.macOs); MouseCursor.initialize(); ensureFlutterViewEmbedderInitialized(); diff --git a/lib/web_ui/lib/src/engine/semantics/accessibility.dart b/lib/web_ui/lib/src/engine/semantics/accessibility.dart index 22d93833b8ec8..9f95393eb2397 100644 --- a/lib/web_ui/lib/src/engine/semantics/accessibility.dart +++ b/lib/web_ui/lib/src/engine/semantics/accessibility.dart @@ -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; @@ -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 inputMap = - codec.decodeMessage(data) as Map; + assert(!_isDisposed); + final Map inputMap = codec.decodeMessage(data) as Map; final Map 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; } } diff --git a/lib/web_ui/test/engine/semantics/accessibility_test.dart b/lib/web_ui/test/engine/semantics/accessibility_test.dart index c8b2059f202ea..99176120a5b37 100644 --- a/lib/web_ui/test/engine/semantics/accessibility_test.dart +++ b/lib/web_ui/test/engine/semantics/accessibility_test.dart @@ -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 testInput = { - 'data': {'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.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 testInput = {'data': {'message': 'message'}}; + resetAccessibilityAnnouncements(); + const Map testInput = {'data': {'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 testInput = {'data': {'message': 'message', 'assertiveness': 1}}; + 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)); - 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 testInput = {'data': {'message': 'message', 'assertiveness': null}}; + resetAccessibilityAnnouncements(); + const Map testInput = {'data': {'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 testInput = {'data': {'message': 'message', 'assertiveness': 0}}; + resetAccessibilityAnnouncements(); + const Map testInput = {'data': {'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 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('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'); }); }); }