angular.js/src/widgets.js

705 lines
21 KiB
JavaScript
Raw Normal View History

/**
* @ngdoc widget
* @name angular.widget.HTML
*
* @description
* The most common widgets you will use will be in the from of the
* standard HTML set. These widgets are bound using the name attribute
* to an expression. In addition they can have `ng:validate`, `ng:required`,
* `ng:format`, `ng:change` attribute to further control their behavior.
*
* @usageContent
* see example below for usage
*
* <input type="text|checkbox|..." ... />
* <textarea ... />
* <select ...>
* <option>...</option>
* </select>
*
* @example
<table style="font-size:.9em;">
<tr>
<th>Name</th>
<th>Format</th>
<th>HTML</th>
<th>UI</th>
<th ng:non-bindable>{{input#}}</th>
</tr>
<tr>
<th>text</th>
<td>String</td>
<td><tt>&lt;input type="text" name="input1"&gt;</tt></td>
<td><input type="text" name="input1" size="4"></td>
<td><tt>{{input1|json}}</tt></td>
</tr>
<tr>
<th>textarea</th>
<td>String</td>
<td><tt>&lt;textarea name="input2"&gt;&lt;/textarea&gt;</tt></td>
<td><textarea name="input2" cols='6'></textarea></td>
<td><tt>{{input2|json}}</tt></td>
</tr>
<tr>
<th>radio</th>
<td>String</td>
<td><tt>
&lt;input type="radio" name="input3" value="A"&gt;<br>
&lt;input type="radio" name="input3" value="B"&gt;
</tt></td>
<td>
<input type="radio" name="input3" value="A">
<input type="radio" name="input3" value="B">
</td>
<td><tt>{{input3|json}}</tt></td>
</tr>
<tr>
<th>checkbox</th>
<td>Boolean</td>
<td><tt>&lt;input type="checkbox" name="input4" value="checked"&gt;</tt></td>
<td><input type="checkbox" name="input4" value="checked"></td>
<td><tt>{{input4|json}}</tt></td>
</tr>
<tr>
<th>pulldown</th>
<td>String</td>
<td><tt>
&lt;select name="input5"&gt;<br>
&nbsp;&nbsp;&lt;option value="c"&gt;C&lt;/option&gt;<br>
&nbsp;&nbsp;&lt;option value="d"&gt;D&lt;/option&gt;<br>
&lt;/select&gt;<br>
</tt></td>
<td>
<select name="input5">
<option value="c">C</option>
<option value="d">D</option>
</select>
</td>
<td><tt>{{input5|json}}</tt></td>
</tr>
<tr>
<th>multiselect</th>
<td>Array</td>
<td><tt>
&lt;select name="input6" multiple size="4"&gt;<br>
&nbsp;&nbsp;&lt;option value="e"&gt;E&lt;/option&gt;<br>
&nbsp;&nbsp;&lt;option value="f"&gt;F&lt;/option&gt;<br>
&lt;/select&gt;<br>
</tt></td>
<td>
<select name="input6" multiple size="4">
<option value="e">E</option>
<option value="f">F</option>
</select>
</td>
<td><tt>{{input6|json}}</tt></td>
</tr>
</table>
* @scenario
* it('should exercise text', function(){
* input('input1').enter('Carlos');
* expect(binding('input1')).toEqual('"Carlos"');
* });
* it('should exercise textarea', function(){
* input('input2').enter('Carlos');
* expect(binding('input2')).toEqual('"Carlos"');
* });
* it('should exercise radio', function(){
* expect(binding('input3')).toEqual('null');
* input('input3').select('A');
* expect(binding('input3')).toEqual('"A"');
* input('input3').select('B');
* expect(binding('input3')).toEqual('"B"');
* });
* it('should exercise checkbox', function(){
* expect(binding('input4')).toEqual('false');
* input('input4').check();
* expect(binding('input4')).toEqual('true');
* });
* it('should exercise pulldown', function(){
* expect(binding('input5')).toEqual('"c"');
* select('input5').option('d');
* expect(binding('input5')).toEqual('"d"');
* });
* it('should exercise multiselect', function(){
* expect(binding('input6')).toEqual('[]');
* select('input6').options('e');
* expect(binding('input6')).toEqual('["e"]');
* select('input6').options('e', 'f');
* expect(binding('input6')).toEqual('["e","f"]');
* });
*/
function modelAccessor(scope, element) {
var expr = element.attr('name');
if (!expr) throw "Required field 'name' not found.";
return {
get: function() {
return scope.$eval(expr);
},
set: function(value) {
if (value !== _undefined) {
return scope.$tryEval(expr + '=' + toJson(value), element);
}
}
};
}
2010-05-13 23:40:41 +00:00
function modelFormattedAccessor(scope, element) {
var accessor = modelAccessor(scope, element),
2010-07-13 18:20:11 +00:00
formatterName = element.attr('ng:format') || NOOP,
formatter = angularFormatter(formatterName);
if (!formatter) throw "Formatter named '" + formatterName + "' not found.";
2010-05-13 23:40:41 +00:00
return {
get: function() {
return formatter.format(accessor.get());
},
set: function(value) {
return accessor.set(formatter.parse(value));
}
};
}
function compileValidator(expr) {
return parser(expr).validator()();
}
/**
* @ngdoc directive
* @name angular.directive.ng:validate
*
* @description
* This directive validates the user input. If the input does not
* pass validation, this sets an `ng-validation-error` CSS class and
* an `ng:error` attribute on the input element. Visit validators to
* find out more.
*
* @element INPUT
* @css ng-validation-error
* @param {function} validation call this function to validate input
* falsy return means validation passed, To return error, simply
* return the error string.
*
* @exampleDescription
* @example
I don't validate: <input type="text" name="value"><br/>
I cannot be blank: <input type="text" name="value" ng:required><br/>
I need an integer or nothing: <input type="text" name="value" ng:validate="integer"><br/>
I must have an integer: <input type="text" name="value" ng:required ng:validate="integer"><br/>
*
* @scenario
it('should check ng:validate', function(){
expect(element('.doc-example-live :input:last').attr('className')).toMatch(/ng-validation-error/);
input('value').enter('123');
expect(element('.doc-example-live :input:last').attr('className')).not().toMatch(/ng-validation-error/);
});
*/
/**
* @ngdoc directive
* @name angular.directive.ng:required
*
* @description
* This directive requires the user input to be present.
*
* @element INPUT
* @css ng-validation-error
*
* @exampleDescription
* @example
I cannot be blank: <input type="text" name="value" ng:required><br/>
*
* @scenario
it('should check ng:required', function(){
expect(element('.doc-example-live :input').attr('className')).toMatch(/ng-validation-error/);
input('value').enter('123');
expect(element('.doc-example-live :input').attr('className')).not().toMatch(/ng-validation-error/);
});
*/
/**
* @ngdoc directive
* @name angular.directive.ng:format
*
* @description
* The `ng:format` directive formats stored data to user-readable
* text and parses the text back to the stored form. You might
* find this useful for example if you collect user input in a
* text field but need to store the data in the model as a list.
*
* @element INPUT
*
* @exampleDescription
* @example
Enter a comma separated list of items:
<input type="text" name="list" ng:format="list" value="table, chairs, plate">
<pre>list={{list}}</pre>
*
* @scenario
it('should check ng:format', function(){
expect(binding('list')).toBe('list=["table","chairs","plate"]');
input('list').enter(',,, a ,,,');
expect(binding('list')).toBe('list=["a"]');
});
*/
function valueAccessor(scope, element) {
2010-07-13 18:20:11 +00:00
var validatorName = element.attr('ng:validate') || NOOP,
validator = compileValidator(validatorName),
2010-07-13 18:20:11 +00:00
requiredExpr = element.attr('ng:required'),
formatterName = element.attr('ng:format') || NOOP,
formatter = angularFormatter(formatterName),
format, parse, lastError, required,
2010-04-07 21:13:10 +00:00
invalidWidgets = scope.$invalidWidgets || {markValid:noop, markInvalid:noop};
if (!validator) throw "Validator named '" + validatorName + "' not found.";
if (!formatter) throw "Formatter named '" + formatterName + "' not found.";
format = formatter.format;
parse = formatter.parse;
if (requiredExpr) {
2010-06-03 18:03:11 +00:00
scope.$watch(requiredExpr, function(newValue) {
required = newValue;
validate();
});
} else {
required = requiredExpr === '';
}
element.data('$validate', validate);
return {
get: function(){
if (lastError)
elementError(element, NG_VALIDATION_ERROR, _null);
try {
var value = parse(element.val());
validate();
return value;
} catch (e) {
lastError = e;
elementError(element, NG_VALIDATION_ERROR, e);
}
},
set: function(value) {
2010-05-11 03:41:12 +00:00
var oldValue = element.val(),
newValue = format(value);
if (oldValue != newValue) {
2010-05-11 03:41:12 +00:00
element.val(newValue || ''); // needed for ie
}
validate();
2010-04-17 00:03:06 +00:00
}
};
function validate() {
2010-05-11 03:41:12 +00:00
var value = trim(element.val());
2010-04-23 00:11:56 +00:00
if (element[0].disabled || element[0].readOnly) {
elementError(element, NG_VALIDATION_ERROR, _null);
2010-04-16 21:01:29 +00:00
invalidWidgets.markValid(element);
} else {
2010-07-15 20:13:21 +00:00
var error, validateScope = inherit(scope, {$element:element});
error = required && !value ?
'Required' :
(value ? validator(validateScope, value) : _null);
2010-03-30 22:39:51 +00:00
elementError(element, NG_VALIDATION_ERROR, error);
lastError = error;
2010-04-21 21:29:05 +00:00
if (error) {
2010-04-07 21:13:10 +00:00
invalidWidgets.markInvalid(element);
2010-04-21 21:29:05 +00:00
} else {
2010-04-07 21:13:10 +00:00
invalidWidgets.markValid(element);
2010-04-21 21:29:05 +00:00
}
2010-01-12 01:32:33 +00:00
}
}
}
function checkedAccessor(scope, element) {
2010-04-13 21:25:12 +00:00
var domElement = element[0], elementValue = domElement.value;
return {
get: function(){
return !!domElement.checked;
},
set: function(value){
2010-04-13 21:25:12 +00:00
domElement.checked = toBoolean(value);
}
};
}
function radioAccessor(scope, element) {
var domElement = element[0];
return {
get: function(){
return domElement.checked ? domElement.value : _null;
},
set: function(value){
domElement.checked = value == domElement.value;
}
};
}
function optionsAccessor(scope, element) {
var options = element[0].options;
return {
get: function(){
var values = [];
foreach(options, function(option){
if (option.selected) values.push(option.value);
});
return values;
},
set: function(values){
var keys = {};
foreach(values, function(value){ keys[value] = true; });
foreach(options, function(option){
option.selected = keys[option.value];
2010-01-12 01:32:33 +00:00
});
}
};
}
function noopAccessor() { return { get: noop, set: noop }; }
var textWidget = inputWidget('keyup change', modelAccessor, valueAccessor, initWidgetValue()),
2010-04-02 18:10:36 +00:00
buttonWidget = inputWidget('click', noopAccessor, noopAccessor, noop),
INPUT_TYPE = {
'text': textWidget,
'textarea': textWidget,
'hidden': textWidget,
'password': textWidget,
'button': buttonWidget,
'submit': buttonWidget,
'reset': buttonWidget,
'image': buttonWidget,
2010-05-13 23:40:41 +00:00
'checkbox': inputWidget('click', modelFormattedAccessor, checkedAccessor, initWidgetValue(false)),
'radio': inputWidget('click', modelFormattedAccessor, radioAccessor, radioInit),
'select-one': inputWidget('change', modelFormattedAccessor, valueAccessor, initWidgetValue(_null)),
2010-05-13 23:40:41 +00:00
'select-multiple': inputWidget('change', modelFormattedAccessor, optionsAccessor, initWidgetValue([]))
// 'file': fileWidget???
};
2010-02-18 04:50:13 +00:00
2010-04-02 18:10:36 +00:00
function initWidgetValue(initValue) {
return function (model, view) {
var value = view.get();
2010-05-11 03:41:12 +00:00
if (!value && isDefined(initValue)) {
value = copy(initValue);
2010-05-11 03:41:12 +00:00
}
if (isUndefined(model.get()) && isDefined(value)) {
2010-04-02 18:10:36 +00:00
model.set(value);
}
2010-04-02 18:10:36 +00:00
};
}
2010-04-02 18:49:48 +00:00
function radioInit(model, view, element) {
var modelValue = model.get(), viewValue = view.get(), input = element[0];
input.checked = false;
2010-04-02 18:49:48 +00:00
input.name = this.$id + '@' + input.name;
if (isUndefined(modelValue)) {
model.set(modelValue = _null);
}
if (modelValue == _null && viewValue !== _null) {
model.set(viewValue);
}
view.set(modelValue);
2010-04-02 18:10:36 +00:00
}
/**
* @ngdoc directive
* @name angular.directive.ng:change
*
* @description
* The directive executes an expression whenever the input widget changes.
*
* @element INPUT
* @param {expression} expression to execute.
*
* @exampleDescription
* @example
<div ng:init="checkboxCount=0; textCount=0"></div>
<input type="text" name="text" ng:change="textCount = 1 + textCount">
changeCount {{textCount}}<br/>
<input type="checkbox" name="checkbox" ng:change="checkboxCount = 1 + checkboxCount">
changeCount {{checkboxCount}}<br/>
*
* @scenario
it('should check ng:change', function(){
expect(binding('textCount')).toBe('0');
expect(binding('checkboxCount')).toBe('0');
using('.doc-example-live').input('text').enter('abc');
expect(binding('textCount')).toBe('1');
expect(binding('checkboxCount')).toBe('0');
using('.doc-example-live').input('checkbox').check();
expect(binding('textCount')).toBe('1');
expect(binding('checkboxCount')).toBe('1');
});
*/
2010-04-02 18:10:36 +00:00
function inputWidget(events, modelAccessor, viewAccessor, initFn) {
return function(element) {
var scope = this,
model = modelAccessor(scope, element),
view = viewAccessor(scope, element),
2010-07-13 22:21:42 +00:00
action = element.attr('ng:change') || '',
lastValue;
2010-04-02 18:49:48 +00:00
initFn.call(scope, model, view, element);
this.$eval(element.attr('ng:init')||'');
2010-04-04 00:04:36 +00:00
// Don't register a handler if we are a button (noopAccessor) and there is no action
if (action || modelAccessor !== noopAccessor) {
2010-07-27 22:54:50 +00:00
element.bind(events, function(event){
2010-04-04 00:04:36 +00:00
model.set(view.get());
lastValue = model.get();
2010-04-04 00:04:36 +00:00
scope.$tryEval(action, element);
scope.$root.$eval();
});
}
function updateView(){
view.set(lastValue = model.get());
}
updateView();
element.data('$update', updateView);
scope.$watch(model.get, function(value){
if (lastValue !== value) {
view.set(lastValue = value);
}
});
};
}
function inputWidgetSelector(element){
2010-03-30 21:55:04 +00:00
this.directives(true);
return INPUT_TYPE[lowercase(element[0].type)] || noop;
}
angularWidget('input', inputWidgetSelector);
angularWidget('textarea', inputWidgetSelector);
angularWidget('button', inputWidgetSelector);
angularWidget('select', function(element){
this.descend(true);
return inputWidgetSelector.call(this, element);
});
2010-04-02 18:10:36 +00:00
angularWidget('option', function(){
this.descend(true);
this.directives(true);
return function(element) {
this.$postEval(element.parent().data('$update'));
};
});
2010-04-02 18:10:36 +00:00
/**
* @ngdoc widget
* @name angular.widget.ng:include
*
* @description
* Include external HTML fragment.
*
* Keep in mind that Same Origin Policy applies to included resources
* (e.g. ng:include won't work for file:// access).
*
* @param {string} src expression evaluating to URL.
* @param {Scope=} [scope=new_child_scope] expression evaluating to angular.scope
2010-11-16 19:31:41 +00:00
* @param {string=} onload Expression to evaluate when a new partial is loaded.
*
* @example
* <select name="url">
* <option value="angular.filter.date.html">date filter</option>
* <option value="angular.filter.html.html">html filter</option>
* <option value="">(blank)</option>
* </select>
* <tt>url = <a href="{{url}}">{{url}}</a></tt>
* <hr/>
* <ng:include src="url"></ng:include>
*
* @scenario
2010-11-05 22:05:24 +00:00
* it('should load date filter', function(){
* expect(element('.doc-example ng\\:include').text()).toMatch(/angular\.filter\.date/);
2010-11-05 22:05:24 +00:00
* });
* it('should change to hmtl filter', function(){
* select('url').option('angular.filter.html.html');
* expect(element('.doc-example ng\\:include').text()).toMatch(/angular\.filter\.html/);
2010-11-05 22:05:24 +00:00
* });
* it('should change to blank', function(){
* select('url').option('(blank)');
* expect(element('.doc-example ng\\:include').text()).toEqual('');
2010-11-05 22:05:24 +00:00
* });
*/
angularWidget('ng:include', function(element){
2010-04-02 18:10:36 +00:00
var compiler = this,
2010-04-16 21:01:29 +00:00
srcExp = element.attr("src"),
2010-11-16 19:31:41 +00:00
scopeExp = element.attr("scope") || '',
onloadExp = element[0].getAttribute('onload') || ''; //workaround for jquery bug #7537
if (element[0]['ng:compiled']) {
2010-04-07 17:17:15 +00:00
this.descend(true);
this.directives(true);
} else {
element[0]['ng:compiled'] = true;
return extend(function(xhr, element){
2010-04-07 17:17:15 +00:00
var scope = this, childScope;
2010-04-16 21:01:29 +00:00
var changeCounter = 0;
var preventRecursion = false;
2010-04-16 21:01:29 +00:00
function incrementChange(){ changeCounter++;}
this.$watch(srcExp, incrementChange);
this.$watch(scopeExp, incrementChange);
2010-04-30 19:22:07 +00:00
scope.$onEval(function(){
if (childScope && !preventRecursion) {
preventRecursion = true;
try {
childScope.$eval();
} finally {
preventRecursion = false;
}
}
2010-04-30 19:22:07 +00:00
});
2010-04-16 21:01:29 +00:00
this.$watch(function(){return changeCounter;}, function(){
var src = this.$eval(srcExp),
2010-11-16 19:31:41 +00:00
useScope = this.$eval(scopeExp);
2010-04-16 21:01:29 +00:00
if (src) {
xhr('GET', src, function(code, response){
2010-04-16 21:01:29 +00:00
element.html(response);
childScope = useScope || createScope(scope);
compiler.compile(element)(element, childScope);
childScope.$init();
2010-11-16 19:31:41 +00:00
scope.$eval(onloadExp);
2010-04-16 21:01:29 +00:00
});
} else {
childScope = null;
element.html('');
2010-04-16 21:01:29 +00:00
}
2010-04-07 17:17:15 +00:00
});
}, {$inject:['$xhr.cache']});
2010-04-07 17:17:15 +00:00
}
2010-04-02 18:10:36 +00:00
});
/**
* @ngdoc widget
* @name angular.widget.ng:switch
*
* @description
* Conditionally change the DOM structure.
*
* @usageContent
* <any ng:switch-when="matchValue1">...</any>
* <any ng:switch-when="matchValue2">...</any>
* ...
* <any ng:switch-default>...</any>
*
* @param {*} on expression to match against <tt>ng:switch-when</tt>.
2010-11-05 22:05:24 +00:00
* @paramDescription
* On child elments add:
*
* * `ng:switch-when`: the case statement to match against. If match then this
* case will be displayed.
* * `ng:switch-default`: the default case when no other casses match.
*
* @example
<select name="switch">
<option>settings</option>
<option>home</option>
<option>other</option>
</select>
<tt>switch={{switch}}</tt>
</hr>
<ng:switch on="switch" >
<div ng:switch-when="settings">Settings Div</div>
<span ng:switch-when="home">Home Span</span>
<span ng:switch-default>default</span>
</ng:switch>
</code>
*
* @scenario
2010-11-05 22:05:24 +00:00
* it('should start in settings', function(){
* expect(element('.doc-example ng\\:switch').text()).toEqual('Settings Div');
2010-11-05 22:05:24 +00:00
* });
* it('should change to home', function(){
* select('switch').option('home');
* expect(element('.doc-example ng\\:switch').text()).toEqual('Home Span');
2010-11-05 22:05:24 +00:00
* });
* it('should select deafault', function(){
* select('switch').option('other');
* expect(element('.doc-example ng\\:switch').text()).toEqual('default');
* });
*/
var ngSwitch = angularWidget('ng:switch', function (element){
2010-04-02 18:10:36 +00:00
var compiler = this,
2010-04-05 18:46:53 +00:00
watchExpr = element.attr("on"),
2010-04-21 19:50:05 +00:00
usingExpr = (element.attr("using") || 'equals'),
2010-05-10 17:36:02 +00:00
usingExprParams = usingExpr.split(":"),
2010-04-21 19:50:05 +00:00
usingFn = ngSwitch[usingExprParams.shift()],
2010-04-07 17:17:15 +00:00
changeExpr = element.attr('change') || '',
2010-04-05 18:46:53 +00:00
cases = [];
2010-04-21 19:50:05 +00:00
if (!usingFn) throw "Using expression '" + usingExpr + "' unknown.";
if (!watchExpr) throw "Missing 'on' attribute.";
2010-04-05 18:46:53 +00:00
eachNode(element, function(caseElement){
var when = caseElement.attr('ng:switch-when');
var switchCase = {
2010-04-07 17:17:15 +00:00
change: changeExpr,
2010-04-05 18:46:53 +00:00
element: caseElement,
template: compiler.compile(caseElement)
};
if (isString(when)) {
switchCase.when = function(scope, value){
var args = [value, when];
foreach(usingExprParams, function(arg){
args.push(arg);
});
return usingFn.apply(scope, args);
};
cases.unshift(switchCase);
} else if (isString(caseElement.attr('ng:switch-default'))) {
switchCase.when = valueFn(true);
cases.push(switchCase);
2010-04-05 18:46:53 +00:00
}
});
2010-04-21 19:50:05 +00:00
// this needs to be here for IE
foreach(cases, function(_case){
_case.element.remove();
});
2010-04-05 18:46:53 +00:00
element.html('');
return function(element){
2010-04-07 17:17:15 +00:00
var scope = this, childScope;
2010-04-02 18:10:36 +00:00
this.$watch(watchExpr, function(value){
var found = false;
2010-04-05 18:46:53 +00:00
element.html('');
2010-04-09 23:20:15 +00:00
childScope = createScope(scope);
2010-04-05 18:46:53 +00:00
foreach(cases, function(switchCase){
if (!found && switchCase.when(childScope, value)) {
found = true;
var caseElement = quickClone(switchCase.element);
2010-04-21 19:50:05 +00:00
element.append(caseElement);
2010-04-07 17:17:15 +00:00
childScope.$tryEval(switchCase.change, element);
2010-04-21 19:50:05 +00:00
switchCase.template(caseElement, childScope);
2010-04-05 18:46:53 +00:00
childScope.$init();
2010-04-02 18:10:36 +00:00
}
});
});
2010-04-07 17:17:15 +00:00
scope.$onEval(function(){
if (childScope) childScope.$eval();
});
2010-04-02 18:10:36 +00:00
};
2010-04-07 17:17:15 +00:00
}, {
equals: function(on, when) {
return ''+on == when;
2010-04-07 17:17:15 +00:00
},
route: switchRouteMatcher
2010-04-02 18:10:36 +00:00
});
/*
* Modifies the default behavior of html A tag, so that the default action is prevented when href
* attribute is empty.
*
* The reasoning for this change is to allow easy creation of action links with ng:click without
* changing the location or causing page reloads, e.g.:
* <a href="" ng:click="model.$save()">Save</a>
*/
angular.widget('a', function() {
this.descend(true);
this.directives(true);
return function(element) {
if (element.attr('href') === '') {
element.bind('click', function(event){
event.preventDefault();
});
}
};
});