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 14cr.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 * @return {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_: null, 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(new:cr.ui.ListItem, *)} 89 * @private 90 */ 91 itemConstructor_: cr.ui.ListItem, 92 93 /** 94 * Function used to create grid items. 95 * @return {function(new:cr.ui.ListItem, Object)} 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.handleDoubleClick_); 316 this.addEventListener('mousedown', handleMouseDown); 317 this.addEventListener('dragstart', handleDragStart, true); 318 this.addEventListener('mouseup', this.handlePointerDownUp_); 319 this.addEventListener('keydown', this.handleKeyDown); 320 this.addEventListener('focus', this.handleElementFocus_, true); 321 this.addEventListener('blur', this.handleElementBlur_, true); 322 this.addEventListener('scroll', this.handleScroll.bind(this)); 323 this.setAttribute('role', 'list'); 324 325 // Make list focusable 326 if (!this.hasAttribute('tabindex')) 327 this.tabIndex = 0; 328 329 // Try to get an unique id prefix from the id of this element or the 330 // nearest ancestor with an id. 331 var element = this; 332 while (element && !element.id) 333 element = element.parentElement; 334 if (element && element.id) 335 this.uniqueIdPrefix_ = element.id; 336 else 337 this.uniqueIdPrefix_ = 'list'; 338 339 // The next id suffix to use when giving each item an unique id. 340 this.nextUniqueIdSuffix_ = 0; 341 }, 342 343 /** 344 * @param {ListItem=} item The list item to measure. 345 * @return {number} The height of the given item. If the fixed height on CSS 346 * is set by 'px', uses that value as height. Otherwise, measures the size. 347 * @private 348 */ 349 measureItemHeight_: function(item) { 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 var h = this.measureItemHeight_(item); 376 this.cachedItemHeights_[index] = h; 377 return h; 378 } 379 return this.getDefaultItemHeight_(); 380 }, 381 382 /** 383 * @return {{height: number, width: number}} The height and width 384 * of default item, measuring it if necessary. 385 * @private 386 */ 387 getDefaultItemSize_: function() { 388 if (!this.measured_ || !this.measured_.height) { 389 this.measured_ = this.measureItem(); 390 } 391 return this.measured_; 392 }, 393 394 /** 395 * Creates an item (dataModel.item(0)) and measures its height. The item is 396 * cached instead of creating a new one every time.. 397 * @param {ListItem=} opt_item The list item to use to do the measuring. If 398 * this is not provided an item will be created based on the first value 399 * in the model. 400 * @return {{height: number, marginTop: number, marginBottom: number, 401 * width: number, marginLeft: number, marginRight: number}} 402 * The height and width of the item, taking 403 * margins into account, and the top, bottom, left and right margins 404 * themselves. 405 */ 406 measureItem: function(opt_item) { 407 var dataModel = this.dataModel; 408 if (!dataModel || !dataModel.length) { 409 return {height: 0, marginTop: 0, marginBottom: 0, 410 width: 0, marginLeft: 0, marginRight: 0}; 411 } 412 var item = opt_item || this.cachedMeasuredItem_ || 413 this.createItem(dataModel.item(0)); 414 if (!opt_item) { 415 this.cachedMeasuredItem_ = item; 416 this.appendChild(item); 417 } 418 419 var rect = item.getBoundingClientRect(); 420 var cs = getComputedStyle(item); 421 var mt = parseFloat(cs.marginTop); 422 var mb = parseFloat(cs.marginBottom); 423 var ml = parseFloat(cs.marginLeft); 424 var mr = parseFloat(cs.marginRight); 425 var h = rect.height; 426 var w = rect.width; 427 var mh = 0; 428 var mv = 0; 429 430 // Handle margin collapsing. 431 if (mt < 0 && mb < 0) { 432 mv = Math.min(mt, mb); 433 } else if (mt >= 0 && mb >= 0) { 434 mv = Math.max(mt, mb); 435 } else { 436 mv = mt + mb; 437 } 438 h += mv; 439 440 if (ml < 0 && mr < 0) { 441 mh = Math.min(ml, mr); 442 } else if (ml >= 0 && mr >= 0) { 443 mh = Math.max(ml, mr); 444 } else { 445 mh = ml + mr; 446 } 447 w += mh; 448 449 if (!opt_item) 450 this.removeChild(item); 451 return { 452 height: Math.max(0, h), 453 marginTop: mt, marginBottom: mb, 454 width: Math.max(0, w), 455 marginLeft: ml, marginRight: mr}; 456 }, 457 458 /** 459 * Callback for the double click event. 460 * @param {Event} e The mouse event object. 461 * @private 462 */ 463 handleDoubleClick_: function(e) { 464 if (this.disabled) 465 return; 466 467 var target = /** @type {HTMLElement} */(e.target); 468 469 var ancestor = this.getListItemAncestor(target); 470 var index = -1; 471 if (ancestor) { 472 index = this.getIndexOfListItem(ancestor); 473 this.activateItemAtIndex(index); 474 } 475 476 var sm = this.selectionModel; 477 var indexSelected = sm.getIndexSelected(index); 478 if (!indexSelected) 479 this.handlePointerDownUp_(e); 480 }, 481 482 /** 483 * Callback for mousedown and mouseup events. 484 * @param {Event} e The mouse event object. 485 * @private 486 */ 487 handlePointerDownUp_: function(e) { 488 if (this.disabled) 489 return; 490 491 var target = /** @type {HTMLElement} */(e.target); 492 493 // If the target was this element we need to make sure that the user did 494 // not click on a border or a scrollbar. 495 if (target == this) { 496 if (inViewport(target, e)) 497 this.selectionController_.handlePointerDownUp(e, -1); 498 return; 499 } 500 501 target = this.getListItemAncestor(target); 502 503 var index = this.getIndexOfListItem(target); 504 this.selectionController_.handlePointerDownUp(e, index); 505 }, 506 507 /** 508 * Called when an element in the list is focused. Marks the list as having 509 * a focused element, and dispatches an event if it didn't have focus. 510 * @param {Event} e The focus event. 511 * @private 512 */ 513 handleElementFocus_: function(e) { 514 if (!this.hasElementFocus) 515 this.hasElementFocus = true; 516 }, 517 518 /** 519 * Called when an element in the list is blurred. If focus moves outside 520 * the list, marks the list as no longer having focus and dispatches an 521 * event. 522 * @param {Event} e The blur event. 523 * @private 524 */ 525 handleElementBlur_: function(e) { 526 if (!this.contains(e.relatedTarget)) 527 this.hasElementFocus = false; 528 }, 529 530 /** 531 * Returns the list item element containing the given element, or null if 532 * it doesn't belong to any list item element. 533 * @param {HTMLElement} element The element. 534 * @return {HTMLLIElement} The list item containing |element|, or null. 535 */ 536 getListItemAncestor: function(element) { 537 var container = element; 538 while (container && container.parentNode != this) { 539 container = container.parentNode; 540 } 541 return container && assertInstanceof(container, HTMLLIElement); 542 }, 543 544 /** 545 * Handle a keydown event. 546 * @param {Event} e The keydown event. 547 */ 548 handleKeyDown: function(e) { 549 if (!this.disabled) 550 this.selectionController_.handleKeyDown(e); 551 }, 552 553 /** 554 * Handle a scroll event. 555 * @param {Event} e The scroll event. 556 */ 557 handleScroll: function(e) { 558 requestAnimationFrame(this.redraw.bind(this)); 559 }, 560 561 /** 562 * Callback from the selection model. We dispatch {@code change} events 563 * when the selection changes. 564 * @param {!Event} ce Event with change info. 565 * @private 566 */ 567 handleOnChange_: function(ce) { 568 ce.changes.forEach(function(change) { 569 var listItem = this.getListItemByIndex(change.index); 570 if (listItem) { 571 listItem.selected = change.selected; 572 if (change.selected) { 573 listItem.setAttribute('aria-posinset', change.index + 1); 574 listItem.setAttribute('aria-setsize', this.dataModel.length); 575 this.setAttribute('aria-activedescendant', listItem.id); 576 } else { 577 listItem.removeAttribute('aria-posinset'); 578 listItem.removeAttribute('aria-setsize'); 579 } 580 } 581 }, this); 582 583 cr.dispatchSimpleEvent(this, 'change'); 584 }, 585 586 /** 587 * Handles a change of the lead item from the selection model. 588 * @param {Event} pe The property change event. 589 * @private 590 */ 591 handleLeadChange_: function(pe) { 592 var element; 593 if (pe.oldValue != -1) { 594 if ((element = this.getListItemByIndex(pe.oldValue))) 595 element.lead = false; 596 } 597 598 if (pe.newValue != -1) { 599 if ((element = this.getListItemByIndex(pe.newValue))) 600 element.lead = true; 601 if (pe.oldValue != pe.newValue) { 602 this.scrollIndexIntoView(pe.newValue); 603 // If the lead item has a different height than other items, then we 604 // may run into a problem that requires a second attempt to scroll 605 // it into view. The first scroll attempt will trigger a redraw, 606 // which will clear out the list and repopulate it with new items. 607 // During the redraw, the list may shrink temporarily, which if the 608 // lead item is the last item, will move the scrollTop up since it 609 // cannot extend beyond the end of the list. (Sadly, being scrolled to 610 // the bottom of the list is not "sticky.") So, we set a timeout to 611 // rescroll the list after this all gets sorted out. This is perhaps 612 // not the most elegant solution, but no others seem obvious. 613 var self = this; 614 window.setTimeout(function() { 615 self.scrollIndexIntoView(pe.newValue); 616 }, 0); 617 } 618 } 619 }, 620 621 /** 622 * This handles data model 'permuted' event. 623 * this event is dispatched as a part of sort or splice. 624 * We need to 625 * - adjust the cache. 626 * - adjust selection. 627 * - redraw. (called in this.endBatchUpdates()) 628 * It is important that the cache adjustment happens before selection model 629 * adjustments. 630 * @param {Event} e The 'permuted' event. 631 */ 632 handleDataModelPermuted_: function(e) { 633 var newCachedItems = {}; 634 for (var index in this.cachedItems_) { 635 if (e.permutation[index] != -1) { 636 var newIndex = e.permutation[index]; 637 newCachedItems[newIndex] = this.cachedItems_[index]; 638 newCachedItems[newIndex].listIndex = newIndex; 639 } 640 } 641 this.cachedItems_ = newCachedItems; 642 this.pinnedItem_ = null; 643 644 var newCachedItemHeights = {}; 645 for (var index in this.cachedItemHeights_) { 646 if (e.permutation[index] != -1) { 647 newCachedItemHeights[e.permutation[index]] = 648 this.cachedItemHeights_[index]; 649 } 650 } 651 this.cachedItemHeights_ = newCachedItemHeights; 652 653 this.startBatchUpdates(); 654 655 var sm = this.selectionModel; 656 sm.adjustLength(e.newLength); 657 sm.adjustToReordering(e.permutation); 658 659 this.endBatchUpdates(); 660 }, 661 662 handleDataModelChange_: function(e) { 663 delete this.cachedItems_[e.index]; 664 delete this.cachedItemHeights_[e.index]; 665 this.cachedMeasuredItem_ = null; 666 667 if (e.index >= this.firstIndex_ && 668 (e.index < this.lastIndex_ || this.remainingSpace_)) { 669 this.redraw(); 670 } 671 }, 672 673 /** 674 * @param {number} index The index of the item. 675 * @return {number} The top position of the item inside the list. 676 */ 677 getItemTop: function(index) { 678 if (this.fixedHeight_) { 679 var itemHeight = this.getDefaultItemHeight_(); 680 return index * itemHeight; 681 } else { 682 this.ensureAllItemSizesInCache(); 683 var top = 0; 684 for (var i = 0; i < index; i++) { 685 top += this.getItemHeightByIndex_(i); 686 } 687 return top; 688 } 689 }, 690 691 /** 692 * @param {number} index The index of the item. 693 * @return {number} The row of the item. May vary in the case 694 * of multiple columns. 695 */ 696 getItemRow: function(index) { 697 return index; 698 }, 699 700 /** 701 * @param {number} row The row. 702 * @return {number} The index of the first item in the row. 703 */ 704 getFirstItemInRow: function(row) { 705 return row; 706 }, 707 708 /** 709 * Ensures that a given index is inside the viewport. 710 * @param {number} index The index of the item to scroll into view. 711 * @return {boolean} Whether any scrolling was needed. 712 */ 713 scrollIndexIntoView: function(index) { 714 var dataModel = this.dataModel; 715 if (!dataModel || index < 0 || index >= dataModel.length) 716 return false; 717 718 var itemHeight = this.getItemHeightByIndex_(index); 719 var scrollTop = this.scrollTop; 720 var top = this.getItemTop(index); 721 var clientHeight = this.clientHeight; 722 723 var cs = getComputedStyle(this); 724 var paddingY = parseInt(cs.paddingTop, 10) + 725 parseInt(cs.paddingBottom, 10); 726 var availableHeight = clientHeight - paddingY; 727 728 var self = this; 729 // Function to adjust the tops of viewport and row. 730 function scrollToAdjustTop() { 731 self.scrollTop = top; 732 return true; 733 }; 734 // Function to adjust the bottoms of viewport and row. 735 function scrollToAdjustBottom() { 736 self.scrollTop = top + itemHeight - availableHeight; 737 return true; 738 }; 739 740 // Check if the entire of given indexed row can be shown in the viewport. 741 if (itemHeight <= availableHeight) { 742 if (top < scrollTop) 743 return scrollToAdjustTop(); 744 if (scrollTop + availableHeight < top + itemHeight) 745 return scrollToAdjustBottom(); 746 } else { 747 if (scrollTop < top) 748 return scrollToAdjustTop(); 749 if (top + itemHeight < scrollTop + availableHeight) 750 return scrollToAdjustBottom(); 751 } 752 return false; 753 }, 754 755 /** 756 * @return {!ClientRect} The rect to use for the context menu. 757 */ 758 getRectForContextMenu: function() { 759 // TODO(arv): Add trait support so we can share more code between trees 760 // and lists. 761 var index = this.selectionModel.selectedIndex; 762 var el = this.getListItemByIndex(index); 763 if (el) 764 return el.getBoundingClientRect(); 765 return this.getBoundingClientRect(); 766 }, 767 768 /** 769 * Takes a value from the data model and finds the associated list item. 770 * @param {*} value The value in the data model that we want to get the list 771 * item for. 772 * @return {ListItem} The first found list item or null if not found. 773 */ 774 getListItem: function(value) { 775 var dataModel = this.dataModel; 776 if (dataModel) { 777 var index = dataModel.indexOf(value); 778 return this.getListItemByIndex(index); 779 } 780 return null; 781 }, 782 783 /** 784 * Find the list item element at the given index. 785 * @param {number} index The index of the list item to get. 786 * @return {ListItem} The found list item or null if not found. 787 */ 788 getListItemByIndex: function(index) { 789 return this.cachedItems_[index] || null; 790 }, 791 792 /** 793 * Find the index of the given list item element. 794 * @param {ListItem} item The list item to get the index of. 795 * @return {number} The index of the list item, or -1 if not found. 796 */ 797 getIndexOfListItem: function(item) { 798 var index = item.listIndex; 799 if (this.cachedItems_[index] == item) { 800 return index; 801 } 802 return -1; 803 }, 804 805 /** 806 * Creates a new list item. 807 * @param {*} value The value to use for the item. 808 * @return {!ListItem} The newly created list item. 809 */ 810 createItem: function(value) { 811 var item = new this.itemConstructor_(value); 812 item.label = value; 813 item.id = this.uniqueIdPrefix_ + '-' + this.nextUniqueIdSuffix_++; 814 if (typeof item.decorate == 'function') 815 item.decorate(); 816 return item; 817 }, 818 819 /** 820 * Creates the selection controller to use internally. 821 * @param {cr.ui.ListSelectionModel} sm The underlying selection model. 822 * @return {!cr.ui.ListSelectionController} The newly created selection 823 * controller. 824 */ 825 createSelectionController: function(sm) { 826 return new ListSelectionController(sm); 827 }, 828 829 /** 830 * Return the heights (in pixels) of the top of the given item index within 831 * the list, and the height of the given item itself, accounting for the 832 * possibility that the lead item may be a different height. 833 * @param {number} index The index to find the top height of. 834 * @return {{top: number, height: number}} The heights for the given index. 835 * @private 836 */ 837 getHeightsForIndex_: function(index) { 838 var itemHeight = this.getItemHeightByIndex_(index); 839 var top = this.getItemTop(index); 840 return {top: top, height: itemHeight}; 841 }, 842 843 /** 844 * Find the index of the list item containing the given y offset (measured 845 * in pixels from the top) within the list. In the case of multiple columns, 846 * returns the first index in the row. 847 * @param {number} offset The y offset in pixels to get the index of. 848 * @return {number} The index of the list item. Returns the list size if 849 * given offset exceeds the height of list. 850 * @private 851 */ 852 getIndexForListOffset_: function(offset) { 853 var itemHeight = this.getDefaultItemHeight_(); 854 if (!itemHeight) 855 return this.dataModel.length; 856 857 if (this.fixedHeight_) 858 return this.getFirstItemInRow(Math.floor(offset / itemHeight)); 859 860 // If offset exceeds the height of list. 861 var lastHeight = 0; 862 if (this.dataModel.length) { 863 var h = this.getHeightsForIndex_(this.dataModel.length - 1); 864 lastHeight = h.top + h.height; 865 } 866 if (lastHeight < offset) 867 return this.dataModel.length; 868 869 // Estimates index. 870 var estimatedIndex = Math.min(Math.floor(offset / itemHeight), 871 this.dataModel.length - 1); 872 var isIncrementing = this.getItemTop(estimatedIndex) < offset; 873 874 // Searchs the correct index. 875 do { 876 var heights = this.getHeightsForIndex_(estimatedIndex); 877 var top = heights.top; 878 var height = heights.height; 879 880 if (top <= offset && offset <= (top + height)) 881 break; 882 883 isIncrementing ? ++estimatedIndex : --estimatedIndex; 884 } while (0 < estimatedIndex && estimatedIndex < this.dataModel.length); 885 886 return estimatedIndex; 887 }, 888 889 /** 890 * Return the number of items that occupy the range of heights between the 891 * top of the start item and the end offset. 892 * @param {number} startIndex The index of the first visible item. 893 * @param {number} endOffset The y offset in pixels of the end of the list. 894 * @return {number} The number of list items visible. 895 * @private 896 */ 897 countItemsInRange_: function(startIndex, endOffset) { 898 var endIndex = this.getIndexForListOffset_(endOffset); 899 return endIndex - startIndex + 1; 900 }, 901 902 /** 903 * Calculates the number of items fitting in the given viewport. 904 * @param {number} scrollTop The scroll top position. 905 * @param {number} clientHeight The height of viewport. 906 * @return {{first: number, length: number, last: number}} The index of 907 * first item in view port, The number of items, The item past the last. 908 */ 909 getItemsInViewPort: function(scrollTop, clientHeight) { 910 if (this.autoExpands_) { 911 return { 912 first: 0, 913 length: this.dataModel.length, 914 last: this.dataModel.length}; 915 } else { 916 var firstIndex = this.getIndexForListOffset_(scrollTop); 917 var lastIndex = this.getIndexForListOffset_(scrollTop + clientHeight); 918 919 return { 920 first: firstIndex, 921 length: lastIndex - firstIndex + 1, 922 last: lastIndex + 1}; 923 } 924 }, 925 926 /** 927 * Merges list items currently existing in the list with items in the range 928 * [firstIndex, lastIndex). Removes or adds items if needed. 929 * Doesn't delete {@code this.pinnedItem_} if it is present (instead hides 930 * it if it is out of the range). 931 * @param {number} firstIndex The index of first item, inclusively. 932 * @param {number} lastIndex The index of last item, exclusively. 933 */ 934 mergeItems: function(firstIndex, lastIndex) { 935 var self = this; 936 var dataModel = this.dataModel; 937 var currentIndex = firstIndex; 938 939 function insert() { 940 var dataItem = dataModel.item(currentIndex); 941 var newItem = self.cachedItems_[currentIndex] || 942 self.createItem(dataItem); 943 newItem.listIndex = currentIndex; 944 self.cachedItems_[currentIndex] = newItem; 945 self.insertBefore(newItem, item); 946 currentIndex++; 947 } 948 949 function remove() { 950 var next = item.nextSibling; 951 if (item != self.pinnedItem_) 952 self.removeChild(item); 953 item = next; 954 } 955 956 for (var item = this.beforeFiller_.nextSibling; 957 item != this.afterFiller_ && currentIndex < lastIndex;) { 958 if (!this.isItem(item)) { 959 item = item.nextSibling; 960 continue; 961 } 962 963 var index = item.listIndex; 964 if (this.cachedItems_[index] != item || index < currentIndex) { 965 remove(); 966 } else if (index == currentIndex) { 967 this.cachedItems_[currentIndex] = item; 968 item = item.nextSibling; 969 currentIndex++; 970 } else { // index > currentIndex 971 insert(); 972 } 973 } 974 975 while (item != this.afterFiller_) { 976 if (this.isItem(item)) 977 remove(); 978 else 979 item = item.nextSibling; 980 } 981 982 if (this.pinnedItem_) { 983 var index = this.pinnedItem_.listIndex; 984 this.pinnedItem_.hidden = index < firstIndex || index >= lastIndex; 985 this.cachedItems_[index] = this.pinnedItem_; 986 if (index >= lastIndex) 987 item = this.pinnedItem_; // Insert new items before this one. 988 } 989 990 while (currentIndex < lastIndex) 991 insert(); 992 }, 993 994 /** 995 * Ensures that all the item sizes in the list have been already cached. 996 */ 997 ensureAllItemSizesInCache: function() { 998 var measuringIndexes = []; 999 var isElementAppended = []; 1000 for (var y = 0; y < this.dataModel.length; y++) { 1001 if (!this.cachedItemHeights_[y]) { 1002 measuringIndexes.push(y); 1003 isElementAppended.push(false); 1004 } 1005 } 1006 1007 var measuringItems = []; 1008 // Adds temporary elements. 1009 for (var y = 0; y < measuringIndexes.length; y++) { 1010 var index = measuringIndexes[y]; 1011 var dataItem = this.dataModel.item(index); 1012 var listItem = this.cachedItems_[index] || this.createItem(dataItem); 1013 listItem.listIndex = index; 1014 1015 // If |listItems| is not on the list, apppends it to the list and sets 1016 // the flag. 1017 if (!listItem.parentNode) { 1018 this.appendChild(listItem); 1019 isElementAppended[y] = true; 1020 } 1021 1022 this.cachedItems_[index] = listItem; 1023 measuringItems.push(listItem); 1024 } 1025 1026 // All mesurings must be placed after adding all the elements, to prevent 1027 // performance reducing. 1028 for (var y = 0; y < measuringIndexes.length; y++) { 1029 var index = measuringIndexes[y]; 1030 this.cachedItemHeights_[index] = 1031 this.measureItemHeight_(measuringItems[y]); 1032 } 1033 1034 // Removes all the temprary elements. 1035 for (var y = 0; y < measuringIndexes.length; y++) { 1036 // If the list item has been appended above, removes it. 1037 if (isElementAppended[y]) 1038 this.removeChild(measuringItems[y]); 1039 } 1040 }, 1041 1042 /** 1043 * Returns the height of after filler in the list. 1044 * @param {number} lastIndex The index of item past the last in viewport. 1045 * @return {number} The height of after filler. 1046 */ 1047 getAfterFillerHeight: function(lastIndex) { 1048 if (this.fixedHeight_) { 1049 var itemHeight = this.getDefaultItemHeight_(); 1050 return (this.dataModel.length - lastIndex) * itemHeight; 1051 } 1052 1053 var height = 0; 1054 for (var i = lastIndex; i < this.dataModel.length; i++) 1055 height += this.getItemHeightByIndex_(i); 1056 return height; 1057 }, 1058 1059 /** 1060 * Redraws the viewport. 1061 */ 1062 redraw: function() { 1063 if (this.batchCount_ != 0) 1064 return; 1065 1066 var dataModel = this.dataModel; 1067 if (!dataModel || !this.autoExpands_ && this.clientHeight == 0) { 1068 this.cachedItems_ = {}; 1069 this.firstIndex_ = 0; 1070 this.lastIndex_ = 0; 1071 this.remainingSpace_ = this.clientHeight != 0; 1072 this.mergeItems(0, 0); 1073 return; 1074 } 1075 1076 // Save the previous positions before any manipulation of elements. 1077 var scrollTop = this.scrollTop; 1078 var clientHeight = this.clientHeight; 1079 1080 // Store all the item sizes into the cache in advance, to prevent 1081 // interleave measuring with mutating dom. 1082 if (!this.fixedHeight_) 1083 this.ensureAllItemSizesInCache(); 1084 1085 var autoExpands = this.autoExpands_; 1086 1087 var itemsInViewPort = this.getItemsInViewPort(scrollTop, clientHeight); 1088 // Draws the hidden rows just above/below the viewport to prevent 1089 // flashing in scroll. 1090 var firstIndex = Math.max( 1091 0, 1092 Math.min(dataModel.length - 1, 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 the pinned item is hidden and it is not the lead item, then remove 1106 // it from cache. Note, that we restore the hidden status to false, since 1107 // the item is still in cache, and may be reused. 1108 if (this.pinnedItem_ && 1109 this.pinnedItem_ != this.cachedItems_[leadIndex]) { 1110 if (this.pinnedItem_.hidden) { 1111 this.removeChild(this.pinnedItem_); 1112 this.pinnedItem_.hidden = false; 1113 } 1114 this.pinnedItem_ = undefined; 1115 } 1116 1117 this.mergeItems(firstIndex, lastIndex); 1118 1119 if (!this.pinnedItem_ && this.cachedItems_[leadIndex] && 1120 this.cachedItems_[leadIndex].parentNode == this) { 1121 this.pinnedItem_ = this.cachedItems_[leadIndex]; 1122 } 1123 1124 this.afterFiller_.style.height = afterFillerHeight + 'px'; 1125 1126 // Restores the number of pixels scrolled, since it might be changed while 1127 // DOM operations. 1128 this.scrollTop = scrollTop; 1129 1130 // We don't set the lead or selected properties until after adding all 1131 // items, in case they force relayout in response to these events. 1132 if (leadIndex != -1 && this.cachedItems_[leadIndex]) 1133 this.cachedItems_[leadIndex].lead = true; 1134 for (var y = firstIndex; y < lastIndex; y++) { 1135 if (sm.getIndexSelected(y) != this.cachedItems_[y].selected) 1136 this.cachedItems_[y].selected = !this.cachedItems_[y].selected; 1137 } 1138 1139 this.firstIndex_ = firstIndex; 1140 this.lastIndex_ = lastIndex; 1141 1142 this.remainingSpace_ = itemsInViewPort.last > dataModel.length; 1143 1144 // Mesurings must be placed after adding all the elements, to prevent 1145 // performance reducing. 1146 if (!this.fixedHeight_) { 1147 for (var y = firstIndex; y < lastIndex; y++) { 1148 this.cachedItemHeights_[y] = 1149 this.measureItemHeight_(this.cachedItems_[y]); 1150 } 1151 } 1152 }, 1153 1154 /** 1155 * Restore the lead item that is present in the list but may be updated 1156 * in the data model (supposed to be used inside a batch update). Usually 1157 * such an item would be recreated in the redraw method. If reinsertion 1158 * is undesirable (for instance to prevent losing focus) the item may be 1159 * updated and restored. Assumed the listItem relates to the same data item 1160 * as the lead item in the begin of the batch update. 1161 * 1162 * @param {ListItem} leadItem Already existing lead item. 1163 */ 1164 restoreLeadItem: function(leadItem) { 1165 delete this.cachedItems_[leadItem.listIndex]; 1166 1167 leadItem.listIndex = this.selectionModel.leadIndex; 1168 this.pinnedItem_ = this.cachedItems_[leadItem.listIndex] = leadItem; 1169 }, 1170 1171 /** 1172 * Invalidates list by removing cached items. 1173 */ 1174 invalidate: function() { 1175 this.cachedItems_ = {}; 1176 this.cachedItemSized_ = {}; 1177 }, 1178 1179 /** 1180 * Redraws a single item. 1181 * @param {number} index The row index to redraw. 1182 */ 1183 redrawItem: function(index) { 1184 if (index >= this.firstIndex_ && 1185 (index < this.lastIndex_ || this.remainingSpace_)) { 1186 delete this.cachedItems_[index]; 1187 this.redraw(); 1188 } 1189 }, 1190 1191 /** 1192 * Called when a list item is activated, currently only by a double click 1193 * event. 1194 * @param {number} index The index of the activated item. 1195 */ 1196 activateItemAtIndex: function(index) { 1197 }, 1198 1199 /** 1200 * Returns a ListItem for the leadIndex. If the item isn't present in the 1201 * list creates it and inserts to the list (may be invisible if it's out of 1202 * the visible range). 1203 * 1204 * Item returned from this method won't be removed until it remains a lead 1205 * item or til the data model changes (unlike other items that could be 1206 * removed when they go out of the visible range). 1207 * 1208 * @return {cr.ui.ListItem} The lead item for the list. 1209 */ 1210 ensureLeadItemExists: function() { 1211 var index = this.selectionModel.leadIndex; 1212 if (index < 0) 1213 return null; 1214 var cachedItems = this.cachedItems_ || {}; 1215 1216 var item = cachedItems[index] || 1217 this.createItem(this.dataModel.item(index)); 1218 if (this.pinnedItem_ != item && this.pinnedItem_ && 1219 this.pinnedItem_.hidden) { 1220 this.removeChild(this.pinnedItem_); 1221 } 1222 this.pinnedItem_ = item; 1223 cachedItems[index] = item; 1224 item.listIndex = index; 1225 if (item.parentNode == this) 1226 return item; 1227 1228 if (this.batchCount_ != 0) 1229 item.hidden = true; 1230 1231 // Item will get to the right place in redraw. Choose place to insert 1232 // reducing items reinsertion. 1233 if (index <= this.firstIndex_) 1234 this.insertBefore(item, this.beforeFiller_.nextSibling); 1235 else 1236 this.insertBefore(item, this.afterFiller_); 1237 this.redraw(); 1238 return item; 1239 }, 1240 1241 /** 1242 * Starts drag selection by reacting 'dragstart' event. 1243 * @param {Event} event Event of dragstart. 1244 */ 1245 startDragSelection: function(event) { 1246 event.preventDefault(); 1247 var border = document.createElement('div'); 1248 border.className = 'drag-selection-border'; 1249 var rect = this.getBoundingClientRect(); 1250 var startX = event.clientX - rect.left + this.scrollLeft; 1251 var startY = event.clientY - rect.top + this.scrollTop; 1252 border.style.left = startX + 'px'; 1253 border.style.top = startY + 'px'; 1254 var onMouseMove = function(event) { 1255 var inRect = this.getBoundingClientRect(); 1256 var x = event.clientX - inRect.left + this.scrollLeft; 1257 var y = event.clientY - inRect.top + this.scrollTop; 1258 border.style.left = Math.min(startX, x) + 'px'; 1259 border.style.top = Math.min(startY, y) + 'px'; 1260 border.style.width = Math.abs(startX - x) + 'px'; 1261 border.style.height = Math.abs(startY - y) + 'px'; 1262 }.bind(this); 1263 var onMouseUp = function() { 1264 this.removeChild(border); 1265 document.removeEventListener('mousemove', onMouseMove, true); 1266 document.removeEventListener('mouseup', onMouseUp, true); 1267 }.bind(this); 1268 document.addEventListener('mousemove', onMouseMove, true); 1269 document.addEventListener('mouseup', onMouseUp, true); 1270 this.appendChild(border); 1271 }, 1272 }; 1273 1274 cr.defineProperty(List, 'disabled', cr.PropertyKind.BOOL_ATTR); 1275 1276 /** 1277 * Whether the list or one of its descendents has focus. This is necessary 1278 * because list items can contain controls that can be focused, and for some 1279 * purposes (e.g., styling), the list can still be conceptually focused at 1280 * that point even though it doesn't actually have the page focus. 1281 */ 1282 cr.defineProperty(List, 'hasElementFocus', cr.PropertyKind.BOOL_ATTR); 1283 1284 /** 1285 * Mousedown event handler. 1286 * @this {cr.ui.List} 1287 * @param {Event} e The mouse event object. 1288 */ 1289 function handleMouseDown(e) { 1290 e.target = /** @type {!HTMLElement} */(e.target); 1291 var listItem = this.getListItemAncestor(e.target); 1292 var wasSelected = listItem && listItem.selected; 1293 this.handlePointerDownUp_(e); 1294 1295 if (e.defaultPrevented || e.button != 0) 1296 return; 1297 1298 // The following hack is required only if the listItem gets selected. 1299 if (!listItem || wasSelected || !listItem.selected) 1300 return; 1301 1302 // If non-focusable area in a list item is clicked and the item still 1303 // contains the focused element, the item did a special focus handling 1304 // [1] and we should not focus on the list. 1305 // 1306 // [1] For example, clicking non-focusable area gives focus on the first 1307 // form control in the item. 1308 if (!containsFocusableElement(e.target, listItem) && 1309 listItem.contains(listItem.ownerDocument.activeElement)) { 1310 e.preventDefault(); 1311 } 1312 } 1313 1314 /** 1315 * Dragstart event handler. 1316 * If there is an item at starting position of drag operation and the item 1317 * is not selected, select it. 1318 * @this {cr.ui.List} 1319 * @param {Event} e The event object for 'dragstart'. 1320 */ 1321 function handleDragStart(e) { 1322 e = /** @type {MouseEvent} */(e); 1323 var element = e.target.ownerDocument.elementFromPoint(e.clientX, e.clientY); 1324 var listItem = this.getListItemAncestor(element); 1325 if (!listItem) 1326 return; 1327 1328 var index = this.getIndexOfListItem(listItem); 1329 if (index == -1) 1330 return; 1331 1332 var isAlreadySelected = this.selectionModel_.getIndexSelected(index); 1333 if (!isAlreadySelected) 1334 this.selectionModel_.selectedIndex = index; 1335 } 1336 1337 /** 1338 * Check if |start| or its ancestor under |root| is focusable. 1339 * This is a helper for handleMouseDown. 1340 * @param {!Element} start An element which we start to check. 1341 * @param {!Element} root An element which we finish to check. 1342 * @return {boolean} True if we found a focusable element. 1343 */ 1344 function containsFocusableElement(start, root) { 1345 for (var element = start; element && element != root; 1346 element = element.parentElement) { 1347 if (element.tabIndex >= 0 && !element.disabled) 1348 return true; 1349 } 1350 return false; 1351 } 1352 1353 return { 1354 List: List 1355 }; 1356}); 1357