(function (factory) {
backbone.stickit - v0.7.0 The MIT License Copyright (c) 2012 The New York Times, CMS Group, Matthew DeLambo delambo@gmail.com
(function (factory) {
Set up Stickit appropriately for the environment. Start with AMD.
if (typeof define === 'function' && define.amd)
define(['underscore', 'backbone'], factory);
Next for Node.js or CommonJS.
else if (typeof exports === 'object')
factory(require('underscore'), require('backbone'));
Finally, as a browser global.
else
factory(_, Backbone);
}(function (_, Backbone) {
Backbone.Stickit = {
_handlers: [],
addHandler: function(handlers) {
Fill-in default values.
handlers = _.map(_.flatten([handlers]), function(handler) {
return _.extend({
updateModel: true,
updateView: true,
updateMethod: 'text'
}, handler);
});
this._handlers = this._handlers.concat(handlers);
}
};
_.extend(Backbone.View.prototype, {
Collection of model event bindings. [{model,event,fn}, ...]
_modelBindings: null,
Unbind the model and event bindings from this._modelBindings
and
this.$el
. If the optional model
parameter is defined, then only
delete bindings for the given model
and its corresponding view events.
unstickit: function(model) {
var models = [];
_.each(this._modelBindings, function(binding, i) {
if (model && binding.model !== model) return false;
binding.model.off(binding.event, binding.fn);
models.push(binding.model);
delete this._modelBindings[i];
}, this);
Trigger an event for each model that was unbound.
_.invoke(_.uniq(models), 'trigger', 'stickit:unstuck', this.cid);
Cleanup the null values.
this._modelBindings = _.compact(this._modelBindings);
this.$el.off('.stickit' + (model ? '.' + model.cid : ''));
},
Using this.bindings
configuration or the optionalBindingsConfig
, binds this.model
or the optionalModel
to elements in the view.
stickit: function(optionalModel, optionalBindingsConfig) {
var model = optionalModel || this.model,
namespace = '.stickit.' + model.cid,
bindings = optionalBindingsConfig || _.result(this, "bindings") || {};
this._modelBindings || (this._modelBindings = []);
this.unstickit(model);
Iterate through the selectors in the bindings configuration and configure the various options for each field.
_.each(bindings, function(v, selector) {
var $el, options, modelAttr, config,
binding = bindings[selector] || {},
bindId = _.uniqueId();
Support ':el' selector - special case selector for the view managed delegate.
$el = selector === ':el' ? this.$el : this.$(selector);
Fail fast if the selector didn't match an element.
if (!$el.length) return;
Allow shorthand setting of model attributes - 'selector':'observe'
.
if (_.isString(binding)) binding = {observe:binding};
Handle case where observe
is in the form of a function.
if (_.isFunction(binding.observe)) binding.observe = binding.observe.call(this);
config = getConfiguration($el, binding);
modelAttr = config.observe;
Create the model set options with a unique bindId
so that we
can avoid double-binding in the change:attribute
event handler.
config.bindId = bindId;
Add a reference to the view for handlers of stickitChange events
config.view = this;
options = _.extend({stickitChange:config}, config.setOptions);
initializeAttributes(this, $el, config, model, modelAttr);
initializeVisible(this, $el, config, model, modelAttr);
if (modelAttr) {
Setup one-way, form element to model, bindings.
_.each(config.events, function(type) {
var event = type + namespace;
var method = function(event) {
var val = config.getVal.call(this, $el, event, config, _.rest(arguments));
Don't update the model if false is returned from the updateModel
configuration.
if (evaluateBoolean(this, config.updateModel, val, event, config))
setAttr(model, modelAttr, val, options, this, config);
};
method = _.bind(method, this);
if (selector === ':el') this.$el.on(event, method);
else this.$el.on(event, selector, method);
}, this);
Setup a change:modelAttr
observer to keep the view element in sync.
modelAttr
may be an array of attributes or a single string value.
_.each(_.flatten([modelAttr]), function(attr) {
observeModelEvent(model, this, 'change:'+attr, function(model, val, options) {
var changeId = options && options.stickitChange && options.stickitChange.bindId || null;
if (changeId !== bindId)
updateViewBindEl(this, $el, config, getAttr(model, modelAttr, config, this), model);
});
}, this);
updateViewBindEl(this, $el, config, getAttr(model, modelAttr, config, this), model, true);
}
model.once('stickit:unstuck', function(cid) {
if (cid === this.cid) applyViewFn(this, config.destroy, $el, model, config);
}, this);
After each binding is setup, call the initialize
callback.
applyViewFn(this, config.initialize, $el, model, config);
}, this);
Wrap view.remove
to unbind stickit model and dom events.
var remove = this.remove;
this.remove = function() {
var ret = this;
this.unstickit();
if (remove) ret = remove.apply(this, _.rest(arguments));
return ret;
};
}
});
Evaluates the given path
(in object/dot-notation) relative to the given
obj
. If the path is null/undefined, then the given obj
is returned.
var evaluatePath = function(obj, path) {
var parts = (path || '').split('.');
var result = _.reduce(parts, function(memo, i) { return memo[i]; }, obj);
return result == null ? obj : result;
};
If the given fn
is a string, then view[fn] is called, otherwise it is
a function that should be executed.
var applyViewFn = function(view, fn) {
if (fn) return (_.isString(fn) ? evaluatePath(view,fn) : fn).apply(view, _.rest(arguments, 2));
};
var getSelectedOption = function($select) { return $select.find('option').not(function(){ return !this.selected; }); };
Given a function, string (view function reference), or a boolean value, returns the truthy result. Any other types evaluate as false.
var evaluateBoolean = function(view, reference) {
if (_.isBoolean(reference)) return reference;
else if (_.isFunction(reference) || _.isString(reference))
return applyViewFn.apply(this, arguments);
return false;
};
Setup a model event binding with the given function, and track the event in the view's _modelBindings.
var observeModelEvent = function(model, view, event, fn) {
model.on(event, fn, view);
view._modelBindings.push({model:model, event:event, fn:fn});
};
Prepares the given val
ue and sets it into the model
.
var setAttr = function(model, attr, val, options, context, config) {
var value = {};
if (config.onSet)
val = applyViewFn(context, config.onSet, val, config);
if (config.set)
applyViewFn(context, config.set, attr, val, options, config);
else {
value[attr] = val;
If observe
is defined as an array and onSet
returned
an array, then map attributes to their values.
if (_.isArray(attr) && _.isArray(val)) {
value = _.reduce(attr, function(memo, attribute, index) {
memo[attribute] = _.has(val, index) ? val[index] : null;
return memo;
}, {});
}
model.set(value, options);
}
};
Returns the given attr
's value from the model
, escaping and
formatting if necessary. If attr
is an array, then an array of
respective values will be returned.
var getAttr = function(model, attr, config, context) {
var val,
retrieveVal = function(field) {
return model[config.escape ? 'escape' : 'get'](field);
},
sanitizeVal = function(val) {
return val == null ? '' : val;
};
val = _.isArray(attr) ? _.map(attr, retrieveVal) : retrieveVal(attr);
if (config.onGet) val = applyViewFn(context, config.onGet, val, config);
return _.isArray(val) ? _.map(val, sanitizeVal) : sanitizeVal(val);
};
Find handlers in Backbone.Stickit._handlers
with selectors that match
$el
and generate a configuration by mixing them in the order that they
were found with the given binding
.
var getConfiguration = Backbone.Stickit.getConfiguration = function($el, binding) {
var handlers = [{
updateModel: false,
updateMethod: 'text',
update: function($el, val, m, opts) { if ($el[opts.updateMethod]) $el[opts.updateMethod](val); },
getVal: function($el, e, opts) { return $el[opts.updateMethod](); }
}];
handlers = handlers.concat(_.filter(Backbone.Stickit._handlers, function(handler) {
return $el.is(handler.selector);
}));
handlers.push(binding);
var config = _.extend.apply(_, handlers);
updateView
is defaulted to false for configutrations with
visible
; otherwise, updateView
is defaulted to true.
if (config.visible && !_.has(config, 'updateView')) config.updateView = false;
else if (!_.has(config, 'updateView')) config.updateView = true;
delete config.selector;
return config;
};
Setup the attributes configuration - a list that maps an attribute or
property name
, to an observe
d model attribute, using an optional
onGet
formatter.
attributes: [{
name: 'attributeOrPropertyName',
observe: 'modelAttrName'
onGet: function(modelAttrVal, modelAttrName) { ... }
}, ...]
var initializeAttributes = function(view, $el, config, model, modelAttr) {
var props = ['autofocus', 'autoplay', 'async', 'checked', 'controls', 'defer', 'disabled', 'hidden', 'indeterminate', 'loop', 'multiple', 'open', 'readonly', 'required', 'scoped', 'selected'];
_.each(config.attributes || [], function(attrConfig) {
var lastClass = '', observed, updateAttr;
attrConfig = _.clone(attrConfig);
observed = attrConfig.observe || (attrConfig.observe = modelAttr),
updateAttr = function() {
var updateType = _.indexOf(props, attrConfig.name, true) > -1 ? 'prop' : 'attr',
val = getAttr(model, observed, attrConfig, view);
If it is a class then we need to remove the last value and add the new.
if (attrConfig.name === 'class') {
$el.removeClass(lastClass).addClass(val);
lastClass = val;
}
else $el[updateType](attrConfig.name, val);
};
_.each(_.flatten([observed]), function(attr) {
observeModelEvent(model, view, 'change:' + attr, updateAttr);
});
updateAttr();
});
};
If visible
is configured, then the view element will be shown/hidden
based on the truthiness of the modelattr's value or the result of the
given callback. If a visibleFn
is also supplied, then that callback
will be executed to manually handle showing/hiding the view element.
observe: 'isRight',
visible: true, // or function(val, options) {}
visibleFn: function($el, isVisible, options) {} // optional handler
var initializeVisible = function(view, $el, config, model, modelAttr) {
if (config.visible == null) return;
var visibleCb = function() {
var visible = config.visible,
visibleFn = config.visibleFn,
val = getAttr(model, modelAttr, config, view),
isVisible = !!val;
If visible
is a function then it should return a boolean result to show/hide.
if (_.isFunction(visible) || _.isString(visible)) isVisible = !!applyViewFn(view, visible, val, config);
Either use the custom visibleFn
, if provided, or execute the standard show/hide.
if (visibleFn) applyViewFn(view, visibleFn, $el, isVisible, config);
else {
$el.toggle(isVisible);
}
};
_.each(_.flatten([modelAttr]), function(attr) {
observeModelEvent(model, view, 'change:' + attr, visibleCb);
});
visibleCb();
};
Update the value of $el
using the given configuration and trigger the
afterUpdate
callback. This action may be blocked by config.updateView
.
update: function($el, val, model, options) {}, // handler for updating
updateView: true, // defaults to true
afterUpdate: function($el, val, options) {} // optional callback
var updateViewBindEl = function(view, $el, config, val, model, isInitializing) {
if (!evaluateBoolean(view, config.updateView, val, config)) return;
applyViewFn(view, config.update, $el, val, model, config);
if (!isInitializing) applyViewFn(view, config.afterUpdate, $el, val, config);
};
Backbone.Stickit.addHandler([{
selector: '[contenteditable="true"]',
updateMethod: 'html',
events: ['input', 'change']
}, {
selector: 'input',
events: ['propertychange', 'input', 'change'],
update: function($el, val) { $el.val(val); },
getVal: function($el) {
return $el.val();
}
}, {
selector: 'textarea',
events: ['propertychange', 'input', 'change'],
update: function($el, val) { $el.val(val); },
getVal: function($el) { return $el.val(); }
}, {
selector: 'input[type="radio"]',
events: ['change'],
update: function($el, val) {
$el.filter('[value="'+val+'"]').prop('checked', true);
},
getVal: function($el) {
return $el.filter(':checked').val();
}
}, {
selector: 'input[type="checkbox"]',
events: ['change'],
update: function($el, val, model, options) {
if ($el.length > 1) {
There are multiple checkboxes so we need to go through them and check
any that have value attributes that match what's in the array of val
s.
val || (val = []);
$el.each(function(i, el) {
var checkbox = Backbone.$(el);
var checked = _.indexOf(val, checkbox.val()) > -1;
checkbox.prop('checked', checked);
});
} else {
var checked = _.isBoolean(val) ? val : val === $el.val();
$el.prop('checked', checked);
}
},
getVal: function($el) {
var val;
if ($el.length > 1) {
val = _.reduce($el, function(memo, el) {
var checkbox = Backbone.$(el);
if (checkbox.prop('checked')) memo.push(checkbox.val());
return memo;
}, []);
} else {
val = $el.prop('checked');
If the checkbox has a value attribute defined, then use that value. Most browsers use "on" as a default.
var boxval = $el.val();
if (boxval !== 'on' && boxval != null) {
val = val ? $el.val() : null;
}
}
return val;
}
}, {
selector: 'select',
events: ['change'],
update: function($el, val, model, options) {
var optList,
selectConfig = options.selectOptions,
list = selectConfig && selectConfig.collection || undefined,
isMultiple = $el.prop('multiple');
If there are no selectOptions
then we assume that the <select>
is pre-rendered and that we need to generate the collection.
if (!selectConfig) {
selectConfig = {};
var getList = function($el) {
return $el.map(function() {
return {value:this.value, label:this.text};
}).get();
};
if ($el.find('optgroup').length) {
list = {opt_labels:[]};
Search for options without optgroup
if ($el.find('> option').length) {
list.opt_labels.push(undefined);
_.each($el.find('> option'), function(el) {
list[undefined] = getList(Backbone.$(el));
});
}
_.each($el.find('optgroup'), function(el) {
var label = Backbone.$(el).attr('label');
list.opt_labels.push(label);
list[label] = getList(Backbone.$(el).find('option'));
});
} else {
list = getList($el.find('option'));
}
}
Fill in default label and path values.
selectConfig.valuePath = selectConfig.valuePath || 'value';
selectConfig.labelPath = selectConfig.labelPath || 'label';
var addSelectOptions = function(optList, $el, fieldVal) {
_.each(optList, function(obj) {
var option = Backbone.$('<option/>'), optionVal = obj;
var fillOption = function(text, val) {
option.text(text);
optionVal = val;
Save the option value as data so that we can reference it later.
option.data('stickit_bind_val', optionVal);
if (!_.isArray(optionVal) && !_.isObject(optionVal)) option.val(optionVal);
};
if (obj === '__default__')
fillOption(selectConfig.defaultOption.label, selectConfig.defaultOption.value);
else
fillOption(evaluatePath(obj, selectConfig.labelPath), evaluatePath(obj, selectConfig.valuePath));
Determine if this option is selected.
if (!isMultiple && optionVal != null && fieldVal != null && optionVal === fieldVal || (_.isObject(fieldVal) && _.isEqual(optionVal, fieldVal)))
option.prop('selected', true);
else if (isMultiple && _.isArray(fieldVal)) {
_.each(fieldVal, function(val) {
if (_.isObject(val)) val = evaluatePath(val, selectConfig.valuePath);
if (val === optionVal || (_.isObject(val) && _.isEqual(optionVal, val)))
option.prop('selected', true);
});
}
$el.append(option);
});
};
$el.html('');
The list
configuration is a function that returns the options list or a string
which represents the path to the list relative to window
or the view/this
.
var evaluate = function(view, list) {
var context = window;
if (list.indexOf('this.') === 0) context = view;
list = list.replace(/^[a-z]*\.(.+)$/, '$1');
return evaluatePath(context, list);
};
if (_.isString(list)) optList = evaluate(this, list);
else if (_.isFunction(list)) optList = applyViewFn(this, list, $el, options);
else optList = list;
Support Backbone.Collection and deserialize.
if (optList instanceof Backbone.Collection) optList = optList.toJSON();
if (selectConfig.defaultOption) {
addSelectOptions(["__default__"], $el);
}
if (_.isArray(optList)) {
addSelectOptions(optList, $el, val);
} else if (optList.opt_labels) {
To define a select with optgroups, format selectOptions.collection as an object with an 'opt_labels' property, as in the following:
{
'opt_labels': ['Looney Tunes', 'Three Stooges'],
'Looney Tunes': [{id: 1, name: 'Bugs Bunny'}, {id: 2, name: 'Donald Duck'}],
'Three Stooges': [{id: 3, name : 'moe'}, {id: 4, name : 'larry'}, {id: 5, name : 'curly'}]
}
_.each(optList.opt_labels, function(label) {
var $group = Backbone.$('<optgroup/>').attr('label', label);
addSelectOptions(optList[label], $group, val);
$el.append($group);
});
With no 'opt_labels' parameter, the object is assumed to be a simple value-label map. Pass a selectOptions.comparator to override the default order of alphabetical by label.
} else {
var opts = [], opt;
for (var i in optList) {
opt = {};
opt[selectConfig.valuePath] = i;
opt[selectConfig.labelPath] = optList[i];
opts.push(opt);
}
addSelectOptions(_.sortBy(opts, selectConfig.comparator || selectConfig.labelPath), $el, val);
}
},
getVal: function($el) {
var val;
if ($el.prop('multiple')) {
val = Backbone.$(getSelectedOption($el).map(function() {
return Backbone.$(this).data('stickit_bind_val');
})).get();
} else {
val = getSelectedOption($el).data('stickit_bind_val');
}
return val;
}
}]);
}));