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 // require: array_data_model.js
      6 // require: list_selection_model.js
      7 // require: list_selection_controller.js
      8 // require: list_item.js
      9 
     10 /**
     11  * @fileoverview This implements a list control.
     12  */
     13 
     14 cr.define('cr.ui', function() {
     15   /** @const */ var ListSelectionModel = cr.ui.ListSelectionModel;
     16   /** @const */ var ListSelectionController = cr.ui.ListSelectionController;
     17   /** @const */ var ArrayDataModel = cr.ui.ArrayDataModel;
     18 
     19   /**
     20    * Whether a mouse event is inside the element viewport. This will return
     21    * false if the mouseevent was generated over a border or a scrollbar.
     22    * @param {!HTMLElement} el The element to test the event with.
     23    * @param {!Event} e The mouse event.
     24    * @param {boolean} Whether the mouse event was inside the viewport.
     25    */
     26   function inViewport(el, e) {
     27     var rect = el.getBoundingClientRect();
     28     var x = e.clientX;
     29     var y = e.clientY;
     30     return x >= rect.left + el.clientLeft &&
     31            x < rect.left + el.clientLeft + el.clientWidth &&
     32            y >= rect.top + el.clientTop &&
     33            y < rect.top + el.clientTop + el.clientHeight;
     34   }
     35 
     36   function getComputedStyle(el) {
     37     return el.ownerDocument.defaultView.getComputedStyle(el);
     38   }
     39 
     40   /**
     41    * Creates a new list element.
     42    * @param {Object=} opt_propertyBag Optional properties.
     43    * @constructor
     44    * @extends {HTMLUListElement}
     45    */
     46   var List = cr.ui.define('list');
     47 
     48   List.prototype = {
     49     __proto__: HTMLUListElement.prototype,
     50 
     51     /**
     52      * Measured size of list items. This is lazily calculated the first time it
     53      * is needed. Note that lead item is allowed to have a different height, to
     54      * accommodate lists where a single item at a time can be expanded to show
     55      * more detail.
     56      * @type {{height: number, marginTop: number, marginBottom:number,
     57      *     width: number, marginLeft: number, marginRight:number}}
     58      * @private
     59      */
     60     measured_: undefined,
     61 
     62     /**
     63      * Whether or not the list is autoexpanding. If true, the list resizes
     64      * its height to accomadate all children.
     65      * @type {boolean}
     66      * @private
     67      */
     68     autoExpands_: false,
     69 
     70     /**
     71      * Whether or not the rows on list have various heights. If true, all the
     72      * rows have the same fixed height. Otherwise, each row resizes its height
     73      * to accommodate all contents.
     74      * @type {boolean}
     75      * @private
     76      */
     77     fixedHeight_: true,
     78 
     79     /**
     80      * Whether or not the list view has a blank space below the last row.
     81      * @type {boolean}
     82      * @private
     83      */
     84     remainingSpace_: true,
     85 
     86     /**
     87      * Function used to create grid items.
     88      * @type {function(): !ListItem}
     89      * @private
     90      */
     91     itemConstructor_: cr.ui.ListItem,
     92 
     93     /**
     94      * Function used to create grid items.
     95      * @type {function(): !ListItem}
     96      */
     97     get itemConstructor() {
     98       return this.itemConstructor_;
     99     },
    100     set itemConstructor(func) {
    101       if (func != this.itemConstructor_) {
    102         this.itemConstructor_ = func;
    103         this.cachedItems_ = {};
    104         this.redraw();
    105       }
    106     },
    107 
    108     dataModel_: null,
    109 
    110     /**
    111      * The data model driving the list.
    112      * @type {ArrayDataModel}
    113      */
    114     set dataModel(dataModel) {
    115       if (this.dataModel_ != dataModel) {
    116         if (!this.boundHandleDataModelPermuted_) {
    117           this.boundHandleDataModelPermuted_ =
    118               this.handleDataModelPermuted_.bind(this);
    119           this.boundHandleDataModelChange_ =
    120               this.handleDataModelChange_.bind(this);
    121         }
    122 
    123         if (this.dataModel_) {
    124           this.dataModel_.removeEventListener(
    125               'permuted',
    126               this.boundHandleDataModelPermuted_);
    127           this.dataModel_.removeEventListener('change',
    128                                               this.boundHandleDataModelChange_);
    129         }
    130 
    131         this.dataModel_ = dataModel;
    132 
    133         this.cachedItems_ = {};
    134         this.cachedItemHeights_ = {};
    135         this.selectionModel.clear();
    136         if (dataModel)
    137           this.selectionModel.adjustLength(dataModel.length);
    138 
    139         if (this.dataModel_) {
    140           this.dataModel_.addEventListener(
    141               'permuted',
    142               this.boundHandleDataModelPermuted_);
    143           this.dataModel_.addEventListener('change',
    144                                            this.boundHandleDataModelChange_);
    145         }
    146 
    147         this.redraw();
    148       }
    149     },
    150 
    151     get dataModel() {
    152       return this.dataModel_;
    153     },
    154 
    155 
    156     /**
    157      * Cached item for measuring the default item size by measureItem().
    158      * @type {ListItem}
    159      */
    160     cachedMeasuredItem_: null,
    161 
    162     /**
    163      * The selection model to use.
    164      * @type {cr.ui.ListSelectionModel}
    165      */
    166     get selectionModel() {
    167       return this.selectionModel_;
    168     },
    169     set selectionModel(sm) {
    170       var oldSm = this.selectionModel_;
    171       if (oldSm == sm)
    172         return;
    173 
    174       if (!this.boundHandleOnChange_) {
    175         this.boundHandleOnChange_ = this.handleOnChange_.bind(this);
    176         this.boundHandleLeadChange_ = this.handleLeadChange_.bind(this);
    177       }
    178 
    179       if (oldSm) {
    180         oldSm.removeEventListener('change', this.boundHandleOnChange_);
    181         oldSm.removeEventListener('leadIndexChange',
    182                                   this.boundHandleLeadChange_);
    183       }
    184 
    185       this.selectionModel_ = sm;
    186       this.selectionController_ = this.createSelectionController(sm);
    187 
    188       if (sm) {
    189         sm.addEventListener('change', this.boundHandleOnChange_);
    190         sm.addEventListener('leadIndexChange', this.boundHandleLeadChange_);
    191       }
    192     },
    193 
    194     /**
    195      * Whether or not the list auto-expands.
    196      * @type {boolean}
    197      */
    198     get autoExpands() {
    199       return this.autoExpands_;
    200     },
    201     set autoExpands(autoExpands) {
    202       if (this.autoExpands_ == autoExpands)
    203         return;
    204       this.autoExpands_ = autoExpands;
    205       this.redraw();
    206     },
    207 
    208     /**
    209      * Whether or not the rows on list have various heights.
    210      * @type {boolean}
    211      */
    212     get fixedHeight() {
    213       return this.fixedHeight_;
    214     },
    215     set fixedHeight(fixedHeight) {
    216       if (this.fixedHeight_ == fixedHeight)
    217         return;
    218       this.fixedHeight_ = fixedHeight;
    219       this.redraw();
    220     },
    221 
    222     /**
    223      * Convenience alias for selectionModel.selectedItem
    224      * @type {*}
    225      */
    226     get selectedItem() {
    227       var dataModel = this.dataModel;
    228       if (dataModel) {
    229         var index = this.selectionModel.selectedIndex;
    230         if (index != -1)
    231           return dataModel.item(index);
    232       }
    233       return null;
    234     },
    235     set selectedItem(selectedItem) {
    236       var dataModel = this.dataModel;
    237       if (dataModel) {
    238         var index = this.dataModel.indexOf(selectedItem);
    239         this.selectionModel.selectedIndex = index;
    240       }
    241     },
    242 
    243     /**
    244      * Convenience alias for selectionModel.selectedItems
    245      * @type {!Array<*>}
    246      */
    247     get selectedItems() {
    248       var indexes = this.selectionModel.selectedIndexes;
    249       var dataModel = this.dataModel;
    250       if (dataModel) {
    251         return indexes.map(function(i) {
    252           return dataModel.item(i);
    253         });
    254       }
    255       return [];
    256     },
    257 
    258     /**
    259      * The HTML elements representing the items.
    260      * @type {HTMLCollection}
    261      */
    262     get items() {
    263       return Array.prototype.filter.call(this.children,
    264                                          this.isItem, this);
    265     },
    266 
    267     /**
    268      * Returns true if the child is a list item. Subclasses may override this
    269      * to filter out certain elements.
    270      * @param {Node} child Child of the list.
    271      * @return {boolean} True if a list item.
    272      */
    273     isItem: function(child) {
    274       return child.nodeType == Node.ELEMENT_NODE &&
    275              child != this.beforeFiller_ && child != this.afterFiller_;
    276     },
    277 
    278     batchCount_: 0,
    279 
    280     /**
    281      * When making a lot of updates to the list, the code could be wrapped in
    282      * the startBatchUpdates and finishBatchUpdates to increase performance. Be
    283      * sure that the code will not return without calling endBatchUpdates or the
    284      * list will not be correctly updated.
    285      */
    286     startBatchUpdates: function() {
    287       this.batchCount_++;
    288     },
    289 
    290     /**
    291      * See startBatchUpdates.
    292      */
    293     endBatchUpdates: function() {
    294       this.batchCount_--;
    295       if (this.batchCount_ == 0)
    296         this.redraw();
    297     },
    298 
    299     /**
    300      * Initializes the element.
    301      */
    302     decorate: function() {
    303       // Add fillers.
    304       this.beforeFiller_ = this.ownerDocument.createElement('div');
    305       this.afterFiller_ = this.ownerDocument.createElement('div');
    306       this.beforeFiller_.className = 'spacer';
    307       this.afterFiller_.className = 'spacer';
    308       this.textContent = '';
    309       this.appendChild(this.beforeFiller_);
    310       this.appendChild(this.afterFiller_);
    311 
    312       var length = this.dataModel ? this.dataModel.length : 0;
    313       this.selectionModel = new ListSelectionModel(length);
    314 
    315       this.addEventListener('dblclick', this.handleDoubleClickOrTap_);
    316       this.addEventListener('mousedown', this.handlePointerDownUp_);
    317       this.addEventListener('mouseup', this.handlePointerDownUp_);
    318       this.addEventListener('keydown', this.handleKeyDown);
    319       this.addEventListener('focus', this.handleElementFocus_, true);
    320       this.addEventListener('blur', this.handleElementBlur_, true);
    321       this.addEventListener('scroll', this.handleScroll.bind(this));
    322       this.setAttribute('role', 'listbox');
    323 
    324       this.touchHandler_ = new cr.ui.TouchHandler(this);
    325       this.addEventListener(cr.ui.TouchHandler.EventType.TOUCH_START,
    326                             this.handlePointerDownUp_);
    327       this.addEventListener(cr.ui.TouchHandler.EventType.TOUCH_END,
    328                             this.handlePointerDownUp_);
    329       this.addEventListener(cr.ui.TouchHandler.EventType.TAP,
    330                             this.handleDoubleClickOrTap_);
    331       this.touchHandler_.enable(false, false);
    332       // Make list focusable
    333       if (!this.hasAttribute('tabindex'))
    334         this.tabIndex = 0;
    335     },
    336 
    337     /**
    338      * @param {ListItem=} item The list item to measure.
    339      * @return {number} The height of the given item. If the fixed height on CSS
    340      * is set by 'px', uses that value as height. Otherwise, measures the size.
    341      * @private
    342      */
    343     measureItemHeight_: function(item) {
    344       var height = item.style.height;
    345       // Use the fixed height if set it on CSS, to save a time of layout
    346       // calculation.
    347       if (height && height.substr(-2) == 'px')
    348         return parseInt(height.substr(0, height.length - 2));
    349 
    350       return this.measureItem(item).height;
    351     },
    352 
    353     /**
    354      * @return {number} The height of default item, measuring it if necessary.
    355      * @private
    356      */
    357     getDefaultItemHeight_: function() {
    358       return this.getDefaultItemSize_().height;
    359     },
    360 
    361     /**
    362      * @param {number} index The index of the item.
    363      * @return {number} The height of the item, measuring it if necessary.
    364      */
    365     getItemHeightByIndex_: function(index) {
    366       // If |this.fixedHeight_| is true, all the rows have same default height.
    367       if (this.fixedHeight_)
    368         return this.getDefaultItemHeight_();
    369 
    370       if (this.cachedItemHeights_[index])
    371         return this.cachedItemHeights_[index];
    372 
    373       var item = this.getListItemByIndex(index);
    374       if (item)
    375         return this.measureItemHeight_(item);
    376 
    377       return this.getDefaultItemHeight_();
    378     },
    379 
    380     /**
    381      * @return {{height: number, width: number}} The height and width
    382      *     of default item, measuring it if necessary.
    383      * @private
    384      */
    385     getDefaultItemSize_: function() {
    386       if (!this.measured_ || !this.measured_.height) {
    387         this.measured_ = this.measureItem();
    388       }
    389       return this.measured_;
    390     },
    391 
    392     /**
    393      * Creates an item (dataModel.item(0)) and measures its height. The item is
    394      * cached instead of creating a new one every time..
    395      * @param {ListItem=} opt_item The list item to use to do the measuring. If
    396      *     this is not provided an item will be created based on the first value
    397      *     in the model.
    398      * @return {{height: number, marginTop: number, marginBottom:number,
    399      *     width: number, marginLeft: number, marginRight:number}}
    400      *     The height and width of the item, taking
    401      *     margins into account, and the top, bottom, left and right margins
    402      *     themselves.
    403      */
    404     measureItem: function(opt_item) {
    405       var dataModel = this.dataModel;
    406       if (!dataModel || !dataModel.length)
    407         return 0;
    408       var item = opt_item || this.cachedMeasuredItem_ ||
    409           this.createItem(dataModel.item(0));
    410       if (!opt_item) {
    411         this.cachedMeasuredItem_ = item;
    412         this.appendChild(item);
    413       }
    414 
    415       var rect = item.getBoundingClientRect();
    416       var cs = getComputedStyle(item);
    417       var mt = parseFloat(cs.marginTop);
    418       var mb = parseFloat(cs.marginBottom);
    419       var ml = parseFloat(cs.marginLeft);
    420       var mr = parseFloat(cs.marginRight);
    421       var h = rect.height;
    422       var w = rect.width;
    423       var mh = 0;
    424       var mv = 0;
    425 
    426       // Handle margin collapsing.
    427       if (mt < 0 && mb < 0) {
    428         mv = Math.min(mt, mb);
    429       } else if (mt >= 0 && mb >= 0) {
    430         mv = Math.max(mt, mb);
    431       } else {
    432         mv = mt + mb;
    433       }
    434       h += mv;
    435 
    436       if (ml < 0 && mr < 0) {
    437         mh = Math.min(ml, mr);
    438       } else if (ml >= 0 && mr >= 0) {
    439         mh = Math.max(ml, mr);
    440       } else {
    441         mh = ml + mr;
    442       }
    443       w += mh;
    444 
    445       if (!opt_item)
    446         this.removeChild(item);
    447       return {
    448           height: Math.max(0, h),
    449           marginTop: mt, marginBottom: mb,
    450           width: Math.max(0, w),
    451           marginLeft: ml, marginRight: mr};
    452     },
    453 
    454     /**
    455      * Callback for the double click or TAP event.
    456      * @param {Event} e The mouse or TouchHandler event object.
    457      * @private
    458      */
    459     handleDoubleClickOrTap_: function(e) {
    460       if (this.disabled)
    461         return;
    462 
    463       var target = e.target;
    464       if (e.touchedElement)
    465         target = e.touchedElement;
    466 
    467       target = this.getListItemAncestor(target);
    468       if (target)
    469         this.activateItemAtIndex(this.getIndexOfListItem(target));
    470     },
    471 
    472     /**
    473      * Callback for mousedown, mouseup, TOUCH_START and TOUCH_END events.
    474      * @param {Event} e The mouse or TouchHandler event object.
    475      * @private
    476      */
    477     handlePointerDownUp_: function(e) {
    478       if (this.disabled)
    479         return;
    480 
    481       var target = e.target;
    482       if (e.touchedElement)
    483         target = e.touchedElement;
    484 
    485       // If the target was this element we need to make sure that the user did
    486       // not click on a border or a scrollbar.
    487       if (target == this && !inViewport(target, e))
    488         return;
    489 
    490       target = this.getListItemAncestor(target);
    491 
    492       var index = target ? this.getIndexOfListItem(target) : -1;
    493       this.selectionController_.handlePointerDownUp(e, index);
    494     },
    495 
    496     /**
    497      * Called when an element in the list is focused. Marks the list as having
    498      * a focused element, and dispatches an event if it didn't have focus.
    499      * @param {Event} e The focus event.
    500      * @private
    501      */
    502     handleElementFocus_: function(e) {
    503       if (!this.hasElementFocus)
    504         this.hasElementFocus = true;
    505     },
    506 
    507     /**
    508      * Called when an element in the list is blurred. If focus moves outside
    509      * the list, marks the list as no longer having focus and dispatches an
    510      * event.
    511      * @param {Event} e The blur event.
    512      * @private
    513      */
    514     handleElementBlur_: function(e) {
    515       // When the blur event happens we do not know who is getting focus so we
    516       // delay this a bit until we know if the new focus node is outside the
    517       // list.
    518       var list = this;
    519       var doc = e.target.ownerDocument;
    520       window.setTimeout(function() {
    521         var activeElement = doc.activeElement;
    522         if (!list.contains(activeElement))
    523           list.hasElementFocus = false;
    524       });
    525     },
    526 
    527     /**
    528      * Returns the list item element containing the given element, or null if
    529      * it doesn't belong to any list item element.
    530      * @param {HTMLElement} element The element.
    531      * @return {ListItem} The list item containing |element|, or null.
    532      */
    533     getListItemAncestor: function(element) {
    534       var container = element;
    535       while (container && container.parentNode != this) {
    536         container = container.parentNode;
    537       }
    538       return container;
    539     },
    540 
    541     /**
    542      * Handle a keydown event.
    543      * @param {Event} e The keydown event.
    544      * @return {boolean} Whether the key event was handled.
    545      */
    546     handleKeyDown: function(e) {
    547       if (this.disabled)
    548         return;
    549 
    550       return this.selectionController_.handleKeyDown(e);
    551     },
    552 
    553     scrollTopBefore_: 0,
    554 
    555     /**
    556      * Handle a scroll event.
    557      * @param {Event} e The scroll event.
    558      */
    559     handleScroll: function(e) {
    560       var scrollTop = this.scrollTop;
    561       if (scrollTop != this.scrollTopBefore_) {
    562         this.scrollTopBefore_ = scrollTop;
    563         this.redraw();
    564       }
    565     },
    566 
    567     /**
    568      * Callback from the selection model. We dispatch {@code change} events
    569      * when the selection changes.
    570      * @param {!cr.Event} e Event with change info.
    571      * @private
    572      */
    573     handleOnChange_: function(ce) {
    574       ce.changes.forEach(function(change) {
    575         var listItem = this.getListItemByIndex(change.index);
    576         if (listItem)
    577           listItem.selected = change.selected;
    578       }, this);
    579 
    580       cr.dispatchSimpleEvent(this, 'change');
    581     },
    582 
    583     /**
    584      * Handles a change of the lead item from the selection model.
    585      * @param {Event} pe The property change event.
    586      * @private
    587      */
    588     handleLeadChange_: function(pe) {
    589       var element;
    590       if (pe.oldValue != -1) {
    591         if ((element = this.getListItemByIndex(pe.oldValue)))
    592           element.lead = false;
    593       }
    594 
    595       if (pe.newValue != -1) {
    596         if ((element = this.getListItemByIndex(pe.newValue)))
    597           element.lead = true;
    598         if (pe.oldValue != pe.newValue) {
    599           this.scrollIndexIntoView(pe.newValue);
    600           // If the lead item has a different height than other items, then we
    601           // may run into a problem that requires a second attempt to scroll
    602           // it into view. The first scroll attempt will trigger a redraw,
    603           // which will clear out the list and repopulate it with new items.
    604           // During the redraw, the list may shrink temporarily, which if the
    605           // lead item is the last item, will move the scrollTop up since it
    606           // cannot extend beyond the end of the list. (Sadly, being scrolled to
    607           // the bottom of the list is not "sticky.") So, we set a timeout to
    608           // rescroll the list after this all gets sorted out. This is perhaps
    609           // not the most elegant solution, but no others seem obvious.
    610           var self = this;
    611           window.setTimeout(function() {
    612             self.scrollIndexIntoView(pe.newValue);
    613           });
    614         }
    615       }
    616     },
    617 
    618     /**
    619      * This handles data model 'permuted' event.
    620      * this event is dispatched as a part of sort or splice.
    621      * We need to
    622      *  - adjust the cache.
    623      *  - adjust selection.
    624      *  - redraw. (called in this.endBatchUpdates())
    625      *  It is important that the cache adjustment happens before selection model
    626      *  adjustments.
    627      * @param {Event} e The 'permuted' event.
    628      */
    629     handleDataModelPermuted_: function(e) {
    630       var newCachedItems = {};
    631       for (var index in this.cachedItems_) {
    632         if (e.permutation[index] != -1) {
    633           var newIndex = e.permutation[index];
    634           newCachedItems[newIndex] = this.cachedItems_[index];
    635           newCachedItems[newIndex].listIndex = newIndex;
    636         }
    637       }
    638       this.cachedItems_ = newCachedItems;
    639 
    640       var newCachedItemHeights = {};
    641       for (var index in this.cachedItemHeights_) {
    642         if (e.permutation[index] != -1) {
    643           newCachedItemHeights[e.permutation[index]] =
    644               this.cachedItemHeights_[index];
    645         }
    646       }
    647       this.cachedItemHeights_ = newCachedItemHeights;
    648 
    649       this.startBatchUpdates();
    650 
    651       var sm = this.selectionModel;
    652       sm.adjustLength(e.newLength);
    653       sm.adjustToReordering(e.permutation);
    654 
    655       this.endBatchUpdates();
    656     },
    657 
    658     handleDataModelChange_: function(e) {
    659       delete this.cachedItems_[e.index];
    660       delete this.cachedItemHeights_[e.index];
    661       this.cachedMeasuredItem_ = null;
    662 
    663       if (e.index >= this.firstIndex_ &&
    664           (e.index < this.lastIndex_ || this.remainingSpace_)) {
    665         this.redraw();
    666       }
    667     },
    668 
    669     /**
    670      * @param {number} index The index of the item.
    671      * @return {number} The top position of the item inside the list.
    672      */
    673     getItemTop: function(index) {
    674       if (this.fixedHeight_) {
    675         var itemHeight = this.getDefaultItemHeight_();
    676         return index * itemHeight;
    677       } else {
    678         var top = 0;
    679         for (var i = 0; i < index; i++) {
    680           top += this.getItemHeightByIndex_(i);
    681         }
    682         return top;
    683       }
    684     },
    685 
    686     /**
    687      * @param {number} index The index of the item.
    688      * @return {number} The row of the item. May vary in the case
    689      *     of multiple columns.
    690      */
    691     getItemRow: function(index) {
    692       return index;
    693     },
    694 
    695     /**
    696      * @param {number} row The row.
    697      * @return {number} The index of the first item in the row.
    698      */
    699     getFirstItemInRow: function(row) {
    700       return row;
    701     },
    702 
    703     /**
    704      * Ensures that a given index is inside the viewport.
    705      * @param {number} index The index of the item to scroll into view.
    706      * @return {boolean} Whether any scrolling was needed.
    707      */
    708     scrollIndexIntoView: function(index) {
    709       var dataModel = this.dataModel;
    710       if (!dataModel || index < 0 || index >= dataModel.length)
    711         return false;
    712 
    713       var itemHeight = this.getItemHeightByIndex_(index);
    714       var scrollTop = this.scrollTop;
    715       var top = this.getItemTop(index);
    716       var clientHeight = this.clientHeight;
    717 
    718       var self = this;
    719       // Function to adjust the tops of viewport and row.
    720       function scrollToAdjustTop() {
    721           self.scrollTop = top;
    722           return true;
    723       };
    724       // Function to adjust the bottoms of viewport and row.
    725       function scrollToAdjustBottom() {
    726           var cs = getComputedStyle(self);
    727           var paddingY = parseInt(cs.paddingTop, 10) +
    728                          parseInt(cs.paddingBottom, 10);
    729 
    730           if (top + itemHeight > scrollTop + clientHeight - paddingY) {
    731             self.scrollTop = top + itemHeight - clientHeight + paddingY;
    732             return true;
    733           }
    734           return false;
    735       };
    736 
    737       // Check if the entire of given indexed row can be shown in the viewport.
    738       if (itemHeight <= clientHeight) {
    739         if (top < scrollTop)
    740           return scrollToAdjustTop();
    741         if (scrollTop + clientHeight < top + itemHeight)
    742           return scrollToAdjustBottom();
    743       } else {
    744         if (scrollTop < top)
    745           return scrollToAdjustTop();
    746         if (top + itemHeight < scrollTop + clientHeight)
    747           return scrollToAdjustBottom();
    748       }
    749       return false;
    750     },
    751 
    752     /**
    753      * @return {!ClientRect} The rect to use for the context menu.
    754      */
    755     getRectForContextMenu: function() {
    756       // TODO(arv): Add trait support so we can share more code between trees
    757       // and lists.
    758       var index = this.selectionModel.selectedIndex;
    759       var el = this.getListItemByIndex(index);
    760       if (el)
    761         return el.getBoundingClientRect();
    762       return this.getBoundingClientRect();
    763     },
    764 
    765     /**
    766      * Takes a value from the data model and finds the associated list item.
    767      * @param {*} value The value in the data model that we want to get the list
    768      *     item for.
    769      * @return {ListItem} The first found list item or null if not found.
    770      */
    771     getListItem: function(value) {
    772       var dataModel = this.dataModel;
    773       if (dataModel) {
    774         var index = dataModel.indexOf(value);
    775         return this.getListItemByIndex(index);
    776       }
    777       return null;
    778     },
    779 
    780     /**
    781      * Find the list item element at the given index.
    782      * @param {number} index The index of the list item to get.
    783      * @return {ListItem} The found list item or null if not found.
    784      */
    785     getListItemByIndex: function(index) {
    786       return this.cachedItems_[index] || null;
    787     },
    788 
    789     /**
    790      * Find the index of the given list item element.
    791      * @param {ListItem} item The list item to get the index of.
    792      * @return {number} The index of the list item, or -1 if not found.
    793      */
    794     getIndexOfListItem: function(item) {
    795       var index = item.listIndex;
    796       if (this.cachedItems_[index] == item) {
    797         return index;
    798       }
    799       return -1;
    800     },
    801 
    802     /**
    803      * Creates a new list item.
    804      * @param {*} value The value to use for the item.
    805      * @return {!ListItem} The newly created list item.
    806      */
    807     createItem: function(value) {
    808       var item = new this.itemConstructor_(value);
    809       item.label = value;
    810       if (typeof item.decorate == 'function')
    811         item.decorate();
    812       return item;
    813     },
    814 
    815     /**
    816      * Creates the selection controller to use internally.
    817      * @param {cr.ui.ListSelectionModel} sm The underlying selection model.
    818      * @return {!cr.ui.ListSelectionController} The newly created selection
    819      *     controller.
    820      */
    821     createSelectionController: function(sm) {
    822       return new ListSelectionController(sm);
    823     },
    824 
    825     /**
    826      * Return the heights (in pixels) of the top of the given item index within
    827      * the list, and the height of the given item itself, accounting for the
    828      * possibility that the lead item may be a different height.
    829      * @param {number} index The index to find the top height of.
    830      * @return {{top: number, height: number}} The heights for the given index.
    831      * @private
    832      */
    833     getHeightsForIndex_: function(index) {
    834       var itemHeight = this.getItemHeightByIndex_(index);
    835       var top = this.getItemTop(index);
    836       return {top: top, height: itemHeight};
    837     },
    838 
    839     /**
    840      * Find the index of the list item containing the given y offset (measured
    841      * in pixels from the top) within the list. In the case of multiple columns,
    842      * returns the first index in the row.
    843      * @param {number} offset The y offset in pixels to get the index of.
    844      * @return {number} The index of the list item. Returns the list size if
    845      *     given offset exceeds the height of list.
    846      * @private
    847      */
    848     getIndexForListOffset_: function(offset) {
    849       var itemHeight = this.getDefaultItemHeight_();
    850       if (!itemHeight)
    851         return this.dataModel.length;
    852 
    853       if (this.fixedHeight_)
    854         return this.getFirstItemInRow(Math.floor(offset / itemHeight));
    855 
    856       // If offset exceeds the height of list.
    857       var lastHeight = 0;
    858       if (this.dataModel.length) {
    859         var h = this.getHeightsForIndex_(this.dataModel.length - 1);
    860         lastHeight = h.top + h.height;
    861       }
    862       if (lastHeight < offset)
    863         return this.dataModel.length;
    864 
    865       // Estimates index.
    866       var estimatedIndex = Math.min(Math.floor(offset / itemHeight),
    867                                     this.dataModel.length - 1);
    868       var isIncrementing = this.getItemTop(estimatedIndex) < offset;
    869 
    870       // Searchs the correct index.
    871       do {
    872         var heights = this.getHeightsForIndex_(estimatedIndex);
    873         var top = heights.top;
    874         var height = heights.height;
    875 
    876         if (top <= offset && offset <= (top + height))
    877           break;
    878 
    879         isIncrementing ? ++estimatedIndex : --estimatedIndex;
    880       } while (0 < estimatedIndex && estimatedIndex < this.dataModel.length);
    881 
    882       return estimatedIndex;
    883     },
    884 
    885     /**
    886      * Return the number of items that occupy the range of heights between the
    887      * top of the start item and the end offset.
    888      * @param {number} startIndex The index of the first visible item.
    889      * @param {number} endOffset The y offset in pixels of the end of the list.
    890      * @return {number} The number of list items visible.
    891      * @private
    892      */
    893     countItemsInRange_: function(startIndex, endOffset) {
    894       var endIndex = this.getIndexForListOffset_(endOffset);
    895       return endIndex - startIndex + 1;
    896     },
    897 
    898     /**
    899      * Calculates the number of items fitting in the given viewport.
    900      * @param {number} scrollTop The scroll top position.
    901      * @param {number} clientHeight The height of viewport.
    902      * @return {{first: number, length: number, last: number}} The index of
    903      *     first item in view port, The number of items, The item past the last.
    904      */
    905     getItemsInViewPort: function(scrollTop, clientHeight) {
    906       if (this.autoExpands_) {
    907         return {
    908           first: 0,
    909           length: this.dataModel.length,
    910           last: this.dataModel.length};
    911       } else {
    912         var firstIndex = this.getIndexForListOffset_(scrollTop);
    913         var lastIndex = this.getIndexForListOffset_(scrollTop + clientHeight);
    914 
    915         return {
    916           first: firstIndex,
    917           length: lastIndex - firstIndex + 1,
    918           last: lastIndex + 1};
    919       }
    920     },
    921 
    922     /**
    923      * Merges list items currently existing in the list with items in the range
    924      * [firstIndex, lastIndex). Removes or adds items if needed.
    925      * Doesn't delete {@code this.pinnedItem_} if it presents (instead hides if
    926      * it's out of the range). Also adds the items to {@code newCachedItems}.
    927      * @param {number} firstIndex The index of first item, inclusively.
    928      * @param {number} lastIndex The index of last item, exclusively.
    929      * @param {Object.<string, ListItem>} cachedItems Old items cache.
    930      * @param {Object.<string, ListItem>} newCachedItems New items cache.
    931      */
    932     mergeItems: function(firstIndex, lastIndex, cachedItems, newCachedItems) {
    933       var dataModel = this.dataModel;
    934 
    935       function insert(to) {
    936         var dataItem = dataModel.item(currentIndex);
    937         var newItem = cachedItems[currentIndex] || to.createItem(dataItem);
    938         newItem.listIndex = currentIndex;
    939         newCachedItems[currentIndex] = newItem;
    940         to.insertBefore(newItem, item);
    941         currentIndex++;
    942       }
    943 
    944       function remove(from) {
    945         var next = item.nextSibling;
    946         if (item != from.pinnedItem_)
    947           from.removeChild(item);
    948         item = next;
    949       }
    950 
    951       var currentIndex = firstIndex;
    952       for (var item = this.beforeFiller_.nextSibling;
    953            item != this.afterFiller_ && currentIndex < lastIndex;) {
    954         if (!this.isItem(item)) {
    955           item = item.nextSibling;
    956           continue;
    957         }
    958 
    959         var index = item.listIndex;
    960         if (cachedItems[index] != item || index < currentIndex) {
    961           remove(this);
    962         } else if (index == currentIndex) {
    963           newCachedItems[currentIndex] = item;
    964           item = item.nextSibling;
    965           currentIndex++;
    966         } else {  // index > currentIndex
    967           insert(this);
    968         }
    969       }
    970 
    971       while (item != this.afterFiller_) {
    972         if (this.isItem(item))
    973           remove(this);
    974         else
    975           item = item.nextSibling;
    976       }
    977 
    978       if (this.pinnedItem_) {
    979         var index = this.pinnedItem_.listIndex;
    980         this.pinnedItem_.hidden = index < firstIndex || index >= lastIndex;
    981         newCachedItems[index] = this.pinnedItem_;
    982         if (index >= lastIndex)
    983           item = this.pinnedItem_;  // Insert new items before this one.
    984       }
    985 
    986       while (currentIndex < lastIndex)
    987         insert(this);
    988     },
    989 
    990     /**
    991      * Ensures that all the item sizes in the list have been already cached.
    992      */
    993     ensureAllItemSizesInCache: function() {
    994       var measuringIndexes = [];
    995       var isElementAppended = [];
    996       for (var y = 0; y < this.dataModel.length; y++) {
    997         if (!this.cachedItemHeights_[y]) {
    998           measuringIndexes.push(y);
    999           isElementAppended.push(false);
   1000         }
   1001       }
   1002 
   1003       var measuringItems = [];
   1004       // Adds temporary elements.
   1005       for (var y = 0; y < measuringIndexes.length; y++) {
   1006         var index = measuringIndexes[y];
   1007         var dataItem = this.dataModel.item(index);
   1008         var listItem = this.cachedItems_[index] || this.createItem(dataItem);
   1009         listItem.listIndex = index;
   1010 
   1011         // If |listItems| is not on the list, apppends it to the list and sets
   1012         // the flag.
   1013         if (!listItem.parentNode) {
   1014           this.appendChild(listItem);
   1015           isElementAppended[y] = true;
   1016         }
   1017 
   1018         this.cachedItems_[index] = listItem;
   1019         measuringItems.push(listItem);
   1020       }
   1021 
   1022       // All mesurings must be placed after adding all the elements, to prevent
   1023       // performance reducing.
   1024       for (var y = 0; y < measuringIndexes.length; y++) {
   1025         var index = measuringIndexes[y];
   1026         this.cachedItemHeights_[index] =
   1027             this.measureItemHeight_(measuringItems[y]);
   1028       }
   1029 
   1030       // Removes all the temprary elements.
   1031       for (var y = 0; y < measuringIndexes.length; y++) {
   1032         // If the list item has been appended above, removes it.
   1033         if (isElementAppended[y])
   1034           this.removeChild(measuringItems[y]);
   1035       }
   1036     },
   1037 
   1038     /**
   1039      * Returns the height of after filler in the list.
   1040      * @param {number} lastIndex The index of item past the last in viewport.
   1041      * @param {number} itemHeight The height of the item.
   1042      * @return {number} The height of after filler.
   1043      */
   1044     getAfterFillerHeight: function(lastIndex) {
   1045       if (this.fixedHeight_) {
   1046         var itemHeight = this.getDefaultItemHeight_();
   1047         return (this.dataModel.length - lastIndex) * itemHeight;
   1048       }
   1049 
   1050       var height = 0;
   1051       for (var i = lastIndex; i < this.dataModel.length; i++)
   1052         height += this.getItemHeightByIndex_(i);
   1053       return height;
   1054     },
   1055 
   1056     /**
   1057      * Redraws the viewport.
   1058      */
   1059     redraw: function() {
   1060       if (this.batchCount_ != 0)
   1061         return;
   1062 
   1063       var dataModel = this.dataModel;
   1064       if (!dataModel) {
   1065         this.cachedItems_ = {};
   1066         this.firstIndex_ = 0;
   1067         this.lastIndex_ = 0;
   1068         this.remainingSpace_ = true;
   1069         this.mergeItems(0, 0, {}, {});
   1070         return;
   1071       }
   1072 
   1073       // Save the previous positions before any manipulation of elements.
   1074       var scrollTop = this.scrollTop;
   1075       var clientHeight = this.clientHeight;
   1076 
   1077       // Store all the item sizes into the cache in advance, to prevent
   1078       // interleave measuring with mutating dom.
   1079       if (!this.fixedHeight_)
   1080         this.ensureAllItemSizesInCache();
   1081 
   1082       // We cache the list items since creating the DOM nodes is the most
   1083       // expensive part of redrawing.
   1084       var cachedItems = this.cachedItems_ || {};
   1085       var newCachedItems = {};
   1086 
   1087       var autoExpands = this.autoExpands_;
   1088 
   1089       var itemsInViewPort = this.getItemsInViewPort(scrollTop, clientHeight);
   1090       // Draws the hidden rows just above/below the viewport to prevent
   1091       // flashing in scroll.
   1092       var firstIndex = Math.max(0, itemsInViewPort.first - 1);
   1093       var lastIndex = Math.min(itemsInViewPort.last + 1, dataModel.length);
   1094 
   1095       var beforeFillerHeight =
   1096           this.autoExpands ? 0 : this.getItemTop(firstIndex);
   1097       var afterFillerHeight =
   1098           this.autoExpands ? 0 : this.getAfterFillerHeight(lastIndex);
   1099 
   1100       this.beforeFiller_.style.height = beforeFillerHeight + 'px';
   1101 
   1102       var sm = this.selectionModel;
   1103       var leadIndex = sm.leadIndex;
   1104 
   1105       if (this.pinnedItem_ &&
   1106           this.pinnedItem_ != cachedItems[leadIndex]) {
   1107         if (this.pinnedItem_.hidden)
   1108           this.removeChild(this.pinnedItem_);
   1109         this.pinnedItem_ = undefined;
   1110       }
   1111 
   1112       this.mergeItems(firstIndex, lastIndex, cachedItems, newCachedItems);
   1113 
   1114       if (!this.pinnedItem_ && newCachedItems[leadIndex] &&
   1115           newCachedItems[leadIndex].parentNode == this) {
   1116         this.pinnedItem_ = newCachedItems[leadIndex];
   1117       }
   1118 
   1119       this.afterFiller_.style.height = afterFillerHeight + 'px';
   1120 
   1121       // We don't set the lead or selected properties until after adding all
   1122       // items, in case they force relayout in response to these events.
   1123       var listItem = null;
   1124       if (leadIndex != -1 && newCachedItems[leadIndex])
   1125         newCachedItems[leadIndex].lead = true;
   1126       for (var y = firstIndex; y < lastIndex; y++) {
   1127         if (sm.getIndexSelected(y))
   1128           newCachedItems[y].selected = true;
   1129         else if (y != leadIndex)
   1130           listItem = newCachedItems[y];
   1131       }
   1132 
   1133       this.firstIndex_ = firstIndex;
   1134       this.lastIndex_ = lastIndex;
   1135 
   1136       this.remainingSpace_ = itemsInViewPort.last > dataModel.length;
   1137       this.cachedItems_ = newCachedItems;
   1138 
   1139       // Mesurings must be placed after adding all the elements, to prevent
   1140       // performance reducing.
   1141       if (!this.fixedHeight_) {
   1142         for (var y = firstIndex; y < lastIndex; y++)
   1143           this.cachedItemHeights_[y] =
   1144               this.measureItemHeight_(newCachedItems[y]);
   1145       }
   1146 
   1147       // Measure again in case the item height has changed due to a page zoom.
   1148       //
   1149       // The measure above is only done the first time but this measure is done
   1150       // after every redraw. It is done in a timeout so it will not trigger
   1151       // a reflow (which made the redraw speed 3 times slower on my system).
   1152       // By using a timeout the measuring will happen later when there is no
   1153       // need for a reflow.
   1154       if (listItem && this.fixedHeight_) {
   1155         var list = this;
   1156         window.setTimeout(function() {
   1157           if (listItem.parentNode == list) {
   1158             list.measured_ = list.measureItem(listItem);
   1159           }
   1160         });
   1161       }
   1162     },
   1163 
   1164     /**
   1165      * Restore the lead item that is present in the list but may be updated
   1166      * in the data model (supposed to be used inside a batch update). Usually
   1167      * such an item would be recreated in the redraw method. If reinsertion
   1168      * is undesirable (for instance to prevent losing focus) the item may be
   1169      * updated and restored. Assumed the listItem relates to the same data item
   1170      * as the lead item in the begin of the batch update.
   1171      *
   1172      * @param {ListItem} leadItem Already existing lead item.
   1173      */
   1174     restoreLeadItem: function(leadItem) {
   1175       delete this.cachedItems_[leadItem.listIndex];
   1176 
   1177       leadItem.listIndex = this.selectionModel.leadIndex;
   1178       this.pinnedItem_ = this.cachedItems_[leadItem.listIndex] = leadItem;
   1179     },
   1180 
   1181     /**
   1182      * Invalidates list by removing cached items.
   1183      */
   1184     invalidate: function() {
   1185       this.cachedItems_ = {};
   1186       this.cachedItemSized_ = {};
   1187     },
   1188 
   1189     /**
   1190      * Redraws a single item.
   1191      * @param {number} index The row index to redraw.
   1192      */
   1193     redrawItem: function(index) {
   1194       if (index >= this.firstIndex_ &&
   1195           (index < this.lastIndex_ || this.remainingSpace_)) {
   1196         delete this.cachedItems_[index];
   1197         this.redraw();
   1198       }
   1199     },
   1200 
   1201     /**
   1202      * Called when a list item is activated, currently only by a double click
   1203      * event.
   1204      * @param {number} index The index of the activated item.
   1205      */
   1206     activateItemAtIndex: function(index) {
   1207     },
   1208 
   1209     /**
   1210      * Returns a ListItem for the leadIndex. If the item isn't present in the
   1211      * list creates it and inserts to the list (may be invisible if it's out of
   1212      * the visible range).
   1213      *
   1214      * Item returned from this method won't be removed until it remains a lead
   1215      * item or til the data model changes (unlike other items that could be
   1216      * removed when they go out of the visible range).
   1217      *
   1218      * @return {cr.ui.ListItem} The lead item for the list.
   1219      */
   1220     ensureLeadItemExists: function() {
   1221       var index = this.selectionModel.leadIndex;
   1222       if (index < 0)
   1223         return null;
   1224       var cachedItems = this.cachedItems_ || {};
   1225 
   1226       var item = cachedItems[index] ||
   1227                  this.createItem(this.dataModel.item(index));
   1228       if (this.pinnedItem_ != item && this.pinnedItem_ &&
   1229           this.pinnedItem_.hidden) {
   1230         this.removeChild(this.pinnedItem_);
   1231       }
   1232       this.pinnedItem_ = item;
   1233       cachedItems[index] = item;
   1234       item.listIndex = index;
   1235       if (item.parentNode == this)
   1236         return item;
   1237 
   1238       if (this.batchCount_ != 0)
   1239         item.hidden = true;
   1240 
   1241       // Item will get to the right place in redraw. Choose place to insert
   1242       // reducing items reinsertion.
   1243       if (index <= this.firstIndex_)
   1244         this.insertBefore(item, this.beforeFiller_.nextSibling);
   1245       else
   1246         this.insertBefore(item, this.afterFiller_);
   1247       this.redraw();
   1248       return item;
   1249     },
   1250   };
   1251 
   1252   cr.defineProperty(List, 'disabled', cr.PropertyKind.BOOL_ATTR);
   1253 
   1254   /**
   1255    * Whether the list or one of its descendents has focus. This is necessary
   1256    * because list items can contain controls that can be focused, and for some
   1257    * purposes (e.g., styling), the list can still be conceptually focused at
   1258    * that point even though it doesn't actually have the page focus.
   1259    */
   1260   cr.defineProperty(List, 'hasElementFocus', cr.PropertyKind.BOOL_ATTR);
   1261 
   1262   return {
   1263     List: List
   1264   };
   1265 });
   1266