Update To 11.40.268.0
[platform/framework/web/crosswalk.git] / src / chrome / browser / resources / chromeos / chromevox / chromevox / injected / live_regions.js
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
11 goog.provide('cvox.LiveRegions');
12
13 goog.require('cvox.AriaUtil');
14 goog.require('cvox.ChromeVox');
15 goog.require('cvox.DescriptionUtil');
16 goog.require('cvox.DomUtil');
17 goog.require('cvox.Interframe');
18 goog.require('cvox.NavDescription');
19 goog.require('cvox.NavigationSpeaker');
20
21 /**
22  * @constructor
23  */
24 cvox.LiveRegions = function() {
25 };
26
27 /**
28  * @type {Date}
29  */
30 cvox.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  */
39 cvox.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  */
48 cvox.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  */
54 cvox.LiveRegions.lastAnnouncedMap = {};
55
56 /**
57  * Maximum time interval in which to discard duplicate live region announcement.
58  * @type {number}
59  * @const
60  */
61 cvox.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  */
69 cvox.LiveRegions.HIDDEN_DOC_MAX_DISCARD_DUPS_MS = 60000;
70
71 /**
72  * @type {Date}
73 */
74 cvox.LiveRegions.lastAnnouncedTime = null;
75
76 /**
77  * Tracks nodes handled during mutation processing.
78  * @type {!Array.<Node>}
79  */
80 cvox.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 {cvox.QueueMode} 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  */
90 cvox.LiveRegions.init = function(pageLoadTime, queueMode, disableSpeak) {
91   cvox.LiveRegions.pageLoadTime = pageLoadTime;
92
93   if (disableSpeak || !document.hasFocus()) {
94     return false;
95   }
96
97   // Speak any live regions already on the page. The logic below will
98   // make sure that only alerts are actually announced.
99   var anyRegionsAnnounced = false;
100   var regions = cvox.AriaUtil.getLiveRegions(document.body);
101   for (var i = 0; i < regions.length; i++) {
102     cvox.LiveRegions.handleOneChangedNode(
103         regions[i],
104         regions[i],
105         false,
106         false,
107         function(assertive, navDescriptions) {
108           if (!assertive && queueMode == cvox.QueueMode.FLUSH) {
109             queueMode = cvox.QueueMode.QUEUE;
110           }
111           var descSpeaker = new cvox.NavigationSpeaker();
112           descSpeaker.speakDescriptionArray(navDescriptions, queueMode, null);
113           anyRegionsAnnounced = true;
114         });
115   }
116
117   cvox.Interframe.addListener(function(message) {
118     if (message['command'] != 'speakLiveRegion') {
119       return;
120     }
121     var iframes = document.getElementsByTagName('iframe');
122     for (var i = 0, iframe; iframe = iframes[i]; i++) {
123       if (iframe.src == message['src']) {
124         if (!cvox.DomUtil.isVisible(iframe)) {
125           return;
126         }
127         var structs = JSON.parse(message['content']);
128         var descriptions = [];
129         for (var j = 0, description; description = structs[j]; j++) {
130           descriptions.push(new cvox.NavDescription(description));
131         }
132         new cvox.NavigationSpeaker()
133             .speakDescriptionArray(descriptions, message['queueMode'], null);
134       }
135     }
136   });
137
138   return anyRegionsAnnounced;
139 };
140
141 /**
142  * See if any mutations pertain to a live region, and speak them if so.
143  *
144  * This function is not reentrant, it uses some global state to keep
145  * track of nodes it's already spoken once.
146  *
147  * @param {Array.<MutationRecord>} mutations The mutations.
148  * @param {function(boolean, Array.<cvox.NavDescription>)} handler
149  *     A callback function that handles each live region description found.
150  *     The function is passed a boolean indicating if the live region is
151  *     assertive, and an array of navdescriptions to speak.
152  */
153 cvox.LiveRegions.processMutations = function(mutations, handler) {
154   cvox.LiveRegions.nodesAlreadyHandled = [];
155   mutations.forEach(function(mutation) {
156     if (mutation.target.hasAttribute &&
157         mutation.target.hasAttribute('cvoxIgnore')) {
158       return;
159     }
160     if (mutation.addedNodes) {
161       for (var i = 0; i < mutation.addedNodes.length; i++) {
162         if (mutation.addedNodes[i].hasAttribute &&
163             mutation.addedNodes[i].hasAttribute('cvoxIgnore')) {
164           continue;
165         }
166         cvox.LiveRegions.handleOneChangedNode(
167             mutation.addedNodes[i], mutation.target, false, true, handler);
168       }
169     }
170     if (mutation.removedNodes) {
171       for (var i = 0; i < mutation.removedNodes.length; i++) {
172         if (mutation.removedNodes[i].hasAttribute &&
173             mutation.removedNodes[i].hasAttribute('cvoxIgnore')) {
174           continue;
175         }
176         cvox.LiveRegions.handleOneChangedNode(
177             mutation.removedNodes[i], mutation.target, true, false, handler);
178       }
179     }
180     if (mutation.type == 'characterData') {
181       cvox.LiveRegions.handleOneChangedNode(
182           mutation.target, mutation.target, false, false, handler);
183     }
184     if (mutation.attributeName == 'class' ||
185         mutation.attributeName == 'style' ||
186         mutation.attributeName == 'hidden') {
187       var attr = mutation.attributeName;
188       var target = mutation.target;
189       var newInvisible = !cvox.DomUtil.isVisible(target);
190
191       // Create a fake element on the page with the old values of
192       // class, style, and hidden for this element, to see if that test
193       // element would have had different visibility.
194       var testElement = document.createElement('div');
195       testElement.setAttribute('cvoxIgnore', '1');
196       testElement.setAttribute('class', target.getAttribute('class'));
197       testElement.setAttribute('style', target.getAttribute('style'));
198       testElement.setAttribute('hidden', target.getAttribute('hidden'));
199       testElement.setAttribute(attr, /** @type {string} */ (mutation.oldValue));
200
201       var oldInvisible = true;
202       if (target.parentElement) {
203         target.parentElement.appendChild(testElement);
204         oldInvisible = !cvox.DomUtil.isVisible(testElement);
205         target.parentElement.removeChild(testElement);
206       } else {
207         oldInvisible = !cvox.DomUtil.isVisible(testElement);
208       }
209
210       if (oldInvisible === true && newInvisible === false) {
211         cvox.LiveRegions.handleOneChangedNode(
212             mutation.target, mutation.target, false, true, handler);
213       } else if (oldInvisible === false && newInvisible === true) {
214         cvox.LiveRegions.handleOneChangedNode(
215             mutation.target, mutation.target, true, false, handler);
216       }
217     }
218   });
219   cvox.LiveRegions.nodesAlreadyHandled.length = 0;
220 };
221
222 /**
223  * Handle one changed node. First check if this node is itself within
224  * a live region, and if that fails see if there's a live region within it
225  * and call this method recursively. For each actual live region, call a
226  * method to recursively announce all changes.
227  *
228  * @param {Node} node A node that's changed.
229  * @param {Node} parent The parent node.
230  * @param {boolean} isRemoval True if this node was removed.
231  * @param {boolean} subtree True if we should check the subtree.
232  * @param {function(boolean, Array.<cvox.NavDescription>)} handler
233  *     Callback function to be called for each live region found.
234  */
235 cvox.LiveRegions.handleOneChangedNode = function(
236     node, parent, isRemoval, subtree, handler) {
237   var liveRoot = isRemoval ? parent : node;
238   if (!(liveRoot instanceof Element)) {
239     liveRoot = liveRoot.parentElement;
240   }
241   while (liveRoot) {
242     if (cvox.AriaUtil.getAriaLive(liveRoot)) {
243       break;
244     }
245     liveRoot = liveRoot.parentElement;
246   }
247   if (!liveRoot) {
248     if (subtree && node != document.body) {
249       var subLiveRegions = cvox.AriaUtil.getLiveRegions(node);
250       for (var i = 0; i < subLiveRegions.length; i++) {
251         cvox.LiveRegions.handleOneChangedNode(
252             subLiveRegions[i], parent, isRemoval, false, handler);
253       }
254     }
255     return;
256   }
257
258   // If the page just loaded and this is any region type other than 'alert',
259   // skip it. Alerts are the exception, they're announced on page load.
260   var deltaTime = new Date() - cvox.LiveRegions.pageLoadTime;
261   if (cvox.AriaUtil.getRoleAttribute(liveRoot) != 'alert' &&
262       deltaTime < cvox.LiveRegions.INITIAL_SILENCE_MS) {
263     return;
264   }
265
266   if (cvox.LiveRegions.nodesAlreadyHandled.indexOf(node) >= 0) {
267     return;
268   }
269   cvox.LiveRegions.nodesAlreadyHandled.push(node);
270
271   if (cvox.AriaUtil.getAriaBusy(liveRoot)) {
272     return;
273   }
274
275   if (isRemoval) {
276     if (!cvox.AriaUtil.getAriaRelevant(liveRoot, 'removals')) {
277       return;
278     }
279   } else {
280     if (!cvox.AriaUtil.getAriaRelevant(liveRoot, 'additions')) {
281       return;
282     }
283   }
284
285   cvox.LiveRegions.announceChangeIfVisible(node, liveRoot, isRemoval, handler);
286 };
287
288 /**
289  * Announce one node within a live region if it's visible.
290  * In order to handle live regions that fade in, if the node isn't currently
291  * visible, check again after a short timeout.
292  *
293  * @param {Node} node A node in a live region.
294  * @param {Node} liveRoot The root of the live region this node is in.
295  * @param {boolean} isRemoval True if this node was removed.
296  * @param {function(boolean, Array.<cvox.NavDescription>)} handler
297  *     Callback function to be called for each live region found.
298  */
299 cvox.LiveRegions.announceChangeIfVisible = function(
300     node, liveRoot, isRemoval, handler) {
301   if (cvox.DomUtil.isVisible(liveRoot)) {
302     cvox.LiveRegions.announceChange(node, liveRoot, isRemoval, handler);
303   } else {
304     window.setTimeout(function() {
305       if (cvox.DomUtil.isVisible(liveRoot)) {
306         cvox.LiveRegions.announceChange(node, liveRoot, isRemoval, handler);
307       }
308     }, cvox.LiveRegions.VISIBILITY_TIMEOUT_MS);
309   }
310 };
311
312 /**
313  * Announce one node within a live region.
314  *
315  * @param {Node} node A node in a live region.
316  * @param {Node} liveRoot The root of the live region this node is in.
317  * @param {boolean} isRemoval True if this node was removed.
318  * @param {function(boolean, Array.<cvox.NavDescription>)} handler
319  *     Callback function to be called for each live region found.
320  */
321 cvox.LiveRegions.announceChange = function(
322     node, liveRoot, isRemoval, handler) {
323   // If this node is in an atomic container, announce the whole container.
324   // This includes aria-atomic, but also ARIA controls and other nodes
325   // whose ARIA roles make them leaves.
326   if (node != liveRoot) {
327     var atomicContainer = node.parentElement;
328     while (atomicContainer) {
329       if ((cvox.AriaUtil.getAriaAtomic(atomicContainer) ||
330            cvox.AriaUtil.isLeafElement(atomicContainer) ||
331            cvox.AriaUtil.isControlWidget(atomicContainer)) &&
332           !cvox.AriaUtil.isCompositeControl(atomicContainer)) {
333         node = atomicContainer;
334       }
335       if (atomicContainer == liveRoot) {
336         break;
337       }
338       atomicContainer = atomicContainer.parentElement;
339     }
340   }
341
342   var navDescriptions = cvox.LiveRegions.getNavDescriptionsRecursive(node);
343   if (isRemoval) {
344     navDescriptions = [cvox.DescriptionUtil.getDescriptionFromAncestors(
345         [node], true, cvox.ChromeVox.verbosity)];
346     navDescriptions = [new cvox.NavDescription({
347       context: cvox.ChromeVox.msgs.getMsg('live_regions_removed'), text: ''
348     })].concat(navDescriptions);
349   }
350
351   if (navDescriptions.length == 0) {
352     return;
353   }
354
355   // Don't announce alerts on page load if their text and values consist of
356   // just whitespace.
357   var deltaTime = new Date() - cvox.LiveRegions.pageLoadTime;
358   if (cvox.AriaUtil.getRoleAttribute(liveRoot) == 'alert' &&
359       deltaTime < cvox.LiveRegions.INITIAL_SILENCE_MS) {
360     var regionText = '';
361     for (var i = 0; i < navDescriptions.length; i++) {
362       regionText += navDescriptions[i].text;
363       regionText += navDescriptions[i].userValue;
364     }
365     if (cvox.DomUtil.collapseWhitespace(regionText) == '') {
366       return;
367     }
368   }
369
370   var discardDupsMs = document.webkitHidden ?
371       cvox.LiveRegions.HIDDEN_DOC_MAX_DISCARD_DUPS_MS :
372       cvox.LiveRegions.MAX_DISCARD_DUPS_MS;
373
374   // First, evict expired entries.
375   var now = new Date();
376   for (var announced in cvox.LiveRegions.lastAnnouncedMap) {
377     if (now - cvox.LiveRegions.lastAnnouncedMap[announced] > discardDupsMs) {
378       delete cvox.LiveRegions.lastAnnouncedMap[announced];
379     }
380   }
381
382   // Then, skip announcement if it was already spoken in the past 2000 ms.
383   var key = navDescriptions.reduce(function(prev, navDescription) {
384     return prev + '|' + navDescription.text;
385   }, '');
386
387   if (cvox.LiveRegions.lastAnnouncedMap[key]) {
388     return;
389   }
390   cvox.LiveRegions.lastAnnouncedMap[key] = now;
391
392   var assertive = cvox.AriaUtil.getAriaLive(liveRoot) == 'assertive';
393   if (cvox.Interframe.isIframe() && !document.hasFocus()) {
394     cvox.Interframe.sendMessageToParentWindow(
395         {'command': 'speakLiveRegion',
396          'content': JSON.stringify(navDescriptions),
397          'queueMode': assertive ? 0 : 1,
398          'src': window.location.href }
399         );
400     return;
401   }
402
403   // Set a category on the NavDescriptions - that way live regions
404   // interrupt other live regions but not anything else.
405   navDescriptions.forEach(function(desc) {
406     if (!desc.category) {
407       desc.category = cvox.TtsCategory.LIVE;
408     }
409   });
410
411   // TODO(dmazzoni): http://crbug.com/415679 Temporary design decision;
412   // until we have a way to tell the speech queue to group the nav
413   // descriptions together, collapse them into one.
414   // Otherwise, one nav description could be spoken, then something unrelated,
415   // then the rest.
416   if (navDescriptions.length > 1) {
417     var allStrings = [];
418     navDescriptions.forEach(function(desc) {
419       if (desc.context) {
420         allStrings.push(desc.context);
421       }
422       if (desc.text) {
423         allStrings.push(desc.text);
424       }
425       if (desc.userValue) {
426         allStrings.push(desc.userValue);
427       }
428     });
429     navDescriptions = [new cvox.NavDescription({
430       text: allStrings.join(', '),
431       category: cvox.TtsCategory.LIVE
432     })];
433   }
434
435   handler(assertive, navDescriptions);
436 };
437
438 /**
439  * Recursively build up the value of a live region and return it as
440  * an array of NavDescriptions. Each atomic portion of the region gets a
441  * single string, otherwise each leaf node gets its own string.
442  *
443  * @param {Node} node A node in a live region.
444  * @return {Array.<cvox.NavDescription>} An array of NavDescriptions
445  *     describing atomic nodes or leaf nodes in the subtree rooted
446  *     at this node.
447  */
448 cvox.LiveRegions.getNavDescriptionsRecursive = function(node) {
449   if (cvox.AriaUtil.getAriaAtomic(node) ||
450       cvox.DomUtil.isLeafNode(node)) {
451     var description = cvox.DescriptionUtil.getDescriptionFromAncestors(
452         [node], true, cvox.ChromeVox.verbosity);
453     if (!description.isEmpty()) {
454       return [description];
455     } else {
456       return [];
457     }
458   }
459   return cvox.DescriptionUtil.getFullDescriptionsFromChildren(null,
460       /** @type {!Element} */ (node));
461 };