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