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