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.
5 // require: array_data_model.js
6 // require: list_selection_model.js
7 // require: list_selection_controller.js
8 // require: list_item.js
11 * @fileoverview This implements a list control.
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;
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.
26 function inViewport(el, e) {
27 var rect = el.getBoundingClientRect();
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;
36 function getComputedStyle(el) {
37 return el.ownerDocument.defaultView.getComputedStyle(el);
41 * Creates a new list element.
42 * @param {Object=} opt_propertyBag Optional properties.
44 * @extends {HTMLUListElement}
46 var List = cr.ui.define('list');
49 __proto__: HTMLUListElement.prototype,
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
56 * @type {{height: number, marginTop: number, marginBottom:number,
57 * width: number, marginLeft: number, marginRight:number}}
63 * Whether or not the list is autoexpanding. If true, the list resizes
64 * its height to accomadate all children.
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.
80 * Whether or not the list view has a blank space below the last row.
84 remainingSpace_: true,
87 * Function used to create grid items.
88 * @type {function(): !ListItem}
91 itemConstructor_: cr.ui.ListItem,
94 * Function used to create grid items.
95 * @type {function(): !ListItem}
97 get itemConstructor() {
98 return this.itemConstructor_;
100 set itemConstructor(func) {
101 if (func != this.itemConstructor_) {
102 this.itemConstructor_ = func;
103 this.cachedItems_ = {};
111 * The data model driving the list.
112 * @type {ArrayDataModel}
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);
123 if (this.dataModel_) {
124 this.dataModel_.removeEventListener(
126 this.boundHandleDataModelPermuted_);
127 this.dataModel_.removeEventListener('change',
128 this.boundHandleDataModelChange_);
131 this.dataModel_ = dataModel;
133 this.cachedItems_ = {};
134 this.cachedItemHeights_ = {};
135 this.selectionModel.clear();
137 this.selectionModel.adjustLength(dataModel.length);
139 if (this.dataModel_) {
140 this.dataModel_.addEventListener(
142 this.boundHandleDataModelPermuted_);
143 this.dataModel_.addEventListener('change',
144 this.boundHandleDataModelChange_);
152 return this.dataModel_;
157 * Cached item for measuring the default item size by measureItem().
160 cachedMeasuredItem_: null,
163 * The selection model to use.
164 * @type {cr.ui.ListSelectionModel}
166 get selectionModel() {
167 return this.selectionModel_;
169 set selectionModel(sm) {
170 var oldSm = this.selectionModel_;
174 if (!this.boundHandleOnChange_) {
175 this.boundHandleOnChange_ = this.handleOnChange_.bind(this);
176 this.boundHandleLeadChange_ = this.handleLeadChange_.bind(this);
180 oldSm.removeEventListener('change', this.boundHandleOnChange_);
181 oldSm.removeEventListener('leadIndexChange',
182 this.boundHandleLeadChange_);
185 this.selectionModel_ = sm;
186 this.selectionController_ = this.createSelectionController(sm);
189 sm.addEventListener('change', this.boundHandleOnChange_);
190 sm.addEventListener('leadIndexChange', this.boundHandleLeadChange_);
195 * Whether or not the list auto-expands.
199 return this.autoExpands_;
201 set autoExpands(autoExpands) {
202 if (this.autoExpands_ == autoExpands)
204 this.autoExpands_ = autoExpands;
209 * Whether or not the rows on list have various heights.
213 return this.fixedHeight_;
215 set fixedHeight(fixedHeight) {
216 if (this.fixedHeight_ == fixedHeight)
218 this.fixedHeight_ = fixedHeight;
223 * Convenience alias for selectionModel.selectedItem
227 var dataModel = this.dataModel;
229 var index = this.selectionModel.selectedIndex;
231 return dataModel.item(index);
235 set selectedItem(selectedItem) {
236 var dataModel = this.dataModel;
238 var index = this.dataModel.indexOf(selectedItem);
239 this.selectionModel.selectedIndex = index;
244 * Convenience alias for selectionModel.selectedItems
247 get selectedItems() {
248 var indexes = this.selectionModel.selectedIndexes;
249 var dataModel = this.dataModel;
251 return indexes.map(function(i) {
252 return dataModel.item(i);
259 * The HTML elements representing the items.
260 * @type {HTMLCollection}
263 return Array.prototype.filter.call(this.children,
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.
273 isItem: function(child) {
274 return child.nodeType == Node.ELEMENT_NODE &&
275 child != this.beforeFiller_ && child != this.afterFiller_;
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.
286 startBatchUpdates: function() {
291 * See startBatchUpdates.
293 endBatchUpdates: function() {
295 if (this.batchCount_ == 0)
300 * Initializes the element.
302 decorate: function() {
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_);
312 var length = this.dataModel ? this.dataModel.length : 0;
313 this.selectionModel = new ListSelectionModel(length);
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');
324 // Make list focusable
325 if (!this.hasAttribute('tabindex'))
328 // Try to get an unique id prefix from the id of this element or the
329 // nearest ancestor with an id.
331 while (element && !element.id)
332 element = element.parentElement;
333 if (element && element.id)
334 this.uniqueIdPrefix_ = element.id;
336 this.uniqueIdPrefix_ = 'list';
338 // The next id suffix to use when giving each item an unique id.
339 this.nextUniqueIdSuffix_ = 0;
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.
348 measureItemHeight_: function(item) {
349 return this.measureItem(item).height;
353 * @return {number} The height of default item, measuring it if necessary.
356 getDefaultItemHeight_: function() {
357 return this.getDefaultItemSize_().height;
361 * @param {number} index The index of the item.
362 * @return {number} The height of the item, measuring it if necessary.
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_();
369 if (this.cachedItemHeights_[index])
370 return this.cachedItemHeights_[index];
372 var item = this.getListItemByIndex(index);
374 var h = this.measureItemHeight_(item);
375 this.cachedItemHeights_[index] = h;
378 return this.getDefaultItemHeight_();
382 * @return {{height: number, width: number}} The height and width
383 * of default item, measuring it if necessary.
386 getDefaultItemSize_: function() {
387 if (!this.measured_ || !this.measured_.height) {
388 this.measured_ = this.measureItem();
390 return this.measured_;
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
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
405 measureItem: function(opt_item) {
406 var dataModel = this.dataModel;
407 if (!dataModel || !dataModel.length)
409 var item = opt_item || this.cachedMeasuredItem_ ||
410 this.createItem(dataModel.item(0));
412 this.cachedMeasuredItem_ = item;
413 this.appendChild(item);
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);
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);
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);
447 this.removeChild(item);
449 height: Math.max(0, h),
450 marginTop: mt, marginBottom: mb,
451 width: Math.max(0, w),
452 marginLeft: ml, marginRight: mr};
456 * Callback for the double click event.
457 * @param {Event} e The mouse event object.
460 handleDoubleClick_: function(e) {
464 var target = e.target;
466 target = this.getListItemAncestor(target);
468 this.activateItemAtIndex(this.getIndexOfListItem(target));
472 * Callback for mousedown and mouseup events.
473 * @param {Event} e The mouse event object.
476 handlePointerDownUp_: function(e) {
480 var target = e.target;
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);
490 target = this.getListItemAncestor(target);
492 var index = this.getIndexOfListItem(target);
493 this.selectionController_.handlePointerDownUp(e, index);
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.
502 handleElementFocus_: function(e) {
503 if (!this.hasElementFocus)
504 this.hasElementFocus = true;
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
511 * @param {Event} e The blur event.
514 handleElementBlur_: function(e) {
515 if (!this.contains(e.relatedTarget))
516 this.hasElementFocus = false;
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.
525 getListItemAncestor: function(element) {
526 var container = element;
527 while (container && container.parentNode != this) {
528 container = container.parentNode;
534 * Handle a keydown event.
535 * @param {Event} e The keydown event.
536 * @return {boolean} Whether the key event was handled.
538 handleKeyDown: function(e) {
542 return this.selectionController_.handleKeyDown(e);
546 * Handle a scroll event.
547 * @param {Event} e The scroll event.
549 handleScroll: function(e) {
550 requestAnimationFrame(this.redraw.bind(this));
554 * Callback from the selection model. We dispatch {@code change} events
555 * when the selection changes.
556 * @param {!Event} e Event with change info.
559 handleOnChange_: function(ce) {
560 ce.changes.forEach(function(change) {
561 var listItem = this.getListItemByIndex(change.index);
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);
569 listItem.removeAttribute('aria-posinset');
570 listItem.removeAttribute('aria-setsize');
575 cr.dispatchSimpleEvent(this, 'change');
579 * Handles a change of the lead item from the selection model.
580 * @param {Event} pe The property change event.
583 handleLeadChange_: function(pe) {
585 if (pe.oldValue != -1) {
586 if ((element = this.getListItemByIndex(pe.oldValue)))
587 element.lead = false;
590 if (pe.newValue != -1) {
591 if ((element = this.getListItemByIndex(pe.newValue)))
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.
606 window.setTimeout(function() {
607 self.scrollIndexIntoView(pe.newValue);
614 * This handles data model 'permuted' event.
615 * this event is dispatched as a part of sort or splice.
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
622 * @param {Event} e The 'permuted' event.
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;
633 this.cachedItems_ = newCachedItems;
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];
642 this.cachedItemHeights_ = newCachedItemHeights;
644 this.startBatchUpdates();
646 var sm = this.selectionModel;
647 sm.adjustLength(e.newLength);
648 sm.adjustToReordering(e.permutation);
650 this.endBatchUpdates();
653 handleDataModelChange_: function(e) {
654 delete this.cachedItems_[e.index];
655 delete this.cachedItemHeights_[e.index];
656 this.cachedMeasuredItem_ = null;
658 if (e.index >= this.firstIndex_ &&
659 (e.index < this.lastIndex_ || this.remainingSpace_)) {
665 * @param {number} index The index of the item.
666 * @return {number} The top position of the item inside the list.
668 getItemTop: function(index) {
669 if (this.fixedHeight_) {
670 var itemHeight = this.getDefaultItemHeight_();
671 return index * itemHeight;
673 this.ensureAllItemSizesInCache();
675 for (var i = 0; i < index; i++) {
676 top += this.getItemHeightByIndex_(i);
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.
687 getItemRow: function(index) {
692 * @param {number} row The row.
693 * @return {number} The index of the first item in the row.
695 getFirstItemInRow: function(row) {
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.
704 scrollIndexIntoView: function(index) {
705 var dataModel = this.dataModel;
706 if (!dataModel || index < 0 || index >= dataModel.length)
709 var itemHeight = this.getItemHeightByIndex_(index);
710 var scrollTop = this.scrollTop;
711 var top = this.getItemTop(index);
712 var clientHeight = this.clientHeight;
715 // Function to adjust the tops of viewport and row.
716 function scrollToAdjustTop() {
717 self.scrollTop = top;
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);
726 if (top + itemHeight > scrollTop + clientHeight - paddingY) {
727 self.scrollTop = top + itemHeight - clientHeight + paddingY;
733 // Check if the entire of given indexed row can be shown in the viewport.
734 if (itemHeight <= clientHeight) {
736 return scrollToAdjustTop();
737 if (scrollTop + clientHeight < top + itemHeight)
738 return scrollToAdjustBottom();
741 return scrollToAdjustTop();
742 if (top + itemHeight < scrollTop + clientHeight)
743 return scrollToAdjustBottom();
749 * @return {!ClientRect} The rect to use for the context menu.
751 getRectForContextMenu: function() {
752 // TODO(arv): Add trait support so we can share more code between trees
754 var index = this.selectionModel.selectedIndex;
755 var el = this.getListItemByIndex(index);
757 return el.getBoundingClientRect();
758 return this.getBoundingClientRect();
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
765 * @return {ListItem} The first found list item or null if not found.
767 getListItem: function(value) {
768 var dataModel = this.dataModel;
770 var index = dataModel.indexOf(value);
771 return this.getListItemByIndex(index);
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.
781 getListItemByIndex: function(index) {
782 return this.cachedItems_[index] || null;
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.
790 getIndexOfListItem: function(item) {
791 var index = item.listIndex;
792 if (this.cachedItems_[index] == item) {
799 * Creates a new list item.
800 * @param {*} value The value to use for the item.
801 * @return {!ListItem} The newly created list item.
803 createItem: function(value) {
804 var item = new this.itemConstructor_(value);
806 item.id = this.uniqueIdPrefix_ + '-' + this.nextUniqueIdSuffix_++;
807 if (typeof item.decorate == 'function')
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
818 createSelectionController: function(sm) {
819 return new ListSelectionController(sm);
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.
830 getHeightsForIndex_: function(index) {
831 var itemHeight = this.getItemHeightByIndex_(index);
832 var top = this.getItemTop(index);
833 return {top: top, height: itemHeight};
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.
845 getIndexForListOffset_: function(offset) {
846 var itemHeight = this.getDefaultItemHeight_();
848 return this.dataModel.length;
850 if (this.fixedHeight_)
851 return this.getFirstItemInRow(Math.floor(offset / itemHeight));
853 // If offset exceeds the height of list.
855 if (this.dataModel.length) {
856 var h = this.getHeightsForIndex_(this.dataModel.length - 1);
857 lastHeight = h.top + h.height;
859 if (lastHeight < offset)
860 return this.dataModel.length;
863 var estimatedIndex = Math.min(Math.floor(offset / itemHeight),
864 this.dataModel.length - 1);
865 var isIncrementing = this.getItemTop(estimatedIndex) < offset;
867 // Searchs the correct index.
869 var heights = this.getHeightsForIndex_(estimatedIndex);
870 var top = heights.top;
871 var height = heights.height;
873 if (top <= offset && offset <= (top + height))
876 isIncrementing ? ++estimatedIndex : --estimatedIndex;
877 } while (0 < estimatedIndex && estimatedIndex < this.dataModel.length);
879 return estimatedIndex;
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.
890 countItemsInRange_: function(startIndex, endOffset) {
891 var endIndex = this.getIndexForListOffset_(endOffset);
892 return endIndex - startIndex + 1;
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.
902 getItemsInViewPort: function(scrollTop, clientHeight) {
903 if (this.autoExpands_) {
906 length: this.dataModel.length,
907 last: this.dataModel.length};
909 var firstIndex = this.getIndexForListOffset_(scrollTop);
910 var lastIndex = this.getIndexForListOffset_(scrollTop + clientHeight);
914 length: lastIndex - firstIndex + 1,
915 last: lastIndex + 1};
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.
927 mergeItems: function(firstIndex, lastIndex) {
929 var dataModel = this.dataModel;
930 var currentIndex = firstIndex;
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);
943 var next = item.nextSibling;
944 if (item != self.pinnedItem_)
945 self.removeChild(item);
949 for (var item = this.beforeFiller_.nextSibling;
950 item != this.afterFiller_ && currentIndex < lastIndex;) {
951 if (!this.isItem(item)) {
952 item = item.nextSibling;
956 var index = item.listIndex;
957 if (this.cachedItems_[index] != item || index < currentIndex) {
959 } else if (index == currentIndex) {
960 this.cachedItems_[currentIndex] = item;
961 item = item.nextSibling;
963 } else { // index > currentIndex
968 while (item != this.afterFiller_) {
969 if (this.isItem(item))
972 item = item.nextSibling;
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.
983 while (currentIndex < lastIndex)
988 * Ensures that all the item sizes in the list have been already cached.
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);
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;
1008 // If |listItems| is not on the list, apppends it to the list and sets
1010 if (!listItem.parentNode) {
1011 this.appendChild(listItem);
1012 isElementAppended[y] = true;
1015 this.cachedItems_[index] = listItem;
1016 measuringItems.push(listItem);
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]);
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]);
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.
1040 getAfterFillerHeight: function(lastIndex) {
1041 if (this.fixedHeight_) {
1042 var itemHeight = this.getDefaultItemHeight_();
1043 return (this.dataModel.length - lastIndex) * itemHeight;
1047 for (var i = lastIndex; i < this.dataModel.length; i++)
1048 height += this.getItemHeightByIndex_(i);
1053 * Redraws the viewport.
1055 redraw: function() {
1056 if (this.batchCount_ != 0)
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, {}, {});
1069 // Save the previous positions before any manipulation of elements.
1070 var scrollTop = this.scrollTop;
1071 var clientHeight = this.clientHeight;
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();
1078 var autoExpands = this.autoExpands_;
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(
1085 Math.min(dataModel.length - 1, itemsInViewPort.first - 1));
1086 var lastIndex = Math.min(itemsInViewPort.last + 1, dataModel.length);
1088 var beforeFillerHeight =
1089 this.autoExpands ? 0 : this.getItemTop(firstIndex);
1090 var afterFillerHeight =
1091 this.autoExpands ? 0 : this.getAfterFillerHeight(lastIndex);
1093 this.beforeFiller_.style.height = beforeFillerHeight + 'px';
1095 var sm = this.selectionModel;
1096 var leadIndex = sm.leadIndex;
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;
1107 this.pinnedItem_ = undefined;
1110 this.mergeItems(firstIndex, lastIndex);
1112 if (!this.pinnedItem_ && this.cachedItems_[leadIndex] &&
1113 this.cachedItems_[leadIndex].parentNode == this) {
1114 this.pinnedItem_ = this.cachedItems_[leadIndex];
1117 this.afterFiller_.style.height = afterFillerHeight + 'px';
1119 // Restores the number of pixels scrolled, since it might be changed while
1121 this.scrollTop = scrollTop;
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];
1135 this.firstIndex_ = firstIndex;
1136 this.lastIndex_ = lastIndex;
1138 this.remainingSpace_ = itemsInViewPort.last > dataModel.length;
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]);
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.
1158 * @param {ListItem} leadItem Already existing lead item.
1160 restoreLeadItem: function(leadItem) {
1161 delete this.cachedItems_[leadItem.listIndex];
1163 leadItem.listIndex = this.selectionModel.leadIndex;
1164 this.pinnedItem_ = this.cachedItems_[leadItem.listIndex] = leadItem;
1168 * Invalidates list by removing cached items.
1170 invalidate: function() {
1171 this.cachedItems_ = {};
1172 this.cachedItemSized_ = {};
1176 * Redraws a single item.
1177 * @param {number} index The row index to redraw.
1179 redrawItem: function(index) {
1180 if (index >= this.firstIndex_ &&
1181 (index < this.lastIndex_ || this.remainingSpace_)) {
1182 delete this.cachedItems_[index];
1188 * Called when a list item is activated, currently only by a double click
1190 * @param {number} index The index of the activated item.
1192 activateItemAtIndex: function(index) {
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).
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).
1204 * @return {cr.ui.ListItem} The lead item for the list.
1206 ensureLeadItemExists: function() {
1207 var index = this.selectionModel.leadIndex;
1210 var cachedItems = this.cachedItems_ || {};
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_);
1218 this.pinnedItem_ = item;
1219 cachedItems[index] = item;
1220 item.listIndex = index;
1221 if (item.parentNode == this)
1224 if (this.batchCount_ != 0)
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);
1232 this.insertBefore(item, this.afterFiller_);
1238 * Starts drag selection by reacting 'dragstart' event.
1239 * @param {Event} event Event of dragstart.
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';
1259 var onMouseUp = function() {
1260 this.removeChild(border);
1261 document.removeEventListener('mousemove', onMouseMove, true);
1262 document.removeEventListener('mouseup', onMouseUp, true);
1264 document.addEventListener('mousemove', onMouseMove, true);
1265 document.addEventListener('mouseup', onMouseUp, true);
1266 this.appendChild(border);
1270 cr.defineProperty(List, 'disabled', cr.PropertyKind.BOOL_ATTR);
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.
1278 cr.defineProperty(List, 'hasElementFocus', cr.PropertyKind.BOOL_ATTR);
1281 * Mousedown event handler.
1283 * @param {MouseEvent} e The mouse event object.
1285 function handleMouseDown(e) {
1286 var listItem = this.getListItemAncestor(e.target);
1287 var wasSelected = listItem && listItem.selected;
1288 this.handlePointerDownUp_(e);
1290 if (e.defaultPrevented || e.button != 0)
1293 // The following hack is required only if the listItem gets selected.
1294 if (!listItem || wasSelected || !listItem.selected)
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.
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)) {
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.
1316 function tryFocusOnAncestor(start, root) {
1317 for (var element = start; element && element != root;
1318 element = element.parentElement) {
1320 if (root.ownerDocument.activeElement == element)