1 // ========================================================================== 2 // Project: SproutCore - JavaScript Application Framework 3 // Copyright: ©2006-2011 Strobe Inc. and contributors. 4 // Portions ©2008-2011 Apple Inc. All rights reserved. 5 // License: Licensed under MIT license (see license.js) 6 // ========================================================================== 7 sc_require('views/separator'); 8 9 10 /** 11 @class 12 13 An SC.MenuItemView is created for every item in a menu. 14 15 @extends SC.View 16 @since SproutCore 1.0 17 */ 18 SC.MenuItemView = SC.View.extend(SC.ContentDisplay, 19 /** @scope SC.MenuItemView.prototype */ { 20 21 /** 22 @type Array 23 @default ['sc-menu-item'] 24 @see SC.View#classNames 25 */ 26 classNames: ['sc-menu-item'], 27 28 /** 29 @type Array 30 @default ['title', 'isEnabled', 'isSeparator', 'isChecked'] 31 @see SC.View#displayProperties 32 */ 33 displayProperties: ['title', 'toolTip', 'isEnabled', 'icon', 'isSeparator', 'shortcut', 'isChecked'], 34 35 /** 36 The WAI-ARIA role for menu items. 37 38 @type String 39 @default 'menuitem' 40 @readOnly 41 */ 42 ariaRole: 'menuitem', 43 44 /** 45 @type Boolean 46 @default YES 47 */ 48 escapeHTML: YES, 49 50 /** 51 @type Boolean 52 @default YES 53 */ 54 acceptsFirstResponder: YES, 55 56 /** 57 IE only attribute to block blurring of other controls 58 59 @type Boolean 60 @default YES 61 */ 62 blocksIEDeactivate: YES, 63 64 /** 65 @type Boolean 66 @default NO 67 */ 68 isContextMenuEnabled: NO, 69 70 71 // .......................................................... 72 // KEY PROPERTIES 73 // 74 75 /** 76 The content object the menu view will display. 77 78 @type Object 79 @default null 80 */ 81 content: null, 82 83 /** 84 The title from the content property. 85 86 @type String 87 */ 88 title: function () { 89 var ret = this.getContentProperty('itemTitleKey'), 90 localize = this.getPath('parentMenu.localize'); 91 92 if (localize && ret) ret = SC.String.loc(ret); 93 94 return ret || ''; 95 }.property().cacheable(), 96 97 /** 98 The value from the content property. 99 100 @type String 101 */ 102 value: function () { 103 return this.getContentProperty('itemValueKey'); 104 }.property().cacheable(), 105 106 /** 107 The tooltip from the content property. 108 109 @type String 110 */ 111 toolTip: function () { 112 var ret = this.getContentProperty('itemToolTipKey'), 113 localize = this.getPath('parentMenu.localize'); 114 115 if (localize && ret) ret = SC.String.loc(ret); 116 117 return ret || ''; 118 }.property().cacheable(), 119 120 /** 121 Whether the item is enabled or not. 122 123 @type Boolean 124 */ 125 isEnabled: function () { 126 return this.getContentProperty('itemIsEnabledKey') !== NO && 127 this.getContentProperty('itemSeparatorKey') !== YES; 128 }.property().cacheable(), 129 130 /** 131 The icon from the content property. 132 133 @type String 134 */ 135 icon: function () { 136 return this.getContentProperty('itemIconKey'); 137 }.property().cacheable(), 138 139 /** 140 YES if this menu item represents a separator, NO otherwise. 141 142 @type Boolean 143 */ 144 isSeparator: function () { 145 return this.getContentProperty('itemSeparatorKey') === YES; 146 }.property().cacheable(), 147 148 /** 149 The shortcut from the content property. 150 151 @type String 152 */ 153 shortcut: function () { 154 return this.getContentProperty('itemShortCutKey'); 155 }.property().cacheable(), 156 157 /** 158 YES if the menu item should include a check next to it. 159 160 @type Boolean 161 */ 162 isChecked: function () { 163 return this.getContentProperty('itemCheckboxKey'); 164 }.property().cacheable(), 165 166 /** 167 This menu item's submenu, if it exists. 168 169 @type SC.MenuPane 170 */ 171 subMenu: function () { 172 var parentMenu = this.get('parentMenu'), 173 menuItems = this.getContentProperty('itemSubMenuKey'); 174 175 if (menuItems) { 176 if (SC.kindOf(menuItems, SC.MenuPane)) { 177 menuItems.set('isModal', NO); 178 menuItems.set('isSubMenu', YES); 179 menuItems.set('parentMenu', parentMenu); 180 return menuItems; 181 } else { 182 var subMenu = this._subMenu; 183 if (subMenu) { 184 if (subMenu.get('isAttached')) { 185 this.invokeLast('showSubMenu'); 186 } 187 subMenu.remove(); 188 subMenu.destroy(); 189 } 190 191 subMenu = this._subMenu = SC.MenuPane.create({ 192 layout: { width: 200 }, 193 items: menuItems, 194 isModal: NO, 195 isSubMenu: YES, 196 parentMenu: parentMenu, 197 controlSize: parentMenu.get('controlSize'), 198 exampleView: parentMenu.get('exampleView') 199 }); 200 return subMenu; 201 } 202 } 203 204 return null; 205 }.property().cacheable(), 206 207 /** 208 @type Boolean 209 @default NO 210 */ 211 hasSubMenu: function () { 212 return !!this.get('subMenu'); 213 }.property('subMenu').cacheable(), 214 215 /** @private */ 216 getContentProperty: function (property) { 217 var content = this.get('content'), 218 menu = this.get('parentMenu'); 219 220 if (content && menu) { 221 return content.get(menu.get(property)); 222 } 223 }, 224 225 /** @private */ 226 init: function () { 227 sc_super(); 228 this.contentDidChange(); 229 }, 230 231 /** @private */ 232 destroy: function () { 233 sc_super(); 234 235 var subMenu = this._subMenu; 236 if (subMenu) { 237 subMenu.destroy(); 238 this._subMenu = null; 239 } 240 }, 241 242 /** SC.MenuItemView is not able to update itself in place at this time. */ 243 // TODO: add update: support. 244 isReusable: false, 245 246 /** @private 247 Fills the passed html-array with strings that can be joined to form the 248 innerHTML of the receiver element. Also populates an array of classNames 249 to set on the outer element. 250 251 @param {SC.RenderContext} context 252 @param {Boolean} firstTime 253 @returns {void} 254 */ 255 render: function (context) { 256 var content = this.get('content'), 257 val, 258 menu = this.get('parentMenu'), 259 itemWidth = this.get('itemWidth') || menu.layout.width, 260 itemHeight = this.get('itemHeight') || SC.DEFAULT_MENU_ITEM_HEIGHT, 261 escapeHTML = this.get('escapeHTML'); 262 263 this.set('itemWidth', itemWidth); 264 this.set('itemHeight', itemHeight); 265 266 //addressing accessibility 267 if (this.get('isSeparator')) { 268 //assign the role of separator 269 context.setAttr('role', 'separator'); 270 } else if (this.get('isChecked')) { 271 //assign the role of menuitemcheckbox 272 context.setAttr('role', 'menuitemcheckbox'); 273 context.setAttr('aria-checked', true); 274 } 275 276 context = context.begin('a').addClass('menu-item'); 277 278 if (this.get('isSeparator')) { 279 context.push('<span class="separator"></span>'); 280 context.addClass('disabled'); 281 } else { 282 val = this.get('icon'); 283 if (val) { 284 this.renderImage(context, val); 285 context.addClass('has-icon'); 286 } 287 288 val = this.get('title'); 289 if (SC.typeOf(val) !== SC.T_STRING) val = val.toString(); 290 this.renderLabel(context, val); 291 292 val = this.get('toolTip'); 293 if (SC.typeOf(val) !== SC.T_STRING) val = val.toString(); 294 if (escapeHTML) { 295 val = SC.RenderContext.escapeHTML(val); 296 } 297 context.setAttr('title', val); 298 299 if (this.get('isChecked')) { 300 context.push('<div class="checkbox"></div>'); 301 } 302 303 if (this.get('hasSubMenu')) { 304 this.renderBranch(context); 305 } 306 307 val = this.get('shortcut'); 308 if (val) { 309 this.renderShortcut(context, val); 310 } 311 } 312 313 context = context.end(); 314 }, 315 316 /** @private 317 Generates the image used to represent the image icon. override this to 318 return your own custom HTML 319 320 @param {SC.RenderContext} context the render context 321 @param {String} the source path of the image 322 @returns {void} 323 */ 324 renderImage: function (context, image) { 325 // get a class name and url to include if relevant 326 327 var url, className; 328 if (image && SC.ImageView.valueIsUrl(image)) { 329 url = image; 330 className = ''; 331 } else { 332 className = image; 333 url = SC.BLANK_IMAGE_URL; 334 } 335 // generate the img element... 336 context.begin('img').addClass('image').addClass(className).setAttr('src', url).end(); 337 }, 338 339 /** @private 340 Generates the label used to represent the menu item. override this to 341 return your own custom HTML 342 343 @param {SC.RenderContext} context the render context 344 @param {String} menu item name 345 @returns {void} 346 */ 347 348 renderLabel: function (context, label) { 349 if (this.get('escapeHTML')) { 350 label = SC.RenderContext.escapeHTML(label); 351 } 352 context.push("<span class='value ellipsis'>" + label + "</span>"); 353 }, 354 355 /** @private 356 Generates the string used to represent the branch arrow. override this to 357 return your own custom HTML 358 359 @param {SC.RenderContext} context the render context 360 @returns {void} 361 */ 362 renderBranch: function (context) { 363 context.push('<span class="has-branch"></span>'); 364 }, 365 366 /** @private 367 Generates the string used to represent the short cut keys. override this to 368 return your own custom HTML 369 370 @param {SC.RenderContext} context the render context 371 @param {String} the shortcut key string to be displayed with menu item name 372 @returns {void} 373 */ 374 renderShortcut: function (context, shortcut) { 375 context.push('<span class = "shortcut">' + shortcut + '</span>'); 376 }, 377 378 /** 379 This method will check whether the current Menu Item is still 380 selected and then create a submenu accordingly. 381 */ 382 showSubMenu: function () { 383 var subMenu = this.get('subMenu'); 384 if (subMenu && !subMenu.get('isAttached')) { 385 subMenu.set('mouseHasEntered', NO); 386 subMenu.popup(this, [0, 0, 0]); 387 } 388 389 this._subMenuTimer = null; 390 }, 391 392 //.......................................... 393 // Mouse Events Handling 394 // 395 396 /** @private */ 397 mouseUp: function (evt) { 398 // SproutCore's event system will deliver the mouseUp event to the view 399 // that got the mouseDown event, but for menus we want to track the mouse, 400 // so we'll do our own dispatching. 401 var targetMenuItem; 402 403 targetMenuItem = this.getPath('parentMenu.rootMenu.targetMenuItem'); 404 405 if (targetMenuItem) targetMenuItem.performAction(); 406 return YES; 407 }, 408 409 /** @private 410 Called on mouse down to send the action to the target. 411 412 This method will start flashing the menu item to indicate to the user that 413 their selection has been received, unless disableMenuFlash has been set to 414 YES on the menu item. 415 416 @returns {Boolean} 417 */ 418 performAction: function () { 419 // Clicking on a disabled menu item should close the menu. 420 if (!this.get('isEnabled')) { 421 this.getPath('parentMenu.rootMenu').remove(); 422 return YES; 423 } 424 425 // Menus that contain submenus should ignore clicks 426 if (this.get('hasSubMenu')) return NO; 427 428 var disableFlash = this.getContentProperty('itemDisableMenuFlashKey'), 429 menu; 430 431 if (disableFlash) { 432 // Menu flashing has been disabled for this menu item, so perform 433 // the action immediately. 434 this.sendAction(); 435 } else { 436 // Flash the highlight of the menu item to indicate selection, 437 // then actually send the action once its done. 438 this._flashCounter = 0; 439 440 // Set a flag on the root menu to indicate that we are in a 441 // flashing state. In the flashing state, no other menu items 442 // should become selected. 443 menu = this.getPath('parentMenu.rootMenu'); 444 menu._isFlashing = YES; 445 this.invokeLater(this.flashHighlight, 25); 446 this.invokeLater(this.sendAction, 150); 447 } 448 449 return YES; 450 }, 451 452 /** @private 453 Actually sends the action of the menu item to the target. 454 */ 455 sendAction: function () { 456 var action = this.getContentProperty('itemActionKey'), 457 target = this.getContentProperty('itemTargetKey'), 458 rootMenu = this.getPath('parentMenu.rootMenu'), 459 responder; 460 461 // Close the menu 462 this.getPath('parentMenu.rootMenu').remove(); 463 // We're no longer flashing 464 rootMenu._isFlashing = NO; 465 466 action = (action === undefined) ? rootMenu.get('action') : action; 467 target = (target === undefined) ? rootMenu.get('target') : target; 468 469 // Notify the root menu pane that the selection has changed 470 rootMenu.set('selectedItem', this.get('content')); 471 472 // Legacy support for actions that are functions 473 if (SC.typeOf(action) === SC.T_FUNCTION) { 474 action.apply(target, [rootMenu]); 475 //@if (debug) 476 SC.Logger.warn('Support for menu item action functions has been deprecated. Please use target and action.'); 477 //@endif 478 } else { 479 responder = this.getPath('pane.rootResponder') || SC.RootResponder.responder; 480 481 if (responder) { 482 // Send the action down the responder chain 483 responder.sendAction(action, target, rootMenu); 484 } 485 } 486 487 }, 488 489 /** @private 490 Toggles the focus class name on the menu item layer to quickly flash the 491 highlight. This indicates to the user that a selection has been made. 492 493 This is initially called by performAction(). flashHighlight then keeps 494 track of how many flashes have occurred, and calls itself until a maximum 495 has been reached. 496 */ 497 flashHighlight: function () { 498 var flashCounter = this._flashCounter, layer = this.$(); 499 if (flashCounter % 2 === 0) { 500 layer.addClass('focus'); 501 } else { 502 layer.removeClass('focus'); 503 } 504 505 if (flashCounter <= 2) { 506 this.invokeLater(this.flashHighlight, 50); 507 this._flashCounter++; 508 } 509 }, 510 511 /** @private*/ 512 mouseDown: function (evt) { 513 // Accept primary clicks only. 514 return evt.which === 1; 515 }, 516 517 /** @private */ 518 mouseEntered: function (evt) { 519 var menu = this.get('parentMenu'), 520 rootMenu = menu.get('rootMenu'); 521 522 // Ignore mouse entering if we're in the middle of 523 // a menu flash. 524 if (rootMenu._isFlashing) return; 525 526 menu.set('mouseHasEntered', YES); 527 this.set('mouseHasEntered', YES); 528 menu.set('currentMenuItem', this); 529 530 // Become first responder to show highlight 531 if (this.get('isEnabled')) { 532 this.becomeFirstResponder(); 533 } 534 535 if (this.get('hasSubMenu')) { 536 this._subMenuTimer = this.invokeLater(this.showSubMenu, 100); 537 } 538 539 return YES; 540 }, 541 542 /** @private 543 Set the focus based on whether the current menu item is selected or not. 544 */ 545 mouseExited: function (evt) { 546 var parentMenu, timer; 547 548 // If we have a submenu, we need to give the user's mouse time to get 549 // to the new menu before we remove highlight. 550 if (this.get('hasSubMenu')) { 551 // If they are exiting the view before we opened the submenu, 552 // make sure we don't open it once they've left. 553 timer = this._subMenuTimer; 554 if (timer) { 555 timer.invalidate(); 556 } else { 557 this.invokeLater(this.checkMouseLocation, 100); 558 } 559 } else { 560 parentMenu = this.get('parentMenu'); 561 562 if (parentMenu.get('currentMenuItem') === this) { 563 parentMenu.set('currentMenuItem', null); 564 } 565 } 566 567 return YES; 568 }, 569 570 /** @private */ 571 touchStart: function (evt) { 572 this.mouseEntered(evt); 573 return YES; 574 }, 575 576 /** @private */ 577 touchEnd: function (evt) { 578 return this.mouseUp(evt); 579 }, 580 581 /** @private */ 582 touchEntered: function (evt) { 583 return this.mouseEntered(evt); 584 }, 585 586 /** @private */ 587 touchExited: function (evt) { 588 return this.mouseExited(evt); 589 }, 590 591 /** @private */ 592 checkMouseLocation: function () { 593 var subMenu = this.get('subMenu'), parentMenu = this.get('parentMenu'), 594 currentMenuItem, previousMenuItem; 595 596 if (!subMenu.get('mouseHasEntered')) { 597 currentMenuItem = parentMenu.get('currentMenuItem'); 598 if (currentMenuItem === this || currentMenuItem === null) { 599 previousMenuItem = parentMenu.get('previousMenuItem'); 600 601 if (previousMenuItem) { 602 previousMenuItem.resignFirstResponder(); 603 } 604 this.resignFirstResponder(); 605 subMenu.remove(); 606 } 607 } 608 }, 609 610 /** @private 611 Call the moveUp function on the parent Menu 612 */ 613 moveUp: function (sender, evt) { 614 var menu = this.get('parentMenu'); 615 if (menu) { 616 menu.moveUp(this); 617 } 618 return YES; 619 }, 620 621 /** @private 622 Call the moveDown function on the parent Menu 623 */ 624 moveDown: function (sender, evt) { 625 var menu = this.get('parentMenu'); 626 if (menu) { 627 menu.moveDown(this); 628 } 629 return YES; 630 }, 631 632 /** @private 633 Call the function to create a branch 634 */ 635 moveRight: function (sender, evt) { 636 this.showSubMenu(); 637 return YES; 638 }, 639 640 /** @private 641 Proxies insertText events to the parent menu so items can be selected 642 by typing their titles. 643 */ 644 insertText: function (chr, evt) { 645 var menu = this.get('parentMenu'); 646 if (menu) { 647 menu.insertText(chr, evt); 648 } 649 }, 650 651 /** @private*/ 652 keyDown: function (evt) { 653 return this.interpretKeyEvents(evt); 654 }, 655 656 /** @private*/ 657 keyUp: function (evt) { 658 return YES; 659 }, 660 661 /** @private*/ 662 cancel: function (evt) { 663 this.getPath('parentMenu.rootMenu').remove(); 664 return YES; 665 }, 666 667 /** @private*/ 668 didBecomeFirstResponder: function (responder) { 669 if (responder !== this) return; 670 var parentMenu = this.get('parentMenu'); 671 if (parentMenu) { 672 parentMenu.set('currentSelectedMenuItem', this); 673 } 674 }, 675 676 /** @private*/ 677 willLoseFirstResponder: function (responder) { 678 if (responder !== this) return; 679 var parentMenu = this.get('parentMenu'); 680 if (parentMenu) { 681 parentMenu.set('currentSelectedMenuItem', null); 682 parentMenu.set('previousSelectedMenuItem', this); 683 } 684 }, 685 686 /** @private*/ 687 insertNewline: function (sender, evt) { 688 this.mouseUp(evt); 689 }, 690 691 /** 692 Close the parent Menu and remove the focus of the current Selected 693 Menu Item 694 */ 695 closeParent: function () { 696 this.$().removeClass('focus'); 697 var menu = this.get('parentMenu'); 698 if (menu) { 699 menu.remove(); 700 } 701 }, 702 703 /** @private*/ 704 clickInside: function (frame, evt) { 705 return SC.pointInRect({ x: evt.pageX, y: evt.pageY }, frame); 706 }, 707 708 709 // .......................................................... 710 // CONTENT OBSERVING 711 // 712 713 /** @private 714 Add an observer to ensure that we invalidate our cached properties 715 whenever the content object’s associated property changes. 716 */ 717 contentDidChange: function () { 718 var content = this.get('content'), 719 oldContent = this._content; 720 721 if (content === oldContent) return; 722 723 var f = this.contentPropertyDidChange; 724 // remove an observer from the old content if necessary 725 if (oldContent && oldContent.removeObserver) oldContent.removeObserver('*', this, f); 726 727 // add observer to new content if necessary. 728 this._content = content; 729 if (content && content.addObserver) content.addObserver('*', this, f); 730 731 // notify that value did change. 732 this.contentPropertyDidChange(content, '*') ; 733 }.observes('content'), 734 735 736 /** @private 737 Invalidate our cached property whenever the content object’s associated 738 property changes. 739 */ 740 contentPropertyDidChange: function (target, key) { 741 // If the key that changed in the content is one of the fields for which 742 // we (potentially) cache a value, update our cache. 743 var menu = this.get('parentMenu'); 744 if (!menu) return; 745 746 var mapping = SC.MenuItemView._contentPropertyToMenuItemPropertyMapping, 747 contentProperties = SC.keys(mapping), 748 i, len, contentProperty, menuItemProperty; 749 750 751 // Are we invalidating all keys? 752 if (key === '*') { 753 for (i = 0, len = contentProperties.length; i < len; ++i) { 754 contentProperty = contentProperties[i]; 755 menuItemProperty = mapping[contentProperty]; 756 this.notifyPropertyChange(menuItemProperty); 757 } 758 } 759 else { 760 for (i = 0, len = contentProperties.length; i < len; ++i) { 761 contentProperty = contentProperties[i]; 762 if (menu.get(contentProperty) === key) { 763 menuItemProperty = mapping[contentProperty]; 764 this.notifyPropertyChange(menuItemProperty); 765 766 // Note: We won't break here in case the menu is set up to map 767 // multiple properties to the same content key. 768 } 769 } 770 } 771 } 772 773 }); 774 775 776 // .......................................................... 777 // CLASS PROPERTIES 778 // 779 780 /** @private 781 A mapping of the "content property key" keys to the properties we use to 782 wrap them. This hash is used in 'contentPropertyDidChange' to ensure that 783 when the content changes a property that is locally cached inside the menu 784 item, the cache is properly invalidated. 785 786 Implementor note: If you add such a cached property, you must add it to 787 this mapping. 788 */ 789 SC.MenuItemView._contentPropertyToMenuItemPropertyMapping = { 790 itemTitleKey: 'title', 791 itemValueKey: 'value', 792 itemToolTipKey: 'toolTip', 793 itemIsEnabledKey: 'isEnabled', 794 itemIconKey: 'icon', 795 itemSeparatorKey: 'isSeparator', 796 itemShortCutKey: 'shortcut', 797 itemCheckboxKey: 'isChecked', 798 itemSubMenuKey: 'subMenu' 799 }; 800