| // Copyright 2014 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. |
| |
| /** |
| * @fileoverview Keeps track of live regions on the page and speaks updates |
| * when they change. |
| * |
| */ |
| |
| goog.provide('cvox.LiveRegions'); |
| |
| goog.require('cvox.AriaUtil'); |
| goog.require('cvox.ChromeVox'); |
| goog.require('cvox.DescriptionUtil'); |
| goog.require('cvox.DomUtil'); |
| goog.require('cvox.Interframe'); |
| goog.require('cvox.NavDescription'); |
| goog.require('cvox.NavigationSpeaker'); |
| |
| /** |
| * @constructor |
| */ |
| cvox.LiveRegions = function() { |
| }; |
| |
| /** |
| * @type {Date} |
| */ |
| cvox.LiveRegions.pageLoadTime = null; |
| |
| /** |
| * Time in milliseconds after initial page load to ignore live region |
| * updates, to avoid announcing regions as they're initially created. |
| * The exception is alerts, they're announced when a page is loaded. |
| * @type {number} |
| * @const |
| */ |
| cvox.LiveRegions.INITIAL_SILENCE_MS = 2000; |
| |
| /** |
| * Time in milliseconds to wait for a node to become visible after a |
| * mutation. Needed to allow live regions to fade in and have an initial |
| * opacity of zero. |
| * @type {number} |
| * @const |
| */ |
| cvox.LiveRegions.VISIBILITY_TIMEOUT_MS = 50; |
| |
| /** |
| * A mapping from announced text to the time it was last spoken. |
| * @type {Object.<string, Date>} |
| */ |
| cvox.LiveRegions.lastAnnouncedMap = {}; |
| |
| /** |
| * Maximum time interval in which to discard duplicate live region announcement. |
| * @type {number} |
| * @const |
| */ |
| cvox.LiveRegions.MAX_DISCARD_DUPS_MS = 2000; |
| |
| /** |
| * Maximum time interval in which to discard duplicate live region announcement |
| * when document.webkitHidden. |
| * @type {number} |
| * @const |
| */ |
| cvox.LiveRegions.HIDDEN_DOC_MAX_DISCARD_DUPS_MS = 60000; |
| |
| /** |
| * @type {Date} |
| */ |
| cvox.LiveRegions.lastAnnouncedTime = null; |
| |
| /** |
| * Tracks nodes handled during mutation processing. |
| * @type {!Array.<Node>} |
| */ |
| cvox.LiveRegions.nodesAlreadyHandled = []; |
| |
| /** |
| * @param {Date} pageLoadTime The time the page was loaded. Live region |
| * updates within the first INITIAL_SILENCE_MS milliseconds are ignored. |
| * @param {number} queueMode Interrupt or flush. Polite live region |
| * changes always queue. |
| * @param {boolean} disableSpeak true if change announcement should be disabled. |
| * @return {boolean} true if any regions announced. |
| */ |
| cvox.LiveRegions.init = function(pageLoadTime, queueMode, disableSpeak) { |
| if (queueMode == undefined) { |
| queueMode = cvox.AbstractTts.QUEUE_MODE_FLUSH; |
| } |
| |
| cvox.LiveRegions.pageLoadTime = pageLoadTime; |
| |
| if (disableSpeak || !document.hasFocus()) { |
| return false; |
| } |
| |
| // Speak any live regions already on the page. The logic below will |
| // make sure that only alerts are actually announced. |
| var anyRegionsAnnounced = false; |
| var regions = cvox.AriaUtil.getLiveRegions(document.body); |
| for (var i = 0; i < regions.length; i++) { |
| cvox.LiveRegions.handleOneChangedNode( |
| regions[i], |
| regions[i], |
| false, |
| false, |
| function(assertive, navDescriptions) { |
| if (!assertive && queueMode == cvox.AbstractTts.QUEUE_MODE_FLUSH) { |
| queueMode = cvox.AbstractTts.QUEUE_MODE_QUEUE; |
| } |
| var descSpeaker = new cvox.NavigationSpeaker(); |
| descSpeaker.speakDescriptionArray(navDescriptions, queueMode, null); |
| anyRegionsAnnounced = true; |
| }); |
| } |
| |
| cvox.Interframe.addListener(function(message) { |
| if (message['command'] != 'speakLiveRegion') { |
| return; |
| } |
| var iframes = document.getElementsByTagName('iframe'); |
| for (var i = 0, iframe; iframe = iframes[i]; i++) { |
| if (iframe.src == message['src']) { |
| if (!cvox.DomUtil.isVisible(iframe)) { |
| return; |
| } |
| var structs = JSON.parse(message['content']); |
| var descriptions = []; |
| for (var j = 0, description; description = structs[j]; j++) { |
| descriptions.push(new cvox.NavDescription(description)); |
| } |
| new cvox.NavigationSpeaker() |
| .speakDescriptionArray(descriptions, message['queueMode'], null); |
| } |
| } |
| }); |
| |
| return anyRegionsAnnounced; |
| }; |
| |
| /** |
| * See if any mutations pertain to a live region, and speak them if so. |
| * |
| * This function is not reentrant, it uses some global state to keep |
| * track of nodes it's already spoken once. |
| * |
| * @param {Array.<MutationRecord>} mutations The mutations. |
| * @param {function(boolean, Array.<cvox.NavDescription>)} handler |
| * A callback function that handles each live region description found. |
| * The function is passed a boolean indicating if the live region is |
| * assertive, and an array of navdescriptions to speak. |
| */ |
| cvox.LiveRegions.processMutations = function(mutations, handler) { |
| cvox.LiveRegions.nodesAlreadyHandled = []; |
| mutations.forEach(function(mutation) { |
| if (mutation.target.hasAttribute && |
| mutation.target.hasAttribute('cvoxIgnore')) { |
| return; |
| } |
| if (mutation.addedNodes) { |
| for (var i = 0; i < mutation.addedNodes.length; i++) { |
| if (mutation.addedNodes[i].hasAttribute && |
| mutation.addedNodes[i].hasAttribute('cvoxIgnore')) { |
| continue; |
| } |
| cvox.LiveRegions.handleOneChangedNode( |
| mutation.addedNodes[i], mutation.target, false, true, handler); |
| } |
| } |
| if (mutation.removedNodes) { |
| for (var i = 0; i < mutation.removedNodes.length; i++) { |
| if (mutation.removedNodes[i].hasAttribute && |
| mutation.removedNodes[i].hasAttribute('cvoxIgnore')) { |
| continue; |
| } |
| cvox.LiveRegions.handleOneChangedNode( |
| mutation.removedNodes[i], mutation.target, true, false, handler); |
| } |
| } |
| if (mutation.type == 'characterData') { |
| cvox.LiveRegions.handleOneChangedNode( |
| mutation.target, mutation.target, false, false, handler); |
| } |
| if (mutation.attributeName == 'class' || |
| mutation.attributeName == 'style' || |
| mutation.attributeName == 'hidden') { |
| var attr = mutation.attributeName; |
| var target = mutation.target; |
| var newInvisible = !cvox.DomUtil.isVisible(target); |
| |
| // Create a fake element on the page with the old values of |
| // class, style, and hidden for this element, to see if that test |
| // element would have had different visibility. |
| var testElement = document.createElement('div'); |
| testElement.setAttribute('cvoxIgnore', '1'); |
| testElement.setAttribute('class', target.getAttribute('class')); |
| testElement.setAttribute('style', target.getAttribute('style')); |
| testElement.setAttribute('hidden', target.getAttribute('hidden')); |
| testElement.setAttribute(attr, /** @type {string} */ (mutation.oldValue)); |
| |
| var oldInvisible = true; |
| if (target.parentElement) { |
| target.parentElement.appendChild(testElement); |
| oldInvisible = !cvox.DomUtil.isVisible(testElement); |
| target.parentElement.removeChild(testElement); |
| } else { |
| oldInvisible = !cvox.DomUtil.isVisible(testElement); |
| } |
| |
| if (oldInvisible === true && newInvisible === false) { |
| cvox.LiveRegions.handleOneChangedNode( |
| mutation.target, mutation.target, false, true, handler); |
| } else if (oldInvisible === false && newInvisible === true) { |
| cvox.LiveRegions.handleOneChangedNode( |
| mutation.target, mutation.target, true, false, handler); |
| } |
| } |
| }); |
| cvox.LiveRegions.nodesAlreadyHandled.length = 0; |
| }; |
| |
| /** |
| * Handle one changed node. First check if this node is itself within |
| * a live region, and if that fails see if there's a live region within it |
| * and call this method recursively. For each actual live region, call a |
| * method to recursively announce all changes. |
| * |
| * @param {Node} node A node that's changed. |
| * @param {Node} parent The parent node. |
| * @param {boolean} isRemoval True if this node was removed. |
| * @param {boolean} subtree True if we should check the subtree. |
| * @param {function(boolean, Array.<cvox.NavDescription>)} handler |
| * Callback function to be called for each live region found. |
| */ |
| cvox.LiveRegions.handleOneChangedNode = function( |
| node, parent, isRemoval, subtree, handler) { |
| var liveRoot = isRemoval ? parent : node; |
| if (!(liveRoot instanceof Element)) { |
| liveRoot = liveRoot.parentElement; |
| } |
| while (liveRoot) { |
| if (cvox.AriaUtil.getAriaLive(liveRoot)) { |
| break; |
| } |
| liveRoot = liveRoot.parentElement; |
| } |
| if (!liveRoot) { |
| if (subtree && node != document.body) { |
| var subLiveRegions = cvox.AriaUtil.getLiveRegions(node); |
| for (var i = 0; i < subLiveRegions.length; i++) { |
| cvox.LiveRegions.handleOneChangedNode( |
| subLiveRegions[i], parent, isRemoval, false, handler); |
| } |
| } |
| return; |
| } |
| |
| // If the page just loaded and this is any region type other than 'alert', |
| // skip it. Alerts are the exception, they're announced on page load. |
| var deltaTime = new Date() - cvox.LiveRegions.pageLoadTime; |
| if (cvox.AriaUtil.getRoleAttribute(liveRoot) != 'alert' && |
| deltaTime < cvox.LiveRegions.INITIAL_SILENCE_MS) { |
| return; |
| } |
| |
| if (cvox.LiveRegions.nodesAlreadyHandled.indexOf(node) >= 0) { |
| return; |
| } |
| cvox.LiveRegions.nodesAlreadyHandled.push(node); |
| |
| if (cvox.AriaUtil.getAriaBusy(liveRoot)) { |
| return; |
| } |
| |
| if (isRemoval) { |
| if (!cvox.AriaUtil.getAriaRelevant(liveRoot, 'removals')) { |
| return; |
| } |
| } else { |
| if (!cvox.AriaUtil.getAriaRelevant(liveRoot, 'additions')) { |
| return; |
| } |
| } |
| |
| cvox.LiveRegions.announceChangeIfVisible(node, liveRoot, isRemoval, handler); |
| }; |
| |
| /** |
| * Announce one node within a live region if it's visible. |
| * In order to handle live regions that fade in, if the node isn't currently |
| * visible, check again after a short timeout. |
| * |
| * @param {Node} node A node in a live region. |
| * @param {Node} liveRoot The root of the live region this node is in. |
| * @param {boolean} isRemoval True if this node was removed. |
| * @param {function(boolean, Array.<cvox.NavDescription>)} handler |
| * Callback function to be called for each live region found. |
| */ |
| cvox.LiveRegions.announceChangeIfVisible = function( |
| node, liveRoot, isRemoval, handler) { |
| if (cvox.DomUtil.isVisible(liveRoot)) { |
| cvox.LiveRegions.announceChange(node, liveRoot, isRemoval, handler); |
| } else { |
| window.setTimeout(function() { |
| if (cvox.DomUtil.isVisible(liveRoot)) { |
| cvox.LiveRegions.announceChange(node, liveRoot, isRemoval, handler); |
| } |
| }, cvox.LiveRegions.VISIBILITY_TIMEOUT_MS); |
| } |
| }; |
| |
| /** |
| * Announce one node within a live region. |
| * |
| * @param {Node} node A node in a live region. |
| * @param {Node} liveRoot The root of the live region this node is in. |
| * @param {boolean} isRemoval True if this node was removed. |
| * @param {function(boolean, Array.<cvox.NavDescription>)} handler |
| * Callback function to be called for each live region found. |
| */ |
| cvox.LiveRegions.announceChange = function( |
| node, liveRoot, isRemoval, handler) { |
| // If this node is in an atomic container, announce the whole container. |
| // This includes aria-atomic, but also ARIA controls and other nodes |
| // whose ARIA roles make them leaves. |
| if (node != liveRoot) { |
| var atomicContainer = node.parentElement; |
| while (atomicContainer) { |
| if ((cvox.AriaUtil.getAriaAtomic(atomicContainer) || |
| cvox.AriaUtil.isLeafElement(atomicContainer) || |
| cvox.AriaUtil.isControlWidget(atomicContainer)) && |
| !cvox.AriaUtil.isCompositeControl(atomicContainer)) { |
| node = atomicContainer; |
| } |
| if (atomicContainer == liveRoot) { |
| break; |
| } |
| atomicContainer = atomicContainer.parentElement; |
| } |
| } |
| |
| var navDescriptions = cvox.LiveRegions.getNavDescriptionsRecursive(node); |
| if (navDescriptions.length == 0) { |
| return; |
| } |
| |
| if (isRemoval) { |
| navDescriptions = [new cvox.NavDescription({ |
| context: cvox.ChromeVox.msgs.getMsg('live_regions_removed'), text: '' |
| })].concat(navDescriptions); |
| } |
| |
| // Don't announce alerts on page load if their text and values consist of |
| // just whitespace. |
| var deltaTime = new Date() - cvox.LiveRegions.pageLoadTime; |
| if (cvox.AriaUtil.getRoleAttribute(liveRoot) == 'alert' && |
| deltaTime < cvox.LiveRegions.INITIAL_SILENCE_MS) { |
| var regionText = ''; |
| for (var i = 0; i < navDescriptions.length; i++) { |
| regionText += navDescriptions[i].text; |
| regionText += navDescriptions[i].userValue; |
| } |
| if (cvox.DomUtil.collapseWhitespace(regionText) == '') { |
| return; |
| } |
| } |
| |
| var discardDupsMs = document.webkitHidden ? |
| cvox.LiveRegions.HIDDEN_DOC_MAX_DISCARD_DUPS_MS : |
| cvox.LiveRegions.MAX_DISCARD_DUPS_MS; |
| |
| // First, evict expired entries. |
| var now = new Date(); |
| for (var announced in cvox.LiveRegions.lastAnnouncedMap) { |
| if (now - cvox.LiveRegions.lastAnnouncedMap[announced] > discardDupsMs) { |
| delete cvox.LiveRegions.lastAnnouncedMap[announced]; |
| } |
| } |
| |
| // Then, skip announcement if it was already spoken in the past 2000 ms. |
| var key = navDescriptions.reduce(function(prev, navDescription) { |
| return prev + '|' + navDescription.text; |
| }, ''); |
| |
| if (cvox.LiveRegions.lastAnnouncedMap[key]) { |
| return; |
| } |
| cvox.LiveRegions.lastAnnouncedMap[key] = now; |
| |
| var assertive = cvox.AriaUtil.getAriaLive(liveRoot) == 'assertive'; |
| if (cvox.Interframe.isIframe() && !document.hasFocus()) { |
| cvox.Interframe.sendMessageToParentWindow( |
| {'command': 'speakLiveRegion', |
| 'content': JSON.stringify(navDescriptions), |
| 'queueMode': assertive ? 0 : 1, |
| 'src': window.location.href } |
| ); |
| return; |
| } |
| |
| // Set a category on the NavDescriptions - that way live regions |
| // interrupt other live regions but not anything else. |
| navDescriptions.every(function(desc) { |
| if (!desc.category) { |
| desc.category = 'live'; |
| } |
| }); |
| |
| handler(assertive, navDescriptions); |
| }; |
| |
| /** |
| * Recursively build up the value of a live region and return it as |
| * an array of NavDescriptions. Each atomic portion of the region gets a |
| * single string, otherwise each leaf node gets its own string. |
| * |
| * @param {Node} node A node in a live region. |
| * @return {Array.<cvox.NavDescription>} An array of NavDescriptions |
| * describing atomic nodes or leaf nodes in the subtree rooted |
| * at this node. |
| */ |
| cvox.LiveRegions.getNavDescriptionsRecursive = function(node) { |
| if (cvox.AriaUtil.getAriaAtomic(node) || |
| cvox.DomUtil.isLeafNode(node)) { |
| var description = cvox.DescriptionUtil.getDescriptionFromAncestors( |
| [node], true, cvox.ChromeVox.verbosity); |
| if (!description.isEmpty()) { |
| return [description]; |
| } else { |
| return []; |
| } |
| } |
| return cvox.DescriptionUtil.getFullDescriptionsFromChildren(null, |
| /** @type {!Element} */ (node)); |
| }; |