Home | History | Annotate | Download | only in ui
      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 
      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    */
     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