- add sources.
[platform/framework/web/crosswalk.git] / src / ui / webui / resources / js / cr / ui / list.js
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
14 cr.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('mouseup', this.handlePointerDownUp_);
318       this.addEventListener('keydown', this.handleKeyDown);
319       this.addEventListener('focus', this.handleElementFocus_, true);
320       this.addEventListener('blur', this.handleElementBlur_, true);
321       this.addEventListener('scroll', this.handleScroll.bind(this));
322       this.setAttribute('role', 'list');
323
324       // Make list focusable
325       if (!this.hasAttribute('tabindex'))
326         this.tabIndex = 0;
327
328       // Try to get an unique id prefix from the id of this element or the
329       // nearest ancestor with an id.
330       var element = this;
331       while (element && !element.id)
332         element = element.parentElement;
333       if (element && element.id)
334         this.uniqueIdPrefix_ = element.id;
335       else
336         this.uniqueIdPrefix_ = 'list';
337
338       // The next id suffix to use when giving each item an unique id.
339       this.nextUniqueIdSuffix_ = 0;
340     },
341
342     /**
343      * @param {ListItem=} item The list item to measure.
344      * @return {number} The height of the given item. If the fixed height on CSS
345      * is set by 'px', uses that value as height. Otherwise, measures the size.
346      * @private
347      */
348     measureItemHeight_: function(item) {
349       return this.measureItem(item).height;
350     },
351
352     /**
353      * @return {number} The height of default item, measuring it if necessary.
354      * @private
355      */
356     getDefaultItemHeight_: function() {
357       return this.getDefaultItemSize_().height;
358     },
359
360     /**
361      * @param {number} index The index of the item.
362      * @return {number} The height of the item, measuring it if necessary.
363      */
364     getItemHeightByIndex_: function(index) {
365       // If |this.fixedHeight_| is true, all the rows have same default height.
366       if (this.fixedHeight_)
367         return this.getDefaultItemHeight_();
368
369       if (this.cachedItemHeights_[index])
370         return this.cachedItemHeights_[index];
371
372       var item = this.getListItemByIndex(index);
373       if (item) {
374         var h = this.measureItemHeight_(item);
375         this.cachedItemHeights_[index] = h;
376         return h;
377       }
378       return this.getDefaultItemHeight_();
379     },
380
381     /**
382      * @return {{height: number, width: number}} The height and width
383      *     of default item, measuring it if necessary.
384      * @private
385      */
386     getDefaultItemSize_: function() {
387       if (!this.measured_ || !this.measured_.height) {
388         this.measured_ = this.measureItem();
389       }
390       return this.measured_;
391     },
392
393     /**
394      * Creates an item (dataModel.item(0)) and measures its height. The item is
395      * cached instead of creating a new one every time..
396      * @param {ListItem=} opt_item The list item to use to do the measuring. If
397      *     this is not provided an item will be created based on the first value
398      *     in the model.
399      * @return {{height: number, marginTop: number, marginBottom:number,
400      *     width: number, marginLeft: number, marginRight:number}}
401      *     The height and width of the item, taking
402      *     margins into account, and the top, bottom, left and right margins
403      *     themselves.
404      */
405     measureItem: function(opt_item) {
406       var dataModel = this.dataModel;
407       if (!dataModel || !dataModel.length)
408         return 0;
409       var item = opt_item || this.cachedMeasuredItem_ ||
410           this.createItem(dataModel.item(0));
411       if (!opt_item) {
412         this.cachedMeasuredItem_ = item;
413         this.appendChild(item);
414       }
415
416       var rect = item.getBoundingClientRect();
417       var cs = getComputedStyle(item);
418       var mt = parseFloat(cs.marginTop);
419       var mb = parseFloat(cs.marginBottom);
420       var ml = parseFloat(cs.marginLeft);
421       var mr = parseFloat(cs.marginRight);
422       var h = rect.height;
423       var w = rect.width;
424       var mh = 0;
425       var mv = 0;
426
427       // Handle margin collapsing.
428       if (mt < 0 && mb < 0) {
429         mv = Math.min(mt, mb);
430       } else if (mt >= 0 && mb >= 0) {
431         mv = Math.max(mt, mb);
432       } else {
433         mv = mt + mb;
434       }
435       h += mv;
436
437       if (ml < 0 && mr < 0) {
438         mh = Math.min(ml, mr);
439       } else if (ml >= 0 && mr >= 0) {
440         mh = Math.max(ml, mr);
441       } else {
442         mh = ml + mr;
443       }
444       w += mh;
445
446       if (!opt_item)
447         this.removeChild(item);
448       return {
449           height: Math.max(0, h),
450           marginTop: mt, marginBottom: mb,
451           width: Math.max(0, w),
452           marginLeft: ml, marginRight: mr};
453     },
454
455     /**
456      * Callback for the double click event.
457      * @param {Event} e The mouse event object.
458      * @private
459      */
460     handleDoubleClick_: function(e) {
461       if (this.disabled)
462         return;
463
464       var target = e.target;
465
466       target = this.getListItemAncestor(target);
467       if (target)
468         this.activateItemAtIndex(this.getIndexOfListItem(target));
469     },
470
471     /**
472      * Callback for mousedown and mouseup events.
473      * @param {Event} e The mouse event object.
474      * @private
475      */
476     handlePointerDownUp_: function(e) {
477       if (this.disabled)
478         return;
479
480       var target = e.target;
481
482       // If the target was this element we need to make sure that the user did
483       // not click on a border or a scrollbar.
484       if (target == this) {
485         if (inViewport(target, e))
486           this.selectionController_.handlePointerDownUp(e, -1);
487         return;
488       }
489
490       target = this.getListItemAncestor(target);
491
492       var index = this.getIndexOfListItem(target);
493       this.selectionController_.handlePointerDownUp(e, index);
494     },
495
496     /**
497      * Called when an element in the list is focused. Marks the list as having
498      * a focused element, and dispatches an event if it didn't have focus.
499      * @param {Event} e The focus event.
500      * @private
501      */
502     handleElementFocus_: function(e) {
503       if (!this.hasElementFocus)
504         this.hasElementFocus = true;
505     },
506
507     /**
508      * Called when an element in the list is blurred. If focus moves outside
509      * the list, marks the list as no longer having focus and dispatches an
510      * event.
511      * @param {Event} e The blur event.
512      * @private
513      */
514     handleElementBlur_: function(e) {
515       if (!this.contains(e.relatedTarget))
516         this.hasElementFocus = false;
517     },
518
519     /**
520      * Returns the list item element containing the given element, or null if
521      * it doesn't belong to any list item element.
522      * @param {HTMLElement} element The element.
523      * @return {ListItem} The list item containing |element|, or null.
524      */
525     getListItemAncestor: function(element) {
526       var container = element;
527       while (container && container.parentNode != this) {
528         container = container.parentNode;
529       }
530       return container;
531     },
532
533     /**
534      * Handle a keydown event.
535      * @param {Event} e The keydown event.
536      * @return {boolean} Whether the key event was handled.
537      */
538     handleKeyDown: function(e) {
539       if (this.disabled)
540         return;
541
542       return this.selectionController_.handleKeyDown(e);
543     },
544
545     /**
546      * Handle a scroll event.
547      * @param {Event} e The scroll event.
548      */
549     handleScroll: function(e) {
550       requestAnimationFrame(this.redraw.bind(this));
551     },
552
553     /**
554      * Callback from the selection model. We dispatch {@code change} events
555      * when the selection changes.
556      * @param {!Event} e Event with change info.
557      * @private
558      */
559     handleOnChange_: function(ce) {
560       ce.changes.forEach(function(change) {
561         var listItem = this.getListItemByIndex(change.index);
562         if (listItem) {
563           listItem.selected = change.selected;
564           if (change.selected) {
565             listItem.setAttribute('aria-posinset', change.index + 1);
566             listItem.setAttribute('aria-setsize', this.dataModel.length);
567             this.setAttribute('aria-activedescendant', listItem.id);
568           } else {
569             listItem.removeAttribute('aria-posinset');
570             listItem.removeAttribute('aria-setsize');
571           }
572         }
573       }, this);
574
575       cr.dispatchSimpleEvent(this, 'change');
576     },
577
578     /**
579      * Handles a change of the lead item from the selection model.
580      * @param {Event} pe The property change event.
581      * @private
582      */
583     handleLeadChange_: function(pe) {
584       var element;
585       if (pe.oldValue != -1) {
586         if ((element = this.getListItemByIndex(pe.oldValue)))
587           element.lead = false;
588       }
589
590       if (pe.newValue != -1) {
591         if ((element = this.getListItemByIndex(pe.newValue)))
592           element.lead = true;
593         if (pe.oldValue != pe.newValue) {
594           this.scrollIndexIntoView(pe.newValue);
595           // If the lead item has a different height than other items, then we
596           // may run into a problem that requires a second attempt to scroll
597           // it into view. The first scroll attempt will trigger a redraw,
598           // which will clear out the list and repopulate it with new items.
599           // During the redraw, the list may shrink temporarily, which if the
600           // lead item is the last item, will move the scrollTop up since it
601           // cannot extend beyond the end of the list. (Sadly, being scrolled to
602           // the bottom of the list is not "sticky.") So, we set a timeout to
603           // rescroll the list after this all gets sorted out. This is perhaps
604           // not the most elegant solution, but no others seem obvious.
605           var self = this;
606           window.setTimeout(function() {
607             self.scrollIndexIntoView(pe.newValue);
608           });
609         }
610       }
611     },
612
613     /**
614      * This handles data model 'permuted' event.
615      * this event is dispatched as a part of sort or splice.
616      * We need to
617      *  - adjust the cache.
618      *  - adjust selection.
619      *  - redraw. (called in this.endBatchUpdates())
620      *  It is important that the cache adjustment happens before selection model
621      *  adjustments.
622      * @param {Event} e The 'permuted' event.
623      */
624     handleDataModelPermuted_: function(e) {
625       var newCachedItems = {};
626       for (var index in this.cachedItems_) {
627         if (e.permutation[index] != -1) {
628           var newIndex = e.permutation[index];
629           newCachedItems[newIndex] = this.cachedItems_[index];
630           newCachedItems[newIndex].listIndex = newIndex;
631         }
632       }
633       this.cachedItems_ = newCachedItems;
634
635       var newCachedItemHeights = {};
636       for (var index in this.cachedItemHeights_) {
637         if (e.permutation[index] != -1) {
638           newCachedItemHeights[e.permutation[index]] =
639               this.cachedItemHeights_[index];
640         }
641       }
642       this.cachedItemHeights_ = newCachedItemHeights;
643
644       this.startBatchUpdates();
645
646       var sm = this.selectionModel;
647       sm.adjustLength(e.newLength);
648       sm.adjustToReordering(e.permutation);
649
650       this.endBatchUpdates();
651     },
652
653     handleDataModelChange_: function(e) {
654       delete this.cachedItems_[e.index];
655       delete this.cachedItemHeights_[e.index];
656       this.cachedMeasuredItem_ = null;
657
658       if (e.index >= this.firstIndex_ &&
659           (e.index < this.lastIndex_ || this.remainingSpace_)) {
660         this.redraw();
661       }
662     },
663
664     /**
665      * @param {number} index The index of the item.
666      * @return {number} The top position of the item inside the list.
667      */
668     getItemTop: function(index) {
669       if (this.fixedHeight_) {
670         var itemHeight = this.getDefaultItemHeight_();
671         return index * itemHeight;
672       } else {
673         this.ensureAllItemSizesInCache();
674         var top = 0;
675         for (var i = 0; i < index; i++) {
676           top += this.getItemHeightByIndex_(i);
677         }
678         return top;
679       }
680     },
681
682     /**
683      * @param {number} index The index of the item.
684      * @return {number} The row of the item. May vary in the case
685      *     of multiple columns.
686      */
687     getItemRow: function(index) {
688       return index;
689     },
690
691     /**
692      * @param {number} row The row.
693      * @return {number} The index of the first item in the row.
694      */
695     getFirstItemInRow: function(row) {
696       return row;
697     },
698
699     /**
700      * Ensures that a given index is inside the viewport.
701      * @param {number} index The index of the item to scroll into view.
702      * @return {boolean} Whether any scrolling was needed.
703      */
704     scrollIndexIntoView: function(index) {
705       var dataModel = this.dataModel;
706       if (!dataModel || index < 0 || index >= dataModel.length)
707         return false;
708
709       var itemHeight = this.getItemHeightByIndex_(index);
710       var scrollTop = this.scrollTop;
711       var top = this.getItemTop(index);
712       var clientHeight = this.clientHeight;
713
714       var self = this;
715       // Function to adjust the tops of viewport and row.
716       function scrollToAdjustTop() {
717           self.scrollTop = top;
718           return true;
719       };
720       // Function to adjust the bottoms of viewport and row.
721       function scrollToAdjustBottom() {
722           var cs = getComputedStyle(self);
723           var paddingY = parseInt(cs.paddingTop, 10) +
724                          parseInt(cs.paddingBottom, 10);
725
726           if (top + itemHeight > scrollTop + clientHeight - paddingY) {
727             self.scrollTop = top + itemHeight - clientHeight + paddingY;
728             return true;
729           }
730           return false;
731       };
732
733       // Check if the entire of given indexed row can be shown in the viewport.
734       if (itemHeight <= clientHeight) {
735         if (top < scrollTop)
736           return scrollToAdjustTop();
737         if (scrollTop + clientHeight < top + itemHeight)
738           return scrollToAdjustBottom();
739       } else {
740         if (scrollTop < top)
741           return scrollToAdjustTop();
742         if (top + itemHeight < scrollTop + clientHeight)
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       var listItem = null;
1126       if (leadIndex != -1 && this.cachedItems_[leadIndex])
1127         this.cachedItems_[leadIndex].lead = true;
1128       for (var y = firstIndex; y < lastIndex; y++) {
1129         if (sm.getIndexSelected(y))
1130           this.cachedItems_[y].selected = true;
1131         else if (y != leadIndex)
1132           listItem = this.cachedItems_[y];
1133       }
1134
1135       this.firstIndex_ = firstIndex;
1136       this.lastIndex_ = lastIndex;
1137
1138       this.remainingSpace_ = itemsInViewPort.last > dataModel.length;
1139
1140       // Mesurings must be placed after adding all the elements, to prevent
1141       // performance reducing.
1142       if (!this.fixedHeight_) {
1143         for (var y = firstIndex; y < lastIndex; y++) {
1144           this.cachedItemHeights_[y] =
1145               this.measureItemHeight_(this.cachedItems_[y]);
1146         }
1147       }
1148     },
1149
1150     /**
1151      * Restore the lead item that is present in the list but may be updated
1152      * in the data model (supposed to be used inside a batch update). Usually
1153      * such an item would be recreated in the redraw method. If reinsertion
1154      * is undesirable (for instance to prevent losing focus) the item may be
1155      * updated and restored. Assumed the listItem relates to the same data item
1156      * as the lead item in the begin of the batch update.
1157      *
1158      * @param {ListItem} leadItem Already existing lead item.
1159      */
1160     restoreLeadItem: function(leadItem) {
1161       delete this.cachedItems_[leadItem.listIndex];
1162
1163       leadItem.listIndex = this.selectionModel.leadIndex;
1164       this.pinnedItem_ = this.cachedItems_[leadItem.listIndex] = leadItem;
1165     },
1166
1167     /**
1168      * Invalidates list by removing cached items.
1169      */
1170     invalidate: function() {
1171       this.cachedItems_ = {};
1172       this.cachedItemSized_ = {};
1173     },
1174
1175     /**
1176      * Redraws a single item.
1177      * @param {number} index The row index to redraw.
1178      */
1179     redrawItem: function(index) {
1180       if (index >= this.firstIndex_ &&
1181           (index < this.lastIndex_ || this.remainingSpace_)) {
1182         delete this.cachedItems_[index];
1183         this.redraw();
1184       }
1185     },
1186
1187     /**
1188      * Called when a list item is activated, currently only by a double click
1189      * event.
1190      * @param {number} index The index of the activated item.
1191      */
1192     activateItemAtIndex: function(index) {
1193     },
1194
1195     /**
1196      * Returns a ListItem for the leadIndex. If the item isn't present in the
1197      * list creates it and inserts to the list (may be invisible if it's out of
1198      * the visible range).
1199      *
1200      * Item returned from this method won't be removed until it remains a lead
1201      * item or til the data model changes (unlike other items that could be
1202      * removed when they go out of the visible range).
1203      *
1204      * @return {cr.ui.ListItem} The lead item for the list.
1205      */
1206     ensureLeadItemExists: function() {
1207       var index = this.selectionModel.leadIndex;
1208       if (index < 0)
1209         return null;
1210       var cachedItems = this.cachedItems_ || {};
1211
1212       var item = cachedItems[index] ||
1213                  this.createItem(this.dataModel.item(index));
1214       if (this.pinnedItem_ != item && this.pinnedItem_ &&
1215           this.pinnedItem_.hidden) {
1216         this.removeChild(this.pinnedItem_);
1217       }
1218       this.pinnedItem_ = item;
1219       cachedItems[index] = item;
1220       item.listIndex = index;
1221       if (item.parentNode == this)
1222         return item;
1223
1224       if (this.batchCount_ != 0)
1225         item.hidden = true;
1226
1227       // Item will get to the right place in redraw. Choose place to insert
1228       // reducing items reinsertion.
1229       if (index <= this.firstIndex_)
1230         this.insertBefore(item, this.beforeFiller_.nextSibling);
1231       else
1232         this.insertBefore(item, this.afterFiller_);
1233       this.redraw();
1234       return item;
1235     },
1236
1237     /**
1238      * Starts drag selection by reacting 'dragstart' event.
1239      * @param {Event} event Event of dragstart.
1240      */
1241     startDragSelection: function(event) {
1242       event.preventDefault();
1243       var border = document.createElement('div');
1244       border.className = 'drag-selection-border';
1245       var rect = this.getBoundingClientRect();
1246       var startX = event.clientX - rect.left + this.scrollLeft;
1247       var startY = event.clientY - rect.top + this.scrollTop;
1248       border.style.left = startX + 'px';
1249       border.style.top = startY + 'px';
1250       var onMouseMove = function(event) {
1251         var inRect = this.getBoundingClientRect();
1252         var x = event.clientX - inRect.left + this.scrollLeft;
1253         var y = event.clientY - inRect.top + this.scrollTop;
1254         border.style.left = Math.min(startX, x) + 'px';
1255         border.style.top = Math.min(startY, y) + 'px';
1256         border.style.width = Math.abs(startX - x) + 'px';
1257         border.style.height = Math.abs(startY - y) + 'px';
1258       }.bind(this);
1259       var onMouseUp = function() {
1260         this.removeChild(border);
1261         document.removeEventListener('mousemove', onMouseMove, true);
1262         document.removeEventListener('mouseup', onMouseUp, true);
1263       }.bind(this);
1264       document.addEventListener('mousemove', onMouseMove, true);
1265       document.addEventListener('mouseup', onMouseUp, true);
1266       this.appendChild(border);
1267     },
1268   };
1269
1270   cr.defineProperty(List, 'disabled', cr.PropertyKind.BOOL_ATTR);
1271
1272   /**
1273    * Whether the list or one of its descendents has focus. This is necessary
1274    * because list items can contain controls that can be focused, and for some
1275    * purposes (e.g., styling), the list can still be conceptually focused at
1276    * that point even though it doesn't actually have the page focus.
1277    */
1278   cr.defineProperty(List, 'hasElementFocus', cr.PropertyKind.BOOL_ATTR);
1279
1280   /**
1281    * Mousedown event handler.
1282    * @this {List}
1283    * @param {MouseEvent} e The mouse event object.
1284    */
1285   function handleMouseDown(e) {
1286     var listItem = this.getListItemAncestor(e.target);
1287     var wasSelected = listItem && listItem.selected;
1288     this.handlePointerDownUp_(e);
1289
1290     if (e.defaultPrevented || e.button != 0)
1291       return;
1292
1293     // The following hack is required only if the listItem gets selected.
1294     if (!listItem || wasSelected || !listItem.selected)
1295       return;
1296
1297     // If non-focusable area in a list item is clicked and the item still
1298     // contains the focused element, the item did a special focus handling
1299     // [1] and we should not focus on the list.
1300     //
1301     // [1] For example, clicking non-focusable area gives focus on the first
1302     // form control in the item.
1303     if (!tryFocusOnAncestor(e.target, listItem) &&
1304         listItem.contains(listItem.ownerDocument.activeElement)) {
1305       e.preventDefault();
1306     }
1307   }
1308
1309   /**
1310    * Try focusing on |eventTarget| or its ancestor under |root|.
1311    * This is a helper for handleMouseDown.
1312    * @param {!Element} start An element which we start to try.
1313    * @param {!Element} root An element which we finish to try.
1314    * @return {boolean} True if we focused on an element successfully.
1315    */
1316   function tryFocusOnAncestor(start, root) {
1317     for (var element = start; element && element != root;
1318         element = element.parentElement) {
1319       element.focus();
1320       if (root.ownerDocument.activeElement == element)
1321         return true;
1322     }
1323     return false;
1324   }
1325
1326   return {
1327     List: List
1328   };
1329 });