blob: d9a5077ac99b997443cace235e63c44f2e3ae9c6 [file] [log] [blame]
// ==========================================
// Copyright 2013 Twitter, Inc
// Licensed under The MIT License
// http://opensource.org/licenses/MIT
// ==========================================
"use strict";
define(
[
'./advice',
'./utils',
'./compose',
'./registry'
],
function(advice, utils, compose, registry) {
var functionNameRegEx = /function (.*?)\s?\(/;
var componentId = 0;
function teardownInstance(instanceInfo){
instanceInfo.events.slice().forEach(function(event) {
var args = [event.type];
event.element && args.unshift(event.element);
(typeof event.callback == 'function') && args.push(event.callback);
this.off.apply(this, args);
}, instanceInfo.instance);
}
function teardown() {
teardownInstance(registry.findInstanceInfo(this));
}
//teardown for all instances of this constructor
function teardownAll() {
var componentInfo = registry.findComponentInfo(this);
componentInfo && Object.keys(componentInfo.instances).forEach(function(k) {
var info = componentInfo.instances[k];
info.instance.teardown();
});
}
function checkSerializable(type, data) {
try {
window.postMessage(data, '*');
} catch(e) {
console.log('unserializable data for event',type,':',data);
throw new Error(
["The event", type, "on component", this.toString(), "was triggered with non-serializable data"].join(" ")
);
}
}
//common mixin allocates basic functionality - used by all component prototypes
//callback context is bound to component
function withBaseComponent() {
// delegate trigger, bind and unbind to an element
// if $element not supplied, use component's node
// other arguments are passed on
// event can be either a string specifying the type
// of the event, or a hash specifying both the type
// and a default function to be called.
this.trigger = function() {
var $element, type, data, event, defaultFn;
var lastIndex = arguments.length - 1, lastArg = arguments[lastIndex];
if (typeof lastArg != "string" && !(lastArg && lastArg.defaultBehavior)) {
lastIndex--;
data = lastArg;
}
if (lastIndex == 1) {
$element = $(arguments[0]);
event = arguments[1];
} else {
$element = this.$node;
event = arguments[0];
}
if (event.defaultBehavior) {
defaultFn = event.defaultBehavior;
event = $.Event(event.type);
}
type = event.type || event;
if (window.DEBUG && window.DEBUG.enabled && window.postMessage) {
checkSerializable.call(this, type, data);
}
if (typeof this.attr.eventData === 'object') {
data = $.extend(true, {}, this.attr.eventData, data);
}
$element.trigger((event || type), data);
if (defaultFn && !event.isDefaultPrevented()) {
(this[defaultFn] || defaultFn).call(this);
}
return $element;
};
this.on = function() {
var $element, type, callback, originalCb;
var lastIndex = arguments.length - 1, origin = arguments[lastIndex];
if (typeof origin == "object") {
//delegate callback
originalCb = utils.delegate(
this.resolveDelegateRules(origin)
);
} else {
originalCb = origin;
}
if (lastIndex == 2) {
$element = $(arguments[0]);
type = arguments[1];
} else {
$element = this.$node;
type = arguments[0];
}
if (typeof originalCb != 'function' && typeof originalCb != 'object') {
throw new Error("Unable to bind to '" + type + "' because the given callback is not a function or an object");
}
callback = originalCb.bind(this);
callback.target = originalCb;
// if the original callback is already branded by jQuery's guid, copy it to the context-bound version
if (originalCb.guid) {
callback.guid = originalCb.guid;
}
$element.on(type, callback);
// get jquery's guid from our bound fn, so unbinding will work
originalCb.guid = callback.guid;
return callback;
};
this.off = function() {
var $element, type, callback;
var lastIndex = arguments.length - 1;
if (typeof arguments[lastIndex] == "function") {
callback = arguments[lastIndex];
lastIndex -= 1;
}
if (lastIndex == 1) {
$element = $(arguments[0]);
type = arguments[1];
} else {
$element = this.$node;
type = arguments[0];
}
return $element.off(type, callback);
};
this.resolveDelegateRules = function(ruleInfo) {
var rules = {};
Object.keys(ruleInfo).forEach(function(r) {
if (!r in this.attr) {
throw new Error('Component "' + this.toString() + '" wants to listen on "' + r + '" but no such attribute was defined.');
}
rules[this.attr[r]] = ruleInfo[r];
}, this);
return rules;
};
this.defaultAttrs = function(defaults) {
utils.push(this.defaults, defaults, true) || (this.defaults = defaults);
};
this.select = function(attributeKey) {
return this.$node.find(this.attr[attributeKey]);
};
this.initialize = $.noop;
this.teardown = teardown;
}
function attachTo(selector/*, options args */) {
// unpacking arguments by hand benchmarked faster
var l = arguments.length;
var args = new Array(l - 1);
for (var i = 1; i < l; i++) args[i - 1] = arguments[i];
if (!selector) {
throw new Error("Component needs to be attachTo'd a jQuery object, native node or selector string");
}
var options = utils.merge.apply(utils, args);
$(selector).each(function(i, node) {
var rawNode = node.jQuery ? node[0] : node;
var componentInfo = registry.findComponentInfo(this)
if (componentInfo && componentInfo.isAttachedTo(rawNode)) {
//already attached
return;
}
new this(node, options);
}.bind(this));
}
// define the constructor for a custom component type
// takes an unlimited number of mixin functions as arguments
// typical api call with 3 mixins: define(timeline, withTweetCapability, withScrollCapability);
function define(/*mixins*/) {
// unpacking arguments by hand benchmarked faster
var l = arguments.length;
var mixins = new Array(l);
for (var i = 0; i < l; i++) mixins[i] = arguments[i];
Component.toString = function() {
var prettyPrintMixins = mixins.map(function(mixin) {
if (mixin.name == null) {
//function name property not supported by this browser, use regex
var m = mixin.toString().match(functionNameRegEx);
return (m && m[1]) ? m[1] : "";
} else {
return (mixin.name != "withBaseComponent") ? mixin.name : "";
}
}).filter(Boolean).join(', ');
return prettyPrintMixins;
};
if (window.DEBUG && window.DEBUG.enabled) {
Component.describe = Component.toString();
}
//'options' is optional hash to be merged with 'defaults' in the component definition
function Component(node, options) {
options = options || {};
this.identity = componentId++;
if (!node) {
throw new Error("Component needs a node");
}
if (node.jquery) {
this.node = node[0];
this.$node = node;
} else {
this.node = node;
this.$node = $(node);
}
this.toString = Component.toString;
if (window.DEBUG && window.DEBUG.enabled) {
this.describe = this.toString();
}
//merge defaults with supplied options
//put options in attr.__proto__ to avoid merge overhead
var attr = Object.create(options);
for (var key in this.defaults) {
if (!options.hasOwnProperty(key)) {
attr[key] = this.defaults[key];
}
}
this.attr = attr;
Object.keys(this.defaults || {}).forEach(function(key) {
if (this.defaults[key] === null && this.attr[key] === null) {
throw new Error('Required attribute "' + key + '" not specified in attachTo for component "' + this.toString() + '".');
}
}, this);
this.initialize.call(this, options);
}
Component.attachTo = attachTo;
Component.teardownAll = teardownAll;
// prepend common mixins to supplied list, then mixin all flavors
mixins.unshift(withBaseComponent, advice.withAdvice, registry.withRegistration);
compose.mixin(Component.prototype, mixins);
return Component;
}
define.teardownAll = function() {
registry.components.slice().forEach(function(c) {
c.component.teardownAll();
});
registry.reset();
};
return define;
}
);