Permalink
Fetching contributors…
Cannot retrieve contributors at this time
712 lines (615 sloc) 26.3 KB
// Backbone.Stickit v0.9.2, MIT Licensed
// Copyright (c) 2012-2015 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', 'exports'], factory);
// Next for Node.js or CommonJS.
else if (typeof exports === 'object')
factory(require('underscore'), require('backbone'), exports);
// Finally, as a browser global.
else
factory(_, Backbone, {});
}(function (_, Backbone, Stickit) {
// Stickit Namespace
// --------------------------
// Export onto Backbone object
Backbone.Stickit = Stickit;
Stickit._handlers = [];
Stickit.addHandler = function(handlers) {
// Fill-in default values.
handlers = _.map(_.flatten([handlers]), function(handler) {
return _.defaults({}, handler, {
updateModel: true,
updateView: true,
updateMethod: 'text'
});
});
this._handlers = this._handlers.concat(handlers);
};
// Backbone.View Mixins
// --------------------
Stickit.ViewMixin = {
// Collection of model event bindings.
// [{model,event,fn,config}, ...]
_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, bindingSelector) {
// Support passing a bindings hash in place of bindingSelector.
if (_.isObject(bindingSelector)) {
_.each(bindingSelector, _.bind(function(v, selector) {
this.unstickit(model, selector);
}, this));
return;
}
var models = [], destroyFns = [];
this._modelBindings = _.reject(this._modelBindings, function(binding) {
if (model && binding.model !== model) return;
if (bindingSelector && binding.config.selector != bindingSelector) return;
binding.model.off(binding.event, binding.fn);
destroyFns.push(binding.config._destroy);
models.push(binding.model);
return true;
});
// Trigger an event for each model that was unbound.
_.each(_.uniq(models), _.bind(function (model) {
model.trigger('stickit:unstuck', this.cid);
}, this));
// Call `_destroy` on a unique list of the binding callbacks.
_.each(_.uniq(destroyFns), _.bind(function(fn) { fn.call(this); }, this));
this.$el.off('.stickit' + (model ? '.' + model.cid : ''), bindingSelector);
},
// Initilize Stickit bindings for the view. Subsequent binding additions
// can either call `stickit` with the new bindings, or add them directly
// with `addBinding`. Both arguments to `stickit` are optional.
stickit: function(optionalModel, optionalBindingsConfig) {
var model = optionalModel || this.model,
bindings = optionalBindingsConfig || _.result(this, "bindings") || {};
this._modelBindings || (this._modelBindings = []);
// Add bindings in bulk using `addBinding`.
this.addBinding(model, bindings);
// Wrap `view.remove` to unbind stickit model and dom events.
var remove = this.remove;
if (!remove.stickitWrapped) {
this.remove = function() {
var ret = this;
this.unstickit();
if (remove) ret = remove.apply(this, arguments);
return ret;
};
}
this.remove.stickitWrapped = true;
return this;
},
// Add a single Stickit binding or a hash of bindings to the model. If
// `optionalModel` is ommitted, will default to the view's `model` property.
addBinding: function(optionalModel, selector, binding) {
var model = optionalModel || this.model,
namespace = '.stickit.' + model.cid;
binding = binding || {};
// Support jQuery-style {key: val} event maps.
if (_.isObject(selector)) {
var bindings = selector;
_.each(bindings, _.bind(function(val, key) {
this.addBinding(model, key, val);
}, this));
return;
}
// Special case the ':el' selector to use the view's this.$el.
var $el = selector === ':el' ? this.$el : this.$(selector);
// Clear any previous matching bindings.
this.unstickit(model, 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);
// Find all matching Stickit handlers that could apply to this element
// and store in a config object.
var config = getConfiguration($el, binding);
// The attribute we're observing in our config.
var modelAttr = config.observe;
// Store needed properties for later.
config.selector = selector;
config.view = this;
// Create the model set options with a unique `bindId` so that we
// can avoid double-binding in the `change:attribute` event handler.
var bindId = config.bindId = _.uniqueId();
// Add a reference to the view for handlers of stickitChange events
var options = _.extend({stickitChange: config}, config.setOptions);
// Add a `_destroy` callback to the configuration, in case `destroy`
// is a named function and we need a unique function when unsticking.
config._destroy = function() {
applyViewFn.call(this, config.destroy, $el, model, config);
};
initializeAttributes($el, config, model, modelAttr);
initializeVisible($el, config, model, modelAttr);
initializeClasses($el, config, model, modelAttr);
if (modelAttr) {
// Setup one-way (input element -> model) bindings.
_.each(config.events, _.bind(function(type) {
var eventName = type + namespace;
var listener = function(event) {
var val = applyViewFn.call(this, config.getVal, $el, event, config, slice.call(arguments, 1));
// Don't update the model if false is returned from the `updateModel` configuration.
var currentVal = evaluateBoolean(config.updateModel, val, event, config);
if (currentVal) setAttr(model, modelAttr, val, options, config);
};
var sel = selector === ':el'? '' : selector;
this.$el.on(eventName, sel, _.bind(listener, this));
}, 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, 'change:' + attr, config, function(m, val, options) {
var changeId = options && options.stickitChange && options.stickitChange.bindId;
if (changeId !== bindId) {
var currentVal = getAttr(model, modelAttr, config);
updateViewBindEl($el, config, currentVal, model);
}
});
});
var currentVal = getAttr(model, modelAttr, config);
updateViewBindEl($el, config, currentVal, model, true);
}
// After each binding is setup, call the `initialize` callback.
applyViewFn.call(this, config.initialize, $el, model, config);
}
};
_.extend(Backbone.View.prototype, Stickit.ViewMixin);
// Helpers
// -------
var slice = [].slice;
// 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(fn) {
fn = _.isString(fn) ? evaluatePath(this, fn) : fn;
if (fn) return (fn).apply(this, slice.call(arguments, 1));
};
// Given a function, string (view function reference), or a boolean
// value, returns the truthy result. Any other types evaluate as false.
// The first argument must be `reference` and the last must be `config`, but
// middle arguments can be variadic.
var evaluateBoolean = function(reference, val, config) {
if (_.isBoolean(reference)) {
return reference;
} else if (_.isFunction(reference) || _.isString(reference)) {
var view = _.last(arguments).view;
return applyViewFn.apply(view, 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, event, config, fn) {
var view = config.view;
model.on(event, fn, view);
view._modelBindings.push({model:model, event:event, fn:fn, config:config});
};
// Prepares the given `val`ue and sets it into the `model`.
var setAttr = function(model, attr, val, options, config) {
var value = {}, view = config.view;
if (config.onSet) {
val = applyViewFn.call(view, config.onSet, val, config);
}
if (config.set) {
applyViewFn.call(view, 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) {
var view = config.view;
var retrieveVal = function(field) {
return model[config.escape ? 'escape' : 'get'](field);
};
var sanitizeVal = function(val) {
return val == null ? '' : val;
};
var val = _.isArray(attr) ? _.map(attr, retrieveVal) : retrieveVal(attr);
if (config.onGet) val = applyViewFn.call(view, 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 = 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(Stickit._handlers, function(handler) {
return $el.is(handler.selector);
}));
handlers.push(binding);
// Merge handlers into a single config object. Last props in wins.
var config = _.extend.apply(_, handlers);
// `updateView` is defaulted to false for configutrations with
// `visible`; otherwise, `updateView` is defaulted to true.
if (!_.has(config, 'updateView')) config.updateView = !config.visible;
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($el, config, model, modelAttr) {
var props = ['autofocus', 'autoplay', 'async', 'checked', 'controls',
'defer', 'disabled', 'hidden', 'indeterminate', 'loop', 'multiple',
'open', 'readonly', 'required', 'scoped', 'selected'];
var view = config.view;
_.each(config.attributes || [], function(attrConfig) {
attrConfig = _.clone(attrConfig);
attrConfig.view = view;
var lastClass = '';
var observed = attrConfig.observe || (attrConfig.observe = modelAttr);
var updateAttr = function() {
var updateType = _.includes(props, attrConfig.name) ? 'prop' : 'attr',
val = getAttr(model, observed, attrConfig);
// 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, 'change:' + attr, config, updateAttr);
});
// Initialize the matched element's state.
updateAttr();
});
};
var initializeClasses = function($el, config, model, modelAttr) {
_.each(config.classes || [], function(classConfig, name) {
if (_.isString(classConfig)) classConfig = {observe: classConfig};
var observed = classConfig.observe;
var updateClass = function() {
classConfig.view = config.view;
var val = getAttr(model, observed, classConfig);
$el.toggleClass(name, !!val);
};
_.each(_.flatten([observed]), function(attr) {
observeModelEvent(model, 'change:' + attr, config, updateClass);
});
updateClass();
});
};
// 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($el, config, model, modelAttr) {
if (config.visible == null) return;
var view = config.view;
var visibleCb = function() {
var visible = config.visible,
visibleFn = config.visibleFn,
val = getAttr(model, modelAttr, config),
isVisible = !!val;
// If `visible` is a function then it should return a boolean result to show/hide.
if (_.isFunction(visible) || _.isString(visible)) {
isVisible = !!applyViewFn.call(view, visible, val, config);
}
// Either use the custom `visibleFn`, if provided, or execute the standard show/hide.
if (visibleFn) {
applyViewFn.call(view, visibleFn, $el, isVisible, config);
} else {
$el.toggle(isVisible);
}
};
_.each(_.flatten([modelAttr]), function(attr) {
observeModelEvent(model, 'change:' + attr, config, 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($el, config, val, model, isInitializing) {
var view = config.view;
if (!evaluateBoolean(config.updateView, val, config)) return;
applyViewFn.call(view, config.update, $el, val, model, config);
if (!isInitializing) applyViewFn.call(view, config.afterUpdate, $el, val, config);
};
// Default Handlers
// ----------------
Stickit.addHandler([{
selector: '[contenteditable]',
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) {
// Because we use " in the value descriptor, we need to escape any
// quotes in the value itself otherwise jQuery/Sizzle complain that
// the selector is invalid.
var escapedVal = val.toString().replace(/"/g, '\\"');
$el.filter('[value="'+escapedVal+'"]').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 checkboxVal = checkbox.val();
var checked = _.some(val, function(item) { return ''+item === checkboxVal; });
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(index, option) {
// Retrieve the text and value of the option, preferring "stickit-bind-val"
// data attribute over value property.
var dataVal = Backbone.$(option).data('stickit-bind-val');
return {
value: dataVal !== undefined ? dataVal : option.value,
label: option.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';
selectConfig.disabledPath = selectConfig.disabledPath || 'disabled';
var addSelectOptions = function(optList, $el, fieldVal) {
var fragment = document.createDocumentFragment();
_.each(optList, function(obj) {
var option = Backbone.$('<option/>'), optionVal = obj;
var fillOption = function(text, val, disabled) {
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 (disabled === true) option.prop('disabled', 'disabled');
};
var text, val, disabled;
if (obj === '__default__') {
text = fieldVal.label,
val = fieldVal.value,
disabled = fieldVal.disabled;
} else {
text = evaluatePath(obj, selectConfig.labelPath),
val = evaluatePath(obj, selectConfig.valuePath),
disabled = evaluatePath(obj, selectConfig.disabledPath);
}
fillOption(text, val, disabled);
// Determine if this option is selected.
var isSelected = function() {
if (!isMultiple && optionVal != null && fieldVal != null && optionVal === fieldVal) {
return true;
} else if (_.isObject(fieldVal) && _.isEqual(optionVal, fieldVal)) {
return true;
}
return false;
};
if (isSelected()) {
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);
});
}
option.appendTo(fragment);
});
$el.append(fragment);
};
$el.empty();
// 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`.
if (_.isString(list)) {
var context = window;
if (list.indexOf('this.') === 0) context = this;
list = list.replace(/^[a-z]*\.(.+)$/, '$1');
optList = evaluatePath(context, list);
} else if (_.isFunction(list)) {
optList = applyViewFn.call(this, list, $el, options);
} else {
optList = list;
}
// Support Backbone.Collection and deserialize.
if (optList instanceof Backbone.Collection) {
var collection = optList;
var refreshSelectOptions = function() {
var currentVal = getAttr(model, options.observe, options);
applyViewFn.call(this, options.update, $el, currentVal, model, options);
};
// We need to call this function after unstickit and after an update so we don't end up
// with multiple listeners doing the same thing
var removeCollectionListeners = function() {
collection.off('add remove reset sort', refreshSelectOptions);
};
var removeAllListeners = function() {
removeCollectionListeners();
collection.off('stickit:selectRefresh');
collection.off('stickit:selectRefreshForView');
model.off('stickit:selectRefresh');
};
collection.trigger('stickit:selectRefreshForView', this.cid);
// Compare the view cid on the event to this.cid to ensure we don't
// remove events from the collection that are tied to other views.
collection.once('stickit:selectRefreshForView', function(viewCid) {
if(this.cid == viewCid){
// Remove previously set event listeners by triggering a custom event
collection.trigger('stickit:selectRefresh');
}
}, this);
collection.once('stickit:selectRefresh', removeCollectionListeners, this);
// Listen to the collection and trigger an update of the select options
collection.on('add remove reset sort', refreshSelectOptions, this);
// Remove the previous model event listener
model.trigger('stickit:selectRefresh');
model.once('stickit:selectRefresh', function() {
model.off('stickit:unstuck', removeAllListeners);
});
// Remove collection event listeners once this binding is unstuck
model.once('stickit:unstuck', removeAllListeners, this);
optList = optList.toJSON();
}
if (selectConfig.defaultOption) {
var option = _.isFunction(selectConfig.defaultOption) ?
selectConfig.defaultOption.call(this, $el, options) :
selectConfig.defaultOption;
addSelectOptions(["__default__"], $el, option);
}
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);
}
opts = _.sortBy(opts, selectConfig.comparator || selectConfig.labelPath);
addSelectOptions(opts, $el, val);
}
},
getVal: function($el) {
var selected = $el.find('option:selected');
if ($el.prop('multiple')) {
return _.map(selected, function(el) {
return Backbone.$(el).data('stickit-bind-val');
});
} else {
return selected.data('stickit-bind-val');
}
}
}]);
return Stickit;
}));