react-events
Version:
Declarative managed event bindings for React components
564 lines (524 loc) • 17.4 kB
JavaScript
/*!
* react-events v0.7.5
* https://github.com/jhudson8/react-events
*
*
* Copyright (c) 2014 Joe Hudson<joehud_AT_gmail.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
(function(main) {
if (typeof define === 'function' && define.amd) {
define([], function() {
// with AMD
// require(
// ['react', 'react-events'], function(React, reactEvents) {
// reactEvents(React);
// });
return main;
});
} else if (typeof exports !== 'undefined' && typeof require !== 'undefined') {
// with CommonJS
// require('react-events')(require('react'));
module.exports = main;
} else {
main(React);
}
})(function(React) {
var handlers = {},
patternHandlers = [],
splitter = /^([^:]+):?(.*)/,
specialWrapper = /^\*([^\(]+)\(([^)]*)\)[->:]*(.*)/,
noArgMethods = ['forceUpdate'],
setState = React.mixins.setState,
getState = React.mixins.getState;
/**
* Allow events to be referenced in a hierarchical structure. All parts in the
* hierarchy will be appended together using ":" as the separator
* window: {
* scroll: 'onScroll',
* resize: 'onResize'
* }
* will return as
* {
* 'window:scroll': 'onScroll',
* 'window:resize': 'onResize'
* }
}
*/
function normalizeEvents(events, rtn, prefix) {
rtn = rtn || {};
if (prefix) {
prefix += ':';
} else {
prefix = '';
}
var value, valueType;
for (var key in events) {
value = events[key];
valueType = typeof value;
if (valueType === 'string' || valueType === 'function') {
rtn[prefix + key] = value;
} else if (value) {
normalizeEvents(value, rtn, prefix + key);
}
}
return rtn;
}
/**
* Internal model event binding handler
* (type(on|once|off), {event, callback, context, target})
*/
function manageEvent(type, data) {
var eventsParent = this;
var _data = {
type: type
};
for (var name in data) {
_data[name] = data[name];
}
var watchedEvents = React.mixins.getState('__watchedEvents', this);
if (!watchedEvents) {
watchedEvents = [];
setState({
__watchedEvents: watchedEvents
}, this);
}
_data.context = _data.context || this;
watchedEvents.push(_data);
// bind now if we are already mounted (as the mount function won't be called)
var target = getTarget(_data.target, this);
if (this.isMounted()) {
if (target) {
target[_data.type](_data.event, _data.callback, _data.context);
}
}
if (type === 'off') {
var watchedEvent;
for (var i=0; i<watchedEvents.length; i++) {
watchedEvent = watchedEvents[i];
if (watchedEvent.event === data.event &&
watchedEvent.callback === data.callback &&
getTarget(watchedEvent.target, this) === target) {
watchedEvents.splice(i, 1);
}
}
}
}
// bind all registered events to the model
function _watchedEventsBindAll(context) {
var watchedEvents = getState('__watchedEvents', context);
if (watchedEvents) {
var data;
for (var name in watchedEvents) {
data = watchedEvents[name];
var target = getTarget(data.target, context);
if (target) {
target[data.type](data.event, data.callback, data.context);
}
}
}
}
// unbind all registered events from the model
function _watchedEventsUnbindAll(keepRegisteredEvents, context) {
var watchedEvents = getState('__watchedEvents', context);
if (watchedEvents) {
var data;
for (var name in watchedEvents) {
data = watchedEvents[name];
var target = getTarget(data.target, context);
if (target) {
target.off(data.event, data.callback, data.context);
}
}
if (!keepRegisteredEvents) {
setState({
__watchedEvents: []
}, context);
}
}
}
function getTarget(target, context) {
if (typeof target === 'function') {
return target.call(context);
}
return target;
}
/*
* wrapper for event implementations - includes on/off methods
*/
function createHandler(event, callback, context, dontWrapCallback) {
if (!dontWrapCallback) {
var _callback = callback,
noArg;
if (typeof callback === 'object') {
// use the "callback" attribute to get the callback function. useful if you need to reference the component as "this"
_callback = callback.callback.call(this);
}
if (typeof callback === 'string') {
noArg = (noArgMethods.indexOf(callback) >= 0);
_callback = context[callback];
}
if (!_callback) {
throw 'no callback function exists for "' + callback + '"';
}
callback = function() {
return _callback.apply(context, noArg ? [] : arguments);
};
}
// check for special wrapper function
var match = event.match(specialWrapper);
if (match) {
var specialMethodName = match[1],
args = eval('[' + match[2] + ']'),
rest = match[3],
specialHandler = React.events.specials[specialMethodName];
if (specialHandler) {
if (args.length === 1 && args[0] === '') {
args = [];
}
callback = specialHandler.call(context, callback, args);
return createHandler(rest, callback, context, true);
} else {
throw new Error('invalid special event handler "' + specialMethodName + "'");
}
}
var parts = event.match(splitter),
handlerName = parts[1];
path = parts[2],
handler = handlers[handlerName];
// check pattern handlers if no match
for (var i = 0; !handler && i < patternHandlers.length; i++) {
if (handlerName.match(patternHandlers[i].pattern)) {
handler = patternHandlers[i].handler;
}
}
if (!handler) {
throw new Error('no handler registered for "' + event + '"');
}
return handler.call(context, {
key: handlerName,
path: path
}, callback);
}
// predefined templates of common handler types for simpler custom handling
var handlerTemplates = {
/**
* Return a handler which will use a standard format of on(eventName, handlerFunction) and off(eventName, handlerFunction)
* @param data {object} handler options
* - target {object or function()}: the target to bind to or function(name, event) which returns this target ("this" is the React component)
* - onKey {string}: the function attribute used to add the event binding (default is "on")
* - offKey {string}: the function attribute used to add the event binding (default is "off")
*/
standard: function(data) {
var accessors = {
on: data.onKey || 'on',
off: data.offKey || 'off'
},
target = data.target;
return function(options, callback) {
var path = options.path;
function checkTarget(type, context) {
return function() {
var _target = (typeof target === 'function') ? target.call(context, path) : target;
if (_target) {
// register the handler
_target[accessors[type]](path, callback);
}
};
}
return {
on: checkTarget('on', this),
off: checkTarget('off', this),
initialize: data.initialize
};
};
}
};
var eventManager = React.events = {
// placeholder for special methods
specials: {},
/**
* Register an event handler
* @param identifier {string} the event type (first part of event definition)
* @param handlerOrOptions {function(options, callback) *OR* options object}
*
* handlerOrOptions as function(options, callback) a function which returns the object used as the event handler.
* @param options {object}: will contain a *path* attribute - the event key (without the handler key prefix).
* if the custom handler was registered as "foo" and events hash was { "foo:abc": "..." }, the path is "abc"
* @param callback {function}: the callback function to be bound to the event
*
* handlerOrOptions as options: will use a predefined "standard" handler; this assumes the event format of "{handler identifier}:{target identifier}:{event name}"
* @param target {object or function(targetIdentifier, eventName)} the target to bind/unbind from or the functions which retuns this target
* @param onKey {string} the attribute which identifies the event binding function on the target (default is "on")
* @param offKey {string} the attribute which identifies the event un-binding function on the target (default is "off")
*/
handle: function(identifier, optionsOrHandler) {
if (typeof optionsOrHandler !== 'function') {
// it's options
optionsOrHandler = handlerTemplates[optionsOrHandler.type || 'standard'](optionsOrHandler);
}
if (identifier instanceof RegExp) {
patternHandlers.push({
pattern: identifier,
handler: optionsOrHandler
});
} else {
handlers[identifier] = optionsOrHandler;
}
}
};
//// REGISTER THE DEFAULT EVENT HANDLERS
if (typeof window != 'undefined') {
/**
* Bind to window events
* format: "window:{event name}"
* example: events: { 'window:scroll': 'onScroll' }
*/
eventManager.handle('window', {
target: window,
onKey: 'addEventListener',
offKey: 'removeEventListener'
});
}
var objectHandlers = {
/**
* Bind to events on components that are given a [ref](http://facebook.github.io/react/docs/more-about-refs.html)
* format: "ref:{ref name}:{event name}"
* example: "ref:myComponent:something-happened": "onSomethingHappened"
*/
ref: function(refKey) {
return this.refs[refKey];
},
/**
* Bind to events on components that are provided as property values
* format: "prop:{prop name}:{event name}"
* example: "prop:componentProp:something-happened": "onSomethingHappened"
*/
prop: function(propKey) {
return this.props[propKey];
}
};
function registerObjectHandler(key, objectFactory) {
eventManager.handle(key, function(options, callback) {
var parts = options.path.match(splitter),
objectKey = parts[1],
ev = parts[2],
bound, componentState;
return {
on: function() {
var target = objectFactory.call(this, objectKey);
if (target) {
componentState = target.state || target;
target.on(ev, callback);
bound = target;
}
},
off: function() {
if (bound) {
bound.off(ev, callback);
bound = undefined;
componentState = undefined;
}
},
isStale: function() {
if (bound) {
var target = objectFactory.call(this, objectKey);
if (!target || (target.state || target) !== componentState) {
// if the target doesn't exist now and we were bound before or the target state has changed we are stale
return true;
}
} else {
// if we weren't bound before but the component exists now, we are stale
return !!target;
}
}
};
});
}
var objectFactory;
for (var key in objectHandlers) {
registerObjectHandler(key, objectHandlers[key]);
}
/**
* Allow binding to setInterval events
* format: "repeat:{milis}"
* example: events: { 'repeat:3000': 'onRepeat3Sec' }
*/
eventManager.handle('repeat', function(options, callback) {
var delay = parseInt(options.path, 10),
id;
return {
on: function() {
id = setInterval(callback, delay);
},
off: function() {
id = !!clearInterval(id);
}
};
});
/**
* Like setInterval events *but* will only fire when the user is actively viewing the web page
* format: "!repeat:{milis}"
* example: events: { '!repeat:3000': 'onRepeat3Sec' }
*/
eventManager.handle('!repeat', function(options, callback) {
var delay = parseInt(options.path, 10),
keepGoing;
function doInterval(suppressCallback) {
if (suppressCallback !== true) {
callback();
}
setTimeout(function() {
if (keepGoing) {
requestAnimationFrame(doInterval);
}
}, delay);
}
return {
on: function() {
keepGoing = true;
doInterval(true);
},
off: function() {
keepGoing = false;
}
};
});
//// REGISTER THE REACT MIXIN
React.mixins.add('events', function() {
var rtn = [{
/**
* Return a callback fundtion that will trigger an event on "this" when executed with the provided parameters
*/
triggerWith: function(eventName) {
var args = Array.prototype.slice.call(arguments),
self = this;
return function() {
self.trigger.apply(this, args);
};
},
getInitialState: function() {
var handlers = [];
if (this.events) {
var events = normalizeEvents(this.events);
var handler;
for (var ev in events) {
handler = createHandler(ev, events[ev], this);
if (handler.initialize) {
handler.initialize.call(this);
}
handlers.push(handler);
}
}
return {_eventHandlers: handlers};
},
componentDidUpdate: function() {
var handlers = getState('_eventHandlers', this),
handler;
for (var i = 0; i < handlers.length; i++) {
handler = handlers[i];
if (handler.isStale && handler.isStale.call(this)) {
handler.off.call(this);
handler.on.call(this);
}
}
},
componentDidMount: function() {
var handlers = getState('_eventHandlers', this);
for (var i = 0; i < handlers.length; i++) {
handlers[i].on.call(this);
}
},
componentWillUnmount: function() {
var handlers = getState('_eventHandlers', this);
for (var i = 0; i < handlers.length; i++) {
handlers[i].off.call(this);
}
}
}];
function bind(func, context) {
return function() {
func.apply(context, arguments);
};
}
var eventHandler = eventManager.mixin;
if (eventHandler) {
var eventHandlerMixin = {},
state = {},
key;
var keys = ['on', 'off', 'trigger'];
for (var i=0; i<keys.length; i++) {
var key = keys[i];
if (eventHandler[key]) {
eventHandlerMixin[key] = bind(eventHandler[key], state);
}
}
eventHandlerMixin.getInitialState = function() {
return {
__events: state
};
};
rtn.push(eventHandlerMixin);
}
// React.eventHandler.mixin should contain impl for "on" "off" and "trigger"
return rtn;
}, 'state');
/**
* Allow for managed bindings to any object which supports on/off.
*/
React.mixins.add('listen', {
componentDidMount: function() {
// sanity check to prevent duplicate binding
_watchedEventsUnbindAll(true, this);
_watchedEventsBindAll(this);
},
componentWillUnmount: function() {
_watchedEventsUnbindAll(true, this);
},
// {event, callback, context, model}
listenTo: function(target, ev, callback, context) {
var data = ev ? {
event: ev,
callback: callback,
target: target,
context: context
} : target;
manageEvent.call(this, 'on', data);
},
listenToOnce: function(target, ev, callback, context) {
var data = {
event: ev,
callback: callback,
target: target,
context: context
};
manageEvent.call(this, 'once', data);
},
stopListening: function(target, ev, callback, context) {
var data = {
event: ev,
callback: callback,
target: target,
context: context
};
manageEvent.call(this, 'off', data);
}
});
});