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