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 * @return {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(new:cr.ui.ListItem, *)}
91 itemConstructor_: cr.ui.ListItem,
94 * Function used to create grid items.
95 * @return {function(new:cr.ui.ListItem, Object)}
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('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');
325 // Make list focusable
326 if (!this.hasAttribute('tabindex'))
329 // Try to get an unique id prefix from the id of this element or the
330 // nearest ancestor with an id.
332 while (element && !element.id)
333 element = element.parentElement;
334 if (element && element.id)
335 this.uniqueIdPrefix_ = element.id;
337 this.uniqueIdPrefix_ = 'list';
339 // The next id suffix to use when giving each item an unique id.
340 this.nextUniqueIdSuffix_ = 0;
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.
349 measureItemHeight_: function(item) {
350 return this.measureItem(item).height;
354 * @return {number} The height of default item, measuring it if necessary.
357 getDefaultItemHeight_: function() {
358 return this.getDefaultItemSize_().height;
362 * @param {number} index The index of the item.
363 * @return {number} The height of the item, measuring it if necessary.
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_();
370 if (this.cachedItemHeights_[index])
371 return this.cachedItemHeights_[index];
373 var item = this.getListItemByIndex(index);
375 var h = this.measureItemHeight_(item);
376 this.cachedItemHeights_[index] = h;
379 return this.getDefaultItemHeight_();
383 * @return {{height: number, width: number}} The height and width
384 * of default item, measuring it if necessary.
387 getDefaultItemSize_: function() {
388 if (!this.measured_ || !this.measured_.height) {
389 this.measured_ = this.measureItem();
391 return this.measured_;
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
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
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};
412 var item = opt_item || this.cachedMeasuredItem_ ||
413 this.createItem(dataModel.item(0));
415 this.cachedMeasuredItem_ = item;
416 this.appendChild(item);
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);
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);
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);
450 this.removeChild(item);
452 height: Math.max(0, h),
453 marginTop: mt, marginBottom: mb,
454 width: Math.max(0, w),
455 marginLeft: ml, marginRight: mr};
459 * Callback for the double click event.
460 * @param {Event} e The mouse event object.
463 handleDoubleClick_: function(e) {
467 var target = /** @type {HTMLElement} */(e.target);
469 var ancestor = this.getListItemAncestor(target);
472 index = this.getIndexOfListItem(ancestor);
473 this.activateItemAtIndex(index);
476 var sm = this.selectionModel;
477 var indexSelected = sm.getIndexSelected(index);
479 this.handlePointerDownUp_(e);
483 * Callback for mousedown and mouseup events.
484 * @param {Event} e The mouse event object.
487 handlePointerDownUp_: function(e) {
491 var target = /** @type {HTMLElement} */(e.target);
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);
501 target = this.getListItemAncestor(target);
503 var index = this.getIndexOfListItem(target);
504 this.selectionController_.handlePointerDownUp(e, index);
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.
513 handleElementFocus_: function(e) {
514 if (!this.hasElementFocus)
515 this.hasElementFocus = true;
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
522 * @param {Event} e The blur event.
525 handleElementBlur_: function(e) {
526 if (!this.contains(e.relatedTarget))
527 this.hasElementFocus = false;
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.
536 getListItemAncestor: function(element) {
537 var container = element;
538 while (container && container.parentNode != this) {
539 container = container.parentNode;
541 return container && assertInstanceof(container, HTMLLIElement);
545 * Handle a keydown event.
546 * @param {Event} e The keydown event.
548 handleKeyDown: function(e) {
550 this.selectionController_.handleKeyDown(e);
554 * Handle a scroll event.
555 * @param {Event} e The scroll event.
557 handleScroll: function(e) {
558 requestAnimationFrame(this.redraw.bind(this));
562 * Callback from the selection model. We dispatch {@code change} events
563 * when the selection changes.
564 * @param {!Event} ce Event with change info.
567 handleOnChange_: function(ce) {
568 ce.changes.forEach(function(change) {
569 var listItem = this.getListItemByIndex(change.index);
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);
577 listItem.removeAttribute('aria-posinset');
578 listItem.removeAttribute('aria-setsize');
583 cr.dispatchSimpleEvent(this, 'change');
587 * Handles a change of the lead item from the selection model.
588 * @param {Event} pe The property change event.
591 handleLeadChange_: function(pe) {
593 if (pe.oldValue != -1) {
594 if ((element = this.getListItemByIndex(pe.oldValue)))
595 element.lead = false;
598 if (pe.newValue != -1) {
599 if ((element = this.getListItemByIndex(pe.newValue)))
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.
614 window.setTimeout(function() {
615 self.scrollIndexIntoView(pe.newValue);
622 * This handles data model 'permuted' event.
623 * this event is dispatched as a part of sort or splice.
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
630 * @param {Event} e The 'permuted' event.
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;
641 this.cachedItems_ = newCachedItems;
642 this.pinnedItem_ = null;
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];
651 this.cachedItemHeights_ = newCachedItemHeights;
653 this.startBatchUpdates();
655 var sm = this.selectionModel;
656 sm.adjustLength(e.newLength);
657 sm.adjustToReordering(e.permutation);
659 this.endBatchUpdates();
662 handleDataModelChange_: function(e) {
663 delete this.cachedItems_[e.index];
664 delete this.cachedItemHeights_[e.index];
665 this.cachedMeasuredItem_ = null;
667 if (e.index >= this.firstIndex_ &&
668 (e.index < this.lastIndex_ || this.remainingSpace_)) {
674 * @param {number} index The index of the item.
675 * @return {number} The top position of the item inside the list.
677 getItemTop: function(index) {
678 if (this.fixedHeight_) {
679 var itemHeight = this.getDefaultItemHeight_();
680 return index * itemHeight;
682 this.ensureAllItemSizesInCache();
684 for (var i = 0; i < index; i++) {
685 top += this.getItemHeightByIndex_(i);
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.
696 getItemRow: function(index) {
701 * @param {number} row The row.
702 * @return {number} The index of the first item in the row.
704 getFirstItemInRow: function(row) {
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.
713 scrollIndexIntoView: function(index) {
714 var dataModel = this.dataModel;
715 if (!dataModel || index < 0 || index >= dataModel.length)
718 var itemHeight = this.getItemHeightByIndex_(index);
719 var scrollTop = this.scrollTop;
720 var top = this.getItemTop(index);
721 var clientHeight = this.clientHeight;
723 var cs = getComputedStyle(this);
724 var paddingY = parseInt(cs.paddingTop, 10) +
725 parseInt(cs.paddingBottom, 10);
726 var availableHeight = clientHeight - paddingY;
729 // Function to adjust the tops of viewport and row.
730 function scrollToAdjustTop() {
731 self.scrollTop = top;
734 // Function to adjust the bottoms of viewport and row.
735 function scrollToAdjustBottom() {
736 self.scrollTop = top + itemHeight - availableHeight;
740 // Check if the entire of given indexed row can be shown in the viewport.
741 if (itemHeight <= availableHeight) {
743 return scrollToAdjustTop();
744 if (scrollTop + availableHeight < top + itemHeight)
745 return scrollToAdjustBottom();
748 return scrollToAdjustTop();
749 if (top + itemHeight < scrollTop + availableHeight)
750 return scrollToAdjustBottom();
756 * @return {!ClientRect} The rect to use for the context menu.
758 getRectForContextMenu: function() {
759 // TODO(arv): Add trait support so we can share more code between trees
761 var index = this.selectionModel.selectedIndex;
762 var el = this.getListItemByIndex(index);
764 return el.getBoundingClientRect();
765 return this.getBoundingClientRect();
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
772 * @return {ListItem} The first found list item or null if not found.
774 getListItem: function(value) {
775 var dataModel = this.dataModel;
777 var index = dataModel.indexOf(value);
778 return this.getListItemByIndex(index);
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.
788 getListItemByIndex: function(index) {
789 return this.cachedItems_[index] || null;
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.
797 getIndexOfListItem: function(item) {
798 var index = item.listIndex;
799 if (this.cachedItems_[index] == item) {
806 * Creates a new list item.
807 * @param {*} value The value to use for the item.
808 * @return {!ListItem} The newly created list item.
810 createItem: function(value) {
811 var item = new this.itemConstructor_(value);
813 item.id = this.uniqueIdPrefix_ + '-' + this.nextUniqueIdSuffix_++;
814 if (typeof item.decorate == 'function')
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
825 createSelectionController: function(sm) {
826 return new ListSelectionController(sm);
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.
837 getHeightsForIndex_: function(index) {
838 var itemHeight = this.getItemHeightByIndex_(index);
839 var top = this.getItemTop(index);
840 return {top: top, height: itemHeight};
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.
852 getIndexForListOffset_: function(offset) {
853 var itemHeight = this.getDefaultItemHeight_();
855 return this.dataModel.length;
857 if (this.fixedHeight_)
858 return this.getFirstItemInRow(Math.floor(offset / itemHeight));
860 // If offset exceeds the height of list.
862 if (this.dataModel.length) {
863 var h = this.getHeightsForIndex_(this.dataModel.length - 1);
864 lastHeight = h.top + h.height;
866 if (lastHeight < offset)
867 return this.dataModel.length;
870 var estimatedIndex = Math.min(Math.floor(offset / itemHeight),
871 this.dataModel.length - 1);
872 var isIncrementing = this.getItemTop(estimatedIndex) < offset;
874 // Searchs the correct index.
876 var heights = this.getHeightsForIndex_(estimatedIndex);
877 var top = heights.top;
878 var height = heights.height;
880 if (top <= offset && offset <= (top + height))
883 isIncrementing ? ++estimatedIndex : --estimatedIndex;
884 } while (0 < estimatedIndex && estimatedIndex < this.dataModel.length);
886 return estimatedIndex;
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.
897 countItemsInRange_: function(startIndex, endOffset) {
898 var endIndex = this.getIndexForListOffset_(endOffset);
899 return endIndex - startIndex + 1;
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.
909 getItemsInViewPort: function(scrollTop, clientHeight) {
910 if (this.autoExpands_) {
913 length: this.dataModel.length,
914 last: this.dataModel.length};
916 var firstIndex = this.getIndexForListOffset_(scrollTop);
917 var lastIndex = this.getIndexForListOffset_(scrollTop + clientHeight);
921 length: lastIndex - firstIndex + 1,
922 last: lastIndex + 1};
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.
934 mergeItems: function(firstIndex, lastIndex) {
936 var dataModel = this.dataModel;
937 var currentIndex = firstIndex;
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);
950 var next = item.nextSibling;
951 if (item != self.pinnedItem_)
952 self.removeChild(item);
956 for (var item = this.beforeFiller_.nextSibling;
957 item != this.afterFiller_ && currentIndex < lastIndex;) {
958 if (!this.isItem(item)) {
959 item = item.nextSibling;
963 var index = item.listIndex;
964 if (this.cachedItems_[index] != item || index < currentIndex) {
966 } else if (index == currentIndex) {
967 this.cachedItems_[currentIndex] = item;
968 item = item.nextSibling;
970 } else { // index > currentIndex
975 while (item != this.afterFiller_) {
976 if (this.isItem(item))
979 item = item.nextSibling;
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.
990 while (currentIndex < lastIndex)
995 * Ensures that all the item sizes in the list have been already cached.
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);
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;
1015 // If |listItems| is not on the list, apppends it to the list and sets
1017 if (!listItem.parentNode) {
1018 this.appendChild(listItem);
1019 isElementAppended[y] = true;
1022 this.cachedItems_[index] = listItem;
1023 measuringItems.push(listItem);
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]);
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]);
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.
1047 getAfterFillerHeight: function(lastIndex) {
1048 if (this.fixedHeight_) {
1049 var itemHeight = this.getDefaultItemHeight_();
1050 return (this.dataModel.length - lastIndex) * itemHeight;
1054 for (var i = lastIndex; i < this.dataModel.length; i++)
1055 height += this.getItemHeightByIndex_(i);
1060 * Redraws the viewport.
1062 redraw: function() {
1063 if (this.batchCount_ != 0)
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);
1076 // Save the previous positions before any manipulation of elements.
1077 var scrollTop = this.scrollTop;
1078 var clientHeight = this.clientHeight;
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();
1085 var autoExpands = this.autoExpands_;
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(
1092 Math.min(dataModel.length - 1, itemsInViewPort.first - 1));
1093 var lastIndex = Math.min(itemsInViewPort.last + 1, dataModel.length);
1095 var beforeFillerHeight =
1096 this.autoExpands ? 0 : this.getItemTop(firstIndex);
1097 var afterFillerHeight =
1098 this.autoExpands ? 0 : this.getAfterFillerHeight(lastIndex);
1100 this.beforeFiller_.style.height = beforeFillerHeight + 'px';
1102 var sm = this.selectionModel;
1103 var leadIndex = sm.leadIndex;
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;
1114 this.pinnedItem_ = undefined;
1117 this.mergeItems(firstIndex, lastIndex);
1119 if (!this.pinnedItem_ && this.cachedItems_[leadIndex] &&
1120 this.cachedItems_[leadIndex].parentNode == this) {
1121 this.pinnedItem_ = this.cachedItems_[leadIndex];
1124 this.afterFiller_.style.height = afterFillerHeight + 'px';
1126 // Restores the number of pixels scrolled, since it might be changed while
1128 this.scrollTop = scrollTop;
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;
1139 this.firstIndex_ = firstIndex;
1140 this.lastIndex_ = lastIndex;
1142 this.remainingSpace_ = itemsInViewPort.last > dataModel.length;
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]);
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.
1162 * @param {ListItem} leadItem Already existing lead item.
1164 restoreLeadItem: function(leadItem) {
1165 delete this.cachedItems_[leadItem.listIndex];
1167 leadItem.listIndex = this.selectionModel.leadIndex;
1168 this.pinnedItem_ = this.cachedItems_[leadItem.listIndex] = leadItem;
1172 * Invalidates list by removing cached items.
1174 invalidate: function() {
1175 this.cachedItems_ = {};
1176 this.cachedItemSized_ = {};
1180 * Redraws a single item.
1181 * @param {number} index The row index to redraw.
1183 redrawItem: function(index) {
1184 if (index >= this.firstIndex_ &&
1185 (index < this.lastIndex_ || this.remainingSpace_)) {
1186 delete this.cachedItems_[index];
1192 * Called when a list item is activated, currently only by a double click
1194 * @param {number} index The index of the activated item.
1196 activateItemAtIndex: function(index) {
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).
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).
1208 * @return {cr.ui.ListItem} The lead item for the list.
1210 ensureLeadItemExists: function() {
1211 var index = this.selectionModel.leadIndex;
1214 var cachedItems = this.cachedItems_ || {};
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_);
1222 this.pinnedItem_ = item;
1223 cachedItems[index] = item;
1224 item.listIndex = index;
1225 if (item.parentNode == this)
1228 if (this.batchCount_ != 0)
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);
1236 this.insertBefore(item, this.afterFiller_);
1242 * Starts drag selection by reacting 'dragstart' event.
1243 * @param {Event} event Event of dragstart.
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';
1263 var onMouseUp = function() {
1264 this.removeChild(border);
1265 document.removeEventListener('mousemove', onMouseMove, true);
1266 document.removeEventListener('mouseup', onMouseUp, true);
1268 document.addEventListener('mousemove', onMouseMove, true);
1269 document.addEventListener('mouseup', onMouseUp, true);
1270 this.appendChild(border);
1274 cr.defineProperty(List, 'disabled', cr.PropertyKind.BOOL_ATTR);
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.
1282 cr.defineProperty(List, 'hasElementFocus', cr.PropertyKind.BOOL_ATTR);
1285 * Mousedown event handler.
1286 * @this {cr.ui.List}
1287 * @param {Event} e The mouse event object.
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);
1295 if (e.defaultPrevented || e.button != 0)
1298 // The following hack is required only if the listItem gets selected.
1299 if (!listItem || wasSelected || !listItem.selected)
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.
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)) {
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'.
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);
1328 var index = this.getIndexOfListItem(listItem);
1332 var isAlreadySelected = this.selectionModel_.getIndexSelected(index);
1333 if (!isAlreadySelected)
1334 this.selectionModel_.selectedIndex = index;
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.
1344 function containsFocusableElement(start, root) {
1345 for (var element = start; element && element != root;
1346 element = element.parentElement) {
1347 if (element.tabIndex >= 0 && !element.disabled)