list.js revision 4e180b6a0b4720a9b8e9e959a882386f690f08ff
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 {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});
972