blob: 07fa9bd460fbde95365e2dc20d096313a24b2db6 [file] [log] [blame]
// 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));
};