// ==========================================================================
// Project: SproutCore - JavaScript Application Framework
// Copyright: ©2006-2010 Sprout Systems, Inc. and contributors.
// Portions ©2008-2010 Apple Inc. All rights reserved.
// License: Licensed under MIT license (see license.js)
// ==========================================================================
/*jslint evil:true */
/** @class
Implements a push-button-style button. This class is used to implement
both standard push buttons and tab-style controls. See also SC.CheckboxView
and SC.RadioView which are implemented as field views, but can also be
treated as buttons.
By default, a button uses the SC.Control mixin which will apply CSS
classnames when the state of the button changes:
- active when button is active
- sel when button is toggled to a selected state
@extends SC.View
@extends SC.Control
@extends SC.Button
@since SproutCore 1.0
*/
SC.ButtonView = SC.View.extend(SC.Control, SC.Button, SC.StaticLayout,
/** @scope SC.ButtonView.prototype */ {
/**
What type of element this view is represented as
@property {String}
*/
tagName: 'div',
/**
Class names that will be applied to this view
@property {Array}
*/
classNames: ['sc-button-view'],
/**
optionally set this to the theme you want this button to have.
This is used to determine the type of button this is. You generally
should set a class name on the HTML with the same value to allow CSS
styling.
The default SproutCore theme supports "regular", "capsule", "checkbox",
and "radio"
@property {String}
*/
theme: 'square',
/**
Optionally set the behavioral mode of this button.
Possible values are:
- *SC.PUSH_BEHAVIOR* Pressing the button will trigger an action tied to the
button. Does not change the value of the button.
- *SC.TOGGLE_BEHAVIOR* Pressing the button will invert the current value of
the button. If the button has a mixed value, it will be set to true.
- *SC.TOGGLE_ON_BEHAVIOR* Pressing the button will set the current state to
true no matter the previous value.
- *SC.TOGGLE_OFF_BEHAVIOR* Pressing the button will set the current state to
false no matter the previous value.
@property {String}
*/
buttonBehavior: SC.PUSH_BEHAVIOR,
/*
If buttonBehavior is SC.HOLD_BEHAVIOR, this specifies, in miliseconds, how
often to trigger the action. Ignored for other behaviors.
@property {Number}
*/
holdInterval: 100,
/**
If YES, then this button will be triggered when you hit return.
This is the same as setting the keyEquivalent to 'return'. This will also
apply the "def" classname to the button.
@property {Boolean}
*/
isDefault: NO,
isDefaultBindingDefault: SC.Binding.oneWay().bool(),
/**
If YES, then this button will be triggered when you hit escape.
This is the same as setting the keyEquivalent to 'escape'.
@property {Boolean}
*/
isCancel: NO,
isCancelBindingDefault: SC.Binding.oneWay().bool(),
/**
The button href value. This can be used to create localized button href
values. Setting an empty or null href will set it to javascript:;
@property {String}
*/
href: '',
/**
The name of the action you want triggered when the button is pressed.
This property is used in conjunction with the target property to execute
a method when a regular button is pressed. These properties are not
relevant when the button is used in toggle mode.
If you do not set a target, then pressing a button will cause the
responder chain to search for a view that implements the action you name
here. If you set a target, then the button will try to call the method
on the target itself.
For legacy support, you can also set the action property to a function.
Doing so will cause the function itself to be called when the button is
clicked. It is generally better to use the target/action approach and
to implement your code in a controller of some type.
@property {String}
*/
action: null,
/**
The target object to invoke the action on when the button is pressed.
If you set this target, the action will be called on the target object
directly when the button is clicked. If you leave this property set to
null, then the button will search the responder chain for a view that
implements the action when the button is pressed instead.
@property {Object}
*/
target: null,
/**
If YES, use a focus ring.
@property {Boolean}
*/
supportFocusRing: NO,
_labelMinWidthIE7: 0,
/**
Called when the user presses a shortcut key, such as return or cancel,
associated with this button.
Highlights the button to show that it is being triggered, then, after a
delay, performs the button's action.
Does nothing if the button is disabled.
@param {Event} evt
@returns {Boolean} success/failure of the request
*/
triggerAction: function(evt) {
// If this button is disabled, we have nothing to do
if (!this.get('isEnabled')) return NO;
// Set active state of the button so it appears highlighted
this.set('isActive', YES);
// Invoke the actual action method after a small delay to give the user a
// chance to see the highlight. This is especially important if the button
// closes a pane, for example.
this.invokeLater('_triggerActionAfterDelay', 200, evt);
return YES;
},
/** @private
Called by triggerAction after a delay; this method actually
performs the action and restores the button's state.
@param {Event} evt
*/
_triggerActionAfterDelay: function(evt) {
this._action(evt, YES);
this.didTriggerAction();
this.set('isActive', NO);
},
/**
This method is called anytime the button's action is triggered. You can
implement this method in your own subclass to perform any cleanup needed
after an action is performed.
@property {function}
*/
didTriggerAction: function() {},
/**
The minimum width the button title should consume. This property is used
when generating the HTML styling for the title itself. The default
width of 80 usually provides a nice looking style, but you can set it to 0
if you want to disable minimum title width.
Note that the title width does not exactly match the width of the button
itself. Extra padding added by the theme can impact the final total
size.
@property {Number}
*/
titleMinWidth: 80,
// ................................................................
// INTERNAL SUPPORT
/** @private - save keyEquivalent for later use */
init: function() {
sc_super();
//cache the key equivalent
if(this.get("keyEquivalent")) this._defaultKeyEquivalent = this.get("keyEquivalent");
},
_TEMPORARY_CLASS_HASH: {},
// display properties that should automatically cause a refresh.
// isCancel and isDefault also cause a refresh but this is implemented as
// a separate observer (see below)
displayProperties: ['href', 'icon', 'title', 'value', 'toolTip'],
/**
This property is used to call the right render style for the button.
* This might be a future way to start implementing the render method
as part of the theme
*/
renderStyle: 'renderDefault', //SUPPORTED DEFAULT, IMAGE
render: function(context, firstTime) {
// add href attr if tagName is anchor...
var href, toolTip, classes, theme;
if (this.get('tagName') === 'a') {
href = this.get('href');
if (!href || (href.length === 0)) href = "javascript:;";
context.attr('href', href);
}
// If there is a toolTip set, grab it and localize if necessary.
toolTip = this.get('toolTip') ;
if (SC.typeOf(toolTip) === SC.T_STRING) {
if (this.get('localize')) toolTip = toolTip.loc() ;
context.attr('title', toolTip) ;
context.attr('alt', toolTip) ;
}
// add some standard attributes & classes.
classes = this._TEMPORARY_CLASS_HASH;
classes.def = this.get('isDefault');
classes.cancel = this.get('isCancel');
classes.icon = !!this.get('icon');
context.attr('role', 'button').setClass(classes);
theme = this.get('theme');
if (theme && !context.hasClass(theme)) context.addClass(theme);
// render inner html
this[this.get('renderStyle')](context, firstTime);
},
/**
Render the button with the default render style.
*/
renderDefault: function(context, firstTime){
if(firstTime) {
context = context.push("");
this.renderTitle(context, firstTime) ; // from button mixin
context.push("") ;
if(this.get('supportFocusRing')) {
context.push('
');
}
}
else {
this.renderTitle(context, firstTime) ;
}
},
/**
Render the button with the image render style. To set image
set the icon property with the classname that has the style with the image
*/
renderImage: function(context, firstTime){
var icon = this.get('icon');
context.addClass('no-min-width');
if(icon) context.push("");
else context.push("");
},
/** @private {String} used to store a previously defined key equiv */
_defaultKeyEquivalent: null,
/** @private
Whenever the isDefault or isCancel property changes, update the display and change the keyEquivalent.
*/
_isDefaultOrCancelDidChange: function() {
var isDef = !!this.get('isDefault'),
isCancel = !isDef && this.get('isCancel') ;
if(this.didChangeFor('defaultCancelChanged','isDefault','isCancel')) {
this.displayDidChange() ; // make sure to update the UI
if (isDef) {
this.set('keyEquivalent', 'return'); // change the key equivalent
} else if (isCancel) {
this.setIfChanged('keyEquivalent', 'escape') ;
} else {
//restore the default key equivalent
this.set("keyEquivalent",this._defaultKeyEquivalent);
}
}
}.observes('isDefault', 'isCancel'),
isMouseDown: false,
/** @private
On mouse down, set active only if enabled.
*/
mouseDown: function(evt) {
var buttonBehavior = this.get('buttonBehavior');
if (!this.get('isEnabled')) return YES ; // handled event, but do nothing
this.set('isActive', YES);
this._isMouseDown = YES;
if (buttonBehavior === SC.HOLD_BEHAVIOR) {
this._action(evt);
} else if (!this._isFocused && (buttonBehavior!==SC.PUSH_BEHAVIOR)) {
this._isFocused = YES ;
this.becomeFirstResponder();
if (this.get('isVisibleInWindow')) {
this.$()[0].focus();
}
}
return YES ;
},
/** @private
Remove the active class on mouseOut if mouse is down.
*/
mouseExited: function(evt) {
if (this._isMouseDown) {
this.set('isActive', NO);
}
return YES;
},
/** @private
If mouse was down and we renter the button area, set the active state again.
*/
mouseEntered: function(evt) {
if (this._isMouseDown) {
this.set('isActive', YES);
}
return YES;
},
/** @private
ON mouse up, trigger the action only if we are enabled and the mouse was released inside of the view.
*/
mouseUp: function(evt) {
if (this._isMouseDown) this.set('isActive', NO); // track independently in case isEnabled has changed
this._isMouseDown = false;
if (this.get('buttonBehavior') !== SC.HOLD_BEHAVIOR) {
var inside = this.$().within(evt.target) ;
if (inside && this.get('isEnabled')) this._action(evt) ;
}
return YES ;
},
touchStart: function(touch){
var buttonBehavior = this.get('buttonBehavior');
if (!this.get('isEnabled')) return YES ; // handled event, but do nothing
this.set('isActive', YES);
if (buttonBehavior === SC.HOLD_BEHAVIOR) {
this._action(touch);
} else if (!this._isFocused && (buttonBehavior!==SC.PUSH_BEHAVIOR)) {
this._isFocused = YES ;
this.becomeFirstResponder();
if (this.get('isVisibleInWindow')) {
this.$()[0].focus();
}
}
// don't want to do whatever default is...
touch.preventDefault();
return YES;
},
touchesDragged: function(evt, touches) {
if (!this.touchIsInBoundary(evt)) {
if (!this._touch_exited) this.set('isActive', NO);
this._touch_exited = YES;
} else {
if (this._touch_exited) this.set('isActive', YES);
this._touch_exited = NO;
}
evt.preventDefault();
return YES;
},
touchEnd: function(touch){
this._touch_exited = NO;
this.set('isActive', NO); // track independently in case isEnabled has changed
if (this.get('buttonBehavior') !== SC.HOLD_BEHAVIOR) {
if (this.touchIsInBoundary(touch)) this._action();
}
touch.preventDefault();
return YES ;
},
/** @private */
keyDown: function(evt) {
// handle tab key
if (evt.which === 9) {
var view = evt.shiftKey ? this.get('previousValidKeyView') : this.get('nextValidKeyView');
if(view) view.becomeFirstResponder();
else evt.allowDefault();
return YES ; // handled
}
if (evt.which === 13) {
this.triggerAction(evt);
return YES ; // handled
}
return NO;
},
/** @private Perform an action based on the behavior of the button.
- toggle behavior: switch to on/off state
- on behavior: turn on.
- off behavior: turn off.
- otherwise: invoke target/action
*/
_action: function(evt, skipHoldRepeat) {
switch(this.get('buttonBehavior')) {
// When toggling, try to invert like values. i.e. 1 => 0, etc.
case SC.TOGGLE_BEHAVIOR:
var sel = this.get('isSelected') ;
if (sel) {
this.set('value', this.get('toggleOffValue')) ;
} else {
this.set('value', this.get('toggleOnValue')) ;
}
break ;
// set value to on. change 0 => 1.
case SC.TOGGLE_ON_BEHAVIOR:
this.set('value', this.get('toggleOnValue')) ;
break ;
// set the value to false. change 1 => 0
case SC.TOGGLE_OFF_BEHAVIOR:
this.set('value', this.get('toggleOffValue')) ;
break ;
case SC.HOLD_BEHAVIOR:
this._runHoldAction(evt, skipHoldRepeat);
break ;
// otherwise, just trigger an action if there is one.
default:
//if (this.action) this.action(evt);
this._runAction(evt);
}
},
/** @private */
_runAction: function(evt) {
var action = this.get('action'),
target = this.get('target') || null,
rootResponder = this.getPath('pane.rootResponder');
if (action) {
if (this._hasLegacyActionHandler()) {
// old school... V
this._triggerLegacyActionHandler(evt);
} else {
if (rootResponder) {
// newer action method + optional target syntax...
rootResponder.sendAction(action, target, this, this.get('pane'));
}
}
}
},
/** @private */
_runHoldAction: function(evt, skipRepeat) {
if (this.get('isActive')) {
this._runAction();
if (!skipRepeat) {
// This run loop appears to only be necessary for testing
SC.RunLoop.begin();
this.invokeLater('_runHoldAction', this.get('holdInterval'), evt);
SC.RunLoop.end();
}
}
},
/** @private */
_hasLegacyActionHandler: function()
{
var action = this.get('action');
if (action && (SC.typeOf(action) === SC.T_FUNCTION)) return true;
if (action && (SC.typeOf(action) === SC.T_STRING) && (action.indexOf('.') != -1)) return true;
return false;
},
/** @private */
_triggerLegacyActionHandler: function( evt )
{
if (!this._hasLegacyActionHandler()) return false;
var action = this.get('action');
if (SC.typeOf(action) === SC.T_FUNCTION) this.action(evt);
if (SC.typeOf(action) === SC.T_STRING) {
eval("this.action = function(e) { return "+ action +"(this, e); };");
this.action(evt);
}
},
/** tied to the isEnabled state */
acceptsFirstResponder: function() {
if(!SC.SAFARI_FOCUS_BEHAVIOR) return this.get('isEnabled');
else return NO;
}.property('isEnabled'),
willBecomeKeyResponderFrom: function(keyView) {
// focus the text field.
if (!this._isFocused) {
this._isFocused = YES ;
this.becomeFirstResponder();
if (this.get('isVisibleInWindow')) {
var elem=this.$()[0];
if (elem) elem.focus();
}
}
},
willLoseKeyResponderTo: function(responder) {
if (this._isFocused) this._isFocused = NO ;
},
didAppendToDocument: function() {
if(parseInt(SC.browser.msie, 0)===7 && this.get('useStaticLayout')){
var layout = this.get('layout'),
elem = this.$(), w=0;
if(elem && elem[0] && (w=elem[0].clientWidth) && w!==0 && this._labelMinWidthIE7===0){
var label = this.$('.sc-button-label'),
paddingRight = parseInt(label.css('paddingRight'),0),
paddingLeft = parseInt(label.css('paddingLeft'),0),
marginRight = parseInt(label.css('marginRight'),0),
marginLeft = parseInt(label.css('marginLeft'),0);
if(marginRight=='auto') console.log(marginRight+","+marginLeft+","+paddingRight+","+paddingLeft);
if(!paddingRight && isNaN(paddingRight)) paddingRight = 0;
if(!paddingLeft && isNaN(paddingLeft)) paddingLeft = 0;
if(!marginRight && isNaN(marginRight)) marginRight = 0;
if(!marginLeft && isNaN(marginLeft)) marginLeft = 0;
this._labelMinWidthIE7 = w-(paddingRight + paddingLeft)-(marginRight + marginLeft);
label.css('minWidth', this._labelMinWidthIE7+'px');
}else{
this.invokeLater(this.didAppendToDocument, 1);
}
}
}
}) ;
// ..........................................................
// CONSTANTS
//
SC.TOGGLE_BEHAVIOR = 'toggle';
SC.PUSH_BEHAVIOR = 'push';
SC.TOGGLE_ON_BEHAVIOR = 'on';
SC.TOGGLE_OFF_BEHAVIOR = 'off';
SC.HOLD_BEHAVIOR = 'hold';
/**
The delay after which "click" behavior should transition to "click and hold"
behavior. This is used by subclasses such as PopupButtonView and
SelectButtonView.
@constant
@type Number
*/
SC.ButtonView.CLICK_AND_HOLD_DELAY = SC.browser.msie ? 600 : 300;
SC.REGULAR_BUTTON_HEIGHT=24;