• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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          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    },
214
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  };
226
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);
236
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;
244
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  })();
260
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  });
272
273  TreeItem.prototype = {
274    __proto__: HTMLElement.prototype,
275
276    /**
277     * Initializes the element.
278     */
279    decorate: function() {
280
281    },
282
283    /**
284     * The tree items children.
285     */
286    get items() {
287      return this.lastElementChild.children;
288    },
289
290    /**
291     * The depth of the tree item.
292     * @type {number}
293     */
294    depth_: 0,
295    get depth() {
296      return this.depth_;
297    },
298
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    },
315
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    },
323
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    },
335
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;
346
347      this.lastElementChild.removeChild(child);
348      if (this.items.length == 0)
349        this.hasChildren = false;
350    },
351
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    },
363
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    },
375
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;
386
387      var treeChildren = this.lastElementChild;
388
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    },
408
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    },
419
420    /**
421     * The element representing the row that gets highlighted.
422     * @type {!HTMLElement}
423     */
424    get rowElement() {
425      return this.firstElementChild;
426    },
427
428    /**
429     * The element containing the label text and the icon.
430     * @type {!HTMLElement}
431     */
432    get labelElement() {
433      return this.firstElementChild.lastElementChild;
434    },
435
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    },
446
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    },
457
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    },
484
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    },
502
503    /**
504     * Whether the tree item has children.
505     * @type {boolean}
506     */
507    get hasChildren() {
508      return !!this.items[0];
509    },
510
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    },
522
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    },
534
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;
544
545      var self = this;
546      var labelEl = this.labelElement;
547      var text = this.label;
548      var input;
549
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();
554
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      }
565
566      function stopPropagation(e) {
567        e.stopPropagation();
568      }
569
570      if (editing) {
571        this.selected = true;
572        this.setAttribute('editing', '');
573        this.draggable = false;
574
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);
583
584        input.addEventListener('keydown', handleKeydown);
585        input.addEventListener('blur', (function() {
586          this.editing = false;
587        }).bind(this));
588
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        });
595
596        // Wait for the input element to recieve focus before sizing it.
597        var rowElement = this.rowElement;
598        function onFocus() {
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();
606
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    },
624
625    get editing() {
626      return this.hasAttribute('editing');
627    }
628  };
629
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    }
642
643    return getNextHelper(item);
644  }
645
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;
654
655    var nextSibling = item.nextElementSibling;
656    if (nextSibling) {
657      return nextSibling;
658    }
659    return getNextHelper(item.parentItem);
660  }
661
662  /**
663   * Helper function that returns the previous visible tree item.
664   * @param {cr.ui.TreeItem} item The tree item.
665   * @return {cr.ui.TreeItem} The found item or null.
666   */
667  function getPrevious(item) {
668    var previousSibling = item.previousElementSibling;
669    return previousSibling ? getLastHelper(previousSibling) : item.parentItem;
670  }
671
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  }
686
687  // Export
688  return {
689    Tree: Tree,
690    TreeItem: TreeItem
691  };
692});
693