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 sc_require('views/popup_button');
  7 sc_require('mixins/select_view_menu');
  8 sc_require('ext/menu');
  9 
 10 /**
 11  * @class
 12  * @extends SC.PopupButtonView
 13  * @version 2.0
 14  * @author Alex Iskander
 15  */
 16 SC.SelectView = SC.PopupButtonView.extend({
 17   /** @scope SC.SelectView.prototype */
 18 
 19   //
 20   // Properties
 21   //
 22   theme: 'popup',
 23   renderDelegateName: 'selectRenderDelegate',
 24 
 25   /**
 26     The array of items to populate the menu. This can be a simple array of strings,
 27     objects or hashes. If you pass objects or hashes, you can also set the
 28     various itemKey properties to tell the menu how to extract the information
 29     it needs.
 30 
 31     @type Array
 32     @default []
 33   */
 34   items: null,
 35 
 36   /**
 37     Binding default for an array of items
 38 
 39     @property
 40     @default SC.Binding.multiple()
 41   */
 42   itemsBindingDefault: SC.Binding.multiple(),
 43 
 44   /**
 45     They key in the items which maps to the title.
 46     This only applies for items that are hashes or SC.Objects.
 47 
 48     @property
 49     @type {String}
 50     @default null
 51   */
 52   itemTitleKey: null,
 53 
 54   /**
 55     If you set this to a non-null value, then the value of this key will
 56     be used to sort the items.  If this is not set, then itemTitleKey will
 57     be used.
 58 
 59     @property
 60     @type: {String}
 61     @default null
 62   */
 63   itemSortKey: null,
 64 
 65   /**
 66     They key in the items which maps to the value.
 67     This only applies for items that are hashes or SC.Objects.
 68 
 69      @property
 70      @type {String}
 71      @default null
 72   */
 73   itemValueKey: null,
 74 
 75   /**
 76      Key used to extract icons from the items array.
 77 
 78      @property
 79      @type {String}
 80      @default null
 81   */
 82   itemIconKey: null,
 83 
 84   /**
 85     Key to use to identify separators.
 86 
 87     Items that have this property set to YES will be drawn as separators.
 88 
 89     @property
 90     @type {String}
 91     @default null
 92   */
 93   itemSeparatorKey: "isSeparator",
 94 
 95   /**
 96     Key used to indicate if the item is to be enabled.
 97 
 98     @property
 99     @type {String}
100     @default null
101   */
102   itemIsEnabledKey: "isEnabled",
103 
104   /**
105     Set this to non-null to place an empty option at the top of the menu.
106 
107     @property
108     @type String
109     @default null
110   */
111   emptyName: null,
112 
113   /**
114     If true, titles will be escaped to avoid scripting attacks.
115 
116     @type Boolean
117     @default YES
118   */
119   escapeHTML: YES,
120 
121   /**
122     If true, the empty name and the default title will be localized.
123     
124     @type Boolean
125     @default YES
126   */
127   localize: YES,
128 
129   /**
130     if true, it means that no sorting will occur, items will appear
131     in the same order as in the array
132 
133     @type Boolean
134     @default YES
135   */
136   disableSort: YES,
137 
138   /**
139    The menu that will pop up when this button is clicked.
140 
141    The default menu has its properties bound to the SC.SelectView,
142    meaning that it will get all its items from the SelectView.
143    You may override the menu entirely with one of your own; if you
144    mix in SC.SelectViewMenu, it'll get the bindings and the extended
145    MenuItemView that draws its checkbox when it is the selected item.
146 
147    @property
148    @type {SC.MenuPane}
149    @default SC.AutoResizingMenuPane.extend(SC.SelectViewMenu)
150   */
151   menu: SC.AutoResizingMenuPane.extend(SC.SelectViewMenu),
152 
153   /**
154     The currently selected item. If no item is selected, `null`.
155 
156     @private
157     @type SC.Object
158     @default null
159     @isReadOnly
160    */
161   selectedItem: null,
162   selectedItemBinding: '*menu.rootMenu.selectedItem',
163 
164 
165   /**
166     This is a property to enable/disable focus rings in buttons.
167     For SelectView, it is a default.
168 
169     @property
170     @type {Boolean}
171     @default YES
172   */
173   supportsFocusRing: YES,
174 
175 
176   /**
177     * @private
178   */
179   init: function() {
180     sc_super();
181 
182     // call valueDidChange to get the initial item, if any
183     this._scsv_valueDidChange();
184   },
185 
186   /** @private */
187   _itemTitleKey: function() {
188     return this.get('itemTitleKey') || 'title';
189   }.property('itemTitleKey').cacheable(),
190 
191   /** @private */
192   _itemValueKey: function() {
193     return this.get('itemValueKey') || 'value';
194   }.property('itemValueKey').cacheable(),
195 
196   /** @private */
197   _itemIsEnabledKey: function() {
198     return this.get('itemIsEnabledKey') || 'isEnabled';
199   }.property('itemIsEnabledKey').cacheable(),
200 
201   /**
202     @private
203 
204     This gets the value for a specific menu item. 
205     
206     This method therefore accepts both the menu items as created for the menupane's displayItems
207     AND the raw items provided by the developer in `items`.
208   */
209   _scsv_getValueForMenuItem: function(item) {
210     var valueKey = this.get('_itemValueKey');
211 
212     if (!item.isDisplayItem && !this.get('itemValueKey')) {
213       return item;
214     } else if (item.get) {
215       return item.get(valueKey);
216     } else {
217       return item[valueKey];
218     }
219   },
220 
221   /**
222     * When the selected item changes, we need to update our value.
223     * @private
224   */
225   _scsv_selectedItemDidChange: function() {
226     var sel = this.get('selectedItem'),
227         last = this._scsv_lastSelection;
228 
229     // selected item could be a menu item from SC.MenuPane's displayItems, or it could
230     // be a raw item. So, we have to use _scsv_getValueForMenuItem to resolve it.
231     if (sel) {
232       this.setIfChanged('value', this._scsv_getValueForMenuItem(sel));
233     }
234 
235     // add/remove observers for the title and value so we can invalidate.
236     if (last && last.addObserver && sel !== last) {
237       last.removeObserver('*', this, '_scsv_selectedItemPropertyDidChange');
238     }
239 
240     if (sel && sel.addObserver && sel !== last) {
241       sel.addObserver('*', this, '_scsv_selectedItemPropertyDidChange');
242     }
243 
244     this._scsv_lastSelection = sel;
245   }.observes('selectedItem'),
246 
247   // called when either title or value changes on the selected item
248   _scsv_selectedItemPropertyDidChange: function(item) {
249     this.notifyPropertyChange('title');
250     this.notifyPropertyChange('icon');
251     this.set('value', this._scsv_getValueForMenuItem(item));
252   },
253 
254   /**
255     The title of the button, derived from the selected item.
256   */
257   title: function() {
258     var sel = this.get('selectedItem');
259 
260     if (!sel) {
261       return this.get('defaultTitle');
262     } else {
263       var itemTitleKey = this.get('_itemTitleKey');
264       if (itemTitleKey) {
265         if (sel.get) return sel.get(itemTitleKey);
266         else if (SC.typeOf(sel) == SC.T_HASH) return sel[itemTitleKey];
267       }
268       return sel.toString();
269     }
270   }.property('selectedItem').cacheable(),
271 
272   /** @private */
273   defaultTitle: function() {
274     var emptyName = this.get('emptyName');
275     if (emptyName) {
276       emptyName = this.get('localize') ? SC.String.loc(emptyName) : emptyName;
277       emptyName = this.get('escapeHTML') ? SC.RenderContext.escapeHTML(emptyName) : emptyName;
278     }
279     return emptyName || '';
280   }.property('emptyName').cacheable(),
281 
282   /**
283     The icon of the button, derived from the selected item.
284   */
285   icon: function() {
286     var sel = this.get('selectedItem'),
287       itemIconKey = this.get('itemIconKey');
288 
289     if (sel && itemIconKey) {
290       if (sel.get) return sel.get(itemIconKey);
291       else if (SC.typeOf(sel) == SC.T_HASH) return sel[itemIconKey];
292     }
293     return null;      
294   }.property('selectedItem').cacheable(),
295 
296   /**
297     Returns an array of normalized display items.
298 
299     Adds the empty name to the items if applicable.
300 
301     `displayItems` should never be set directly; instead, set `items` and
302     `displayItems` will update automatically.
303 
304     @type Array
305     @returns {Array} array of display items.
306     @isReadOnly
307   */
308   displayItems: function () {
309     var items = this.get('items'),
310       emptyName = this.get('emptyName'),
311       len,
312       ret = [], idx, item, itemType;
313 
314     if (!items) len = 0;
315     else len = items.get('length');
316 
317     for (idx = 0; idx < len; idx++) {
318       item = items.objectAt(idx);
319 
320       // fast track out if we can't do anything with this item
321       if (!item || (!ret.length && item[this.get('itemSeparatorKey')])) continue;
322 
323       itemType = SC.typeOf(item);
324       if (itemType === SC.T_STRING) {
325         item = this._addDisplayItem(item, item);
326       } else if (itemType === SC.T_HASH) {
327         item = SC.Object.create(item);
328       }
329       item.contentIndex = idx;
330 
331       ret.push(item);
332     }
333 
334     ret = this.sortObjects(ret);
335 
336     if (emptyName) {
337       if (len) ret.unshift(this._addDisplayItem(null, null, true));
338       ret.unshift(this._addDisplayItem(emptyName, null));
339     }
340 
341     return ret;
342   }.property().cacheable(),
343 
344   /** @private */
345   _scsv_itemsDidChange: function () {
346     this.notifyPropertyChange('displayItems');
347   }.observes('*items.[]'),
348 
349   /** @private */
350   _addDisplayItem: function (title, value, isSeparator) {
351     var item = SC.Object.create({
352       isDisplayItem: true
353     });
354 
355     item[this.get('_itemTitleKey')] = title;
356     item[this.get('_itemValueKey')] = value;
357     item[this.get('_itemIsEnabledKey')] = true;
358     item[this.get('itemSeparatorKey')] = !!isSeparator;
359 
360     return item;
361   },
362 
363   /**
364 
365     override this method to implement your own sorting of the menu. By
366     default, menu items are sorted using the value shown or the sortKey
367 
368     @param {SC.Array} objects the unsorted array of objects to display.
369     @returns {SC.Array} sorted array of objects
370   */
371   sortObjects: function (objects) {
372     if (!this.get('disableSort')) {
373       var nameKey = this.get('itemSortKey') || this.get('_itemTitleKey');
374       objects = objects.sort(function(a, b) {
375         if (nameKey) {
376           a = a.get ? a.get(nameKey) : a[nameKey];
377           b = b.get ? b.get(nameKey) : b[nameKey];
378         }
379         return (a<b) ? -1 : ((a>b) ? 1 : 0);
380       });
381     }
382     return objects;
383   },
384 
385   /**
386     * When the value changes, we need to update selectedItem.
387     * @private
388   */
389   _scsv_valueDidChange: function() {
390     var displayItems = this.get('displayItems');
391     if (!displayItems) return;
392 
393     var len = displayItems.get ? displayItems.get('length') : displayItems.length, 
394       idx, item;
395 
396     for (idx = 0; idx < len; idx++) {
397       item = displayItems.objectAt(idx);
398       
399       if (this.isValueEqualTo(item)) {
400         this.setIfChanged('selectedItem', item);
401         return;
402       }
403     }
404 
405     // if we got here, this means no item is selected
406     this.setIfChanged('selectedItem', null);
407   }.observes('value', 'displayItems'),
408 
409   /**
410     Check is the passed item is equal to the current value.
411 
412     @param {Object} object to check
413     @returns {Boolean}
414   */
415   isValueEqualTo: function(item) {
416     var a = this.get('value'),
417       b = this._scsv_getValueForMenuItem(item);
418 
419     return a === b;
420   },
421 
422   /**
423     SelectView must set the selectView property on the menu so that the menu's
424     properties get bound to the SelectView's. The bindings get set up by
425     the SelectViewMenu mixin, which should be mixed in to any SelectView menu.
426 
427     In addition, the initial selected item and the initial minimum menu width are set.
428     @private
429   */
430   createMenu: function(klass) {
431     var attrs = {
432       selectView: this,
433       selectedItem: this.get('selectedItem'),
434       minimumMenuWidth: this.get('minimumMenuWidth'),
435       escapeHTML: this.get('escapeHTML'),
436       localize: this.get('localize')
437     };
438 
439     return klass.create(attrs);
440   },
441 
442   /**
443     The amount by which to offset the menu's left position when displaying it.
444     This can be used to make sure the selected menu item is directly on top of
445     the label in the SelectView.
446 
447     By default, this comes from the render delegate's menuLeftOffset property.
448     If you are writing a theme, you should set the value there.
449 
450     @property
451     @type Number
452     @default 'menuLeftOffset' from render delegate if present, or 0.
453   */
454   menuLeftOffset: SC.propertyFromRenderDelegate('menuLeftOffset', 0),
455 
456   /**
457     The amount by which to offset the menu's top position when displaying it.
458     This is added to any amount calculated based on the 'top' of a menu item.
459 
460     This can be used to make sure the selected menu item's label is directly on
461     top of the SelectView's label.
462 
463     By default, this comes from the render delegate's menuTopOffset property.
464     If you are writing a theme, you should set the value there.
465 
466     @property
467     @type Number
468     @default 'menuTopOffset' from render delegate if present, or 0.
469   */
470   menuTopOffset: SC.propertyFromRenderDelegate('menuTopOffset', 0),
471 
472   /**
473     An amount to add to the menu's minimum width. For instance, this could be
474     set to a negative value to let arrows on the side of the SelectView be visible.
475 
476     By default, this comes from the render delegate's menuMinimumWidthOffset property.
477     If you are writing a theme, you should set the value there.
478 
479     @property
480     @type Number
481     @default 'menuWidthOffset' from render delegate if present, or 0.
482   */
483   menuMinimumWidthOffset: SC.propertyFromRenderDelegate('menuMinimumWidthOffset', 0),
484 
485   /**
486     The prefer matrix for menu positioning. It is calculated so that the selected
487     menu item is positioned directly over the SelectView.
488 
489     @property
490     @type Array
491     @private
492   */
493   menuPreferMatrix: function() {
494     var menu = this.get('menu'),
495         leftPosition = this.get('menuLeftOffset'),
496         topPosition = this.get('menuTopOffset');
497 
498     if (!menu) {
499       return [leftPosition, topPosition, 2];
500     }
501 
502     var idx = this.get('_selectedItemIndex'), itemViews = menu.get('menuItemViews');
503     if (idx > -1) {
504       var layout = itemViews[idx].get('layout');
505       return [leftPosition, topPosition - layout.top + (layout.height/2), 2];
506     }
507 
508     return [leftPosition, topPosition, 2];
509 
510   }.property('value', 'menu').cacheable(),
511 
512   /**
513     Used to calculate things like the menu's top position.
514 
515     @private
516   */
517   _selectedItemIndex: function() {
518     var menu = this.get('menu');
519     if (!menu) {
520       return -1;
521     }
522 
523     // We have to find the selected item, and then get its 'top' position so we
524     // can position the menu correctly.
525     var itemViews = menu.get('menuItemViews'), 
526       len = itemViews.length,
527       idx, view;
528 
529     for (idx = 0; idx < len; idx++) {
530       view = itemViews[idx];
531 
532       if (this.isValueEqualTo(view.get('content'))) break;
533     }
534 
535     if (idx < len) {
536       return idx;
537     }
538 
539     return -1;
540   }.property('value', 'menu').cacheable(),
541 
542   /**
543     The minimum width for the child menu. For instance, this property can make the
544     menu always cover the entire SelectView--or, alternatively, cover all but the
545     arrows on the side.
546 
547     By default, it is calculated by adding the menuMinimumWidthOffset to the view's
548     width. If you are writing a theme and want to change the width so the menu covers
549     a specific part of the select view, change your render delegate's menuMinimumWidthOffset
550     property.
551 
552     @type Number
553     @property
554   */
555   minimumMenuWidth: function() {
556     return this.get('frame').width + this.get('menuMinimumWidthOffset');
557   }.property('frame', 'menuMinimumWidthOffset').cacheable(),
558 
559   //
560   // KEY HANDLING
561   //
562   /**
563     @private
564 
565     Handle Key event - Down arrow key
566   */
567   keyDown: function(event) {
568     if ( this.interpretKeyEvents(event) ) {
569       return YES;
570     }
571     else {
572       sc_super();
573     }
574   },
575 
576   /**
577     @private
578     Pressing the Up or Down arrow key should display the menu pane. Pressing escape should
579     resign first responder.
580   */
581   moveUp: function(evt) {
582     this._action();
583     return YES;
584   },
585   /** @private */
586   moveDown: function(evt) {
587     this._action();
588     return YES;
589   },
590   cancel: function(evt) {
591     this.resignFirstResponder();
592   },
593 
594   /** @private
595    Function overridden - tied to the isEnabled state
596   */
597   acceptsFirstResponder: function() {
598     return this.get('isEnabled');
599   }.property('isEnabled').cacheable()
600 
601 });
602