308 lines
9.1 KiB
JavaScript
308 lines
9.1 KiB
JavaScript
// ==========================================
|
|
// 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;
|
|
}
|
|
);
|