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 3 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
113 changes: 64 additions & 49 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,100 @@ 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;

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

/// Attaches accessibility announcements coming from the 'flutter/accessibility'
/// channel as temporary elements to the DOM.
Copy link
Contributor

Choose a reason for hiding this comment

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

Now that the DOM element is not temporarily attached anymore, and have a permanent live region, should we update the documentation to reflect that?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done!

class AccessibilityAnnouncements {
AccessibilityAnnouncements._() {
registerHotRestartListener(() {
_removeElementTimer?.cancel();
});
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);
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();
void dispose() {
assert(!_isDisposed);
_isDisposed = true;
_politeElement.remove();
_assertiveElement.remove();
_accessibilityAnnouncements = null;
}

/// Decodes the message coming from the 'flutter/accessibility' channel.
void handleMessage(StandardMessageCodec codec, ByteData? data) {
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();
});
}
}
final int assertivenessIndex = dataMap.tryInt('assertiveness') ?? 0;
final Assertiveness assertiveness = Assertiveness.values[assertivenessIndex];
final DomHTMLElement liveRegion = ariaLiveElementFor(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);
// 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 = liveRegion.innerText == message ? '.' : '';
liveRegion.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;
}
}
113 changes: 63 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,103 @@
// 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, '');
});

expect(input.getAttribute('aria-live'), equals('polite'));
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, '');
});
});
}