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