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