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 
  8 sc_require('views/button');
  9 
 10 /** @class
 11 
 12   SC.PopupButtonView displays a pop-up menu when clicked, from which the user
 13   can select an item.
 14 
 15   To use, create the SC.PopupButtonView as you would a standard SC.ButtonView,
 16   then set the menu property to an instance of SC.MenuPane. For example:
 17 
 18       SC.PopupButtonView.design({
 19         layout: { width: 200, height: 18 },
 20         menuBinding: 'MyApp.menuController.menuPane'
 21       });
 22 
 23   You would then have your MyApp.menuController return an instance of the menu
 24   to display.
 25 
 26   @extends SC.ButtonView
 27   @author Santosh Shanbhogue
 28   @author Tom Dale
 29   @copyright 2008-2011, Strobe Inc. and contributors.
 30   @version 1.0
 31 */
 32 SC.PopupButtonView = SC.ButtonView.extend(
 33 /** @scope SC.PopupButtonView.prototype */ {
 34 
 35   /**
 36     @type Array
 37     @default ['sc-popup-button']
 38     @see SC.View#classNames
 39   */
 40   classNames: ['sc-popup-button'],
 41 
 42   /**
 43     @type String
 44     @default 'popupButtonRenderDelegate'
 45   */
 46   renderDelegateName: 'popupButtonRenderDelegate',
 47 
 48 
 49   // ..........................................................
 50   // PROPERTIES
 51   //
 52 
 53   /**
 54     The prefer matrix to use when displaying the menu.
 55 
 56     @property
 57   */
 58   preferMatrix: null,
 59 
 60   /**
 61     The SC.MenuPane that should be displayed when the button is clicked.
 62 
 63     @type {SC.MenuPane}
 64     @default null
 65   */
 66   menu: null,
 67 
 68   /**
 69     If YES and the menu is a class, this will cause a task that will instantiate the menu
 70     to be added to SC.backgroundTaskQueue.
 71 
 72     @type Boolean
 73     @default NO
 74   */
 75   shouldLoadInBackground: NO,
 76 
 77   // ..........................................................
 78   // INTERNAL SUPPORT
 79   //
 80 
 81   /** @private
 82     If necessary, adds the loading of the menu to the background task queue.
 83   */
 84   init: function() {
 85     sc_super();
 86     this._setupMenu();
 87     if (this.get('shouldLoadInBackground')) {
 88       SC.backgroundTaskQueue.push(SC.PopupButtonMenuLoader.create({ popupButton: this }));
 89     }
 90   },
 91 
 92   /** @private
 93     Sets up binding on the menu, removing any old ones if necessary.
 94   */
 95   _setupMenu: function() {
 96     var menu = this.get('instantiatedMenu');
 97 
 98     // clear existing bindings
 99     if (this.isActiveBinding) this.isActiveBinding.disconnect();
100     this.isActiveBinding = null;
101 
102     // if there is a menu
103     if (menu && !menu.isClass) {
104       this.isActiveBinding = this.bind('isActive', menu, 'isVisibleInWindow');
105     }
106   },
107 
108   /** @private
109     Setup the bindings for menu...
110   */
111   _popup_menuDidChange: function() {
112     this._setupMenu();
113   }.observes('menu'),
114 
115   /** @private
116     isActive is NO, but when the menu is instantiated, it is bound to the menu's isVisibleInWindow property.
117   */
118   isActive: NO,
119 
120   /** @private
121     Instantiates the menu if it is not already instantiated.
122   */
123   _instantiateMenu: function() {
124     // get menu
125     var menu = this.get('menu');
126 
127     // if it is already instantiated or does not exist, we cannot do anything
128     if (!menu || !menu.isClass) return;
129 
130     // create
131     this.menu = menu.create();
132 
133     // setup
134     this._setupMenu();
135   },
136 
137   /** @private
138     The guaranteed-instantiated menu.
139   */
140   instantiatedMenu: function() {
141     // get the menu
142     var menu = this.get('menu');
143 
144     // if it is a class, we need to instantiate it
145     if (menu && menu.isClass) {
146       // do so
147       this._instantiateMenu();
148 
149       // get the new version of the local
150       menu = this.get('menu');
151     }
152 
153     // return
154     return menu;
155   }.property('menu').cacheable(),
156 
157   /** @private
158     Displays the menu.
159 
160     @param {SC.Event} evt
161   */
162   action: function(evt) {
163     var menu = this.get('instantiatedMenu') ;
164 
165     if (!menu) {
166       // @if (debug)
167       SC.Logger.warn("SC.PopupButton - Unable to show menu because the menu property is set to %@.".fmt(menu));
168       // @endif
169       return NO ;
170     }
171 
172     menu.popup(this, this.get('preferMatrix')) ;
173     return YES;
174   },
175 
176   /** @private
177     On mouse down, we set the state of the button, save some state for further
178     processing, then call the button's action method.
179 
180     @param {SC.Event} evt
181     @returns {Boolean}
182   */
183   mouseDown: function(evt) {
184     // Fast path, reject secondary clicks.
185     if (evt.which && evt.which !== 1) return false;
186 
187     // If disabled, handle mouse down but ignore it.
188     if (!this.get('isEnabledInPane')) return YES ;
189 
190     this._isMouseDown = YES;
191 
192     this._action() ;
193 
194     // Store the current timestamp. We register the timestamp after a setTimeout
195     // so that the menu has been rendered, in case that operation
196     // takes more than a few hundred milliseconds.
197 
198     // One mouseUp, we'll use this value to determine how long the mouse was
199     // pressed.
200 
201     // we need to keep track that we opened it just now in case we get the
202     // mouseUp before render finishes. If it is 0, then we know we have not
203     // waited long enough.
204     this._menuRenderedTimestamp = 0;
205 
206     var self = this;
207 
208     // setTimeout guarantees that all rendering is done. The browser will even
209     // have rendered by this point.
210     setTimeout(function() {
211       SC.run(function(){
212         // a run loop might be overkill here but what if Date.now fails?
213         self._menuRenderedTimestamp = Date.now();
214       });
215     }, 1);
216 
217     this.becomeFirstResponder();
218 
219     return YES ;
220   },
221 
222   /** @private
223     Because we responded YES to the mouseDown event, we have responsibility
224     for handling the corresponding mouseUp event.
225 
226     However, the user may click on this button, then drag the mouse down to a
227     menu item, and release the mouse over the menu item. We therefore need to
228     delegate any mouseUp events to the menu's menu item, if one is selected.
229 
230     We also need to differentiate between a single click and a click and hold.
231     If the user clicks and holds, we want to close the menu when they release.
232     Otherwise, we should wait until they click on the menu's modal pane before
233     removing our active state.
234 
235     @param {SC.Event} evt
236     @returns {Boolean}
237   */
238   mouseUp: function(evt) {
239     var timestamp = new Date().getTime(),
240         previousTimestamp = this._menuRenderedTimestamp,
241         menu = this.get('instantiatedMenu'),
242         touch = SC.platform.touch,
243         targetMenuItem;
244 
245     // normalize the previousTimestamp: if it is 0, it might as well be now.
246     // 0 means that we have not even triggered the nearly-immediate saving of timestamp.
247     if (previousTimestamp === 0) previousTimestamp = Date.now();
248 
249     if (menu) {
250       // Get the menu item the user is currently hovering their mouse over
251       targetMenuItem = menu.getPath('rootMenu.targetMenuItem');
252 
253       if (targetMenuItem) {
254         // Have the menu item perform its action.
255         // If the menu returns NO, it had no action to
256         // perform, so we should close the menu immediately.
257         if (!targetMenuItem.performAction()) menu.remove();
258       } else {
259         // If the user waits more than certain amount of time between
260         // mouseDown and mouseUp, we can assume that they are clicking and
261         // dragging to the menu item, and we should close the menu if they
262         //mouseup anywhere not inside the menu.
263         if (!touch && (timestamp - previousTimestamp > SC.ButtonView.CLICK_AND_HOLD_DELAY)) {
264           menu.remove();
265         }
266       }
267     }
268 
269     // Reset state.
270     this._isMouseDown = NO;
271     sc_super();
272     return YES;
273   },
274 
275   /** @private
276     Overrides ButtonView's mouseExited method to remove the behavior where the
277     active state is removed on mouse exit. We want the button to remain active
278     as long as the menu is visible.
279 
280     @param {SC.Event} evt
281     @returns {Boolean}
282   */
283   mouseExited: function(evt) {
284     return YES;
285   },
286 
287   /** @private
288     Overrides performKeyEquivalent method to pass any keyboard shortcuts to
289     the menu.
290 
291     @param {String} charCode string corresponding to shortcut pressed (e.g.,
292     alt_shift_z)
293     @param {SC.Event} evt
294   */
295   performKeyEquivalent: function(charCode, evt) {
296     if (!this.get('isEnabledInPane')) return NO ;
297     var menu = this.get('instantiatedMenu') ;
298 
299     return (!!menu && menu.performKeyEquivalent(charCode, evt, YES)) ;
300   },
301 
302   /** @private */
303   touchStart: function(evt) {
304     return this.mouseDown(evt);
305   },
306 
307   /** @private */
308   touchEnd: function(evt) {
309     return this.mouseUp(evt);
310   }
311 
312 });
313 
314 /**
315   @private
316   Handles lazy instantiation of popup button menu.
317 */
318 SC.PopupButtonMenuLoader = SC.Task.extend({
319   popupButton: null,
320   run: function() {
321     if (this.popupButton) this.popupButton._instantiateMenu();
322   }
323 });
324