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