- add sources.
[platform/framework/web/crosswalk.git] / src / chrome / common / extensions / docs / examples / extensions / plugin_settings / domui / js / cr / ui / list.js
1 // Copyright (c) 2011 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 ListSelectionModel = cr.ui.ListSelectionModel;
16   const ListSelectionController = cr.ui.ListSelectionController;
17   const 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   /**
37    * Creates an item (dataModel.item(0)) and measures its height.
38    * @param {!List} list The list to create the item for.
39    * @param {ListItem=} opt_item The list item to use to do the measuring. If
40    *     this is not provided an item will be created based on the first value
41    *     in the model.
42    * @return {{height: number, marginVertical: number, width: number,
43    *     marginHorizontal: number}} The height and width of the item, taking
44    *     margins into account, and the height and width of the margins
45    *     themselves.
46    */
47   function measureItem(list, opt_item) {
48     var dataModel = list.dataModel;
49     if (!dataModel || !dataModel.length)
50       return 0;
51     var item = opt_item || list.createItem(dataModel.item(0));
52     if (!opt_item)
53       list.appendChild(item);
54
55     var rect = item.getBoundingClientRect();
56     var cs = getComputedStyle(item);
57     var mt = parseFloat(cs.marginTop);
58     var mb = parseFloat(cs.marginBottom);
59     var ml = parseFloat(cs.marginLeft);
60     var mr = parseFloat(cs.marginRight);
61     var h = rect.height;
62     var w = rect.width;
63     var mh = 0;
64     var mv = 0;
65
66     // Handle margin collapsing.
67     if (mt < 0 && mb < 0) {
68       mv = Math.min(mt, mb);
69     } else if (mt >= 0 && mb >= 0) {
70       mv = Math.max(mt, mb);
71     } else {
72       mv = mt + mb;
73     }
74     h += mv;
75
76     if (ml < 0 && mr < 0) {
77       mh = Math.min(ml, mr);
78     } else if (ml >= 0 && mr >= 0) {
79       mh = Math.max(ml, mr);
80     } else {
81       mh = ml + mr;
82     }
83     w += mh;
84
85     if (!opt_item)
86       list.removeChild(item);
87     return {
88         height: Math.max(0, h), marginVertical: mv,
89         width: Math.max(0, w), marginHorizontal: mh};
90   }
91
92   function getComputedStyle(el) {
93     return el.ownerDocument.defaultView.getComputedStyle(el);
94   }
95
96   /**
97    * Creates a new list element.
98    * @param {Object=} opt_propertyBag Optional properties.
99    * @constructor
100    * @extends {HTMLUListElement}
101    */
102   var List = cr.ui.define('list');
103
104   List.prototype = {
105     __proto__: HTMLUListElement.prototype,
106
107     /**
108      * Measured size of list items. This is lazily calculated the first time it
109      * is needed. Note that lead item is allowed to have a different height, to
110      * accommodate lists where a single item at a time can be expanded to show
111      * more detail.
112      * @type {{height: number, marginVertical: number, width: number,
113      *     marginHorizontal: number}}
114      * @private
115      */
116     measured_: undefined,
117
118     /**
119      * The height of the lead item, which is allowed to have a different height
120      * than other list items to accommodate lists where a single item at a time
121      * can be expanded to show more detail. It is explicitly set by client code
122      * when the height of the lead item is changed with {@code set
123      * leadItemHeight}, and presumed equal to {@code itemHeight_} otherwise.
124      * @type {number}
125      * @private
126      */
127     leadItemHeight_: 0,
128
129     /**
130      * Whether or not the list is autoexpanding. If true, the list resizes
131      * its height to accomadate all children.
132      * @type {boolean}
133      * @private
134      */
135     autoExpands_: false,
136
137     /**
138      * Function used to create grid items.
139      * @type {function(): !ListItem}
140      * @private
141      */
142     itemConstructor_: cr.ui.ListItem,
143
144     /**
145      * Function used to create grid items.
146      * @type {function(): !ListItem}
147      */
148     get itemConstructor() {
149       return this.itemConstructor_;
150     },
151     set itemConstructor(func) {
152       if (func != this.itemConstructor_) {
153         this.itemConstructor_ = func;
154         this.cachedItems_ = {};
155         this.redraw();
156       }
157     },
158
159     dataModel_: null,
160
161     /**
162      * The data model driving the list.
163      * @type {ArrayDataModel}
164      */
165     set dataModel(dataModel) {
166       if (this.dataModel_ != dataModel) {
167         if (!this.boundHandleDataModelPermuted_) {
168           this.boundHandleDataModelPermuted_ =
169               this.handleDataModelPermuted_.bind(this);
170           this.boundHandleDataModelChange_ =
171               this.handleDataModelChange_.bind(this);
172         }
173
174         if (this.dataModel_) {
175           this.dataModel_.removeEventListener(
176               'permuted',
177               this.boundHandleDataModelPermuted_);
178           this.dataModel_.removeEventListener('change',
179                                               this.boundHandleDataModelChange_);
180         }
181
182         this.dataModel_ = dataModel;
183
184         this.cachedItems_ = {};
185         this.selectionModel.clear();
186         if (dataModel)
187           this.selectionModel.adjustLength(dataModel.length);
188
189         if (this.dataModel_) {
190           this.dataModel_.addEventListener(
191               'permuted',
192               this.boundHandleDataModelPermuted_);
193           this.dataModel_.addEventListener('change',
194                                            this.boundHandleDataModelChange_);
195         }
196
197         this.redraw();
198       }
199     },
200
201     get dataModel() {
202       return this.dataModel_;
203     },
204
205     /**
206      * The selection model to use.
207      * @type {cr.ui.ListSelectionModel}
208      */
209     get selectionModel() {
210       return this.selectionModel_;
211     },
212     set selectionModel(sm) {
213       var oldSm = this.selectionModel_;
214       if (oldSm == sm)
215         return;
216
217       if (!this.boundHandleOnChange_) {
218         this.boundHandleOnChange_ = this.handleOnChange_.bind(this);
219         this.boundHandleLeadChange_ = this.handleLeadChange_.bind(this);
220       }
221
222       if (oldSm) {
223         oldSm.removeEventListener('change', this.boundHandleOnChange_);
224         oldSm.removeEventListener('leadIndexChange',
225                                   this.boundHandleLeadChange_);
226       }
227
228       this.selectionModel_ = sm;
229       this.selectionController_ = this.createSelectionController(sm);
230
231       if (sm) {
232         sm.addEventListener('change', this.boundHandleOnChange_);
233         sm.addEventListener('leadIndexChange', this.boundHandleLeadChange_);
234       }
235     },
236
237     /**
238      * Whether or not the list auto-expands.
239      * @type {boolean}
240      */
241     get autoExpands() {
242       return this.autoExpands_;
243     },
244     set autoExpands(autoExpands) {
245       if (this.autoExpands_ == autoExpands)
246         return;
247       this.autoExpands_ = autoExpands;
248       this.redraw();
249     },
250
251     /**
252      * Convenience alias for selectionModel.selectedItem
253      * @type {cr.ui.ListItem}
254      */
255     get selectedItem() {
256       var dataModel = this.dataModel;
257       if (dataModel) {
258         var index = this.selectionModel.selectedIndex;
259         if (index != -1)
260           return dataModel.item(index);
261       }
262       return null;
263     },
264     set selectedItem(selectedItem) {
265       var dataModel = this.dataModel;
266       if (dataModel) {
267         var index = this.dataModel.indexOf(selectedItem);
268         this.selectionModel.selectedIndex = index;
269       }
270     },
271
272     /**
273      * The height of the lead item.
274      * If set to 0, resets to the same height as other items.
275      * @type {number}
276      */
277     get leadItemHeight() {
278       return this.leadItemHeight_ || this.getItemHeight_();
279     },
280     set leadItemHeight(height) {
281       if (height) {
282         var size = this.getItemSize_();
283         this.leadItemHeight_ = Math.max(0, height + size.marginVertical);
284       } else {
285         this.leadItemHeight_ = 0;
286       }
287     },
288
289     /**
290      * Convenience alias for selectionModel.selectedItems
291      * @type {!Array<cr.ui.ListItem>}
292      */
293     get selectedItems() {
294       var indexes = this.selectionModel.selectedIndexes;
295       var dataModel = this.dataModel;
296       if (dataModel) {
297         return indexes.map(function(i) {
298           return dataModel.item(i);
299         });
300       }
301       return [];
302     },
303
304     /**
305      * The HTML elements representing the items. This is just all the list item
306      * children but subclasses may override this to filter out certain elements.
307      * @type {HTMLCollection}
308      */
309     get items() {
310       return Array.prototype.filter.call(this.children, function(child) {
311         return !child.classList.contains('spacer');
312       });
313     },
314
315     batchCount_: 0,
316
317     /**
318      * When making a lot of updates to the list, the code could be wrapped in
319      * the startBatchUpdates and finishBatchUpdates to increase performance. Be
320      * sure that the code will not return without calling endBatchUpdates or the
321      * list will not be correctly updated.
322      */
323     startBatchUpdates: function() {
324       this.batchCount_++;
325     },
326
327     /**
328      * See startBatchUpdates.
329      */
330     endBatchUpdates: function() {
331       this.batchCount_--;
332       if (this.batchCount_ == 0)
333         this.redraw();
334     },
335
336     /**
337      * Initializes the element.
338      */
339     decorate: function() {
340       // Add fillers.
341       this.beforeFiller_ = this.ownerDocument.createElement('div');
342       this.afterFiller_ = this.ownerDocument.createElement('div');
343       this.beforeFiller_.className = 'spacer';
344       this.afterFiller_.className = 'spacer';
345       this.appendChild(this.beforeFiller_);
346       this.appendChild(this.afterFiller_);
347
348       var length = this.dataModel ? this.dataModel.length : 0;
349       this.selectionModel = new ListSelectionModel(length);
350
351       this.addEventListener('dblclick', this.handleDoubleClick_);
352       this.addEventListener('mousedown', this.handleMouseDownUp_);
353       this.addEventListener('mouseup', this.handleMouseDownUp_);
354       this.addEventListener('keydown', this.handleKeyDown);
355       this.addEventListener('focus', this.handleElementFocus_, true);
356       this.addEventListener('blur', this.handleElementBlur_, true);
357       this.addEventListener('scroll', this.redraw.bind(this));
358       this.setAttribute('role', 'listbox');
359
360       // Make list focusable
361       if (!this.hasAttribute('tabindex'))
362         this.tabIndex = 0;
363     },
364
365     /**
366      * @return {number} The height of an item, measuring it if necessary.
367      * @private
368      */
369     getItemHeight_: function() {
370       return this.getItemSize_().height;
371     },
372
373     /**
374      * @return {number} The width of an item, measuring it if necessary.
375      * @private
376      */
377     getItemWidth_: function() {
378       return this.getItemSize_().width;
379     },
380
381     /**
382      * @return {{height: number, width: number}} The height and width
383      *     of an item, measuring it if necessary.
384      * @private
385      */
386     getItemSize_: function() {
387       if (!this.measured_ || !this.measured_.height) {
388         this.measured_ = measureItem(this);
389       }
390       return this.measured_;
391     },
392
393     /**
394      * Callback for the double click event.
395      * @param {Event} e The mouse event object.
396      * @private
397      */
398     handleDoubleClick_: function(e) {
399       if (this.disabled)
400         return;
401
402       var target = this.getListItemAncestor(e.target);
403       if (target)
404         this.activateItemAtIndex(this.getIndexOfListItem(target));
405     },
406
407     /**
408      * Callback for mousedown and mouseup events.
409      * @param {Event} e The mouse event object.
410      * @private
411      */
412     handleMouseDownUp_: function(e) {
413       if (this.disabled)
414         return;
415
416       var target = e.target;
417
418       // If the target was this element we need to make sure that the user did
419       // not click on a border or a scrollbar.
420       if (target == this && !inViewport(target, e))
421         return;
422
423       target = this.getListItemAncestor(target);
424
425       var index = target ? this.getIndexOfListItem(target) : -1;
426       this.selectionController_.handleMouseDownUp(e, index);
427     },
428
429     /**
430      * Called when an element in the list is focused. Marks the list as having
431      * a focused element, and dispatches an event if it didn't have focus.
432      * @param {Event} e The focus event.
433      * @private
434      */
435     handleElementFocus_: function(e) {
436       if (!this.hasElementFocus) {
437         this.hasElementFocus = true;
438         // Force styles based on hasElementFocus to take effect.
439         this.forceRepaint_();
440       }
441     },
442
443     /**
444      * Called when an element in the list is blurred. If focus moves outside
445      * the list, marks the list as no longer having focus and dispatches an
446      * event.
447      * @param {Event} e The blur event.
448      * @private
449      */
450     handleElementBlur_: function(e) {
451       // When the blur event happens we do not know who is getting focus so we
452       // delay this a bit until we know if the new focus node is outside the
453       // list.
454       var list = this;
455       var doc = e.target.ownerDocument;
456       window.setTimeout(function() {
457         var activeElement = doc.activeElement;
458         if (!list.contains(activeElement)) {
459           list.hasElementFocus = false;
460           // Force styles based on hasElementFocus to take effect.
461           list.forceRepaint_();
462         }
463       });
464     },
465
466     /**
467      * Forces a repaint of the list. Changing custom attributes, even if there
468      * are style rules depending on them, doesn't cause a repaint
469      * (<https://bugs.webkit.org/show_bug.cgi?id=12519>), so this can be called
470      * to force the list to repaint.
471      * @private
472      */
473     forceRepaint_: function(e) {
474       var dummyElement = document.createElement('div');
475       this.appendChild(dummyElement);
476       this.removeChild(dummyElement);
477     },
478
479     /**
480      * Returns the list item element containing the given element, or null if
481      * it doesn't belong to any list item element.
482      * @param {HTMLElement} element The element.
483      * @return {ListItem} The list item containing |element|, or null.
484      */
485     getListItemAncestor: function(element) {
486       var container = element;
487       while (container && container.parentNode != this) {
488         container = container.parentNode;
489       }
490       return container;
491     },
492
493     /**
494      * Handle a keydown event.
495      * @param {Event} e The keydown event.
496      * @return {boolean} Whether the key event was handled.
497      */
498     handleKeyDown: function(e) {
499       if (this.disabled)
500         return;
501
502       return this.selectionController_.handleKeyDown(e);
503     },
504
505     /**
506      * Callback from the selection model. We dispatch {@code change} events
507      * when the selection changes.
508      * @param {!Event} e Event with change info.
509      * @private
510      */
511     handleOnChange_: function(ce) {
512       ce.changes.forEach(function(change) {
513         var listItem = this.getListItemByIndex(change.index);
514         if (listItem)
515           listItem.selected = change.selected;
516       }, this);
517
518       cr.dispatchSimpleEvent(this, 'change');
519     },
520
521     /**
522      * Handles a change of the lead item from the selection model.
523      * @property {Event} pe The property change event.
524      * @private
525      */
526     handleLeadChange_: function(pe) {
527       var element;
528       if (pe.oldValue != -1) {
529         if ((element = this.getListItemByIndex(pe.oldValue)))
530           element.lead = false;
531       }
532
533       if (pe.newValue != -1) {
534         if ((element = this.getListItemByIndex(pe.newValue)))
535           element.lead = true;
536         this.scrollIndexIntoView(pe.newValue);
537         // If the lead item has a different height than other items, then we
538         // may run into a problem that requires a second attempt to scroll
539         // it into view. The first scroll attempt will trigger a redraw,
540         // which will clear out the list and repopulate it with new items.
541         // During the redraw, the list may shrink temporarily, which if the
542         // lead item is the last item, will move the scrollTop up since it
543         // cannot extend beyond the end of the list. (Sadly, being scrolled to
544         // the bottom of the list is not "sticky.") So, we set a timeout to
545         // rescroll the list after this all gets sorted out. This is perhaps
546         // not the most elegant solution, but no others seem obvious.
547         var self = this;
548         window.setTimeout(function() {
549           self.scrollIndexIntoView(pe.newValue);
550         });
551       }
552     },
553
554     /**
555      * This handles data model 'permuted' event.
556      * this event is dispatched as a part of sort or splice.
557      * We need to
558      *  - adjust the cache.
559      *  - adjust selection.
560      *  - redraw.
561      *  - scroll the list to show selection.
562      *  It is important that the cache adjustment happens before selection model
563      *  adjustments.
564      * @param {Event} e The 'permuted' event.
565      */
566     handleDataModelPermuted_: function(e) {
567       var newCachedItems = {};
568       for (var index in this.cachedItems_) {
569         if (e.permutation[index] != -1)
570           newCachedItems[e.permutation[index]] = this.cachedItems_[index];
571         else
572           delete this.cachedItems_[index];
573       }
574       this.cachedItems_ = newCachedItems;
575
576       this.startBatchUpdates();
577
578       var sm = this.selectionModel;
579       sm.adjustLength(e.newLength);
580       sm.adjustToReordering(e.permutation);
581
582       this.endBatchUpdates();
583
584       if (sm.leadIndex != -1)
585         this.scrollIndexIntoView(sm.leadIndex);
586     },
587
588     handleDataModelChange_: function(e) {
589       if (e.index >= this.firstIndex_ && e.index < this.lastIndex_) {
590         if (this.cachedItems_[e.index])
591           delete this.cachedItems_[e.index];
592         this.redraw();
593       }
594     },
595
596     /**
597      * @param {number} index The index of the item.
598      * @return {number} The top position of the item inside the list, not taking
599      *     into account lead item. May vary in the case of multiple columns.
600      */
601     getItemTop: function(index) {
602       return index * this.getItemHeight_();
603     },
604
605     /**
606      * @param {number} index The index of the item.
607      * @return {number} The row of the item. May vary in the case
608      *     of multiple columns.
609      */
610     getItemRow: function(index) {
611       return index;
612     },
613
614     /**
615      * @param {number} row The row.
616      * @return {number} The index of the first item in the row.
617      */
618     getFirstItemInRow: function(row) {
619       return row;
620     },
621
622     /**
623      * Ensures that a given index is inside the viewport.
624      * @param {number} index The index of the item to scroll into view.
625      * @return {boolean} Whether any scrolling was needed.
626      */
627     scrollIndexIntoView: function(index) {
628       var dataModel = this.dataModel;
629       if (!dataModel || index < 0 || index >= dataModel.length)
630         return false;
631
632       var itemHeight = this.getItemHeight_();
633       var scrollTop = this.scrollTop;
634       var top = this.getItemTop(index);
635       var leadIndex = this.selectionModel.leadIndex;
636
637       // Adjust for the lead item if it is above the given index.
638       if (leadIndex > -1 && leadIndex < index)
639         top += this.leadItemHeight - itemHeight;
640       else if (leadIndex == index)
641         itemHeight = this.leadItemHeight;
642
643       if (top < scrollTop) {
644         this.scrollTop = top;
645         return true;
646       } else {
647         var clientHeight = this.clientHeight;
648         var cs = getComputedStyle(this);
649         var paddingY = parseInt(cs.paddingTop, 10) +
650                        parseInt(cs.paddingBottom, 10);
651
652         if (top + itemHeight > scrollTop + clientHeight - paddingY) {
653           this.scrollTop = top + itemHeight - clientHeight + paddingY;
654           return true;
655         }
656       }
657
658       return false;
659     },
660
661     /**
662      * @return {!ClientRect} The rect to use for the context menu.
663      */
664     getRectForContextMenu: function() {
665       // TODO(arv): Add trait support so we can share more code between trees
666       // and lists.
667       var index = this.selectionModel.selectedIndex;
668       var el = this.getListItemByIndex(index);
669       if (el)
670         return el.getBoundingClientRect();
671       return this.getBoundingClientRect();
672     },
673
674     /**
675      * Takes a value from the data model and finds the associated list item.
676      * @param {*} value The value in the data model that we want to get the list
677      *     item for.
678      * @return {ListItem} The first found list item or null if not found.
679      */
680     getListItem: function(value) {
681       var dataModel = this.dataModel;
682       if (dataModel) {
683         var index = dataModel.indexOf(value);
684         return this.getListItemByIndex(index);
685       }
686       return null;
687     },
688
689     /**
690      * Find the list item element at the given index.
691      * @param {number} index The index of the list item to get.
692      * @return {ListItem} The found list item or null if not found.
693      */
694     getListItemByIndex: function(index) {
695       return this.cachedItems_[index] || null;
696     },
697
698     /**
699      * Find the index of the given list item element.
700      * @param {ListItem} item The list item to get the index of.
701      * @return {number} The index of the list item, or -1 if not found.
702      */
703     getIndexOfListItem: function(item) {
704       var index = item.listIndex;
705       if (this.cachedItems_[index] == item) {
706         return index;
707       }
708       return -1;
709     },
710
711     /**
712      * Creates a new list item.
713      * @param {*} value The value to use for the item.
714      * @return {!ListItem} The newly created list item.
715      */
716     createItem: function(value) {
717       var item = new this.itemConstructor_(value);
718       item.label = value;
719       if (typeof item.decorate == 'function')
720         item.decorate();
721       return item;
722     },
723
724     /**
725      * Creates the selection controller to use internally.
726      * @param {cr.ui.ListSelectionModel} sm The underlying selection model.
727      * @return {!cr.ui.ListSelectionController} The newly created selection
728      *     controller.
729      */
730     createSelectionController: function(sm) {
731       return new ListSelectionController(sm);
732     },
733
734     /**
735      * Return the heights (in pixels) of the top of the given item index within
736      * the list, and the height of the given item itself, accounting for the
737      * possibility that the lead item may be a different height.
738      * @param {number} index The index to find the top height of.
739      * @return {{top: number, height: number}} The heights for the given index.
740      * @private
741      */
742     getHeightsForIndex_: function(index) {
743       var itemHeight = this.getItemHeight_();
744       var top = this.getItemTop(index);
745       if (this.selectionModel.leadIndex > -1 &&
746           this.selectionModel.leadIndex < index) {
747         top += this.leadItemHeight - itemHeight;
748       } else if (this.selectionModel.leadIndex == index) {
749         itemHeight = this.leadItemHeight;
750       }
751       return {top: top, height: itemHeight};
752     },
753
754     /**
755      * Find the index of the list item containing the given y offset (measured
756      * in pixels from the top) within the list. In the case of multiple columns,
757      * returns the first index in the row.
758      * @param {number} offset The y offset in pixels to get the index of.
759      * @return {number} The index of the list item.
760      * @private
761      */
762     getIndexForListOffset_: function(offset) {
763       var itemHeight = this.getItemHeight_();
764       var leadIndex = this.selectionModel.leadIndex;
765       var leadItemHeight = this.leadItemHeight;
766       if (leadIndex < 0 || leadItemHeight == itemHeight) {
767         // Simple case: no lead item or lead item height is not different.
768         return this.getFirstItemInRow(Math.floor(offset / itemHeight));
769       }
770       var leadTop = this.getItemTop(leadIndex);
771       // If the given offset is above the lead item, it's also simple.
772       if (offset < leadTop)
773         return this.getFirstItemInRow(Math.floor(offset / itemHeight));
774       // If the lead item contains the given offset, we just return its index.
775       if (offset < leadTop + leadItemHeight)
776         return this.getFirstItemInRow(this.getItemRow(leadIndex));
777       // The given offset must be below the lead item. Adjust and recalculate.
778       offset -= leadItemHeight - itemHeight;
779       return this.getFirstItemInRow(Math.floor(offset / itemHeight));
780     },
781
782     /**
783      * Return the number of items that occupy the range of heights between the
784      * top of the start item and the end offset.
785      * @param {number} startIndex The index of the first visible item.
786      * @param {number} endOffset The y offset in pixels of the end of the list.
787      * @return {number} The number of list items visible.
788      * @private
789      */
790     countItemsInRange_: function(startIndex, endOffset) {
791       var endIndex = this.getIndexForListOffset_(endOffset);
792       return endIndex - startIndex + 1;
793     },
794
795     /**
796      * Calculates the number of items fitting in viewport given the index of
797      * first item and heights.
798      * @param {number} itemHeight The height of the item.
799      * @param {number} firstIndex Index of the first item in viewport.
800      * @param {number} scrollTop The scroll top position.
801      * @return {number} The number of items in view port.
802      */
803     getItemsInViewPort: function(itemHeight, firstIndex, scrollTop) {
804       // This is a bit tricky. We take the minimum of the available items to
805       // show and the number we want to show, so as not to go off the end of the
806       // list. For the number we want to show, we take the maximum of the number
807       // that would fit without a differently-sized lead item, and with one. We
808       // do this so that if the size of the lead item changes without a scroll
809       // event to trigger redrawing the list, we won't end up with empty space.
810       var clientHeight = this.clientHeight;
811       return this.autoExpands_ ? this.dataModel.length : Math.min(
812           this.dataModel.length - firstIndex,
813           Math.max(
814               Math.ceil(clientHeight / itemHeight) + 1,
815               this.countItemsInRange_(firstIndex, scrollTop + clientHeight)));
816     },
817
818     /**
819      * Adds items to the list and {@code newCachedItems}.
820      * @param {number} firstIndex The index of first item, inclusively.
821      * @param {number} lastIndex The index of last item, exclusively.
822      * @param {Object.<string, ListItem>} cachedItems Old items cache.
823      * @param {Object.<string, ListItem>} newCachedItems New items cache.
824      */
825     addItems: function(firstIndex, lastIndex, cachedItems, newCachedItems) {
826       var listItem;
827       var dataModel = this.dataModel;
828
829       window.l = this;
830       for (var y = firstIndex; y < lastIndex; y++) {
831         var dataItem = dataModel.item(y);
832         listItem = cachedItems[y] || this.createItem(dataItem);
833         listItem.listIndex = y;
834         this.appendChild(listItem);
835         newCachedItems[y] = listItem;
836       }
837     },
838
839     /**
840      * Returns the height of after filler in the list.
841      * @param {number} lastIndex The index of item past the last in viewport.
842      * @param {number} itemHeight The height of the item.
843      * @return {number} The height of after filler.
844      */
845     getAfterFillerHeight: function(lastIndex, itemHeight) {
846       return (this.dataModel.length - lastIndex) * itemHeight;
847     },
848
849     /**
850      * Redraws the viewport.
851      */
852     redraw: function() {
853       if (this.batchCount_ != 0)
854         return;
855
856       var dataModel = this.dataModel;
857       if (!dataModel) {
858         this.textContent = '';
859         return;
860       }
861
862       var scrollTop = this.scrollTop;
863       var clientHeight = this.clientHeight;
864
865       var itemHeight = this.getItemHeight_();
866
867       // We cache the list items since creating the DOM nodes is the most
868       // expensive part of redrawing.
869       var cachedItems = this.cachedItems_ || {};
870       var newCachedItems = {};
871
872       var desiredScrollHeight = this.getHeightsForIndex_(dataModel.length).top;
873
874       var autoExpands = this.autoExpands_;
875       var firstIndex = autoExpands ? 0 : this.getIndexForListOffset_(scrollTop);
876       var itemsInViewPort = this.getItemsInViewPort(itemHeight, firstIndex,
877           scrollTop);
878       var lastIndex = firstIndex + itemsInViewPort;
879
880       this.textContent = '';
881
882       this.beforeFiller_.style.height =
883           this.getHeightsForIndex_(firstIndex).top + 'px';
884       this.appendChild(this.beforeFiller_);
885
886       var sm = this.selectionModel;
887       var leadIndex = sm.leadIndex;
888
889       this.addItems(firstIndex, lastIndex, cachedItems, newCachedItems);
890
891       var afterFillerHeight = this.getAfterFillerHeight(lastIndex, itemHeight);
892       if (leadIndex >= lastIndex)
893         afterFillerHeight += this.leadItemHeight - itemHeight;
894       this.afterFiller_.style.height = afterFillerHeight + 'px';
895       this.appendChild(this.afterFiller_);
896
897       // We don't set the lead or selected properties until after adding all
898       // items, in case they force relayout in response to these events.
899       var listItem = null;
900       if (newCachedItems[leadIndex])
901         newCachedItems[leadIndex].lead = true;
902       for (var y = firstIndex; y < lastIndex; y++) {
903         if (sm.getIndexSelected(y))
904           newCachedItems[y].selected = true;
905         else if (y != leadIndex)
906           listItem = newCachedItems[y];
907       }
908
909       this.firstIndex_ = firstIndex;
910       this.lastIndex_ = lastIndex;
911
912       this.cachedItems_ = newCachedItems;
913
914       // Measure again in case the item height has changed due to a page zoom.
915       //
916       // The measure above is only done the first time but this measure is done
917       // after every redraw. It is done in a timeout so it will not trigger
918       // a reflow (which made the redraw speed 3 times slower on my system).
919       // By using a timeout the measuring will happen later when there is no
920       // need for a reflow.
921       if (listItem) {
922         var list = this;
923         window.setTimeout(function() {
924           if (listItem.parentNode == list) {
925             list.measured_ = measureItem(list, listItem);
926           }
927         });
928       }
929     },
930
931     /**
932      * Invalidates list by removing cached items.
933      */
934     invalidate: function() {
935       this.cachedItems_ = {};
936     },
937
938     /**
939      * Redraws a single item.
940      * @param {number} index The row index to redraw.
941      */
942     redrawItem: function(index) {
943       if (index >= this.firstIndex_ && index < this.lastIndex_) {
944         delete this.cachedItems_[index];
945         this.redraw();
946       }
947     },
948
949     /**
950      * Called when a list item is activated, currently only by a double click
951      * event.
952      * @param {number} index The index of the activated item.
953      */
954     activateItemAtIndex: function(index) {
955     },
956   };
957
958   cr.defineProperty(List, 'disabled', cr.PropertyKind.BOOL_ATTR);
959
960   /**
961    * Whether the list or one of its descendents has focus. This is necessary
962    * because list items can contain controls that can be focused, and for some
963    * purposes (e.g., styling), the list can still be conceptually focused at
964    * that point even though it doesn't actually have the page focus.
965    */
966   cr.defineProperty(List, 'hasElementFocus', cr.PropertyKind.BOOL_ATTR);
967
968   return {
969     List: List
970   }
971 });