• backbone.stickit.js

  • ¶

    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 Namespace

  • ¶
      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);
        }
      };
  • ¶

    Backbone.View Mixins

  • ¶
      _.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;
          };
        }
      });
  • ¶

    Helpers

  • ¶

    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 value 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 observed 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);
      };
  • ¶

    Default Handlers

  • ¶
      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 vals.

            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;
        }
      }]);
    
    }));