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