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
5cr.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});
679