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