- add sources.
[platform/framework/web/crosswalk.git] / src / ui / webui / resources / js / cr / ui / tree.js
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.
4
5 cr.define('cr.ui', function() {
6   // require cr.ui.define
7   // require cr.ui.limitInputWidth
8
9   /**
10    * The number of pixels to indent per level.
11    * @type {number}
12    * @const
13    */
14   var INDENT = 20;
15
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   }
24
25   /**
26    * Helper function that finds the first ancestor tree item.
27    * @param {!Element} el The element to start searching from.
28    * @return {cr.ui.TreeItem} The found tree item or null if not found.
29    */
30   function findTreeItem(el) {
31     while (el && !(el instanceof TreeItem)) {
32       el = el.parentNode;
33     }
34     return el;
35   }
36
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');
44
45   Tree.prototype = {
46     __proto__: HTMLElement.prototype,
47
48     /**
49      * Initializes the element.
50      */
51     decorate: function() {
52       // Make list focusable
53       if (!this.hasAttribute('tabindex'))
54         this.tabIndex = 0;
55
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     },
61
62     /**
63      * Returns the tree item that are children of this tree.
64      */
65     get items() {
66       return this.children;
67     },
68
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     },
76
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     },
86
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     },
94
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     },
102
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(e.target);
110       if (treeItem)
111         treeItem.handleClick(e);
112     },
113
114     handleMouseDown: function(e) {
115       if (e.button == 2) // right
116         this.handleClick(e);
117     },
118
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(e.target);
125       if (treeItem)
126         treeItem.expanded = !treeItem.expanded;
127     },
128
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;
138
139       var item = this.selectedItem;
140       if (!item)
141         return;
142
143       var rtl = getComputedStyle(item).direction == 'rtl';
144
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;
159
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       }
180
181       if (itemToSelect) {
182         itemToSelect.selected = true;
183         e.preventDefault();
184       }
185     },
186
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;
200
201         if (oldSelectedItem)
202           oldSelectedItem.selected = false;
203
204         if (item)
205           item.selected = true;
206
207         cr.dispatchSimpleEvent(this, 'change');
208       }
209     },
210
211     /**
212      * @return {!ClientRect} The rect to use for the context menu.
213      */
214     getRectForContextMenu: function() {
215       // TODO(arv): Add trait support so we can share more code between trees
216       // and lists.
217       if (this.selectedItem)
218         return this.selectedItem.rowElement.getBoundingClientRect();
219       return this.getBoundingClientRect();
220     }
221   };
222
223   /**
224    * Determines the visibility of icons next to the treeItem labels. If set to
225    * 'hidden', no space is reserved for icons and no icons are displayed next
226    * to treeItem labels. If set to 'parent', folder icons will be displayed
227    * next to expandable parent nodes. If set to 'all' folder icons will be
228    * displayed next to all nodes. Icons can be set using the treeItem's icon
229    * property.
230    */
231   cr.defineProperty(Tree, 'iconVisibility', cr.PropertyKind.ATTR);
232
233   /**
234    * This is used as a blueprint for new tree item elements.
235    * @type {!HTMLElement}
236    */
237   var treeItemProto = (function() {
238     var treeItem = cr.doc.createElement('div');
239     treeItem.className = 'tree-item';
240     treeItem.innerHTML = '<div class=tree-row>' +
241         '<span class=expand-icon></span>' +
242         '<span class=tree-label></span>' +
243         '</div>' +
244         '<div class=tree-children></div>';
245     treeItem.setAttribute('role', 'treeitem');
246     return treeItem;
247   })();
248
249   /**
250    * Creates a new tree item.
251    * @param {Object=} opt_propertyBag Optional properties.
252    * @constructor
253    * @extends {HTMLElement}
254    */
255   var TreeItem = cr.ui.define(function() {
256     return treeItemProto.cloneNode(true);
257   });
258
259   TreeItem.prototype = {
260     __proto__: HTMLElement.prototype,
261
262     /**
263      * Initializes the element.
264      */
265     decorate: function() {
266
267     },
268
269     /**
270      * The tree items children.
271      */
272     get items() {
273       return this.lastElementChild.children;
274     },
275
276     /**
277      * The depth of the tree item.
278      * @type {number}
279      */
280     depth_: 0,
281     get depth() {
282       return this.depth_;
283     },
284
285     /**
286      * Sets the depth.
287      * @param {number} depth The new depth.
288      * @private
289      */
290     setDepth_: function(depth) {
291       if (depth != this.depth_) {
292         this.rowElement.style.WebkitPaddingStart = Math.max(0, depth - 1) *
293             INDENT + 'px';
294         this.depth_ = depth;
295         var items = this.items;
296         for (var i = 0, item; item = items[i]; i++) {
297           item.setDepth_(depth + 1);
298         }
299       }
300     },
301
302     /**
303      * Adds a tree item as a child.
304      * @param {!cr.ui.TreeItem} child The child to add.
305      */
306     add: function(child) {
307       this.addAt(child, 0xffffffff);
308     },
309
310     /**
311      * Adds a tree item as a child at a given index.
312      * @param {!cr.ui.TreeItem} child The child to add.
313      * @param {number} index The index where to add the child.
314      */
315     addAt: function(child, index) {
316       this.lastElementChild.insertBefore(child, this.items[index]);
317       if (this.items.length == 1)
318         this.hasChildren = true;
319       child.setDepth_(this.depth + 1);
320     },
321
322     /**
323      * Removes a child.
324      * @param {!cr.ui.TreeItem} child The tree item child to remove.
325      */
326     remove: function(child) {
327       // If we removed the selected item we should become selected.
328       var tree = this.tree;
329       var selectedItem = tree.selectedItem;
330       if (selectedItem && child.contains(selectedItem))
331         this.selected = true;
332
333       this.lastElementChild.removeChild(child);
334       if (this.items.length == 0)
335         this.hasChildren = false;
336     },
337
338     /**
339      * The parent tree item.
340      * @type {!cr.ui.Tree|cr.ui.TreeItem}
341      */
342     get parentItem() {
343       var p = this.parentNode;
344       while (p && !(p instanceof TreeItem) && !(p instanceof Tree)) {
345         p = p.parentNode;
346       }
347       return p;
348     },
349
350     /**
351      * The tree that the tree item belongs to or null of no added to a tree.
352      * @type {cr.ui.Tree}
353      */
354     get tree() {
355       var t = this.parentItem;
356       while (t && !(t instanceof Tree)) {
357         t = t.parentItem;
358       }
359       return t;
360     },
361
362     /**
363      * Whether the tree item is expanded or not.
364      * @type {boolean}
365      */
366     get expanded() {
367       return this.hasAttribute('expanded');
368     },
369     set expanded(b) {
370       if (this.expanded == b)
371         return;
372
373       var treeChildren = this.lastElementChild;
374
375       if (b) {
376         if (this.mayHaveChildren_) {
377           this.setAttribute('expanded', '');
378           treeChildren.setAttribute('expanded', '');
379           cr.dispatchSimpleEvent(this, 'expand', true);
380           this.scrollIntoViewIfNeeded(false);
381         }
382       } else {
383         var tree = this.tree;
384         if (tree && !this.selected) {
385           var oldSelected = tree.selectedItem;
386           if (oldSelected && this.contains(oldSelected))
387             this.selected = true;
388         }
389         this.removeAttribute('expanded');
390         treeChildren.removeAttribute('expanded');
391         cr.dispatchSimpleEvent(this, 'collapse', true);
392       }
393     },
394
395     /**
396      * Expands all parent items.
397      */
398     reveal: function() {
399       var pi = this.parentItem;
400       while (pi && !(pi instanceof Tree)) {
401         pi.expanded = true;
402         pi = pi.parentItem;
403       }
404     },
405
406     /**
407      * The element representing the row that gets highlighted.
408      * @type {!HTMLElement}
409      */
410     get rowElement() {
411       return this.firstElementChild;
412     },
413
414     /**
415      * The element containing the label text and the icon.
416      * @type {!HTMLElement}
417      */
418     get labelElement() {
419       return this.firstElementChild.lastElementChild;
420     },
421
422     /**
423      * The label text.
424      * @type {string}
425      */
426     get label() {
427       return this.labelElement.textContent;
428     },
429     set label(s) {
430       this.labelElement.textContent = s;
431     },
432
433     /**
434      * The URL for the icon.
435      * @type {string}
436      */
437     get icon() {
438       return getComputedStyle(this.labelElement).backgroundImage.slice(4, -1);
439     },
440     set icon(icon) {
441       return this.labelElement.style.backgroundImage = url(icon);
442     },
443
444     /**
445      * Whether the tree item is selected or not.
446      * @type {boolean}
447      */
448     get selected() {
449       return this.hasAttribute('selected');
450     },
451     set selected(b) {
452       if (this.selected == b)
453         return;
454       var rowItem = this.firstElementChild;
455       var tree = this.tree;
456       if (b) {
457         this.setAttribute('selected', '');
458         rowItem.setAttribute('selected', '');
459         this.reveal();
460         this.labelElement.scrollIntoViewIfNeeded(false);
461         if (tree)
462           tree.selectedItem = this;
463       } else {
464         this.removeAttribute('selected');
465         rowItem.removeAttribute('selected');
466         if (tree && tree.selectedItem == this)
467           tree.selectedItem = null;
468       }
469     },
470
471     /**
472      * Whether the tree item has children.
473      * @type {boolean}
474      */
475     get mayHaveChildren_() {
476       return this.hasAttribute('may-have-children');
477     },
478     set mayHaveChildren_(b) {
479       var rowItem = this.firstElementChild;
480       if (b) {
481         this.setAttribute('may-have-children', '');
482         rowItem.setAttribute('may-have-children', '');
483       } else {
484         this.removeAttribute('may-have-children');
485         rowItem.removeAttribute('may-have-children');
486       }
487     },
488
489     /**
490      * Whether the tree item has children.
491      * @type {boolean}
492      */
493     get hasChildren() {
494       return !!this.items[0];
495     },
496
497     /**
498      * Whether the tree item has children.
499      * @type {boolean}
500      */
501     set hasChildren(b) {
502       var rowItem = this.firstElementChild;
503       this.setAttribute('has-children', b);
504       rowItem.setAttribute('has-children', b);
505       if (b)
506         this.mayHaveChildren_ = true;
507     },
508
509     /**
510      * Called when the user clicks on a tree item. This is forwarded from the
511      * cr.ui.Tree.
512      * @param {Event} e The click event.
513      */
514     handleClick: function(e) {
515       if (e.target.className == 'expand-icon')
516         this.expanded = !this.expanded;
517       else
518         this.selected = true;
519     },
520
521     /**
522      * Makes the tree item user editable. If the user renamed the item a
523      * bubbling {@code rename} event is fired.
524      * @type {boolean}
525      */
526     set editing(editing) {
527       var oldEditing = this.editing;
528       if (editing == oldEditing)
529         return;
530
531       var self = this;
532       var labelEl = this.labelElement;
533       var text = this.label;
534       var input;
535
536       // Handles enter and escape which trigger reset and commit respectively.
537       function handleKeydown(e) {
538         // Make sure that the tree does not handle the key.
539         e.stopPropagation();
540
541         // Calling tree.focus blurs the input which will make the tree item
542         // non editable.
543         switch (e.keyIdentifier) {
544           case 'U+001B':  // Esc
545             input.value = text;
546             // fall through
547           case 'Enter':
548             self.tree.focus();
549         }
550       }
551
552       function stopPropagation(e) {
553         e.stopPropagation();
554       }
555
556       if (editing) {
557         this.selected = true;
558         this.setAttribute('editing', '');
559         this.draggable = false;
560
561         // We create an input[type=text] and copy over the label value. When
562         // the input loses focus we set editing to false again.
563         input = this.ownerDocument.createElement('input');
564         input.value = text;
565         if (labelEl.firstChild)
566           labelEl.replaceChild(input, labelEl.firstChild);
567         else
568           labelEl.appendChild(input);
569
570         input.addEventListener('keydown', handleKeydown);
571         input.addEventListener('blur', (function() {
572           this.editing = false;
573         }).bind(this));
574
575         // Make sure that double clicks do not expand and collapse the tree
576         // item.
577         var eventsToStop = ['mousedown', 'mouseup', 'contextmenu', 'dblclick'];
578         eventsToStop.forEach(function(type) {
579           input.addEventListener(type, stopPropagation);
580         });
581
582         // Wait for the input element to recieve focus before sizing it.
583         var rowElement = this.rowElement;
584         function onFocus() {
585           input.removeEventListener('focus', onFocus);
586           // 20 = the padding and border of the tree-row
587           cr.ui.limitInputWidth(input, rowElement, 100);
588         }
589         input.addEventListener('focus', onFocus);
590         input.focus();
591         input.select();
592
593         this.oldLabel_ = text;
594       } else {
595         this.removeAttribute('editing');
596         this.draggable = true;
597         input = labelEl.firstChild;
598         var value = input.value;
599         if (/^\s*$/.test(value)) {
600           labelEl.textContent = this.oldLabel_;
601         } else {
602           labelEl.textContent = value;
603           if (value != this.oldLabel_) {
604             cr.dispatchSimpleEvent(this, 'rename', true);
605           }
606         }
607         delete this.oldLabel_;
608       }
609     },
610
611     get editing() {
612       return this.hasAttribute('editing');
613     }
614   };
615
616   /**
617    * Helper function that returns the next visible tree item.
618    * @param {cr.ui.TreeItem} item The tree item.
619    * @return {cr.ui.TreeItem} The found item or null.
620    */
621   function getNext(item) {
622     if (item.expanded) {
623       var firstChild = item.items[0];
624       if (firstChild) {
625         return firstChild;
626       }
627     }
628
629     return getNextHelper(item);
630   }
631
632   /**
633    * Another helper function that returns the next visible tree item.
634    * @param {cr.ui.TreeItem} item The tree item.
635    * @return {cr.ui.TreeItem} The found item or null.
636    */
637   function getNextHelper(item) {
638     if (!item)
639       return null;
640
641     var nextSibling = item.nextElementSibling;
642     if (nextSibling) {
643       return nextSibling;
644     }
645     return getNextHelper(item.parentItem);
646   }
647
648   /**
649    * Helper function that returns the previous visible tree item.
650    * @param {cr.ui.TreeItem} item The tree item.
651    * @return {cr.ui.TreeItem} The found item or null.
652    */
653   function getPrevious(item) {
654     var previousSibling = item.previousElementSibling;
655     return previousSibling ? getLastHelper(previousSibling) : item.parentItem;
656   }
657
658   /**
659    * Helper function that returns the last visible tree item in the subtree.
660    * @param {cr.ui.TreeItem} item The item to find the last visible item for.
661    * @return {cr.ui.TreeItem} The found item or null.
662    */
663   function getLastHelper(item) {
664     if (!item)
665       return null;
666     if (item.expanded && item.hasChildren) {
667       var lastChild = item.items[item.items.length - 1];
668       return getLastHelper(lastChild);
669     }
670     return item;
671   }
672
673   // Export
674   return {
675     Tree: Tree,
676     TreeItem: TreeItem
677   };
678 });