Roll src/third_party/WebKit 6f84130:7353389 (svn 184386:184391)
[chromium-blink-merge.git] / ui / webui / resources / js / cr / ui / tree.js
blob58ca92f52f11478ea373e953452a00585b11a2fd
1 // Copyright (c) 2012 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.
5 cr.define('cr.ui', function() {
6   // require cr.ui.define
7   // require cr.ui.limitInputWidth
9   /**
10    * The number of pixels to indent per level.
11    * @type {number}
12    * @const
13    */
14   var INDENT = 20;
16   /**
17    * Returns the computed style for an element.
18    * @param {!Element} el The element to get the computed style for.
19    * @return {!CSSStyleDeclaration} The computed style.
20    */
21   function getComputedStyle(el) {
22     return el.ownerDocument.defaultView.getComputedStyle(el);
23   }
25   /**
26    * Helper function that finds the first ancestor tree item.
27    * @param {Node} node The node to start searching from.
28    * @return {cr.ui.TreeItem} The found tree item or null if not found.
29    */
30   function findTreeItem(node) {
31     while (node && !(node instanceof TreeItem)) {
32       node = node.parentNode;
33     }
34     return node;
35   }
37   /**
38    * Creates a new tree element.
39    * @param {Object=} opt_propertyBag Optional properties.
40    * @constructor
41    * @extends {HTMLElement}
42    */
43   var Tree = cr.ui.define('tree');
45   Tree.prototype = {
46     __proto__: HTMLElement.prototype,
48     /**
49      * Initializes the element.
50      */
51     decorate: function() {
52       // Make list focusable
53       if (!this.hasAttribute('tabindex'))
54         this.tabIndex = 0;
56       this.addEventListener('click', this.handleClick);
57       this.addEventListener('mousedown', this.handleMouseDown);
58       this.addEventListener('dblclick', this.handleDblClick);
59       this.addEventListener('keydown', this.handleKeyDown);
60     },
62     /**
63      * Returns the tree item that are children of this tree.
64      */
65     get items() {
66       return this.children;
67     },
69     /**
70      * Adds a tree item to the tree.
71      * @param {!cr.ui.TreeItem} treeItem The item to add.
72      */
73     add: function(treeItem) {
74       this.addAt(treeItem, 0xffffffff);
75     },
77     /**
78      * Adds a tree item at the given index.
79      * @param {!cr.ui.TreeItem} treeItem The item to add.
80      * @param {number} index The index where we want to add the item.
81      */
82     addAt: function(treeItem, index) {
83       this.insertBefore(treeItem, this.children[index]);
84       treeItem.setDepth_(this.depth + 1);
85     },
87     /**
88      * Removes a tree item child.
89      * @param {!cr.ui.TreeItem} treeItem The tree item to remove.
90      */
91     remove: function(treeItem) {
92       this.removeChild(treeItem);
93     },
95     /**
96      * The depth of the node. This is 0 for the tree itself.
97      * @type {number}
98      */
99     get depth() {
100       return 0;
101     },
103     /**
104      * Handles click events on the tree and forwards the event to the relevant
105      * tree items as necesary.
106      * @param {Event} e The click event object.
107      */
108     handleClick: function(e) {
109       var treeItem = findTreeItem(/** @type {!Node} */(e.target));
110       if (treeItem)
111         treeItem.handleClick(e);
112     },
114     handleMouseDown: function(e) {
115       if (e.button == 2) // right
116         this.handleClick(e);
117     },
119     /**
120      * Handles double click events on the tree.
121      * @param {Event} e The dblclick event object.
122      */
123     handleDblClick: function(e) {
124       var treeItem = findTreeItem(/** @type {!Node} */(e.target));
125       if (treeItem)
126         treeItem.expanded = !treeItem.expanded;
127     },
129     /**
130      * Handles keydown events on the tree and updates selection and exanding
131      * of tree items.
132      * @param {Event} e The click event object.
133      */
134     handleKeyDown: function(e) {
135       var itemToSelect;
136       if (e.ctrlKey)
137         return;
139       var item = this.selectedItem;
140       if (!item)
141         return;
143       var rtl = getComputedStyle(item).direction == 'rtl';
145       switch (e.keyIdentifier) {
146         case 'Up':
147           itemToSelect = item ? getPrevious(item) :
148               this.items[this.items.length - 1];
149           break;
150         case 'Down':
151           itemToSelect = item ? getNext(item) :
152               this.items[0];
153           break;
154         case 'Left':
155         case 'Right':
156           // Don't let back/forward keyboard shortcuts be used.
157           if (!cr.isMac && e.altKey || cr.isMac && e.metaKey)
158             break;
160           if (e.keyIdentifier == 'Left' && !rtl ||
161               e.keyIdentifier == 'Right' && rtl) {
162             if (item.expanded)
163               item.expanded = false;
164             else
165               itemToSelect = findTreeItem(item.parentNode);
166           } else {
167             if (!item.expanded)
168               item.expanded = true;
169             else
170               itemToSelect = item.items[0];
171           }
172           break;
173         case 'Home':
174           itemToSelect = this.items[0];
175           break;
176         case 'End':
177           itemToSelect = this.items[this.items.length - 1];
178           break;
179       }
181       if (itemToSelect) {
182         itemToSelect.selected = true;
183         e.preventDefault();
184       }
185     },
187     /**
188      * The selected tree item or null if none.
189      * @type {cr.ui.TreeItem}
190      */
191     get selectedItem() {
192       return this.selectedItem_ || null;
193     },
194     set selectedItem(item) {
195       var oldSelectedItem = this.selectedItem_;
196       if (oldSelectedItem != item) {
197         // Set the selectedItem_ before deselecting the old item since we only
198         // want one change when moving between items.
199         this.selectedItem_ = item;
201         if (oldSelectedItem)
202           oldSelectedItem.selected = false;
204         if (item) {
205           item.selected = true;
206           if (item.id)
207             this.setAttribute('aria-activedescendant', item.id);
208         } else {
209             this.removeAttribute('aria-activedescendant');
210         }
211         cr.dispatchSimpleEvent(this, 'change');
212       }
213     },
215     /**
216      * @return {!ClientRect} The rect to use for the context menu.
217      */
218     getRectForContextMenu: function() {
219       // TODO(arv): Add trait support so we can share more code between trees
220       // and lists.
221       if (this.selectedItem)
222         return this.selectedItem.rowElement.getBoundingClientRect();
223       return this.getBoundingClientRect();
224     }
225   };
227   /**
228    * Determines the visibility of icons next to the treeItem labels. If set to
229    * 'hidden', no space is reserved for icons and no icons are displayed next
230    * to treeItem labels. If set to 'parent', folder icons will be displayed
231    * next to expandable parent nodes. If set to 'all' folder icons will be
232    * displayed next to all nodes. Icons can be set using the treeItem's icon
233    * property.
234    */
235   cr.defineProperty(Tree, 'iconVisibility', cr.PropertyKind.ATTR);
237   /**
238    * Incremental counter for an auto generated ID of the tree item. This will
239    * be incremented per element, so each element never share same ID.
240    *
241    * @type {number}
242    */
243   var treeItemAutoGeneratedIdCounter = 0;
245   /**
246    * This is used as a blueprint for new tree item elements.
247    * @type {!HTMLElement}
248    */
249   var treeItemProto = (function() {
250     var treeItem = cr.doc.createElement('div');
251     treeItem.className = 'tree-item';
252     treeItem.innerHTML = '<div class=tree-row>' +
253         '<span class=expand-icon></span>' +
254         '<span class=tree-label></span>' +
255         '</div>' +
256         '<div class=tree-children></div>';
257     treeItem.setAttribute('role', 'treeitem');
258     return treeItem;
259   })();
261   /**
262    * Creates a new tree item.
263    * @param {Object=} opt_propertyBag Optional properties.
264    * @constructor
265    * @extends {HTMLElement}
266    */
267   var TreeItem = cr.ui.define(function() {
268     var treeItem = treeItemProto.cloneNode(true);
269     treeItem.id = 'tree-item-autogen-id-' + treeItemAutoGeneratedIdCounter++;
270     return treeItem;
271   });
273   TreeItem.prototype = {
274     __proto__: HTMLElement.prototype,
276     /**
277      * Initializes the element.
278      */
279     decorate: function() {
281     },
283     /**
284      * The tree items children.
285      */
286     get items() {
287       return this.lastElementChild.children;
288     },
290     /**
291      * The depth of the tree item.
292      * @type {number}
293      */
294     depth_: 0,
295     get depth() {
296       return this.depth_;
297     },
299     /**
300      * Sets the depth.
301      * @param {number} depth The new depth.
302      * @private
303      */
304     setDepth_: function(depth) {
305       if (depth != this.depth_) {
306         this.rowElement.style.WebkitPaddingStart = Math.max(0, depth - 1) *
307             INDENT + 'px';
308         this.depth_ = depth;
309         var items = this.items;
310         for (var i = 0, item; item = items[i]; i++) {
311           item.setDepth_(depth + 1);
312         }
313       }
314     },
316     /**
317      * Adds a tree item as a child.
318      * @param {!cr.ui.TreeItem} child The child to add.
319      */
320     add: function(child) {
321       this.addAt(child, 0xffffffff);
322     },
324     /**
325      * Adds a tree item as a child at a given index.
326      * @param {!cr.ui.TreeItem} child The child to add.
327      * @param {number} index The index where to add the child.
328      */
329     addAt: function(child, index) {
330       this.lastElementChild.insertBefore(child, this.items[index]);
331       if (this.items.length == 1)
332         this.hasChildren = true;
333       child.setDepth_(this.depth + 1);
334     },
336     /**
337      * Removes a child.
338      * @param {!cr.ui.TreeItem} child The tree item child to remove.
339      */
340     remove: function(child) {
341       // If we removed the selected item we should become selected.
342       var tree = this.tree;
343       var selectedItem = tree.selectedItem;
344       if (selectedItem && child.contains(selectedItem))
345         this.selected = true;
347       this.lastElementChild.removeChild(child);
348       if (this.items.length == 0)
349         this.hasChildren = false;
350     },
352     /**
353      * The parent tree item.
354      * @type {!cr.ui.Tree|cr.ui.TreeItem}
355      */
356     get parentItem() {
357       var p = this.parentNode;
358       while (p && !(p instanceof TreeItem) && !(p instanceof Tree)) {
359         p = p.parentNode;
360       }
361       return p;
362     },
364     /**
365      * The tree that the tree item belongs to or null of no added to a tree.
366      * @type {cr.ui.Tree}
367      */
368     get tree() {
369       var t = this.parentItem;
370       while (t && !(t instanceof Tree)) {
371         t = t.parentItem;
372       }
373       return t;
374     },
376     /**
377      * Whether the tree item is expanded or not.
378      * @type {boolean}
379      */
380     get expanded() {
381       return this.hasAttribute('expanded');
382     },
383     set expanded(b) {
384       if (this.expanded == b)
385         return;
387       var treeChildren = this.lastElementChild;
389       if (b) {
390         if (this.mayHaveChildren_) {
391           this.setAttribute('expanded', '');
392           treeChildren.setAttribute('expanded', '');
393           cr.dispatchSimpleEvent(this, 'expand', true);
394           this.scrollIntoViewIfNeeded(false);
395         }
396       } else {
397         var tree = this.tree;
398         if (tree && !this.selected) {
399           var oldSelected = tree.selectedItem;
400           if (oldSelected && this.contains(oldSelected))
401             this.selected = true;
402         }
403         this.removeAttribute('expanded');
404         treeChildren.removeAttribute('expanded');
405         cr.dispatchSimpleEvent(this, 'collapse', true);
406       }
407     },
409     /**
410      * Expands all parent items.
411      */
412     reveal: function() {
413       var pi = this.parentItem;
414       while (pi && !(pi instanceof Tree)) {
415         pi.expanded = true;
416         pi = pi.parentItem;
417       }
418     },
420     /**
421      * The element representing the row that gets highlighted.
422      * @type {!HTMLElement}
423      */
424     get rowElement() {
425       return this.firstElementChild;
426     },
428     /**
429      * The element containing the label text and the icon.
430      * @type {!HTMLElement}
431      */
432     get labelElement() {
433       return this.firstElementChild.lastElementChild;
434     },
436     /**
437      * The label text.
438      * @type {string}
439      */
440     get label() {
441       return this.labelElement.textContent;
442     },
443     set label(s) {
444       this.labelElement.textContent = s;
445     },
447     /**
448      * The URL for the icon.
449      * @type {string}
450      */
451     get icon() {
452       return getComputedStyle(this.labelElement).backgroundImage.slice(4, -1);
453     },
454     set icon(icon) {
455       return this.labelElement.style.backgroundImage = url(icon);
456     },
458     /**
459      * Whether the tree item is selected or not.
460      * @type {boolean}
461      */
462     get selected() {
463       return this.hasAttribute('selected');
464     },
465     set selected(b) {
466       if (this.selected == b)
467         return;
468       var rowItem = this.firstElementChild;
469       var tree = this.tree;
470       if (b) {
471         this.setAttribute('selected', '');
472         rowItem.setAttribute('selected', '');
473         this.reveal();
474         this.labelElement.scrollIntoViewIfNeeded(false);
475         if (tree)
476           tree.selectedItem = this;
477       } else {
478         this.removeAttribute('selected');
479         rowItem.removeAttribute('selected');
480         if (tree && tree.selectedItem == this)
481           tree.selectedItem = null;
482       }
483     },
485     /**
486      * Whether the tree item has children.
487      * @type {boolean}
488      */
489     get mayHaveChildren_() {
490       return this.hasAttribute('may-have-children');
491     },
492     set mayHaveChildren_(b) {
493       var rowItem = this.firstElementChild;
494       if (b) {
495         this.setAttribute('may-have-children', '');
496         rowItem.setAttribute('may-have-children', '');
497       } else {
498         this.removeAttribute('may-have-children');
499         rowItem.removeAttribute('may-have-children');
500       }
501     },
503     /**
504      * Whether the tree item has children.
505      * @type {boolean}
506      */
507     get hasChildren() {
508       return !!this.items[0];
509     },
511     /**
512      * Whether the tree item has children.
513      * @type {boolean}
514      */
515     set hasChildren(b) {
516       var rowItem = this.firstElementChild;
517       this.setAttribute('has-children', b);
518       rowItem.setAttribute('has-children', b);
519       if (b)
520         this.mayHaveChildren_ = true;
521     },
523     /**
524      * Called when the user clicks on a tree item. This is forwarded from the
525      * cr.ui.Tree.
526      * @param {Event} e The click event.
527      */
528     handleClick: function(e) {
529       if (e.target.className == 'expand-icon')
530         this.expanded = !this.expanded;
531       else
532         this.selected = true;
533     },
535     /**
536      * Makes the tree item user editable. If the user renamed the item a
537      * bubbling {@code rename} event is fired.
538      * @type {boolean}
539      */
540     set editing(editing) {
541       var oldEditing = this.editing;
542       if (editing == oldEditing)
543         return;
545       var self = this;
546       var labelEl = this.labelElement;
547       var text = this.label;
548       var input;
550       // Handles enter and escape which trigger reset and commit respectively.
551       function handleKeydown(e) {
552         // Make sure that the tree does not handle the key.
553         e.stopPropagation();
555         // Calling tree.focus blurs the input which will make the tree item
556         // non editable.
557         switch (e.keyIdentifier) {
558           case 'U+001B':  // Esc
559             input.value = text;
560             // fall through
561           case 'Enter':
562             self.tree.focus();
563         }
564       }
566       function stopPropagation(e) {
567         e.stopPropagation();
568       }
570       if (editing) {
571         this.selected = true;
572         this.setAttribute('editing', '');
573         this.draggable = false;
575         // We create an input[type=text] and copy over the label value. When
576         // the input loses focus we set editing to false again.
577         input = this.ownerDocument.createElement('input');
578         input.value = text;
579         if (labelEl.firstChild)
580           labelEl.replaceChild(input, labelEl.firstChild);
581         else
582           labelEl.appendChild(input);
584         input.addEventListener('keydown', handleKeydown);
585         input.addEventListener('blur', (function() {
586           this.editing = false;
587         }).bind(this));
589         // Make sure that double clicks do not expand and collapse the tree
590         // item.
591         var eventsToStop = ['mousedown', 'mouseup', 'contextmenu', 'dblclick'];
592         eventsToStop.forEach(function(type) {
593           input.addEventListener(type, stopPropagation);
594         });
596         // Wait for the input element to recieve focus before sizing it.
597         var rowElement = this.rowElement;
598         var onFocus = function() {
599           input.removeEventListener('focus', onFocus);
600           // 20 = the padding and border of the tree-row
601           cr.ui.limitInputWidth(input, rowElement, 100);
602         };
603         input.addEventListener('focus', onFocus);
604         input.focus();
605         input.select();
607         this.oldLabel_ = text;
608       } else {
609         this.removeAttribute('editing');
610         this.draggable = true;
611         input = labelEl.firstChild;
612         var value = input.value;
613         if (/^\s*$/.test(value)) {
614           labelEl.textContent = this.oldLabel_;
615         } else {
616           labelEl.textContent = value;
617           if (value != this.oldLabel_) {
618             cr.dispatchSimpleEvent(this, 'rename', true);
619           }
620         }
621         delete this.oldLabel_;
622       }
623     },
625     get editing() {
626       return this.hasAttribute('editing');
627     }
628   };
630   /**
631    * Helper function that returns the next visible tree item.
632    * @param {cr.ui.TreeItem} item The tree item.
633    * @return {cr.ui.TreeItem} The found item or null.
634    */
635   function getNext(item) {
636     if (item.expanded) {
637       var firstChild = item.items[0];
638       if (firstChild) {
639         return firstChild;
640       }
641     }
643     return getNextHelper(item);
644   }
646   /**
647    * Another helper function that returns the next visible tree item.
648    * @param {cr.ui.TreeItem} item The tree item.
649    * @return {cr.ui.TreeItem} The found item or null.
650    */
651   function getNextHelper(item) {
652     if (!item)
653       return null;
655     var nextSibling = item.nextElementSibling;
656     if (nextSibling)
657       return assertInstanceof(nextSibling, cr.ui.TreeItem);
658     return getNextHelper(item.parentItem);
659   }
661   /**
662    * Helper function that returns the previous visible tree item.
663    * @param {cr.ui.TreeItem} item The tree item.
664    * @return {cr.ui.TreeItem} The found item or null.
665    */
666   function getPrevious(item) {
667     var previousSibling = assertInstanceof(item.previousElementSibling,
668                                            cr.ui.TreeItem);
669     return previousSibling ? getLastHelper(previousSibling) : item.parentItem;
670   }
672   /**
673    * Helper function that returns the last visible tree item in the subtree.
674    * @param {cr.ui.TreeItem} item The item to find the last visible item for.
675    * @return {cr.ui.TreeItem} The found item or null.
676    */
677   function getLastHelper(item) {
678     if (!item)
679       return null;
680     if (item.expanded && item.hasChildren) {
681       var lastChild = item.items[item.items.length - 1];
682       return getLastHelper(lastChild);
683     }
684     return item;
685   }
687   // Export
688   return {
689     Tree: Tree,
690     TreeItem: TreeItem
691   };