1 // Copyright 2014 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
6 * @fileoverview Keeps track of live regions on the page and speaks updates
11 goog.provide('cvox.LiveRegions');
13 goog.require('cvox.AriaUtil');
14 goog.require('cvox.ChromeVox');
15 goog.require('cvox.DescriptionUtil');
16 goog.require('cvox.DomUtil');
17 goog.require('cvox.Interframe');
18 goog.require('cvox.NavDescription');
19 goog.require('cvox.NavigationSpeaker');
24 cvox.LiveRegions = function() {
30 cvox.LiveRegions.pageLoadTime = null;
33 * Time in milliseconds after initial page load to ignore live region
34 * updates, to avoid announcing regions as they're initially created.
35 * The exception is alerts, they're announced when a page is loaded.
39 cvox.LiveRegions.INITIAL_SILENCE_MS = 2000;
42 * Time in milliseconds to wait for a node to become visible after a
43 * mutation. Needed to allow live regions to fade in and have an initial
48 cvox.LiveRegions.VISIBILITY_TIMEOUT_MS = 50;
51 * A mapping from announced text to the time it was last spoken.
52 * @type {Object.<string, Date>}
54 cvox.LiveRegions.lastAnnouncedMap = {};
57 * Maximum time interval in which to discard duplicate live region announcement.
61 cvox.LiveRegions.MAX_DISCARD_DUPS_MS = 2000;
64 * Maximum time interval in which to discard duplicate live region announcement
65 * when document.webkitHidden.
69 cvox.LiveRegions.HIDDEN_DOC_MAX_DISCARD_DUPS_MS = 60000;
74 cvox.LiveRegions.lastAnnouncedTime = null;
77 * Tracks nodes handled during mutation processing.
78 * @type {!Array.<Node>}
80 cvox.LiveRegions.nodesAlreadyHandled = [];
83 * @param {Date} pageLoadTime The time the page was loaded. Live region
84 * updates within the first INITIAL_SILENCE_MS milliseconds are ignored.
85 * @param {cvox.QueueMode} queueMode Interrupt or flush. Polite live region
86 * changes always queue.
87 * @param {boolean} disableSpeak true if change announcement should be disabled.
88 * @return {boolean} true if any regions announced.
90 cvox.LiveRegions.init = function(pageLoadTime, queueMode, disableSpeak) {
91 cvox.LiveRegions.pageLoadTime = pageLoadTime;
93 if (disableSpeak || !document.hasFocus()) {
97 // Speak any live regions already on the page. The logic below will
98 // make sure that only alerts are actually announced.
99 var anyRegionsAnnounced = false;
100 var regions = cvox.AriaUtil.getLiveRegions(document.body);
101 for (var i = 0; i < regions.length; i++) {
102 cvox.LiveRegions.handleOneChangedNode(
107 function(assertive, navDescriptions) {
108 if (!assertive && queueMode == cvox.QueueMode.FLUSH) {
109 queueMode = cvox.QueueMode.QUEUE;
111 var descSpeaker = new cvox.NavigationSpeaker();
112 descSpeaker.speakDescriptionArray(navDescriptions, queueMode, null);
113 anyRegionsAnnounced = true;
117 cvox.Interframe.addListener(function(message) {
118 if (message['command'] != 'speakLiveRegion') {
121 var iframes = document.getElementsByTagName('iframe');
122 for (var i = 0, iframe; iframe = iframes[i]; i++) {
123 if (iframe.src == message['src']) {
124 if (!cvox.DomUtil.isVisible(iframe)) {
127 var structs = JSON.parse(message['content']);
128 var descriptions = [];
129 for (var j = 0, description; description = structs[j]; j++) {
130 descriptions.push(new cvox.NavDescription(description));
132 new cvox.NavigationSpeaker()
133 .speakDescriptionArray(descriptions, message['queueMode'], null);
138 return anyRegionsAnnounced;
142 * See if any mutations pertain to a live region, and speak them if so.
144 * This function is not reentrant, it uses some global state to keep
145 * track of nodes it's already spoken once.
147 * @param {Array.<MutationRecord>} mutations The mutations.
148 * @param {function(boolean, Array.<cvox.NavDescription>)} handler
149 * A callback function that handles each live region description found.
150 * The function is passed a boolean indicating if the live region is
151 * assertive, and an array of navdescriptions to speak.
153 cvox.LiveRegions.processMutations = function(mutations, handler) {
154 cvox.LiveRegions.nodesAlreadyHandled = [];
155 mutations.forEach(function(mutation) {
156 if (mutation.target.hasAttribute &&
157 mutation.target.hasAttribute('cvoxIgnore')) {
160 if (mutation.addedNodes) {
161 for (var i = 0; i < mutation.addedNodes.length; i++) {
162 if (mutation.addedNodes[i].hasAttribute &&
163 mutation.addedNodes[i].hasAttribute('cvoxIgnore')) {
166 cvox.LiveRegions.handleOneChangedNode(
167 mutation.addedNodes[i], mutation.target, false, true, handler);
170 if (mutation.removedNodes) {
171 for (var i = 0; i < mutation.removedNodes.length; i++) {
172 if (mutation.removedNodes[i].hasAttribute &&
173 mutation.removedNodes[i].hasAttribute('cvoxIgnore')) {
176 cvox.LiveRegions.handleOneChangedNode(
177 mutation.removedNodes[i], mutation.target, true, false, handler);
180 if (mutation.type == 'characterData') {
181 cvox.LiveRegions.handleOneChangedNode(
182 mutation.target, mutation.target, false, false, handler);
184 if (mutation.attributeName == 'class' ||
185 mutation.attributeName == 'style' ||
186 mutation.attributeName == 'hidden') {
187 var attr = mutation.attributeName;
188 var target = mutation.target;
189 var newInvisible = !cvox.DomUtil.isVisible(target);
191 // Create a fake element on the page with the old values of
192 // class, style, and hidden for this element, to see if that test
193 // element would have had different visibility.
194 var testElement = document.createElement('div');
195 testElement.setAttribute('cvoxIgnore', '1');
196 testElement.setAttribute('class', target.getAttribute('class'));
197 testElement.setAttribute('style', target.getAttribute('style'));
198 testElement.setAttribute('hidden', target.getAttribute('hidden'));
199 testElement.setAttribute(attr, /** @type {string} */ (mutation.oldValue));
201 var oldInvisible = true;
202 if (target.parentElement) {
203 target.parentElement.appendChild(testElement);
204 oldInvisible = !cvox.DomUtil.isVisible(testElement);
205 target.parentElement.removeChild(testElement);
207 oldInvisible = !cvox.DomUtil.isVisible(testElement);
210 if (oldInvisible === true && newInvisible === false) {
211 cvox.LiveRegions.handleOneChangedNode(
212 mutation.target, mutation.target, false, true, handler);
213 } else if (oldInvisible === false && newInvisible === true) {
214 cvox.LiveRegions.handleOneChangedNode(
215 mutation.target, mutation.target, true, false, handler);
219 cvox.LiveRegions.nodesAlreadyHandled.length = 0;
223 * Handle one changed node. First check if this node is itself within
224 * a live region, and if that fails see if there's a live region within it
225 * and call this method recursively. For each actual live region, call a
226 * method to recursively announce all changes.
228 * @param {Node} node A node that's changed.
229 * @param {Node} parent The parent node.
230 * @param {boolean} isRemoval True if this node was removed.
231 * @param {boolean} subtree True if we should check the subtree.
232 * @param {function(boolean, Array.<cvox.NavDescription>)} handler
233 * Callback function to be called for each live region found.
235 cvox.LiveRegions.handleOneChangedNode = function(
236 node, parent, isRemoval, subtree, handler) {
237 var liveRoot = isRemoval ? parent : node;
238 if (!(liveRoot instanceof Element)) {
239 liveRoot = liveRoot.parentElement;
242 if (cvox.AriaUtil.getAriaLive(liveRoot)) {
245 liveRoot = liveRoot.parentElement;
248 if (subtree && node != document.body) {
249 var subLiveRegions = cvox.AriaUtil.getLiveRegions(node);
250 for (var i = 0; i < subLiveRegions.length; i++) {
251 cvox.LiveRegions.handleOneChangedNode(
252 subLiveRegions[i], parent, isRemoval, false, handler);
258 // If the page just loaded and this is any region type other than 'alert',
259 // skip it. Alerts are the exception, they're announced on page load.
260 var deltaTime = new Date() - cvox.LiveRegions.pageLoadTime;
261 if (cvox.AriaUtil.getRoleAttribute(liveRoot) != 'alert' &&
262 deltaTime < cvox.LiveRegions.INITIAL_SILENCE_MS) {
266 if (cvox.LiveRegions.nodesAlreadyHandled.indexOf(node) >= 0) {
269 cvox.LiveRegions.nodesAlreadyHandled.push(node);
271 if (cvox.AriaUtil.getAriaBusy(liveRoot)) {
276 if (!cvox.AriaUtil.getAriaRelevant(liveRoot, 'removals')) {
280 if (!cvox.AriaUtil.getAriaRelevant(liveRoot, 'additions')) {
285 cvox.LiveRegions.announceChangeIfVisible(node, liveRoot, isRemoval, handler);
289 * Announce one node within a live region if it's visible.
290 * In order to handle live regions that fade in, if the node isn't currently
291 * visible, check again after a short timeout.
293 * @param {Node} node A node in a live region.
294 * @param {Node} liveRoot The root of the live region this node is in.
295 * @param {boolean} isRemoval True if this node was removed.
296 * @param {function(boolean, Array.<cvox.NavDescription>)} handler
297 * Callback function to be called for each live region found.
299 cvox.LiveRegions.announceChangeIfVisible = function(
300 node, liveRoot, isRemoval, handler) {
301 if (cvox.DomUtil.isVisible(liveRoot)) {
302 cvox.LiveRegions.announceChange(node, liveRoot, isRemoval, handler);
304 window.setTimeout(function() {
305 if (cvox.DomUtil.isVisible(liveRoot)) {
306 cvox.LiveRegions.announceChange(node, liveRoot, isRemoval, handler);
308 }, cvox.LiveRegions.VISIBILITY_TIMEOUT_MS);
313 * Announce one node within a live region.
315 * @param {Node} node A node in a live region.
316 * @param {Node} liveRoot The root of the live region this node is in.
317 * @param {boolean} isRemoval True if this node was removed.
318 * @param {function(boolean, Array.<cvox.NavDescription>)} handler
319 * Callback function to be called for each live region found.
321 cvox.LiveRegions.announceChange = function(
322 node, liveRoot, isRemoval, handler) {
323 // If this node is in an atomic container, announce the whole container.
324 // This includes aria-atomic, but also ARIA controls and other nodes
325 // whose ARIA roles make them leaves.
326 if (node != liveRoot) {
327 var atomicContainer = node.parentElement;
328 while (atomicContainer) {
329 if ((cvox.AriaUtil.getAriaAtomic(atomicContainer) ||
330 cvox.AriaUtil.isLeafElement(atomicContainer) ||
331 cvox.AriaUtil.isControlWidget(atomicContainer)) &&
332 !cvox.AriaUtil.isCompositeControl(atomicContainer)) {
333 node = atomicContainer;
335 if (atomicContainer == liveRoot) {
338 atomicContainer = atomicContainer.parentElement;
342 var navDescriptions = cvox.LiveRegions.getNavDescriptionsRecursive(node);
344 navDescriptions = [cvox.DescriptionUtil.getDescriptionFromAncestors(
345 [node], true, cvox.ChromeVox.verbosity)];
346 navDescriptions = [new cvox.NavDescription({
347 context: cvox.ChromeVox.msgs.getMsg('live_regions_removed'), text: ''
348 })].concat(navDescriptions);
351 if (navDescriptions.length == 0) {
355 // Don't announce alerts on page load if their text and values consist of
357 var deltaTime = new Date() - cvox.LiveRegions.pageLoadTime;
358 if (cvox.AriaUtil.getRoleAttribute(liveRoot) == 'alert' &&
359 deltaTime < cvox.LiveRegions.INITIAL_SILENCE_MS) {
361 for (var i = 0; i < navDescriptions.length; i++) {
362 regionText += navDescriptions[i].text;
363 regionText += navDescriptions[i].userValue;
365 if (cvox.DomUtil.collapseWhitespace(regionText) == '') {
370 var discardDupsMs = document.webkitHidden ?
371 cvox.LiveRegions.HIDDEN_DOC_MAX_DISCARD_DUPS_MS :
372 cvox.LiveRegions.MAX_DISCARD_DUPS_MS;
374 // First, evict expired entries.
375 var now = new Date();
376 for (var announced in cvox.LiveRegions.lastAnnouncedMap) {
377 if (now - cvox.LiveRegions.lastAnnouncedMap[announced] > discardDupsMs) {
378 delete cvox.LiveRegions.lastAnnouncedMap[announced];
382 // Then, skip announcement if it was already spoken in the past 2000 ms.
383 var key = navDescriptions.reduce(function(prev, navDescription) {
384 return prev + '|' + navDescription.text;
387 if (cvox.LiveRegions.lastAnnouncedMap[key]) {
390 cvox.LiveRegions.lastAnnouncedMap[key] = now;
392 var assertive = cvox.AriaUtil.getAriaLive(liveRoot) == 'assertive';
393 if (cvox.Interframe.isIframe() && !document.hasFocus()) {
394 cvox.Interframe.sendMessageToParentWindow(
395 {'command': 'speakLiveRegion',
396 'content': JSON.stringify(navDescriptions),
397 'queueMode': assertive ? 0 : 1,
398 'src': window.location.href }
403 // Set a category on the NavDescriptions - that way live regions
404 // interrupt other live regions but not anything else.
405 navDescriptions.forEach(function(desc) {
406 if (!desc.category) {
407 desc.category = cvox.TtsCategory.LIVE;
411 // TODO(dmazzoni): http://crbug.com/415679 Temporary design decision;
412 // until we have a way to tell the speech queue to group the nav
413 // descriptions together, collapse them into one.
414 // Otherwise, one nav description could be spoken, then something unrelated,
416 if (navDescriptions.length > 1) {
418 navDescriptions.forEach(function(desc) {
420 allStrings.push(desc.context);
423 allStrings.push(desc.text);
425 if (desc.userValue) {
426 allStrings.push(desc.userValue);
429 navDescriptions = [new cvox.NavDescription({
430 text: allStrings.join(', '),
431 category: cvox.TtsCategory.LIVE
435 handler(assertive, navDescriptions);
439 * Recursively build up the value of a live region and return it as
440 * an array of NavDescriptions. Each atomic portion of the region gets a
441 * single string, otherwise each leaf node gets its own string.
443 * @param {Node} node A node in a live region.
444 * @return {Array.<cvox.NavDescription>} An array of NavDescriptions
445 * describing atomic nodes or leaf nodes in the subtree rooted
448 cvox.LiveRegions.getNavDescriptionsRecursive = function(node) {
449 if (cvox.AriaUtil.getAriaAtomic(node) ||
450 cvox.DomUtil.isLeafNode(node)) {
451 var description = cvox.DescriptionUtil.getDescriptionFromAncestors(
452 [node], true, cvox.ChromeVox.verbosity);
453 if (!description.isEmpty()) {
454 return [description];
459 return cvox.DescriptionUtil.getFullDescriptionsFromChildren(null,
460 /** @type {!Element} */ (node));