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 5cr.define('cr.ui', function() { 6 // require cr.ui.define 7 // require cr.ui.limitInputWidth 8 9 /** 10 * The number of pixels to indent per level. 11 * @type {number} 12 * @const 13 */ 14 var INDENT = 20; 15 16 /** 17 * Returns the computed style for an element. 18 * @param {!Element} el The element to get the computed style for. 19 * @return {!CSSStyleDeclaration} The computed style. 20 */ 21 function getComputedStyle(el) { 22 return el.ownerDocument.defaultView.getComputedStyle(el); 23 } 24 25 /** 26 * Helper function that finds the first ancestor tree item. 27 * @param {!Element} el The element to start searching from. 28 * @return {cr.ui.TreeItem} The found tree item or null if not found. 29 */ 30 function findTreeItem(el) { 31 while (el && !(el instanceof TreeItem)) { 32 el = el.parentNode; 33 } 34 return el; 35 } 36 37 /** 38 * Creates a new tree element. 39 * @param {Object=} opt_propertyBag Optional properties. 40 * @constructor 41 * @extends {HTMLElement} 42 */ 43 var Tree = cr.ui.define('tree'); 44 45 Tree.prototype = { 46 __proto__: HTMLElement.prototype, 47 48 /** 49 * Initializes the element. 50 */ 51 decorate: function() { 52 // Make list focusable 53 if (!this.hasAttribute('tabindex')) 54 this.tabIndex = 0; 55 56 this.addEventListener('click', this.handleClick); 57 this.addEventListener('mousedown', this.handleMouseDown); 58 this.addEventListener('dblclick', this.handleDblClick); 59 this.addEventListener('keydown', this.handleKeyDown); 60 }, 61 62 /** 63 * Returns the tree item that are children of this tree. 64 */ 65 get items() { 66 return this.children; 67 }, 68 69 /** 70 * Adds a tree item to the tree. 71 * @param {!cr.ui.TreeItem} treeItem The item to add. 72 */ 73 add: function(treeItem) { 74 this.addAt(treeItem, 0xffffffff); 75 }, 76 77 /** 78 * Adds a tree item at the given index. 79 * @param {!cr.ui.TreeItem} treeItem The item to add. 80 * @param {number} index The index where we want to add the item. 81 */ 82 addAt: function(treeItem, index) { 83 this.insertBefore(treeItem, this.children[index]); 84 treeItem.setDepth_(this.depth + 1); 85 }, 86 87 /** 88 * Removes a tree item child. 89 * @param {!cr.ui.TreeItem} treeItem The tree item to remove. 90 */ 91 remove: function(treeItem) { 92 this.removeChild(treeItem); 93 }, 94 95 /** 96 * The depth of the node. This is 0 for the tree itself. 97 * @type {number} 98 */ 99 get depth() { 100 return 0; 101 }, 102 103 /** 104 * Handles click events on the tree and forwards the event to the relevant 105 * tree items as necesary. 106 * @param {Event} e The click event object. 107 */ 108 handleClick: function(e) { 109 var treeItem = findTreeItem(e.target); 110 if (treeItem) 111 treeItem.handleClick(e); 112 }, 113 114 handleMouseDown: function(e) { 115 if (e.button == 2) // right 116 this.handleClick(e); 117 }, 118 119 /** 120 * Handles double click events on the tree. 121 * @param {Event} e The dblclick event object. 122 */ 123 handleDblClick: function(e) { 124 var treeItem = findTreeItem(e.target); 125 if (treeItem) 126 treeItem.expanded = !treeItem.expanded; 127 }, 128 129 /** 130 * Handles keydown events on the tree and updates selection and exanding 131 * of tree items. 132 * @param {Event} e The click event object. 133 */ 134 handleKeyDown: function(e) { 135 var itemToSelect; 136 if (e.ctrlKey) 137 return; 138 139 var item = this.selectedItem; 140 if (!item) 141 return; 142 143 var rtl = getComputedStyle(item).direction == 'rtl'; 144 145 switch (e.keyIdentifier) { 146 case 'Up': 147 itemToSelect = item ? getPrevious(item) : 148 this.items[this.items.length - 1]; 149 break; 150 case 'Down': 151 itemToSelect = item ? getNext(item) : 152 this.items[0]; 153 break; 154 case 'Left': 155 case 'Right': 156 // Don't let back/forward keyboard shortcuts be used. 157 if (!cr.isMac && e.altKey || cr.isMac && e.metaKey) 158 break; 159 160 if (e.keyIdentifier == 'Left' && !rtl || 161 e.keyIdentifier == 'Right' && rtl) { 162 if (item.expanded) 163 item.expanded = false; 164 else 165 itemToSelect = findTreeItem(item.parentNode); 166 } else { 167 if (!item.expanded) 168 item.expanded = true; 169 else 170 itemToSelect = item.items[0]; 171 } 172 break; 173 case 'Home': 174 itemToSelect = this.items[0]; 175 break; 176 case 'End': 177 itemToSelect = this.items[this.items.length - 1]; 178 break; 179 } 180 181 if (itemToSelect) { 182 itemToSelect.selected = true; 183 e.preventDefault(); 184 } 185 }, 186 187 /** 188 * The selected tree item or null if none. 189 * @type {cr.ui.TreeItem} 190 */ 191 get selectedItem() { 192 return this.selectedItem_ || null; 193 }, 194 set selectedItem(item) { 195 var oldSelectedItem = this.selectedItem_; 196 if (oldSelectedItem != item) { 197 // Set the selectedItem_ before deselecting the old item since we only 198 // want one change when moving between items. 199 this.selectedItem_ = item; 200 201 if (oldSelectedItem) 202 oldSelectedItem.selected = false; 203 204 if (item) { 205 item.selected = true; 206 if (item.id) 207 this.setAttribute('aria-activedescendant', item.id); 208 } else { 209 this.removeAttribute('aria-activedescendant'); 210 } 211 cr.dispatchSimpleEvent(this, 'change'); 212 } 213 }, 214 215 /** 216 * @return {!ClientRect} The rect to use for the context menu. 217 */ 218 getRectForContextMenu: function() { 219 // TODO(arv): Add trait support so we can share more code between trees 220 // and lists. 221 if (this.selectedItem) 222 return this.selectedItem.rowElement.getBoundingClientRect(); 223 return this.getBoundingClientRect(); 224 } 225 }; 226 227 /** 228 * Determines the visibility of icons next to the treeItem labels. If set to 229 * 'hidden', no space is reserved for icons and no icons are displayed next 230 * to treeItem labels. If set to 'parent', folder icons will be displayed 231 * next to expandable parent nodes. If set to 'all' folder icons will be 232 * displayed next to all nodes. Icons can be set using the treeItem's icon 233 * property. 234 */ 235 cr.defineProperty(Tree, 'iconVisibility', cr.PropertyKind.ATTR); 236 237 /** 238 * Incremental counter for an auto generated ID of the tree item. This will 239 * be incremented per element, so each element never share same ID. 240 * 241 * @type {number} 242 */ 243 var treeItemAutoGeneratedIdCounter = 0; 244 245 /** 246 * This is used as a blueprint for new tree item elements. 247 * @type {!HTMLElement} 248 */ 249 var treeItemProto = (function() { 250 var treeItem = cr.doc.createElement('div'); 251 treeItem.className = 'tree-item'; 252 treeItem.innerHTML = '<div class=tree-row>' + 253 '<span class=expand-icon></span>' + 254 '<span class=tree-label></span>' + 255 '</div>' + 256 '<div class=tree-children></div>'; 257 treeItem.setAttribute('role', 'treeitem'); 258 return treeItem; 259 })(); 260 261 /** 262 * Creates a new tree item. 263 * @param {Object=} opt_propertyBag Optional properties. 264 * @constructor 265 * @extends {HTMLElement} 266 */ 267 var TreeItem = cr.ui.define(function() { 268 var treeItem = treeItemProto.cloneNode(true); 269 treeItem.id = 'tree-item-autogen-id-' + treeItemAutoGeneratedIdCounter++; 270 return treeItem; 271 }); 272 273 TreeItem.prototype = { 274 __proto__: HTMLElement.prototype, 275 276 /** 277 * Initializes the element. 278 */ 279 decorate: function() { 280 281 }, 282 283 /** 284 * The tree items children. 285 */ 286 get items() { 287 return this.lastElementChild.children; 288 }, 289 290 /** 291 * The depth of the tree item. 292 * @type {number} 293 */ 294 depth_: 0, 295 get depth() { 296 return this.depth_; 297 }, 298 299 /** 300 * Sets the depth. 301 * @param {number} depth The new depth. 302 * @private 303 */ 304 setDepth_: function(depth) { 305 if (depth != this.depth_) { 306 this.rowElement.style.WebkitPaddingStart = Math.max(0, depth - 1) * 307 INDENT + 'px'; 308 this.depth_ = depth; 309 var items = this.items; 310 for (var i = 0, item; item = items[i]; i++) { 311 item.setDepth_(depth + 1); 312 } 313 } 314 }, 315 316 /** 317 * Adds a tree item as a child. 318 * @param {!cr.ui.TreeItem} child The child to add. 319 */ 320 add: function(child) { 321 this.addAt(child, 0xffffffff); 322 }, 323 324 /** 325 * Adds a tree item as a child at a given index. 326 * @param {!cr.ui.TreeItem} child The child to add. 327 * @param {number} index The index where to add the child. 328 */ 329 addAt: function(child, index) { 330 this.lastElementChild.insertBefore(child, this.items[index]); 331 if (this.items.length == 1) 332 this.hasChildren = true; 333 child.setDepth_(this.depth + 1); 334 }, 335 336 /** 337 * Removes a child. 338 * @param {!cr.ui.TreeItem} child The tree item child to remove. 339 */ 340 remove: function(child) { 341 // If we removed the selected item we should become selected. 342 var tree = this.tree; 343 var selectedItem = tree.selectedItem; 344 if (selectedItem && child.contains(selectedItem)) 345 this.selected = true; 346 347 this.lastElementChild.removeChild(child); 348 if (this.items.length == 0) 349 this.hasChildren = false; 350 }, 351 352 /** 353 * The parent tree item. 354 * @type {!cr.ui.Tree|cr.ui.TreeItem} 355 */ 356 get parentItem() { 357 var p = this.parentNode; 358 while (p && !(p instanceof TreeItem) && !(p instanceof Tree)) { 359 p = p.parentNode; 360 } 361 return p; 362 }, 363 364 /** 365 * The tree that the tree item belongs to or null of no added to a tree. 366 * @type {cr.ui.Tree} 367 */ 368 get tree() { 369 var t = this.parentItem; 370 while (t && !(t instanceof Tree)) { 371 t = t.parentItem; 372 } 373 return t; 374 }, 375 376 /** 377 * Whether the tree item is expanded or not. 378 * @type {boolean} 379 */ 380 get expanded() { 381 return this.hasAttribute('expanded'); 382 }, 383 set expanded(b) { 384 if (this.expanded == b) 385 return; 386 387 var treeChildren = this.lastElementChild; 388 389 if (b) { 390 if (this.mayHaveChildren_) { 391 this.setAttribute('expanded', ''); 392 treeChildren.setAttribute('expanded', ''); 393 cr.dispatchSimpleEvent(this, 'expand', true); 394 this.scrollIntoViewIfNeeded(false); 395 } 396 } else { 397 var tree = this.tree; 398 if (tree && !this.selected) { 399 var oldSelected = tree.selectedItem; 400 if (oldSelected && this.contains(oldSelected)) 401 this.selected = true; 402 } 403 this.removeAttribute('expanded'); 404 treeChildren.removeAttribute('expanded'); 405 cr.dispatchSimpleEvent(this, 'collapse', true); 406 } 407 }, 408 409 /** 410 * Expands all parent items. 411 */ 412 reveal: function() { 413 var pi = this.parentItem; 414 while (pi && !(pi instanceof Tree)) { 415 pi.expanded = true; 416 pi = pi.parentItem; 417 } 418 }, 419 420 /** 421 * The element representing the row that gets highlighted. 422 * @type {!HTMLElement} 423 */ 424 get rowElement() { 425 return this.firstElementChild; 426 }, 427 428 /** 429 * The element containing the label text and the icon. 430 * @type {!HTMLElement} 431 */ 432 get labelElement() { 433 return this.firstElementChild.lastElementChild; 434 }, 435 436 /** 437 * The label text. 438 * @type {string} 439 */ 440 get label() { 441 return this.labelElement.textContent; 442 }, 443 set label(s) { 444 this.labelElement.textContent = s; 445 }, 446 447 /** 448 * The URL for the icon. 449 * @type {string} 450 */ 451 get icon() { 452 return getComputedStyle(this.labelElement).backgroundImage.slice(4, -1); 453 }, 454 set icon(icon) { 455 return this.labelElement.style.backgroundImage = url(icon); 456 }, 457 458 /** 459 * Whether the tree item is selected or not. 460 * @type {boolean} 461 */ 462 get selected() { 463 return this.hasAttribute('selected'); 464 }, 465 set selected(b) { 466 if (this.selected == b) 467 return; 468 var rowItem = this.firstElementChild; 469 var tree = this.tree; 470 if (b) { 471 this.setAttribute('selected', ''); 472 rowItem.setAttribute('selected', ''); 473 this.reveal(); 474 this.labelElement.scrollIntoViewIfNeeded(false); 475 if (tree) 476 tree.selectedItem = this; 477 } else { 478 this.removeAttribute('selected'); 479 rowItem.removeAttribute('selected'); 480 if (tree && tree.selectedItem == this) 481 tree.selectedItem = null; 482 } 483 }, 484 485 /** 486 * Whether the tree item has children. 487 * @type {boolean} 488 */ 489 get mayHaveChildren_() { 490 return this.hasAttribute('may-have-children'); 491 }, 492 set mayHaveChildren_(b) { 493 var rowItem = this.firstElementChild; 494 if (b) { 495 this.setAttribute('may-have-children', ''); 496 rowItem.setAttribute('may-have-children', ''); 497 } else { 498 this.removeAttribute('may-have-children'); 499 rowItem.removeAttribute('may-have-children'); 500 } 501 }, 502 503 /** 504 * Whether the tree item has children. 505 * @type {boolean} 506 */ 507 get hasChildren() { 508 return !!this.items[0]; 509 }, 510 511 /** 512 * Whether the tree item has children. 513 * @type {boolean} 514 */ 515 set hasChildren(b) { 516 var rowItem = this.firstElementChild; 517 this.setAttribute('has-children', b); 518 rowItem.setAttribute('has-children', b); 519 if (b) 520 this.mayHaveChildren_ = true; 521 }, 522 523 /** 524 * Called when the user clicks on a tree item. This is forwarded from the 525 * cr.ui.Tree. 526 * @param {Event} e The click event. 527 */ 528 handleClick: function(e) { 529 if (e.target.className == 'expand-icon') 530 this.expanded = !this.expanded; 531 else 532 this.selected = true; 533 }, 534 535 /** 536 * Makes the tree item user editable. If the user renamed the item a 537 * bubbling {@code rename} event is fired. 538 * @type {boolean} 539 */ 540 set editing(editing) { 541 var oldEditing = this.editing; 542 if (editing == oldEditing) 543 return; 544 545 var self = this; 546 var labelEl = this.labelElement; 547 var text = this.label; 548 var input; 549 550 // Handles enter and escape which trigger reset and commit respectively. 551 function handleKeydown(e) { 552 // Make sure that the tree does not handle the key. 553 e.stopPropagation(); 554 555 // Calling tree.focus blurs the input which will make the tree item 556 // non editable. 557 switch (e.keyIdentifier) { 558 case 'U+001B': // Esc 559 input.value = text; 560 // fall through 561 case 'Enter': 562 self.tree.focus(); 563 } 564 } 565 566 function stopPropagation(e) { 567 e.stopPropagation(); 568 } 569 570 if (editing) { 571 this.selected = true; 572 this.setAttribute('editing', ''); 573 this.draggable = false; 574 575 // We create an input[type=text] and copy over the label value. When 576 // the input loses focus we set editing to false again. 577 input = this.ownerDocument.createElement('input'); 578 input.value = text; 579 if (labelEl.firstChild) 580 labelEl.replaceChild(input, labelEl.firstChild); 581 else 582 labelEl.appendChild(input); 583 584 input.addEventListener('keydown', handleKeydown); 585 input.addEventListener('blur', (function() { 586 this.editing = false; 587 }).bind(this)); 588 589 // Make sure that double clicks do not expand and collapse the tree 590 // item. 591 var eventsToStop = ['mousedown', 'mouseup', 'contextmenu', 'dblclick']; 592 eventsToStop.forEach(function(type) { 593 input.addEventListener(type, stopPropagation); 594 }); 595 596 // Wait for the input element to recieve focus before sizing it. 597 var rowElement = this.rowElement; 598 function onFocus() { 599 input.removeEventListener('focus', onFocus); 600 // 20 = the padding and border of the tree-row 601 cr.ui.limitInputWidth(input, rowElement, 100); 602 } 603 input.addEventListener('focus', onFocus); 604 input.focus(); 605 input.select(); 606 607 this.oldLabel_ = text; 608 } else { 609 this.removeAttribute('editing'); 610 this.draggable = true; 611 input = labelEl.firstChild; 612 var value = input.value; 613 if (/^\s*$/.test(value)) { 614 labelEl.textContent = this.oldLabel_; 615 } else { 616 labelEl.textContent = value; 617 if (value != this.oldLabel_) { 618 cr.dispatchSimpleEvent(this, 'rename', true); 619 } 620 } 621 delete this.oldLabel_; 622 } 623 }, 624 625 get editing() { 626 return this.hasAttribute('editing'); 627 } 628 }; 629 630 /** 631 * Helper function that returns the next visible tree item. 632 * @param {cr.ui.TreeItem} item The tree item. 633 * @return {cr.ui.TreeItem} The found item or null. 634 */ 635 function getNext(item) { 636 if (item.expanded) { 637 var firstChild = item.items[0]; 638 if (firstChild) { 639 return firstChild; 640 } 641 } 642 643 return getNextHelper(item); 644 } 645 646 /** 647 * Another helper function that returns the next visible tree item. 648 * @param {cr.ui.TreeItem} item The tree item. 649 * @return {cr.ui.TreeItem} The found item or null. 650 */ 651 function getNextHelper(item) { 652 if (!item) 653 return null; 654 655 var nextSibling = item.nextElementSibling; 656 if (nextSibling) { 657 return nextSibling; 658 } 659 return getNextHelper(item.parentItem); 660 } 661 662 /** 663 * Helper function that returns the previous visible tree item. 664 * @param {cr.ui.TreeItem} item The tree item. 665 * @return {cr.ui.TreeItem} The found item or null. 666 */ 667 function getPrevious(item) { 668 var previousSibling = item.previousElementSibling; 669 return previousSibling ? getLastHelper(previousSibling) : item.parentItem; 670 } 671 672 /** 673 * Helper function that returns the last visible tree item in the subtree. 674 * @param {cr.ui.TreeItem} item The item to find the last visible item for. 675 * @return {cr.ui.TreeItem} The found item or null. 676 */ 677 function getLastHelper(item) { 678 if (!item) 679 return null; 680 if (item.expanded && item.hasChildren) { 681 var lastChild = item.items[item.items.length - 1]; 682 return getLastHelper(lastChild); 683 } 684 return item; 685 } 686 687 // Export 688 return { 689 Tree: Tree, 690 TreeItem: TreeItem 691 }; 692}); 693