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:

committed by
Chromium LUCI CQ

parent
021185e0b1
commit
b4d3522cb3
chrome/test/data/webui/cr_elements
ui/webui/resources
64
chrome/test/data/webui/cr_elements/cr_a11y_announcer_test.js
Normal file
64
chrome/test/data/webui/cr_elements/cr_a11y_announcer_test.js
Normal file
@ -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",
|
||||
|
22
ui/webui/resources/cr_elements/cr_a11y_announcer/BUILD.gn
Normal file
22
ui/webui/resources/cr_elements/cr_a11y_announcer/BUILD.gn
Normal file
@ -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);
|
Reference in New Issue
Block a user