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 
  9 /** @class
 10 
 11   A RadioView is used to create a group of radio buttons.  The user can use
 12   these buttons to pick from a choice of options.
 13 
 14   This view renders simulated radio buttons that can display a mixed state and
 15   has other features not found in platform-native controls.
 16 
 17   The radio buttons themselves are designed to be styled using CSS classes with
 18   the following structure:
 19 
 20       <label class="sc-radio-button">
 21         <img class="button" src="some_image.gif"/>
 22         <input type="radio" name="<sc-guid>" value=""/>
 23         <span class="sc-button-label">Label for button1</span>
 24       </label>
 25 
 26   Setting up a RadioView accepts a number of properties, for example:
 27 
 28       radio: SC.RadioView.design({
 29         items: [
 30           {
 31             title: "Red",
 32             value: "red",
 33             enabled: YES,
 34             icon: "button_red"
 35           },{
 36             title: "Green",
 37             value: "green",
 38             enabled: YES,
 39             icon: 'button_green'
 40           }
 41         ],
 42         value: 'red',
 43         itemTitleKey: 'title',
 44         itemValueKey: 'value',
 45         itemIconKey: 'icon',
 46         itemIsEnabledKey: 'enabled',
 47         isEnabled: YES,
 48         layoutDirection: SC.LAYOUT_HORIZONTAL
 49       })
 50 
 51   The items array can contain either strings, or as in the example above a
 52   hash. When using a hash, make sure to also specify the itemTitleKey
 53   and itemValueKey you are using. Similarly, you will have to provide
 54   itemIconKey if you are using icons radio buttons. The individual items
 55   enabled property is YES by default, and the icon is optional.
 56 
 57   @extends SC.View
 58   @extends SC.Control
 59   @since SproutCore 1.0
 60 */
 61 SC.RadioView = SC.View.extend(SC.Control,
 62 /** @scope SC.RadioView.prototype */{
 63 
 64   /**
 65     @field
 66     @type Boolean
 67     @default YES
 68     @observes isEnabled
 69   */
 70   acceptsFirstResponder: function() {
 71     if (SC.FOCUS_ALL_CONTROLS) { return this.get('isEnabledInPane'); }
 72     return NO;
 73   }.property('isEnabledInPane'),
 74 
 75   /**
 76     @type Array
 77     @default ['sc-radio-view']
 78     @see SC.View#classNames
 79   */
 80   classNames: ['sc-radio-view'],
 81 
 82   /**
 83     The WAI-ARIA role for a group of radio buttons.
 84 
 85     @type String
 86     @default 'radiogroup'
 87     @readOnly
 88   */
 89   ariaRole: 'radiogroup',
 90 
 91   /**
 92     @type Array
 93     @default ['displayItems', 'layoutDirection']
 94     @see SC.View#displayProperties
 95   */
 96   displayProperties: ['displayItems', 'layoutDirection'],
 97 
 98   /**
 99     @type String
100     @default 'radioGroupRenderDelegate'
101   */
102   renderDelegateName: 'radioGroupRenderDelegate',
103 
104   // ..........................................................
105   // Properties
106   //
107 
108   /**
109     If items property is a hash, specify which property will function as
110     the ariaLabeledBy with this itemAriaLabeledByKey property.ariaLabeledBy is used
111     as the WAI-ARIA attribute for the radio view. This property is assigned to
112     'aria-labelledby' attribute, which defines a string value that labels the
113     element. Used to support voiceover.  It should be assigned a non-empty string,
114     if the 'aria-labelledby' attribute has to be set for the element.
115 
116     @type String
117     @default null
118   */
119   itemAriaLabeledByKey: null,
120 
121   /**
122     If items property is a hash, specify which property will function as
123     the ariaLabeled with this itemAriaLabelKey property.ariaLabel is used
124     as the WAI-ARIA attribute for the radio view. This property is assigned to
125     'aria-label' attribute, which defines a string value that labels the
126     element. Used to support voiceover.  It should be assigned a non-empty string,
127     if the 'aria-label' attribute has to be set for the element.
128 
129     @type String
130     @default null
131   */
132   itemAriaLabelKey: null,
133 
134   /**
135     The value of the currently selected item, and which will be checked in the
136     UI. This can be either a string or an array with strings for checking
137     multiple values.
138 
139     @type Object|String
140     @default null
141   */
142   value: null,
143 
144   /**
145     This property indicates how the radio buttons are arranged. Possible values:
146 
147       - SC.LAYOUT_VERTICAL
148       - SC.LAYOUT_HORIZONTAL
149 
150     @type String
151     @default SC.LAYOUT_VERTICAL
152   */
153   layoutDirection: SC.LAYOUT_VERTICAL,
154 
155   /**
156     @type Boolean
157     @default YES
158   */
159   escapeHTML: YES,
160 
161   /**
162     The items property can be either an array with strings, or a
163     hash. When using a hash, make sure to also specify the appropriate
164     itemTitleKey, itemValueKey, itemIsEnabledKey and itemIconKey.
165 
166     @type Array
167     @default []
168   */
169   items: [],
170 
171   /**
172     If items property is a hash, specify which property will function as
173     the title with this itemTitleKey property.
174 
175     @type String
176     @default null
177   */
178   itemTitleKey: null,
179 
180   /**
181     If items property is a hash, specify which property will function as
182     the item width with this itemWidthKey property. This is only used when
183     layoutDirection is set to SC.LAYOUT_HORIZONTAL and can be used to override
184     the default value provided by the framework or theme CSS.
185 
186     @type String
187     @default null
188   */
189   itemWidthKey: null,
190 
191   /**
192     If items property is a hash, specify which property will function as
193     the value with this itemValueKey property.
194 
195     @type String
196     @default null
197   */
198   itemValueKey: null,
199 
200   /**
201     If items property is a hash, specify which property will function as
202     the value with this itemIsEnabledKey property.
203 
204     @type String
205     @default null
206   */
207   itemIsEnabledKey: null,
208 
209   /**
210     If items property is a hash, specify which property will function as
211     the value with this itemIconKey property.
212 
213     @type String
214     @default null
215   */
216   itemIconKey: null,
217 
218   /**  @private
219     If the items array itself changes, add/remove observer on item...
220   */
221   itemsDidChange: function() {
222     if (this._items) {
223       this._items.removeObserver('[]', this, this.itemContentDidChange);
224     }
225     this._items = this.get('items');
226     if (this._items) {
227       this._items.addObserver('[]', this, this.itemContentDidChange);
228     }
229     this.itemContentDidChange();
230   }.observes('items'),
231 
232   /** @private
233     Invoked whenever the item array or an item in the array is changed.
234     This method will regenerate the list of items.
235   */
236   itemContentDidChange: function() {
237     // Force regeneration of buttons
238     this._renderAsFirstTime = YES;
239 
240     this.notifyPropertyChange('displayItems');
241   },
242 
243   // ..........................................................
244   // PRIVATE SUPPORT
245   //
246 
247   /** @private
248     Data Sources for radioRenderDelegates, as required by radioGroupRenderDelegate.
249   */
250   displayItems: function() {
251     var items = this.get('items'),
252         viewValue = this.get('value'),
253         isArray = SC.isArray(viewValue),
254         loc = this.get('localize'),
255         titleKey = this.get('itemTitleKey'),
256         valueKey = this.get('itemValueKey'),
257         widthKey = this.get('itemWidthKey'),
258         isHorizontal = this.get('layoutDirection') === SC.LAYOUT_HORIZONTAL,
259         isEnabledKey = this.get('itemIsEnabledKey'),
260         iconKey = this.get('itemIconKey'),
261         ariaLabeledByKey = this.get('itemAriaLabeledByKey'),
262         ariaLabelKey = this.get('itemAriaLabelKey'),
263         ret = this._displayItems || [], max = (items)? items.get('length') : 0,
264         item, title, width, value, idx, isEnabled, icon, sel, active,
265         ariaLabeledBy, ariaLabel;
266 
267     for(idx=0;idx<max;idx++) {
268       item = items.objectAt(idx);
269 
270       // if item is an array, just use the items...
271       if (SC.typeOf(item) === SC.T_ARRAY) {
272         title = item[0];
273         value = item[1];
274 
275         // otherwise, possibly use titleKey,etc.
276       } else if (item) {
277         // get title.  either use titleKey or try to convert the value to a
278         // string.
279         if (titleKey) {
280           title = item.get ? item.get(titleKey) : item[titleKey];
281         } else title = (item.toString) ? item.toString() : null;
282 
283         if (widthKey && isHorizontal) {
284           width = item.get ? item.get(widthKey) : item[widthKey];
285         }
286 
287         if (valueKey) {
288           value = item.get ? item.get(valueKey) : item[valueKey];
289         } else value = item;
290 
291         if (isEnabledKey) {
292           isEnabled = item.get ? item.get(isEnabledKey) : item[isEnabledKey];
293         } else isEnabled = YES;
294 
295         if (iconKey) {
296           icon = item.get ? item.get(iconKey) : item[iconKey];
297         } else icon = null;
298 
299         if (ariaLabeledByKey) {
300           ariaLabeledBy = item.get ? item.get(ariaLabeledByKey) : item[ariaLabeledByKey];
301         } else ariaLabeledBy = null;
302 
303         if (ariaLabelKey) {
304           ariaLabel = item.get ? item.get(ariaLabelKey) : item[ariaLabelKey];
305         } else ariaLabel = null;
306 
307         // if item is nil, use some defaults...
308       } else {
309         title = value = icon = null;
310         isEnabled = NO;
311       }
312 
313       // it can only be enabled if the radio view itself is enabled
314       isEnabled = isEnabled && this.get('isEnabled');
315 
316       if (item) {
317         sel = (isArray) ? (viewValue.indexOf(value) >= 0) : (viewValue === value);
318       } else {
319         sel = NO;
320       }
321 
322       // localize title if needed
323       if (loc) title = SC.String.loc(title);
324       ret.push(SC.Object.create({
325         title: title,
326         icon: icon,
327         width: width,
328         value: value,
329 
330         isEnabled: isEnabled,
331         isSelected: (isArray && viewValue.indexOf(value) >= 0 && viewValue.length === 1) || (viewValue === value),
332         isMixed: (isArray && viewValue.indexOf(value) >= 0),
333         isActive: this._activeRadioButton === idx,
334         theme: this.get('theme'),
335         renderState: {}
336       }));
337     }
338 
339     return ret; // done!
340   }.property('isEnabled', 'value', 'items', 'itemTitleKey', 'itemWidthKey', 'itemValueKey', 'itemIsEnabledKey', 'localize', 'itemIconKey','itemAriaLabeledByKey', 'itemAriaLabelKey').cacheable(),
341 
342   /** @private
343     If the user clicks on of the items mark it as active on mouseDown unless
344     is disabled.
345 
346     Save the element that was clicked on so we can remove the active state on
347     mouseUp.
348   */
349   mouseDown: function(evt) {
350     // Fast path, reject secondary clicks.
351     if (evt.which && evt.which !== 1) return false;
352 
353     if (!this.get('isEnabledInPane')) return YES;
354 
355     var delegate = this.get('renderDelegate'), proxy = this.get('renderDelegateProxy'),
356         elem = this.$(),
357         index = delegate.indexForEvent(proxy, elem, evt);
358 
359     this._activeRadioButton = index;
360 
361     if (index !== undefined) {
362       var item = this.get('displayItems')[index];
363       if (item.get('isEnabled')) {
364         item.set('isActive', YES);
365         delegate.updateRadioAtIndex(proxy, elem, index);
366       }
367     }
368 
369     // even if radiobuttons are not set to get firstResponder, allow default
370     // action, that way textfields loose focus as expected.
371     evt.allowDefault();
372     return YES;
373   },
374 
375   /** @private
376     If we have a radio element that was clicked on previously, make sure we
377     remove the active state. Then update the value if the item clicked is
378     enabled.
379   */
380   mouseUp: function(evt) {
381     if (!this.get('isEnabledInPane')) return YES;
382 
383     var delegate = this.get('renderDelegate'), proxy = this.get('renderDelegateProxy'),
384         elem = this.$(),
385         displayItems = this.get('displayItems'),
386         index = delegate.indexForEvent(proxy, elem, evt);
387 
388     if (this._activeRadioButton !== undefined && index !== this._activeRadioButton) {
389       displayItems[this._activeRadioButton].set('isActive', NO);
390       delegate.updateRadioAtIndex(proxy, elem, this._activeRadioButton);
391     }
392 
393     this._activeRadioButton = undefined;
394 
395     if (index !== undefined) {
396       var item = this.get('displayItems')[index];
397       if (item.get('isEnabled')) {
398         item.set('isActive', NO);
399         delegate.updateRadioAtIndex(proxy, elem, index);
400         this.set('value', item.value);
401       }
402     }
403 
404     evt.allowDefault();
405     return YES;
406   },
407 
408   keyDown: function(evt) {
409     if(!this.get('isEnabledInPane')) return YES;
410     // handle tab key
411     if (evt.which === 9 || evt.keyCode === 9) {
412       var view = evt.shiftKey ? this.get('previousValidKeyView') : this.get('nextValidKeyView');
413       if(view) view.becomeFirstResponder();
414       else evt.allowDefault();
415       return YES ; // handled
416     }
417     if (evt.which >= 37 && evt.which <= 40){
418 
419       var delegate = this.get('renderDelegate'), proxy = this.get('renderDelegateProxy'),
420           elem = this.$(),
421           displayItems = this.get('displayItems'),
422           val = this.get('value');
423       for(var i= 0, iLen = displayItems.length; i<iLen; i++){
424         if(val === displayItems[i].value) break;
425       }
426 
427 
428       if (evt.which === 37 || evt.which === 38 ){
429         if(i<=0) i = displayItems.length-1;
430         else i--;
431       }
432       if (evt.which === 39 || evt.which === 40 ){
433         if(i>=displayItems.length-1) i = 0;
434         else i++;
435       }
436       delegate.updateRadioAtIndex(proxy, elem, i);
437       this.set('value', displayItems[i].value);
438     }
439     evt.allowDefault();
440 
441     return NO;
442   },
443 
444 
445   /** @private */
446   touchStart: function(evt) {
447     return this.mouseDown(evt);
448   },
449 
450   /** @private */
451   touchEnd: function(evt) {
452     return this.mouseUp(evt);
453   }
454 
455 });
456