0

WebUI: Add announcer to read screen reader messages

This CL creates WebUI's own CrAnnouncerElement that adds itself to the
DOM so that WebUI components can queue up messages for screen readers
to use. It queues a timeout and clears the timeout if another message is
queued so that all messages are added to the DOM at once.

The issue with IronA11yAnnouncer is that if multiple messages
were announced at the same time, only the last sent message was being
read. IronA11yAnnouncer also used a timeout of 150ms before adding
messages, so the extra time isn't a regression.

Bug: 1184032
Change-Id: I8355d30575fd31926736ff64787a48f6ddffb38b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/3013011
Commit-Queue: John Lee <johntlee@chromium.org>
Reviewed-by: dpapad <dpapad@chromium.org>
Cr-Commit-Position: refs/heads/master@{#900175}
This commit is contained in:
John Lee
2021-07-09 22:24:25 +00:00
committed by Chromium LUCI CQ
parent 021185e0b1
commit b4d3522cb3
7 changed files with 226 additions and 0 deletions
chrome/test/data/webui/cr_elements
ui/webui/resources

@ -0,0 +1,64 @@
// Copyright 2021 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {CrA11yAnnouncerElement, TIMEOUT_MS} from 'chrome://resources/cr_elements/cr_a11y_announcer/cr_a11y_announcer.js';
import {assertEquals, assertFalse, assertNotEquals, assertTrue} from '../chai_assert.js';
suite('CrA11yAnnouncerElementTest', () => {
setup(() => {
document.body.innerHTML = '';
});
function getMessagesDiv(announcer) {
return announcer.shadowRoot.querySelector('#messages');
}
test('CreatesAndGetsAnnouncers', () => {
const defaultAnnouncer = CrA11yAnnouncerElement.getInstance();
assertEquals(document.body, defaultAnnouncer.parentElement);
assertEquals(defaultAnnouncer, CrA11yAnnouncerElement.getInstance());
const dialog = document.createElement('dialog');
document.body.appendChild(dialog);
const dialogAnnouncer = CrA11yAnnouncerElement.getInstance(dialog);
assertEquals(dialog, dialogAnnouncer.parentElement);
assertEquals(dialogAnnouncer, CrA11yAnnouncerElement.getInstance(dialog));
});
test('QueuesMessages', async () => {
const announcer = CrA11yAnnouncerElement.getInstance();
const messagesDiv = announcer.shadowRoot.querySelector('#messages');
// Queue up 2 messages at once, and assert they both exist.
const message1 = 'Knock knock!';
const message2 = 'Who\'s there?';
announcer.announce(message1);
announcer.announce(message2);
await new Promise(resolve => setTimeout(resolve, TIMEOUT_MS));
assertTrue(messagesDiv.textContent.includes(message1));
assertTrue(messagesDiv.textContent.includes(message2));
// Queue up 1 message, and assert it clears out previous messages.
const message3 = 'No jokes allowed';
announcer.announce(message3);
await new Promise(resolve => setTimeout(resolve, TIMEOUT_MS));
assertFalse(messagesDiv.textContent.includes(message1));
assertFalse(messagesDiv.textContent.includes(message2));
assertTrue(messagesDiv.textContent.includes(message3));
});
test('ClearsAnnouncerOnDisconnect', async () => {
const announcer = CrA11yAnnouncerElement.getInstance();
const lostMessage = 'You will never hear me.';
announcer.announce(lostMessage);
announcer.remove();
await new Promise(resolve => setTimeout(resolve, TIMEOUT_MS));
assertFalse(announcer.shadowRoot.querySelector('#messages')
.textContent.includes(lostMessage));
// Creates new announcer since previous announcer is removed from instances.
assertNotEquals(announcer, CrA11yAnnouncerElement.getInstance());
});
});

@ -25,6 +25,18 @@ var CrElementsV3BrowserTest = class extends PolymerTest {
}
};
// eslint-disable-next-line no-var
var CrElementsA11yAnnouncerV3Test = class extends CrElementsV3BrowserTest {
/** @override */
get browsePreload() {
return 'chrome://test/test_loader.html?module=cr_elements/cr_a11y_announcer_test.js';
}
};
TEST_F('CrElementsA11yAnnouncerV3Test', 'All', function() {
mocha.run();
});
// eslint-disable-next-line no-var
var CrElementsButtonV3Test = class extends CrElementsV3BrowserTest {
/** @override */

@ -258,6 +258,7 @@ ts_definitions("generate_definitions") {
js_files = [
"cr_components/managed_dialog/managed_dialog.js",
"cr_elements/action_link_css.m.js",
"cr_elements/cr_a11y_announcer/cr_a11y_announcer.js",
"cr_elements/cr_actionable_row_style.m.js",
"cr_elements/cr_fingerprint/cr_fingerprint_icon.m.js",
"cr_elements/cr_fingerprint/cr_fingerprint_progress_arc.m.js",

@ -155,6 +155,7 @@ preprocess_if_expr("preprocess_generated") {
out_manifest = "$target_gen_dir/$preprocess_gen_manifest"
in_files = [
"action_link_css.m.js",
"cr_a11y_announcer/cr_a11y_announcer.js",
"cr_actionable_row_style.m.js",
"cr_action_menu/cr_action_menu.m.js",
"cr_button/cr_button.m.js",
@ -251,6 +252,7 @@ group("closure_compile") {
# Targets for auto-generated Polymer 3 JS Modules
":cr_elements_module_resources",
"cr_a11y_announcer:closure_compile",
"cr_action_menu:closure_compile_module",
"cr_button:closure_compile_module",
"cr_checkbox:closure_compile_module",
@ -379,6 +381,7 @@ group("polymer3_elements") {
":shared_style_css_module",
":shared_vars_css_module",
":web_components",
"cr_a11y_announcer:web_components",
"cr_action_menu:cr_action_menu_module",
"cr_button:cr_button_module",
"cr_checkbox:cr_checkbox_module",

@ -0,0 +1,22 @@
# Copyright 2021 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import("//third_party/closure_compiler/compile_js.gni")
import("//tools/polymer/html_to_js.gni")
html_to_js("web_components") {
js_files = [ "cr_a11y_announcer.js" ]
}
js_type_check("closure_compile") {
is_polymer3 = true
deps = [ ":cr_a11y_announcer" ]
}
js_library("cr_a11y_announcer") {
deps = [
"//third_party/polymer/v3_0/components-chromium/polymer:polymer_bundled",
"//ui/webui/resources/js:assert.m",
]
}

@ -0,0 +1,11 @@
<style>
:host {
clip: rect(0 0 0 0);
height: 1px;
overflow: hidden;
position: absolute;
width: 1px;
}
</style>
<div id="messages" role="alert" aria-live="polite"></div>

@ -0,0 +1,113 @@
// Copyright 2021 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {assert} from 'chrome://resources/js/assert.m.js';
import {html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
/**
* The CrA11yAnnouncerElement is a visually hidden element that reads out
* messages to a screen reader. This is preferred over IronA11yAnnouncer.
* @fileoverview
*/
/**
* 150ms seems to be around the minimum time required for screen readers to
* read out consecutively queued messages.
* @type {number}
*/
export const TIMEOUT_MS = 150;
/**
* A map of an HTML element to its corresponding CrA11yAnnouncerElement. There
* may be multiple CrA11yAnnouncerElements on a page, especially for cases in
* which the DocumentElement's CrA11yAnnouncerElement becomes hidden or
* deactivated (eg. when a modal dialog causes the CrA11yAnnouncerElement to
* become inaccessible).
* @type {!Map<!HTMLElement, !CrA11yAnnouncerElement>}
*/
const instances = new Map();
export class CrA11yAnnouncerElement extends PolymerElement {
static get is() {
return 'cr-a11y-announcer';
}
static get template() {
return html`{__html_template__}`;
}
constructor() {
super();
/** @private {?number} */
this.currentTimeout_ = null;
/** @private {!Array<string>} */
this.messages_ = [];
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.currentTimeout_ !== null) {
clearTimeout(this.currentTimeout_);
this.currentTimeout_ = null;
}
for (const [parent, instance] of instances) {
if (instance === this) {
instances.delete(parent);
break;
}
}
}
/** @param {string} message */
announce(message) {
if (this.currentTimeout_ !== null) {
clearTimeout(this.currentTimeout_);
this.currentTimeout_ = null;
}
this.messages_.push(message);
this.currentTimeout_ = setTimeout(() => {
const messagesDiv = this.shadowRoot.querySelector('#messages');
messagesDiv.innerHTML = '';
// <if expr="is_macosx">
// VoiceOver on Mac does not seem to consistently read out the contents of
// a static alert element. Toggling the role of alert seems to force VO
// to consistently read out the messages.
messagesDiv.removeAttribute('role');
messagesDiv.setAttribute('role', 'alert');
// </if>
for (const message of this.messages_) {
const div = document.createElement('div');
div.textContent = message;
messagesDiv.appendChild(div);
}
this.messages_.length = 0;
this.currentTimeout_ = null;
}, TIMEOUT_MS);
}
/**
* @param {!HTMLElement=} container
* @return {!CrA11yAnnouncerElement}
*/
static getInstance(container = document.body) {
if (instances.has(container)) {
return instances.get(container);
}
assert(container.isConnected);
const instance = new CrA11yAnnouncerElement();
container.appendChild(instance);
instances.set(container, instance);
return instance;
}
}
customElements.define(CrA11yAnnouncerElement.is, CrA11yAnnouncerElement);