1 // Copyright: ©2006-2010 Sprout Systems, Inc. and contributors.
  2 //            Portions ©2008-2011 Apple Inc. All rights reserved.
  3 // License:   Licensed under MIT license (see license.js)
  4 // ==========================================================================
  5 
  6 /**
  7  * @class
  8  * @extends SC.ButtonView
  9  * @version 1.6
 10  * @author Alex Iskander
 11  */
 12 SC.PopupButtonView = SC.ButtonView.extend({
 13   /** @scope SC.PopupButtonView.prototype */
 14 
 15 
 16   /**
 17     The render delegate to use to render and update the HTML for the PopupButton.
 18     
 19     @type String
 20     @default 'popupButtonRenderDelegate'
 21   */
 22   renderDelegateName: 'popupButtonRenderDelegate',
 23 
 24   /**
 25     The menu that will pop up when this button is clicked. This can be a class or
 26     an instance.
 27     
 28     @type {SC.MenuPane}
 29     @default SC.MenuPane
 30   */
 31   menu: SC.MenuPane,
 32 
 33   /**
 34     If YES, a menu instantiation task will be placed in SproutCore's
 35     `SC.backgroundTaskQueue` so the menu will be instantiated before 
 36     the user taps the button, improving response time.
 37 
 38     @type Boolean
 39     @default NO
 40     @property
 41   */
 42   shouldLoadInBackground: NO,
 43 
 44   /**
 45    * @private
 46    * If YES, the menu has been instantiated; if NO, the 'menu' property
 47    * still has a class instead of an instance.
 48   */
 49   _menuIsLoaded: NO,
 50 
 51   /** @private
 52     isActive is NO, but when the menu is instantiated, it is bound to the menu's isVisibleInWindow property.
 53   */
 54   isActive: NO,
 55 
 56   acceptsFirstResponder: YES,
 57   
 58 
 59   /**
 60     @private
 61   */
 62   init: function() {
 63     sc_super();
 64 
 65     // keep track of the current instantiated menu separately from
 66     // our property. This allows us to destroy it when the property
 67     // changes, and to track if the property change was initiated by
 68     // us (since we set `menu` to the instantiated menu).
 69     this._currentMenu = null;
 70     this.invokeOnce('scheduleMenuSetupIfNeeded');
 71   },
 72 
 73   /**
 74     Adds menu instantiation to the background task queue if the menu
 75     is not already instantiated and if shouldLoadInBackground is YES.
 76     
 77     @method
 78     @private
 79    */
 80   scheduleMenuSetupIfNeeded: function() {
 81     var menu = this.get('menu');
 82 
 83     if (menu && menu.isClass && this.get('shouldLoadInBackground')) {
 84       SC.backgroundTaskQueue.push(SC.PopupButtonView.InstantiateMenuTask.create({ popupButton: this }));
 85     }
 86   },
 87 
 88   /** @private if the menu changes, it must be set up again. */
 89   menuDidChange: function() {
 90     // first, check if we are the ones who changed the property
 91     // by setting it to the instantiated menu
 92     var menu = this.get('menu');
 93     if (menu === this._currentMenu) { 
 94       return;
 95     }
 96 
 97     this.invokeOnce('scheduleMenuSetupIfNeeded');
 98   }.observes('menu'),
 99 
100   /**
101    Instantiates the menu if it exists and is not already instantiated.
102    If another menu is already instantiated, it will be destroyed.
103   */
104   setupMenu: function() {
105     var menu = this.get('menu');
106 
107     // handle our existing menu, if any
108     if (menu === this._currentMenu) { return; }
109     if (this._currentMenu) {
110       this.isActiveBinding.disconnect();
111 
112       this._currentMenu.destroy();
113       this._currentMenu = null;
114     }
115 
116     // do not do anything if there is nothing to do.
117     if (menu && menu.isClass) {
118       menu = this.createMenu(menu);
119     }
120 
121     this._currentMenu = menu;
122     this.set('menu', menu);
123 
124     this.isActiveBinding = this.bind('isActive', menu, 'isVisibleInWindow');
125   },
126 
127   /**
128     Called to instantiate a menu. You can override this to set properties
129     such as the menu's width or the currently selected item.
130     
131     @param {SC.MenuPane} menu The MenuPane class to instantiate.
132   */
133   createMenu: function(menu) {
134     return menu.create();
135   },
136 
137 
138   /**
139     Shows the PopupButton's menu. You can call this to show it manually.
140     
141     NOTE: The menu will not be shown until the end of the Run Loop.
142   */
143   showMenu: function() {
144     // problem: menu's bindings may not flush
145     this.setupMenu();
146 
147     // solution: pop up the menu later. Ugly-ish, but not too bad:
148     this.invokeLast('_showMenu');
149   },
150 
151   /**
152     Hides the PopupButton's menu if it is currently showing.
153   */
154   hideMenu: function() {
155     var menu = this.get('menu');
156     if (menu && !menu.isClass) {
157       menu.remove();
158     }
159   },
160 
161   /**
162     The prefer matrix (positioning information) to use to pop up the new menu.
163     
164     @property
165     @type Array
166     @default [0, 0, 0]
167   */
168   menuPreferMatrix: [0, 0, 0],
169 
170   /**
171     @private
172     The actual showing of the menu is delayed because bindings may need
173     to flush.
174   */
175   _showMenu: function() {
176     var menu = this.get('menu');
177 
178     menu.popup(this, this.get('menuPreferMatrix'));
179   },
180 
181   /** @private */
182   mouseDown: function(evt) {
183     // If disabled, handle mouse down but ignore it.
184     if (!this.get('isEnabled')) return YES ;
185 
186     this.set('_mouseDown', YES);
187 
188     this.showMenu();
189     
190     this._mouseDownTimestamp = null;
191     
192     // Some nutty stuff going on here. If the number of menu items is large, and
193     // it takes over 400 ms to create, then invokeLater will not return control
194     // to the browser, thereby causing the menu pane to dismiss itself
195     // instantly. Using setTimeout will guarantee that control goes back to the
196     // browser.
197     var self = this;
198 
199     // there is a bit of a race condition: we could get mouse up immediately.
200     // In that case, we will take note that the timestamp is 0 and treat it
201     // as if it were Date.now() at the time of checking.
202     self._mouseDownTimestamp = 0;
203 
204     setTimeout(function() {
205       self._mouseDownTimestamp = Date.now();
206     }, 1);
207     
208     this.becomeFirstResponder();
209 
210     return YES;
211   },
212 
213   /** @private */
214   mouseUp: function(evt) {
215     var menu = this.get('menu'), targetMenuItem, success;
216 
217     if (menu && this.get('_mouseDown')) {
218       targetMenuItem = menu.getPath('rootMenu.targetMenuItem');
219 
220       // normalize the mouseDownTimestamp: it may not have been set yet.
221       if (this._mouseDownTimestamp === 0) {
222         this._mouseDownTimestamp = Date.now();
223       }
224 
225       // If the user waits more than 400ms between mouseDown and mouseUp,
226       // we can assume that they are clicking and dragging to the menu item,
227       // and we should close the menu if they mouseup anywhere not inside
228       // the menu.
229       if(evt.timeStamp - this._mouseDownTimestamp > 400) {
230         if (targetMenuItem && menu.get('mouseHasEntered') && this._mouseDownTimestamp) {
231           // Have the menu item perform its action.
232           // If the menu returns NO, it had no action to
233           // perform, so we should close the menu immediately.
234           if (!targetMenuItem.performAction()) {
235             menu.remove();
236           }
237         }
238 
239         else {
240           menu.remove();
241         }
242       }
243     }
244 
245     this._mouseDownTimestamp = undefined;
246     return YES;
247   },
248 
249   /**
250     @private
251     
252     Shows the menu when the user presses Enter. Otherwise, hands it off to button
253     to decide what to do.
254   */
255   keyDown: function(event) {
256     if (event.which == 13) {
257       this.showMenu();
258       return YES;
259     }
260 
261     return sc_super();
262   },
263 
264   /** @private */
265   touchStart: function(evt) {
266     return this.mouseDown(evt);
267   },
268 
269   /** @private */
270   touchEnd: function(evt) {
271     return this.mouseUp(evt);
272   }
273 });
274 
275 /**
276   @class
277   
278   An SC.Task that handles instantiating a PopupButtonView's menu. It is used
279   by SC.PopupButtonView to instantiate the menu in the backgroundTaskQueue.
280 */
281 SC.PopupButtonView.InstantiateMenuTask = SC.Task.extend(
282   /**@scope SC.PopupButtonView.InstantiateMenuTask.prototype */ {
283     
284   /**
285     The popupButton whose menu should be instantiated.
286     
287     @property
288     @type {SC.PopupButtonView}
289     @default null
290   */
291   popupButton: null,
292   
293   /** Instantiates the menu. */
294   run: function(queue) {
295     this.popupButton.setupMenu();
296   }
297 });
298 
299