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