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