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