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