live_regions.js revision 116680a4aac90f2aa7413d9095a592090648e557
16f31ac30b9092fd02a8c97e5216cf53f3e4fae4jshin@chromium.org// Copyright 2014 The Chromium Authors. All rights reserved. 26f31ac30b9092fd02a8c97e5216cf53f3e4fae4jshin@chromium.org// Use of this source code is governed by a BSD-style license that can be 36f31ac30b9092fd02a8c97e5216cf53f3e4fae4jshin@chromium.org// found in the LICENSE file. 46f31ac30b9092fd02a8c97e5216cf53f3e4fae4jshin@chromium.org 56f31ac30b9092fd02a8c97e5216cf53f3e4fae4jshin@chromium.org/** 66f31ac30b9092fd02a8c97e5216cf53f3e4fae4jshin@chromium.org * @fileoverview Keeps track of live regions on the page and speaks updates 76f31ac30b9092fd02a8c97e5216cf53f3e4fae4jshin@chromium.org * when they change. 86f31ac30b9092fd02a8c97e5216cf53f3e4fae4jshin@chromium.org * 96f31ac30b9092fd02a8c97e5216cf53f3e4fae4jshin@chromium.org */ 106f31ac30b9092fd02a8c97e5216cf53f3e4fae4jshin@chromium.org 116f31ac30b9092fd02a8c97e5216cf53f3e4fae4jshin@chromium.orggoog.provide('cvox.LiveRegions'); 126f31ac30b9092fd02a8c97e5216cf53f3e4fae4jshin@chromium.org 136f31ac30b9092fd02a8c97e5216cf53f3e4fae4jshin@chromium.orggoog.require('cvox.AriaUtil'); 146f31ac30b9092fd02a8c97e5216cf53f3e4fae4jshin@chromium.orggoog.require('cvox.ChromeVox'); 156f31ac30b9092fd02a8c97e5216cf53f3e4fae4jshin@chromium.orggoog.require('cvox.DescriptionUtil'); 166f31ac30b9092fd02a8c97e5216cf53f3e4fae4jshin@chromium.orggoog.require('cvox.DomUtil'); 176f31ac30b9092fd02a8c97e5216cf53f3e4fae4jshin@chromium.orggoog.require('cvox.Interframe'); 186f31ac30b9092fd02a8c97e5216cf53f3e4fae4jshin@chromium.orggoog.require('cvox.NavDescription'); 196f31ac30b9092fd02a8c97e5216cf53f3e4fae4jshin@chromium.orggoog.require('cvox.NavigationSpeaker'); 206f31ac30b9092fd02a8c97e5216cf53f3e4fae4jshin@chromium.org 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 (navDescriptions.length == 0) { 348 return; 349 } 350 351 if (isRemoval) { 352 navDescriptions = [new cvox.NavDescription({ 353 context: cvox.ChromeVox.msgs.getMsg('live_regions_removed'), text: '' 354 })].concat(navDescriptions); 355 } 356 357 // Don't announce alerts on page load if their text and values consist of 358 // just whitespace. 359 var deltaTime = new Date() - cvox.LiveRegions.pageLoadTime; 360 if (cvox.AriaUtil.getRoleAttribute(liveRoot) == 'alert' && 361 deltaTime < cvox.LiveRegions.INITIAL_SILENCE_MS) { 362 var regionText = ''; 363 for (var i = 0; i < navDescriptions.length; i++) { 364 regionText += navDescriptions[i].text; 365 regionText += navDescriptions[i].userValue; 366 } 367 if (cvox.DomUtil.collapseWhitespace(regionText) == '') { 368 return; 369 } 370 } 371 372 var discardDupsMs = document.webkitHidden ? 373 cvox.LiveRegions.HIDDEN_DOC_MAX_DISCARD_DUPS_MS : 374 cvox.LiveRegions.MAX_DISCARD_DUPS_MS; 375 376 // First, evict expired entries. 377 var now = new Date(); 378 for (var announced in cvox.LiveRegions.lastAnnouncedMap) { 379 if (now - cvox.LiveRegions.lastAnnouncedMap[announced] > discardDupsMs) { 380 delete cvox.LiveRegions.lastAnnouncedMap[announced]; 381 } 382 } 383 384 // Then, skip announcement if it was already spoken in the past 2000 ms. 385 var key = navDescriptions.reduce(function(prev, navDescription) { 386 return prev + '|' + navDescription.text; 387 }, ''); 388 389 if (cvox.LiveRegions.lastAnnouncedMap[key]) { 390 return; 391 } 392 cvox.LiveRegions.lastAnnouncedMap[key] = now; 393 394 var assertive = cvox.AriaUtil.getAriaLive(liveRoot) == 'assertive'; 395 if (cvox.Interframe.isIframe() && !document.hasFocus()) { 396 cvox.Interframe.sendMessageToParentWindow( 397 {'command': 'speakLiveRegion', 398 'content': JSON.stringify(navDescriptions), 399 'queueMode': assertive ? 0 : 1, 400 'src': window.location.href } 401 ); 402 return; 403 } 404 405 // Set a category on the NavDescriptions - that way live regions 406 // interrupt other live regions but not anything else. 407 navDescriptions.every(function(desc) { 408 if (!desc.category) { 409 desc.category = 'live'; 410 } 411 }); 412 413 handler(assertive, navDescriptions); 414}; 415 416/** 417 * Recursively build up the value of a live region and return it as 418 * an array of NavDescriptions. Each atomic portion of the region gets a 419 * single string, otherwise each leaf node gets its own string. 420 * 421 * @param {Node} node A node in a live region. 422 * @return {Array.<cvox.NavDescription>} An array of NavDescriptions 423 * describing atomic nodes or leaf nodes in the subtree rooted 424 * at this node. 425 */ 426cvox.LiveRegions.getNavDescriptionsRecursive = function(node) { 427 if (cvox.AriaUtil.getAriaAtomic(node) || 428 cvox.DomUtil.isLeafNode(node)) { 429 var description = cvox.DescriptionUtil.getDescriptionFromAncestors( 430 [node], true, cvox.ChromeVox.verbosity); 431 if (!description.isEmpty()) { 432 return [description]; 433 } else { 434 return []; 435 } 436 } 437 return cvox.DescriptionUtil.getFullDescriptionsFromChildren(null, 438 /** @type {!Element} */ (node)); 439}; 440