1// Copyright (c) 2011 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 14cr.define('cr.ui', function() { 15 const ListSelectionModel = cr.ui.ListSelectionModel; 16 const ListSelectionController = cr.ui.ListSelectionController; 17 const 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 /** 37 * Creates an item (dataModel.item(0)) and measures its height. 38 * @param {!List} list The list to create the item for. 39 * @param {ListItem=} opt_item The list item to use to do the measuring. If 40 * this is not provided an item will be created based on the first value 41 * in the model. 42 * @return {{height: number, marginVertical: number, width: number, 43 * marginHorizontal: number}} The height and width of the item, taking 44 * margins into account, and the height and width of the margins 45 * themselves. 46 */ 47 function measureItem(list, opt_item) { 48 var dataModel = list.dataModel; 49 if (!dataModel || !dataModel.length) 50 return 0; 51 var item = opt_item || list.createItem(dataModel.item(0)); 52 if (!opt_item) 53 list.appendChild(item); 54 55 var rect = item.getBoundingClientRect(); 56 var cs = getComputedStyle(item); 57 var mt = parseFloat(cs.marginTop); 58 var mb = parseFloat(cs.marginBottom); 59 var ml = parseFloat(cs.marginLeft); 60 var mr = parseFloat(cs.marginRight); 61 var h = rect.height; 62 var w = rect.width; 63 var mh = 0; 64 var mv = 0; 65 66 // Handle margin collapsing. 67 if (mt < 0 && mb < 0) { 68 mv = Math.min(mt, mb); 69 } else if (mt >= 0 && mb >= 0) { 70 mv = Math.max(mt, mb); 71 } else { 72 mv = mt + mb; 73 } 74 h += mv; 75 76 if (ml < 0 && mr < 0) { 77 mh = Math.min(ml, mr); 78 } else if (ml >= 0 && mr >= 0) { 79 mh = Math.max(ml, mr); 80 } else { 81 mh = ml + mr; 82 } 83 w += mh; 84 85 if (!opt_item) 86 list.removeChild(item); 87 return { 88 height: Math.max(0, h), marginVertical: mv, 89 width: Math.max(0, w), marginHorizontal: mh}; 90 } 91 92 function getComputedStyle(el) { 93 return el.ownerDocument.defaultView.getComputedStyle(el); 94 } 95 96 /** 97 * Creates a new list element. 98 * @param {Object=} opt_propertyBag Optional properties. 99 * @constructor 100 * @extends {HTMLUListElement} 101 */ 102 var List = cr.ui.define('list'); 103 104 List.prototype = { 105 __proto__: HTMLUListElement.prototype, 106 107 /** 108 * Measured size of list items. This is lazily calculated the first time it 109 * is needed. Note that lead item is allowed to have a different height, to 110 * accommodate lists where a single item at a time can be expanded to show 111 * more detail. 112 * @type {{height: number, marginVertical: number, width: number, 113 * marginHorizontal: number}} 114 * @private 115 */ 116 measured_: undefined, 117 118 /** 119 * The height of the lead item, which is allowed to have a different height 120 * than other list items to accommodate lists where a single item at a time 121 * can be expanded to show more detail. It is explicitly set by client code 122 * when the height of the lead item is changed with {@code set 123 * leadItemHeight}, and presumed equal to {@code itemHeight_} otherwise. 124 * @type {number} 125 * @private 126 */ 127 leadItemHeight_: 0, 128 129 /** 130 * Whether or not the list is autoexpanding. If true, the list resizes 131 * its height to accomadate all children. 132 * @type {boolean} 133 * @private 134 */ 135 autoExpands_: false, 136 137 /** 138 * Function used to create grid items. 139 * @type {function(): !ListItem} 140 * @private 141 */ 142 itemConstructor_: cr.ui.ListItem, 143 144 /** 145 * Function used to create grid items. 146 * @type {function(): !ListItem} 147 */ 148 get itemConstructor() { 149 return this.itemConstructor_; 150 }, 151 set itemConstructor(func) { 152 if (func != this.itemConstructor_) { 153 this.itemConstructor_ = func; 154 this.cachedItems_ = {}; 155 this.redraw(); 156 } 157 }, 158 159 dataModel_: null, 160 161 /** 162 * The data model driving the list. 163 * @type {ListDataModel} 164 */ 165 set dataModel(dataModel) { 166 if (this.dataModel_ != dataModel) { 167 if (!this.boundHandleDataModelSplice_) { 168 this.boundHandleDataModelSplice_ = 169 this.handleDataModelSplice_.bind(this); 170 this.boundHandleDataModelChange_ = 171 this.handleDataModelChange_.bind(this); 172 this.boundHandleSorted_ = 173 this.handleSorted_.bind(this); 174 } 175 176 if (this.dataModel_) { 177 this.dataModel_.removeEventListener('splice', 178 this.boundHandleDataModelSplice_); 179 this.dataModel_.removeEventListener('change', 180 this.boundHandleDataModelChange_); 181 this.dataModel_.removeEventListener('sorted', 182 this.boundHandleSorted_); 183 } 184 185 this.dataModel_ = dataModel; 186 187 this.cachedItems_ = {}; 188 this.selectionModel.clear(); 189 if (dataModel) 190 this.selectionModel.adjust(0, 0, dataModel.length); 191 192 if (this.dataModel_) { 193 this.dataModel_.addEventListener('splice', 194 this.boundHandleDataModelSplice_); 195 this.dataModel_.addEventListener('change', 196 this.boundHandleDataModelChange_); 197 this.dataModel_.addEventListener('sorted', 198 this.boundHandleSorted_); 199 } 200 201 this.redraw(); 202 } 203 }, 204 205 get dataModel() { 206 return this.dataModel_; 207 }, 208 209 /** 210 * The selection model to use. 211 * @type {cr.ui.ListSelectionModel} 212 */ 213 get selectionModel() { 214 return this.selectionModel_; 215 }, 216 set selectionModel(sm) { 217 var oldSm = this.selectionModel_; 218 if (oldSm == sm) 219 return; 220 221 if (!this.boundHandleOnChange_) { 222 this.boundHandleOnChange_ = this.handleOnChange_.bind(this); 223 this.boundHandleLeadChange_ = this.handleLeadChange_.bind(this); 224 } 225 226 if (oldSm) { 227 oldSm.removeEventListener('change', this.boundHandleOnChange_); 228 oldSm.removeEventListener('leadIndexChange', 229 this.boundHandleLeadChange_); 230 } 231 232 this.selectionModel_ = sm; 233 this.selectionController_ = this.createSelectionController(sm); 234 235 if (sm) { 236 sm.addEventListener('change', this.boundHandleOnChange_); 237 sm.addEventListener('leadIndexChange', this.boundHandleLeadChange_); 238 } 239 }, 240 241 /** 242 * Whether or not the list auto-expands. 243 * @type {boolean} 244 */ 245 get autoExpands() { 246 return this.autoExpands_; 247 }, 248 set autoExpands(autoExpands) { 249 if (this.autoExpands_ == autoExpands) 250 return; 251 this.autoExpands_ = autoExpands; 252 this.redraw(); 253 }, 254 255 /** 256 * Convenience alias for selectionModel.selectedItem 257 * @type {cr.ui.ListItem} 258 */ 259 get selectedItem() { 260 var dataModel = this.dataModel; 261 if (dataModel) { 262 var index = this.selectionModel.selectedIndex; 263 if (index != -1) 264 return dataModel.item(index); 265 } 266 return null; 267 }, 268 set selectedItem(selectedItem) { 269 var dataModel = this.dataModel; 270 if (dataModel) { 271 var index = this.dataModel.indexOf(selectedItem); 272 this.selectionModel.selectedIndex = index; 273 } 274 }, 275 276 /** 277 * The height of the lead item. 278 * If set to 0, resets to the same height as other items. 279 * @type {number} 280 */ 281 get leadItemHeight() { 282 return this.leadItemHeight_ || this.getItemHeight_(); 283 }, 284 set leadItemHeight(height) { 285 if (height) { 286 var size = this.getItemSize_(); 287 this.leadItemHeight_ = Math.max(0, height + size.marginVertical); 288 } else { 289 this.leadItemHeight_ = 0; 290 } 291 }, 292 293 /** 294 * Convenience alias for selectionModel.selectedItems 295 * @type {!Array<cr.ui.ListItem>} 296 */ 297 get selectedItems() { 298 var indexes = this.selectionModel.selectedIndexes; 299 var dataModel = this.dataModel; 300 if (dataModel) { 301 return indexes.map(function(i) { 302 return dataModel.item(i); 303 }); 304 } 305 return []; 306 }, 307 308 /** 309 * The HTML elements representing the items. This is just all the list item 310 * children but subclasses may override this to filter out certain elements. 311 * @type {HTMLCollection} 312 */ 313 get items() { 314 return Array.prototype.filter.call(this.children, function(child) { 315 return !child.classList.contains('spacer'); 316 }); 317 }, 318 319 batchCount_: 0, 320 321 /** 322 * When making a lot of updates to the list, the code could be wrapped in 323 * the startBatchUpdates and finishBatchUpdates to increase performance. Be 324 * sure that the code will not return without calling endBatchUpdates or the 325 * list will not be correctly updated. 326 */ 327 startBatchUpdates: function() { 328 this.batchCount_++; 329 }, 330 331 /** 332 * See startBatchUpdates. 333 */ 334 endBatchUpdates: function() { 335 this.batchCount_--; 336 if (this.batchCount_ == 0) 337 this.redraw(); 338 }, 339 340 /** 341 * Initializes the element. 342 */ 343 decorate: function() { 344 // Add fillers. 345 this.beforeFiller_ = this.ownerDocument.createElement('div'); 346 this.afterFiller_ = this.ownerDocument.createElement('div'); 347 this.beforeFiller_.className = 'spacer'; 348 this.afterFiller_.className = 'spacer'; 349 this.appendChild(this.beforeFiller_); 350 this.appendChild(this.afterFiller_); 351 352 var length = this.dataModel ? this.dataModel.length : 0; 353 this.selectionModel = new ListSelectionModel(length); 354 355 this.addEventListener('dblclick', this.handleDoubleClick_); 356 this.addEventListener('mousedown', this.handleMouseDownUp_); 357 this.addEventListener('mouseup', this.handleMouseDownUp_); 358 this.addEventListener('keydown', this.handleKeyDown); 359 this.addEventListener('focus', this.handleElementFocus_, true); 360 this.addEventListener('blur', this.handleElementBlur_, true); 361 this.addEventListener('scroll', this.redraw.bind(this)); 362 363 // Make list focusable 364 if (!this.hasAttribute('tabindex')) 365 this.tabIndex = 0; 366 }, 367 368 /** 369 * @return {number} The height of an item, measuring it if necessary. 370 * @private 371 */ 372 getItemHeight_: function() { 373 return this.getItemSize_().height; 374 }, 375 376 /** 377 * @return {number} The width of an item, measuring it if necessary. 378 * @private 379 */ 380 getItemWidth_: function() { 381 return this.getItemSize_().width; 382 }, 383 384 /** 385 * @return {{height: number, width: number}} The height and width 386 * of an item, measuring it if necessary. 387 * @private 388 */ 389 getItemSize_: function() { 390 if (!this.measured_ || !this.measured_.height) { 391 this.measured_ = measureItem(this); 392 } 393 return this.measured_; 394 }, 395 396 /** 397 * Callback for the double click event. 398 * @param {Event} e The mouse event object. 399 * @private 400 */ 401 handleDoubleClick_: function(e) { 402 if (this.disabled) 403 return; 404 405 var target = this.getListItemAncestor(e.target); 406 var index = target ? this.getIndexOfListItem(target) : -1; 407 this.activateItemAtIndex(index); 408 }, 409 410 /** 411 * Callback for mousedown and mouseup events. 412 * @param {Event} e The mouse event object. 413 * @private 414 */ 415 handleMouseDownUp_: function(e) { 416 if (this.disabled) 417 return; 418 419 var target = e.target; 420 421 // If the target was this element we need to make sure that the user did 422 // not click on a border or a scrollbar. 423 if (target == this && !inViewport(target, e)) 424 return; 425 426 target = this.getListItemAncestor(target); 427 428 var index = target ? this.getIndexOfListItem(target) : -1; 429 this.selectionController_.handleMouseDownUp(e, index); 430 }, 431 432 /** 433 * Called when an element in the list is focused. Marks the list as having 434 * a focused element, and dispatches an event if it didn't have focus. 435 * @param {Event} e The focus event. 436 * @private 437 */ 438 handleElementFocus_: function(e) { 439 if (!this.hasElementFocus) { 440 this.hasElementFocus = true; 441 // Force styles based on hasElementFocus to take effect. 442 this.forceRepaint_(); 443 } 444 }, 445 446 /** 447 * Called when an element in the list is blurred. If focus moves outside 448 * the list, marks the list as no longer having focus and dispatches an 449 * event. 450 * @param {Event} e The blur event. 451 * @private 452 */ 453 handleElementBlur_: function(e) { 454 // When the blur event happens we do not know who is getting focus so we 455 // delay this a bit until we know if the new focus node is outside the 456 // list. 457 var list = this; 458 var doc = e.target.ownerDocument; 459 window.setTimeout(function() { 460 var activeElement = doc.activeElement; 461 if (!list.contains(activeElement)) { 462 list.hasElementFocus = false; 463 // Force styles based on hasElementFocus to take effect. 464 list.forceRepaint_(); 465 } 466 }); 467 }, 468 469 /** 470 * Forces a repaint of the list. Changing custom attributes, even if there 471 * are style rules depending on them, doesn't cause a repaint 472 * (<https://bugs.webkit.org/show_bug.cgi?id=12519>), so this can be called 473 * to force the list to repaint. 474 * @private 475 */ 476 forceRepaint_: function(e) { 477 var dummyElement = document.createElement('div'); 478 this.appendChild(dummyElement); 479 this.removeChild(dummyElement); 480 }, 481 482 /** 483 * Returns the list item element containing the given element, or null if 484 * it doesn't belong to any list item element. 485 * @param {HTMLElement} element The element. 486 * @return {ListItem} The list item containing |element|, or null. 487 */ 488 getListItemAncestor: function(element) { 489 var container = element; 490 while (container && container.parentNode != this) { 491 container = container.parentNode; 492 } 493 return container; 494 }, 495 496 /** 497 * Handle a keydown event. 498 * @param {Event} e The keydown event. 499 * @return {boolean} Whether the key event was handled. 500 */ 501 handleKeyDown: function(e) { 502 if (this.disabled) 503 return; 504 505 return this.selectionController_.handleKeyDown(e); 506 }, 507 508 /** 509 * Callback from the selection model. We dispatch {@code change} events 510 * when the selection changes. 511 * @param {!cr.Event} e Event with change info. 512 * @private 513 */ 514 handleOnChange_: function(ce) { 515 ce.changes.forEach(function(change) { 516 var listItem = this.getListItemByIndex(change.index); 517 if (listItem) 518 listItem.selected = change.selected; 519 }, this); 520 521 cr.dispatchSimpleEvent(this, 'change'); 522 }, 523 524 /** 525 * Handles a change of the lead item from the selection model. 526 * @property {Event} pe The property change event. 527 * @private 528 */ 529 handleLeadChange_: function(pe) { 530 var element; 531 if (pe.oldValue != -1) { 532 if ((element = this.getListItemByIndex(pe.oldValue))) 533 element.lead = false; 534 } 535 536 if (pe.newValue != -1) { 537 if ((element = this.getListItemByIndex(pe.newValue))) 538 element.lead = true; 539 this.scrollIndexIntoView(pe.newValue); 540 // If the lead item has a different height than other items, then we 541 // may run into a problem that requires a second attempt to scroll 542 // it into view. The first scroll attempt will trigger a redraw, 543 // which will clear out the list and repopulate it with new items. 544 // During the redraw, the list may shrink temporarily, which if the 545 // lead item is the last item, will move the scrollTop up since it 546 // cannot extend beyond the end of the list. (Sadly, being scrolled to 547 // the bottom of the list is not "sticky.") So, we set a timeout to 548 // rescroll the list after this all gets sorted out. This is perhaps 549 // not the most elegant solution, but no others seem obvious. 550 var self = this; 551 window.setTimeout(function() { 552 self.scrollIndexIntoView(pe.newValue); 553 }); 554 } 555 }, 556 557 handleDataModelSplice_: function(e) { 558 this.selectionModel.adjust(e.index, e.removed.length, e.added.length); 559 // Remove the cache of everything above index. 560 for (var index in this.cachedItems_) { 561 if (index >= e.index) 562 delete this.cachedItems_[index]; 563 } 564 this.redraw(); 565 }, 566 567 handleDataModelChange_: function(e) { 568 if (e.index >= this.firstIndex_ && e.index < this.lastIndex_) { 569 this.cachedItems_ = null; 570 this.redraw(); 571 } 572 }, 573 574 /** 575 * This handles data model 'sorted' event. 576 * After sorting we need to 577 * - adjust selection. 578 * - delete the cache. 579 * - redraw all the items. 580 * - scroll the list to show selection. 581 * @param {Event} e The 'sorted' event. 582 */ 583 handleSorted_: function(e) { 584 var sm = this.selectionModel; 585 sm.adjustToReordering(e.sortPermutation); 586 587 this.cachedItems_ = null; 588 this.redraw(); 589 if (sm.leadIndex != -1) 590 this.scrollIndexIntoView(sm.leadIndex); 591 }, 592 593 /** 594 * @param {number} index The index of the item. 595 * @return {number} The top position of the item inside the list, not taking 596 * into account lead item. May vary in the case of multiple columns. 597 */ 598 getItemTop: function(index) { 599 return index * this.getItemHeight_(); 600 }, 601 602 /** 603 * @param {number} index The index of the item. 604 * @return {number} The row of the item. May vary in the case 605 * of multiple columns. 606 */ 607 getItemRow: function(index) { 608 return index; 609 }, 610 611 /** 612 * @param {number} row The row. 613 * @return {number} The index of the first item in the row. 614 */ 615 getFirstItemInRow: function(row) { 616 return row; 617 }, 618 619 /** 620 * Ensures that a given index is inside the viewport. 621 * @param {number} index The index of the item to scroll into view. 622 * @return {boolean} Whether any scrolling was needed. 623 */ 624 scrollIndexIntoView: function(index) { 625 var dataModel = this.dataModel; 626 if (!dataModel || index < 0 || index >= dataModel.length) 627 return false; 628 629 var itemHeight = this.getItemHeight_(); 630 var scrollTop = this.scrollTop; 631 var top = this.getItemTop(index); 632 var leadIndex = this.selectionModel.leadIndex; 633 634 // Adjust for the lead item if it is above the given index. 635 if (leadIndex > -1 && leadIndex < index) 636 top += this.leadItemHeight - itemHeight; 637 else if (leadIndex == index) 638 itemHeight = this.leadItemHeight; 639 640 if (top < scrollTop) { 641 this.scrollTop = top; 642 return true; 643 } else { 644 var clientHeight = this.clientHeight; 645 var cs = getComputedStyle(this); 646 var paddingY = parseInt(cs.paddingTop, 10) + 647 parseInt(cs.paddingBottom, 10); 648 649 if (top + itemHeight > scrollTop + clientHeight - paddingY) { 650 this.scrollTop = top + itemHeight - clientHeight + paddingY; 651 return true; 652 } 653 } 654 655 return false; 656 }, 657 658 /** 659 * @return {!ClientRect} The rect to use for the context menu. 660 */ 661 getRectForContextMenu: function() { 662 // TODO(arv): Add trait support so we can share more code between trees 663 // and lists. 664 var index = this.selectionModel.selectedIndex; 665 var el = this.getListItemByIndex(index); 666 if (el) 667 return el.getBoundingClientRect(); 668 return this.getBoundingClientRect(); 669 }, 670 671 /** 672 * Takes a value from the data model and finds the associated list item. 673 * @param {*} value The value in the data model that we want to get the list 674 * item for. 675 * @return {ListItem} The first found list item or null if not found. 676 */ 677 getListItem: function(value) { 678 var dataModel = this.dataModel; 679 if (dataModel) { 680 var index = dataModel.indexOf(value); 681 return this.getListItemByIndex(index); 682 } 683 return null; 684 }, 685 686 /** 687 * Find the list item element at the given index. 688 * @param {number} index The index of the list item to get. 689 * @return {ListItem} The found list item or null if not found. 690 */ 691 getListItemByIndex: function(index) { 692 return this.cachedItems_[index] || null; 693 }, 694 695 /** 696 * Find the index of the given list item element. 697 * @param {ListItem} item The list item to get the index of. 698 * @return {number} The index of the list item, or -1 if not found. 699 */ 700 getIndexOfListItem: function(item) { 701 var index = item.listIndex; 702 if (this.cachedItems_[index] == item) { 703 return index; 704 } 705 return -1; 706 }, 707 708 /** 709 * Creates a new list item. 710 * @param {*} value The value to use for the item. 711 * @return {!ListItem} The newly created list item. 712 */ 713 createItem: function(value) { 714 var item = new this.itemConstructor_(value); 715 item.label = value; 716 if (typeof item.decorate == 'function') 717 item.decorate(); 718 return item; 719 }, 720 721 /** 722 * Creates the selection controller to use internally. 723 * @param {cr.ui.ListSelectionModel} sm The underlying selection model. 724 * @return {!cr.ui.ListSelectionController} The newly created selection 725 * controller. 726 */ 727 createSelectionController: function(sm) { 728 return new ListSelectionController(sm); 729 }, 730 731 /** 732 * Return the heights (in pixels) of the top of the given item index within 733 * the list, and the height of the given item itself, accounting for the 734 * possibility that the lead item may be a different height. 735 * @param {number} index The index to find the top height of. 736 * @return {{top: number, height: number}} The heights for the given index. 737 * @private 738 */ 739 getHeightsForIndex_: function(index) { 740 var itemHeight = this.getItemHeight_(); 741 var top = this.getItemTop(index); 742 if (this.selectionModel.leadIndex > -1 && 743 this.selectionModel.leadIndex < index) { 744 top += this.leadItemHeight - itemHeight; 745 } else if (this.selectionModel.leadIndex == index) { 746 itemHeight = this.leadItemHeight; 747 } 748 return {top: top, height: itemHeight}; 749 }, 750 751 /** 752 * Find the index of the list item containing the given y offset (measured 753 * in pixels from the top) within the list. In the case of multiple columns, 754 * returns the first index in the row. 755 * @param {number} offset The y offset in pixels to get the index of. 756 * @return {number} The index of the list item. 757 * @private 758 */ 759 getIndexForListOffset_: function(offset) { 760 var itemHeight = this.getItemHeight_(); 761 var leadIndex = this.selectionModel.leadIndex; 762 var leadItemHeight = this.leadItemHeight; 763 if (leadIndex < 0 || leadItemHeight == itemHeight) { 764 // Simple case: no lead item or lead item height is not different. 765 return this.getFirstItemInRow(Math.floor(offset / itemHeight)); 766 } 767 var leadTop = this.getItemTop(leadIndex); 768 // If the given offset is above the lead item, it's also simple. 769 if (offset < leadTop) 770 return this.getFirstItemInRow(Math.floor(offset / itemHeight)); 771 // If the lead item contains the given offset, we just return its index. 772 if (offset < leadTop + leadItemHeight) 773 return this.getFirstItemInRow(this.getItemRow(leadIndex)); 774 // The given offset must be below the lead item. Adjust and recalculate. 775 offset -= leadItemHeight - itemHeight; 776 return this.getFirstItemInRow(Math.floor(offset / itemHeight)); 777 }, 778 779 /** 780 * Return the number of items that occupy the range of heights between the 781 * top of the start item and the end offset. 782 * @param {number} startIndex The index of the first visible item. 783 * @param {number} endOffset The y offset in pixels of the end of the list. 784 * @return {number} The number of list items visible. 785 * @private 786 */ 787 countItemsInRange_: function(startIndex, endOffset) { 788 var endIndex = this.getIndexForListOffset_(endOffset); 789 return endIndex - startIndex + 1; 790 }, 791 792 /** 793 * Calculates the number of items fitting in viewport given the index of 794 * first item and heights. 795 * @param {number} itemHeight The height of the item. 796 * @param {number} firstIndex Index of the first item in viewport. 797 * @param {number} scrollTop The scroll top position. 798 * @return {number} The number of items in view port. 799 */ 800 getItemsInViewPort: function(itemHeight, firstIndex, scrollTop) { 801 // This is a bit tricky. We take the minimum of the available items to 802 // show and the number we want to show, so as not to go off the end of the 803 // list. For the number we want to show, we take the maximum of the number 804 // that would fit without a differently-sized lead item, and with one. We 805 // do this so that if the size of the lead item changes without a scroll 806 // event to trigger redrawing the list, we won't end up with empty space. 807 var clientHeight = this.clientHeight; 808 return this.autoExpands_ ? this.dataModel.length : Math.min( 809 this.dataModel.length - firstIndex, 810 Math.max( 811 Math.ceil(clientHeight / itemHeight) + 1, 812 this.countItemsInRange_(firstIndex, scrollTop + clientHeight))); 813 }, 814 815 /** 816 * Adds items to the list and {@code newCachedItems}. 817 * @param {number} firstIndex The index of first item, inclusively. 818 * @param {number} lastIndex The index of last item, exclusively. 819 * @param {Object.<string, ListItem>} cachedItems Old items cache. 820 * @param {Object.<string, ListItem>} newCachedItems New items cache. 821 */ 822 addItems: function(firstIndex, lastIndex, cachedItems, newCachedItems) { 823 var listItem; 824 var dataModel = this.dataModel; 825 826 for (var y = firstIndex; y < lastIndex; y++) { 827 var dataItem = dataModel.item(y); 828 listItem = cachedItems[y] || this.createItem(dataItem); 829 listItem.listIndex = y; 830 this.appendChild(listItem); 831 newCachedItems[y] = listItem; 832 } 833 }, 834 835 /** 836 * Returns the height of after filler in the list. 837 * @param {number} lastIndex The index of item past the last in viewport. 838 * @param {number} itemHeight The height of the item. 839 * @return {number} The height of after filler. 840 */ 841 getAfterFillerHeight: function(lastIndex, itemHeight) { 842 return (this.dataModel.length - lastIndex) * itemHeight; 843 }, 844 845 /** 846 * Redraws the viewport. 847 */ 848 redraw: function() { 849 if (this.batchCount_ != 0) 850 return; 851 852 var dataModel = this.dataModel; 853 if (!dataModel) { 854 this.textContent = ''; 855 return; 856 } 857 858 var scrollTop = this.scrollTop; 859 var clientHeight = this.clientHeight; 860 861 var itemHeight = this.getItemHeight_(); 862 863 // We cache the list items since creating the DOM nodes is the most 864 // expensive part of redrawing. 865 var cachedItems = this.cachedItems_ || {}; 866 var newCachedItems = {}; 867 868 var desiredScrollHeight = this.getHeightsForIndex_(dataModel.length).top; 869 870 var autoExpands = this.autoExpands_; 871 var firstIndex = autoExpands ? 0 : this.getIndexForListOffset_(scrollTop); 872 // This is a bit tricky. We take the minimum of the available items to 873 // show and the number we want to show, so as not to go off the end of the 874 // list. For the number we want to show, we take the maximum of the number 875 // that would fit without a differently-sized lead item, and with one. We 876 // do this so that if the size of the lead item changes without a scroll 877 // event to trigger redrawing the list, we won't end up with empty space. 878 var itemsInViewPort = this.getItemsInViewPort(itemHeight, firstIndex, 879 scrollTop); 880 var lastIndex = firstIndex + itemsInViewPort; 881 882 this.textContent = ''; 883 884 this.beforeFiller_.style.height = 885 this.getHeightsForIndex_(firstIndex).top + 'px'; 886 this.appendChild(this.beforeFiller_); 887 888 var sm = this.selectionModel; 889 var leadIndex = sm.leadIndex; 890 891 this.addItems(firstIndex, lastIndex, cachedItems, newCachedItems); 892 893 var afterFillerHeight = this.getAfterFillerHeight(lastIndex, itemHeight); 894 if (leadIndex >= lastIndex) 895 afterFillerHeight += this.leadItemHeight - itemHeight; 896 this.afterFiller_.style.height = afterFillerHeight + 'px'; 897 this.appendChild(this.afterFiller_); 898 899 // We don't set the lead or selected properties until after adding all 900 // items, in case they force relayout in response to these events. 901 var listItem = null; 902 if (newCachedItems[leadIndex]) 903 newCachedItems[leadIndex].lead = true; 904 for (var y = firstIndex; y < lastIndex; y++) { 905 if (sm.getIndexSelected(y)) 906 newCachedItems[y].selected = true; 907 else if (y != leadIndex) 908 listItem = newCachedItems[y]; 909 } 910 911 this.firstIndex_ = firstIndex; 912 this.lastIndex_ = lastIndex; 913 914 this.cachedItems_ = newCachedItems; 915 916 // Measure again in case the item height has changed due to a page zoom. 917 // 918 // The measure above is only done the first time but this measure is done 919 // after every redraw. It is done in a timeout so it will not trigger 920 // a reflow (which made the redraw speed 3 times slower on my system). 921 // By using a timeout the measuring will happen later when there is no 922 // need for a reflow. 923 if (listItem) { 924 var list = this; 925 window.setTimeout(function() { 926 if (listItem.parentNode == list) { 927 list.measured_ = measureItem(list, listItem); 928 } 929 }); 930 } 931 }, 932 933 /** 934 * Redraws a single item. 935 * @param {number} index The row index to redraw. 936 */ 937 redrawItem: function(index) { 938 if (index >= this.firstIndex_ && index < this.lastIndex_) { 939 delete this.cachedItems_[index]; 940 this.redraw(); 941 } 942 }, 943 944 /** 945 * Called when a list item is activated, currently only by a double click 946 * event. 947 * @param {number} index The index of the activated item. 948 */ 949 activateItemAtIndex: function(index) { 950 }, 951 }; 952 953 cr.defineProperty(List, 'disabled', cr.PropertyKind.BOOL_ATTR); 954 955 /** 956 * Whether the list or one of its descendents has focus. This is necessary 957 * because list items can contain controls that can be focused, and for some 958 * purposes (e.g., styling), the list can still be conceptually focused at 959 * that point even though it doesn't actually have the page focus. 960 */ 961 cr.defineProperty(List, 'hasElementFocus', cr.PropertyKind.BOOL_ATTR); 962 963 return { 964 List: List 965 } 966}); 967