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