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

Use announce function in live region #38084

Merged
merged 4 commits into from
Dec 7, 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
4 changes: 4 additions & 0 deletions lib/web_ui/lib/src/engine/semantics/accessibility.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ AccessibilityAnnouncements get accessibilityAnnouncements {
}
AccessibilityAnnouncements? _accessibilityAnnouncements;

void debugOverrideAccessibilityAnnouncements(AccessibilityAnnouncements override) {
_accessibilityAnnouncements = override;
}

/// Initializes the [accessibilityAnnouncements] singleton.
///
/// It is an error to attempt to initialize the singleton more than once. Call
Expand Down
34 changes: 15 additions & 19 deletions lib/web_ui/lib/src/engine/semantics/live_region.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,37 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import '../dom.dart';
import 'accessibility.dart';
import 'semantics.dart';

/// Manages semantics configurations that represent live regions.
///
/// "aria-live" attribute is added to communicate the live region to the
/// assistive technology.
/// Assistive technologies treat "aria-live" attribute differently. To keep
/// the behavior consistent, [accessibilityAnnouncements.announce] is used.
///
/// The usage of "aria-live" is browser-dependent.
///
/// VoiceOver only supports "aria-live" with "polite" politeness setting. When
/// the inner html content is changed. It doesn't read the "aria-label".
///
/// When there is an aria-live attribute added, assistive technologies read the
/// When there is an update to [LiveRegion], assistive technologies read the
/// label of the element. See [LabelAndValue]. If there is no label provided
/// no content will be read, therefore DOM is cleaned.
/// no content will be read.
class LiveRegion extends RoleManager {
LiveRegion(SemanticsObject semanticsObject)
: super(Role.labelAndValue, semanticsObject);

String? _lastAnnouncement;

@override
void update() {
if (semanticsObject.hasLabel) {
semanticsObject.element.setAttribute('aria-live', 'polite');
} else {
_cleanupDom();
// Avoid announcing the same message over and over.
Copy link
Contributor

Choose a reason for hiding this comment

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

I think the logic that protects us from announcing the same message again is missing a test. It can be tested by clearing the contents of ariaLiveElementFor(Assertiveness.polite) after the first announcement, issuing an update with the same message, and verifying that the element remains empty.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Based on our conversation, I implemented a mock class for AccessibilityAnnouncements and added this test case.

if (_lastAnnouncement != semanticsObject.label) {
_lastAnnouncement = semanticsObject.label;
if (semanticsObject.hasLabel) {
accessibilityAnnouncements.announce(
_lastAnnouncement! , Assertiveness.polite
);
}
}
}

void _cleanupDom() {
semanticsObject.element.removeAttribute('aria-live');
}

@override
void dispose() {
_cleanupDom();
}
}
81 changes: 72 additions & 9 deletions lib/web_ui/test/engine/semantics/semantics_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1743,12 +1743,43 @@ void _testImage() {
});
}

class MockAccessibilityAnnouncements implements AccessibilityAnnouncements {
int announceInvoked = 0;

@override
void announce(String message, Assertiveness assertiveness) {
announceInvoked += 1;
}

@override
DomHTMLElement ariaLiveElementFor(Assertiveness assertiveness) {
throw UnsupportedError(
'ariaLiveElementFor is not supported in MockAccessibilityAnnouncements');
}

@override
void dispose() {
throw UnsupportedError(
'dispose is not supported in MockAccessibilityAnnouncements!');
}

@override
void handleMessage(StandardMessageCodec codec, ByteData? data) {
throw UnsupportedError(
'handleMessage is not supported in MockAccessibilityAnnouncements!');
}
}

void _testLiveRegion() {
test('renders a live region if there is a label', () async {
test('announces the label after an update', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;

final MockAccessibilityAnnouncements mockAccessibilityAnnouncements =
MockAccessibilityAnnouncements();
debugOverrideAccessibilityAnnouncements(mockAccessibilityAnnouncements);

final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
Expand All @@ -1758,19 +1789,20 @@ void _testLiveRegion() {
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
semantics().updateSemantics(builder.build());

expectSemanticsTree('''
<sem aria-label="This is a snackbar" aria-live="polite" style="$rootSemanticStyle"></sem>
''');
expect(mockAccessibilityAnnouncements.announceInvoked, 1);

semantics().semanticsEnabled = false;
});

test('does not render a live region if there is no label', () async {
test('does not announce anything if there is no label', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;

final MockAccessibilityAnnouncements mockAccessibilityAnnouncements =
MockAccessibilityAnnouncements();
debugOverrideAccessibilityAnnouncements(mockAccessibilityAnnouncements);

final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
Expand All @@ -1779,10 +1811,41 @@ void _testLiveRegion() {
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
semantics().updateSemantics(builder.build());
expect(mockAccessibilityAnnouncements.announceInvoked, 0);

expectSemanticsTree('''
<sem style="$rootSemanticStyle"></sem>
''');
semantics().semanticsEnabled = false;
});

test('does not announce the same label over and over', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;

final MockAccessibilityAnnouncements mockAccessibilityAnnouncements =
MockAccessibilityAnnouncements();
debugOverrideAccessibilityAnnouncements(mockAccessibilityAnnouncements);

ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
label: 'This is a snackbar',
flags: 0 | ui.SemanticsFlag.isLiveRegion.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
semantics().updateSemantics(builder.build());
expect(mockAccessibilityAnnouncements.announceInvoked, 1);

Copy link
Contributor

Choose a reason for hiding this comment

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

nit: I'd add expect(mockAccessibilityAnnouncements.announceInvoked, 1); here too to ensure that the count increases after the first update and then stays stable afterwards.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point! Done!

builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
label: 'This is a snackbar',
flags: 0 | ui.SemanticsFlag.isLiveRegion.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
semantics().updateSemantics(builder.build());
expect(mockAccessibilityAnnouncements.announceInvoked, 1);

semantics().semanticsEnabled = false;
});
Expand Down