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. 4 5/** 6 * @fileoverview Keeps track of live regions on the page and speaks updates 7 * when they change. 8 * 9 */ 10 11goog.provide('cvox.LiveRegions'); 12 13goog.require('cvox.AriaUtil'); 14goog.require('cvox.ChromeVox'); 15goog.require('cvox.DescriptionUtil'); 16goog.require('cvox.DomUtil'); 17goog.require('cvox.Interframe'); 18goog.require('cvox.NavDescription'); 19goog.require('cvox.NavigationSpeaker'); 20 21/** 22 * @constructor 23 */ 24cvox.LiveRegions = function() { 25}; 26 27/** 28 * @type {Date} 29 */ 30cvox.LiveRegions.pageLoadTime = null; 31 32/** 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. 36 * @type {number} 37 * @const 38 */ 39cvox.LiveRegions.INITIAL_SILENCE_MS = 2000; 40 41/** 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 44 * opacity of zero. 45 * @type {number} 46 * @const 47 */ 48cvox.LiveRegions.VISIBILITY_TIMEOUT_MS = 50; 49 50/** 51 * A mapping from announced text to the time it was last spoken. 52 * @type {Object.<string, Date>} 53 */ 54cvox.LiveRegions.lastAnnouncedMap = {}; 55 56/** 57 * Maximum time interval in which to discard duplicate live region announcement. 58 * @type {number} 59 * @const 60 */ 61cvox.LiveRegions.MAX_DISCARD_DUPS_MS = 2000; 62 63/** 64 * Maximum time interval in which to discard duplicate live region announcement 65 * when document.webkitHidden. 66 * @type {number} 67 * @const 68 */ 69cvox.LiveRegions.HIDDEN_DOC_MAX_DISCARD_DUPS_MS = 60000; 70 71/** 72 * @type {Date} 73*/ 74cvox.LiveRegions.lastAnnouncedTime = null; 75 76/** 77 * Tracks nodes handled during mutation processing. 78 * @type {!Array.<Node>} 79 */ 80cvox.LiveRegions.nodesAlreadyHandled = []; 81 82/** 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 {number} 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. 89 */ 90cvox.LiveRegions.init = function(pageLoadTime, queueMode, disableSpeak) { 91 if (queueMode == undefined) { 92 queueMode = cvox.AbstractTts.QUEUE_MODE_FLUSH; 93 } 94 95 cvox.LiveRegions.pageLoadTime = pageLoadTime; 96 97 if (disableSpeak || !document.hasFocus()) { 98 return false; 99 } 100 101 // Speak any live regions already on the page. The logic below will 102 // make sure that only alerts are actually announced. 103 var anyRegionsAnnounced = false; 104 var regions = cvox.AriaUtil.getLiveRegions(document.body); 105 for (var i = 0; i < regions.length; i++) { 106 cvox.LiveRegions.handleOneChangedNode( 107 regions[i], 108 regions[i], 109 false, 110 false, 111 function(assertive, navDescriptions) { 112 if (!assertive && queueMode == cvox.AbstractTts.QUEUE_MODE_FLUSH) { 113 queueMode = cvox.AbstractTts.QUEUE_MODE_QUEUE; 114 } 115 var descSpeaker = new cvox.NavigationSpeaker(); 116 descSpeaker.speakDescriptionArray(navDescriptions, queueMode, null); 117 anyRegionsAnnounced = true; 118 }); 119 } 120 121 cvox.Interframe.addListener(function(message) { 122 if (message['command'] != 'speakLiveRegion') { 123 return; 124 } 125 var iframes = document.getElementsByTagName('iframe'); 126 for (var i = 0, iframe; iframe = iframes[i]; i++) { 127 if (iframe.src == message['src']) { 128 if (!cvox.DomUtil.isVisible(iframe)) { 129 return; 130 } 131 var structs = JSON.parse(message['content']); 132 var descriptions = []; 133 for (var j = 0, description; description = structs[j]; j++) { 134 descriptions.push(new cvox.NavDescription(description)); 135 } 136 new cvox.NavigationSpeaker() 137 .speakDescriptionArray(descriptions, message['queueMode'], null); 138 } 139 } 140 }); 141 142 return anyRegionsAnnounced; 143}; 144 145/** 146 * See if any mutations pertain to a live region, and speak them if so. 147 * 148 * This function is not reentrant, it uses some global state to keep 149 * track of nodes it's already spoken once. 150 * 151 * @param {Array.<MutationRecord>} mutations The mutations. 152 * @param {function(boolean, Array.<cvox.NavDescription>)} handler 153 * A callback function that handles each live region description found. 154 * The function is passed a boolean indicating if the live region is 155 * assertive, and an array of navdescriptions to speak. 156 */ 157cvox.LiveRegions.processMutations = function(mutations, handler) { 158 cvox.LiveRegions.nodesAlreadyHandled = []; 159 mutations.forEach(function(mutation) { 160 if (mutation.target.hasAttribute && 161 mutation.target.hasAttribute('cvoxIgnore')) { 162 return; 163 } 164 if (mutation.addedNodes) { 165 for (var i = 0; i < mutation.addedNodes.length; i++) { 166 if (mutation.addedNodes[i].hasAttribute && 167 mutation.addedNodes[i].hasAttribute('cvoxIgnore')) { 168 continue; 169 } 170 cvox.LiveRegions.handleOneChangedNode( 171 mutation.addedNodes[i], mutation.target, false, true, handler); 172 } 173 } 174 if (mutation.removedNodes) { 175 for (var i = 0; i < mutation.removedNodes.length; i++) { 176 if (mutation.removedNodes[i].hasAttribute && 177 mutation.removedNodes[i].hasAttribute('cvoxIgnore')) { 178 continue; 179 } 180 cvox.LiveRegions.handleOneChangedNode( 181 mutation.removedNodes[i], mutation.target, true, false, handler); 182 } 183 } 184 if (mutation.type == 'characterData') { 185 cvox.LiveRegions.handleOneChangedNode( 186 mutation.target, mutation.target, false, false, handler); 187 } 188 if (mutation.attributeName == 'class' || 189 mutation.attributeName == 'style' || 190 mutation.attributeName == 'hidden') { 191 var attr = mutation.attributeName; 192 var target = mutation.target; 193 var newInvisible = !cvox.DomUtil.isVisible(target); 194 195 // Create a fake element on the page with the old values of 196 // class, style, and hidden for this element, to see if that test 197 // element would have had different visibility. 198 var testElement = document.createElement('div'); 199 testElement.setAttribute('cvoxIgnore', '1'); 200 testElement.setAttribute('class', target.getAttribute('class')); 201 testElement.setAttribute('style', target.getAttribute('style')); 202 testElement.setAttribute('hidden', target.getAttribute('hidden')); 203 testElement.setAttribute(attr, /** @type {string} */ (mutation.oldValue)); 204 205 var oldInvisible = true; 206 if (target.parentElement) { 207 target.parentElement.appendChild(testElement); 208 oldInvisible = !cvox.DomUtil.isVisible(testElement); 209 target.parentElement.removeChild(testElement); 210 } else { 211 oldInvisible = !cvox.DomUtil.isVisible(testElement); 212 } 213 214 if (oldInvisible === true && newInvisible === false) { 215 cvox.LiveRegions.handleOneChangedNode( 216 mutation.target, mutation.target, false, true, handler); 217 } else if (oldInvisible === false && newInvisible === true) { 218 cvox.LiveRegions.handleOneChangedNode( 219 mutation.target, mutation.target, true, false, handler); 220 } 221 } 222 }); 223 cvox.LiveRegions.nodesAlreadyHandled.length = 0; 224}; 225 226/** 227 * Handle one changed node. First check if this node is itself within 228 * a live region, and if that fails see if there's a live region within it 229 * and call this method recursively. For each actual live region, call a 230 * method to recursively announce all changes. 231 * 232 * @param {Node} node A node that's changed. 233 * @param {Node} parent The parent node. 234 * @param {boolean} isRemoval True if this node was removed. 235 * @param {boolean} subtree True if we should check the subtree. 236 * @param {function(boolean, Array.<cvox.NavDescription>)} handler 237 * Callback function to be called for each live region found. 238 */ 239cvox.LiveRegions.handleOneChangedNode = function( 240 node, parent, isRemoval, subtree, handler) { 241 var liveRoot = isRemoval ? parent : node; 242 if (!(liveRoot instanceof Element)) { 243 liveRoot = liveRoot.parentElement; 244 } 245 while (liveRoot) { 246 if (cvox.AriaUtil.getAriaLive(liveRoot)) { 247 break; 248 } 249 liveRoot = liveRoot.parentElement; 250 } 251 if (!liveRoot) { 252 if (subtree && node != document.body) { 253 var subLiveRegions = cvox.AriaUtil.getLiveRegions(node); 254 for (var i = 0; i < subLiveRegions.length; i++) { 255 cvox.LiveRegions.handleOneChangedNode( 256 subLiveRegions[i], parent, isRemoval, false, handler); 257 } 258 } 259 return; 260 } 261 262 // If the page just loaded and this is any region type other than 'alert', 263 // skip it. Alerts are the exception, they're announced on page load. 264 var deltaTime = new Date() - cvox.LiveRegions.pageLoadTime; 265 if (cvox.AriaUtil.getRoleAttribute(liveRoot) != 'alert' && 266 deltaTime < cvox.LiveRegions.INITIAL_SILENCE_MS) { 267 return; 268 } 269 270 if (cvox.LiveRegions.nodesAlreadyHandled.indexOf(node) >= 0) { 271 return; 272 } 273 cvox.LiveRegions.nodesAlreadyHandled.push(node); 274 275 if (cvox.AriaUtil.getAriaBusy(liveRoot)) { 276 return; 277 } 278 279 if (isRemoval) { 280 if (!cvox.AriaUtil.getAriaRelevant(liveRoot, 'removals')) { 281 return; 282 } 283 } else { 284 if (!cvox.AriaUtil.getAriaRelevant(liveRoot, 'additions')) { 285 return; 286 } 287 } 288 289 cvox.LiveRegions.announceChangeIfVisible(node, liveRoot, isRemoval, handler); 290}; 291 292/** 293 * Announce one node within a live region if it's visible. 294 * In order to handle live regions that fade in, if the node isn't currently 295 * visible, check again after a short timeout. 296 * 297 * @param {Node} node A node in a live region. 298 * @param {Node} liveRoot The root of the live region this node is in. 299 * @param {boolean} isRemoval True if this node was removed. 300 * @param {function(boolean, Array.<cvox.NavDescription>)} handler 301 * Callback function to be called for each live region found. 302 */ 303cvox.LiveRegions.announceChangeIfVisible = function( 304 node, liveRoot, isRemoval, handler) { 305 if (cvox.DomUtil.isVisible(liveRoot)) { 306 cvox.LiveRegions.announceChange(node, liveRoot, isRemoval, handler); 307 } else { 308 window.setTimeout(function() { 309 if (cvox.DomUtil.isVisible(liveRoot)) { 310 cvox.LiveRegions.announceChange(node, liveRoot, isRemoval, handler); 311 } 312 }, cvox.LiveRegions.VISIBILITY_TIMEOUT_MS); 313 } 314}; 315 316/** 317 * Announce one node within a live region. 318 * 319 * @param {Node} node A node in a live region. 320 * @param {Node} liveRoot The root of the live region this node is in. 321 * @param {boolean} isRemoval True if this node was removed. 322 * @param {function(boolean, Array.<cvox.NavDescription>)} handler 323 * Callback function to be called for each live region found. 324 */ 325cvox.LiveRegions.announceChange = function( 326 node, liveRoot, isRemoval, handler) { 327 // If this node is in an atomic container, announce the whole container. 328 // This includes aria-atomic, but also ARIA controls and other nodes 329 // whose ARIA roles make them leaves. 330 if (node != liveRoot) { 331 var atomicContainer = node.parentElement; 332 while (atomicContainer) { 333 if ((cvox.AriaUtil.getAriaAtomic(atomicContainer) || 334 cvox.AriaUtil.isLeafElement(atomicContainer) || 335 cvox.AriaUtil.isControlWidget(atomicContainer)) && 336 !cvox.AriaUtil.isCompositeControl(atomicContainer)) { 337 node = atomicContainer; 338 } 339 if (atomicContainer == liveRoot) { 340 break; 341 } 342 atomicContainer = atomicContainer.parentElement; 343 } 344 } 345 346 var navDescriptions = cvox.LiveRegions.getNavDescriptionsRecursive(node); 347 if (isRemoval) { 348 navDescriptions = [cvox.DescriptionUtil.getDescriptionFromAncestors( 349 [node], true, cvox.ChromeVox.verbosity)]; 350 navDescriptions = [new cvox.NavDescription({ 351 context: cvox.ChromeVox.msgs.getMsg('live_regions_removed'), text: '' 352 })].concat(navDescriptions); 353 } 354 355 if (navDescriptions.length == 0) { 356 return; 357 } 358 359 // Don't announce alerts on page load if their text and values consist of 360 // just whitespace. 361 var deltaTime = new Date() - cvox.LiveRegions.pageLoadTime; 362 if (cvox.AriaUtil.getRoleAttribute(liveRoot) == 'alert' && 363 deltaTime < cvox.LiveRegions.INITIAL_SILENCE_MS) { 364 var regionText = ''; 365 for (var i = 0; i < navDescriptions.length; i++) { 366 regionText += navDescriptions[i].text; 367 regionText += navDescriptions[i].userValue; 368 } 369 if (cvox.DomUtil.collapseWhitespace(regionText) == '') { 370 return; 371 } 372 } 373 374 var discardDupsMs = document.webkitHidden ? 375 cvox.LiveRegions.HIDDEN_DOC_MAX_DISCARD_DUPS_MS : 376 cvox.LiveRegions.MAX_DISCARD_DUPS_MS; 377 378 // First, evict expired entries. 379 var now = new Date(); 380 for (var announced in cvox.LiveRegions.lastAnnouncedMap) { 381 if (now - cvox.LiveRegions.lastAnnouncedMap[announced] > discardDupsMs) { 382 delete cvox.LiveRegions.lastAnnouncedMap[announced]; 383 } 384 } 385 386 // Then, skip announcement if it was already spoken in the past 2000 ms. 387 var key = navDescriptions.reduce(function(prev, navDescription) { 388 return prev + '|' + navDescription.text; 389 }, ''); 390 391 if (cvox.LiveRegions.lastAnnouncedMap[key]) { 392 return; 393 } 394 cvox.LiveRegions.lastAnnouncedMap[key] = now; 395 396 var assertive = cvox.AriaUtil.getAriaLive(liveRoot) == 'assertive'; 397 if (cvox.Interframe.isIframe() && !document.hasFocus()) { 398 cvox.Interframe.sendMessageToParentWindow( 399 {'command': 'speakLiveRegion', 400 'content': JSON.stringify(navDescriptions), 401 'queueMode': assertive ? 0 : 1, 402 'src': window.location.href } 403 ); 404 return; 405 } 406 407 // Set a category on the NavDescriptions - that way live regions 408 // interrupt other live regions but not anything else. 409 navDescriptions.forEach(function(desc) { 410 if (!desc.category) { 411 desc.category = 'live'; 412 } 413 }); 414 415 // TODO(dmazzoni): http://crbug.com/415679 Temporary design decision; 416 // until we have a way to tell the speech queue to group the nav 417 // descriptions together, collapse them into one. 418 // Otherwise, one nav description could be spoken, then something unrelated, 419 // then the rest. 420 if (navDescriptions.length > 1) { 421 var allStrings = []; 422 navDescriptions.forEach(function(desc) { 423 if (desc.context) { 424 allStrings.push(desc.context); 425 } 426 if (desc.text) { 427 allStrings.push(desc.text); 428 } 429 if (desc.userValue) { 430 allStrings.push(desc.userValue); 431 } 432 }); 433 navDescriptions = [new cvox.NavDescription({ 434 text: allStrings.join(', '), 435 category: 'live' 436 })]; 437 } 438 439 handler(assertive, navDescriptions); 440}; 441 442/** 443 * Recursively build up the value of a live region and return it as 444 * an array of NavDescriptions. Each atomic portion of the region gets a 445 * single string, otherwise each leaf node gets its own string. 446 * 447 * @param {Node} node A node in a live region. 448 * @return {Array.<cvox.NavDescription>} An array of NavDescriptions 449 * describing atomic nodes or leaf nodes in the subtree rooted 450 * at this node. 451 */ 452cvox.LiveRegions.getNavDescriptionsRecursive = function(node) { 453 if (cvox.AriaUtil.getAriaAtomic(node) || 454 cvox.DomUtil.isLeafNode(node)) { 455 var description = cvox.DescriptionUtil.getDescriptionFromAncestors( 456 [node], true, cvox.ChromeVox.verbosity); 457 if (!description.isEmpty()) { 458 return [description]; 459 } else { 460 return []; 461 } 462 } 463 return cvox.DescriptionUtil.getFullDescriptionsFromChildren(null, 464 /** @type {!Element} */ (node)); 465}; 466