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