317 lines
12 KiB
JavaScript
317 lines
12 KiB
JavaScript
|
/**
|
||
|
* A higher-order-component for handling onClickOutside for React components.
|
||
|
*/
|
||
|
(function(root) {
|
||
|
|
||
|
// administrative
|
||
|
var registeredComponents = [];
|
||
|
var handlers = [];
|
||
|
var IGNORE_CLASS = 'ignore-react-onclickoutside';
|
||
|
var DEFAULT_EVENTS = ['mousedown', 'touchstart'];
|
||
|
|
||
|
/**
|
||
|
* Check whether some DOM node is our Component's node.
|
||
|
*/
|
||
|
var isNodeFound = function(current, componentNode, ignoreClass) {
|
||
|
if (current === componentNode) {
|
||
|
return true;
|
||
|
}
|
||
|
// SVG <use/> elements do not technically reside in the rendered DOM, so
|
||
|
// they do not have classList directly, but they offer a link to their
|
||
|
// corresponding element, which can have classList. This extra check is for
|
||
|
// that case.
|
||
|
// See: http://www.w3.org/TR/SVG11/struct.html#InterfaceSVGUseElement
|
||
|
// Discussion: https://github.com/Pomax/react-onclickoutside/pull/17
|
||
|
if (current.correspondingElement) {
|
||
|
return current.correspondingElement.classList.contains(ignoreClass);
|
||
|
}
|
||
|
return current.classList.contains(ignoreClass);
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* Try to find our node in a hierarchy of nodes, returning the document
|
||
|
* node as highest noode if our node is not found in the path up.
|
||
|
*/
|
||
|
var findHighest = function(current, componentNode, ignoreClass) {
|
||
|
if (current === componentNode) {
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
// If source=local then this event came from 'somewhere'
|
||
|
// inside and should be ignored. We could handle this with
|
||
|
// a layered approach, too, but that requires going back to
|
||
|
// thinking in terms of Dom node nesting, running counter
|
||
|
// to React's 'you shouldn't care about the DOM' philosophy.
|
||
|
while(current.parentNode) {
|
||
|
if (isNodeFound(current, componentNode, ignoreClass)) {
|
||
|
return true;
|
||
|
}
|
||
|
current = current.parentNode;
|
||
|
}
|
||
|
return current;
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* Check if the browser scrollbar was clicked
|
||
|
*/
|
||
|
var clickedScrollbar = function(evt) {
|
||
|
return document.documentElement.clientWidth <= evt.clientX || document.documentElement.clientHeight <= evt.clientY;
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* Generate the event handler that checks whether a clicked DOM node
|
||
|
* is inside of, or lives outside of, our Component's node tree.
|
||
|
*/
|
||
|
var generateOutsideCheck = function(componentNode, componentInstance, eventHandler, ignoreClass, excludeScrollbar, preventDefault, stopPropagation) {
|
||
|
return function(evt) {
|
||
|
if (preventDefault) {
|
||
|
evt.preventDefault();
|
||
|
}
|
||
|
if (stopPropagation) {
|
||
|
evt.stopPropagation();
|
||
|
}
|
||
|
var current = evt.target;
|
||
|
if((excludeScrollbar && clickedScrollbar(evt)) || (findHighest(current, componentNode, ignoreClass) !== document)) {
|
||
|
return;
|
||
|
}
|
||
|
eventHandler(evt);
|
||
|
};
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* This function generates the HOC function that you'll use
|
||
|
* in order to impart onOutsideClick listening to an
|
||
|
* arbitrary component. It gets called at the end of the
|
||
|
* bootstrapping code to yield an instance of the
|
||
|
* onClickOutsideHOC function defined inside setupHOC().
|
||
|
*/
|
||
|
function setupHOC(root, React, ReactDOM, createReactClass) {
|
||
|
|
||
|
// The actual Component-wrapping HOC:
|
||
|
return function onClickOutsideHOC(Component, config) {
|
||
|
var wrapComponentWithOnClickOutsideHandling = createReactClass({
|
||
|
statics: {
|
||
|
/**
|
||
|
* Access the wrapped Component's class.
|
||
|
*/
|
||
|
getClass: function() {
|
||
|
if (Component.getClass) {
|
||
|
return Component.getClass();
|
||
|
}
|
||
|
return Component;
|
||
|
}
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Access the wrapped Component's instance.
|
||
|
*/
|
||
|
getInstance: function() {
|
||
|
return Component.prototype.isReactComponent ? this.refs.instance : this;
|
||
|
},
|
||
|
|
||
|
// this is given meaning in componentDidMount
|
||
|
__outsideClickHandler: function() {},
|
||
|
|
||
|
getDefaultProps: function() {
|
||
|
return {
|
||
|
excludeScrollbar: config && config.excludeScrollbar
|
||
|
};
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Add click listeners to the current document,
|
||
|
* linked to this component's state.
|
||
|
*/
|
||
|
componentDidMount: function() {
|
||
|
// If we are in an environment without a DOM such
|
||
|
// as shallow rendering or snapshots then we exit
|
||
|
// early to prevent any unhandled errors being thrown.
|
||
|
if (typeof document === 'undefined' || !document.createElement){
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
var instance = this.getInstance();
|
||
|
var clickOutsideHandler;
|
||
|
|
||
|
if(config && typeof config.handleClickOutside === 'function') {
|
||
|
clickOutsideHandler = config.handleClickOutside(instance);
|
||
|
if(typeof clickOutsideHandler !== 'function') {
|
||
|
throw new Error('Component lacks a function for processing outside click events specified by the handleClickOutside config option.');
|
||
|
}
|
||
|
} else if(typeof instance.handleClickOutside === 'function') {
|
||
|
if (React.Component.prototype.isPrototypeOf(instance)) {
|
||
|
clickOutsideHandler = instance.handleClickOutside.bind(instance);
|
||
|
} else {
|
||
|
clickOutsideHandler = instance.handleClickOutside;
|
||
|
}
|
||
|
} else if(typeof instance.props.handleClickOutside === 'function') {
|
||
|
clickOutsideHandler = instance.props.handleClickOutside;
|
||
|
} else {
|
||
|
throw new Error('Component lacks a handleClickOutside(event) function for processing outside click events.');
|
||
|
}
|
||
|
|
||
|
var componentNode = ReactDOM.findDOMNode(instance);
|
||
|
if (componentNode === null) {
|
||
|
console.warn('Antipattern warning: there was no DOM node associated with the component that is being wrapped by outsideClick.');
|
||
|
console.warn([
|
||
|
'This is typically caused by having a component that starts life with a render function that',
|
||
|
'returns `null` (due to a state or props value), so that the component \'exist\' in the React',
|
||
|
'chain of components, but not in the DOM.\n\nInstead, you need to refactor your code so that the',
|
||
|
'decision of whether or not to show your component is handled by the parent, in their render()',
|
||
|
'function.\n\nIn code, rather than:\n\n A{render(){return check? <.../> : null;}\n B{render(){<A check=... />}\n\nmake sure that you',
|
||
|
'use:\n\n A{render(){return <.../>}\n B{render(){return <...>{ check ? <A/> : null }<...>}}\n\nThat is:',
|
||
|
'the parent is always responsible for deciding whether or not to render any of its children.',
|
||
|
'It is not the child\'s responsibility to decide whether a render instruction from above should',
|
||
|
'get ignored or not by returning `null`.\n\nWhen any component gets its render() function called,',
|
||
|
'that is the signal that it should be rendering its part of the UI. It may in turn decide not to',
|
||
|
'render all of *its* children, but it should never return `null` for itself. It is not responsible',
|
||
|
'for that decision.'
|
||
|
].join(' '));
|
||
|
}
|
||
|
|
||
|
var fn = this.__outsideClickHandler = generateOutsideCheck(
|
||
|
componentNode,
|
||
|
instance,
|
||
|
clickOutsideHandler,
|
||
|
this.props.outsideClickIgnoreClass || IGNORE_CLASS,
|
||
|
this.props.excludeScrollbar, // fallback not needed, prop always exists because of getDefaultProps
|
||
|
this.props.preventDefault || false,
|
||
|
this.props.stopPropagation || false
|
||
|
);
|
||
|
|
||
|
var pos = registeredComponents.length;
|
||
|
registeredComponents.push(this);
|
||
|
handlers[pos] = fn;
|
||
|
|
||
|
// If there is a truthy disableOnClickOutside property for this
|
||
|
// component, don't immediately start listening for outside events.
|
||
|
if (!this.props.disableOnClickOutside) {
|
||
|
this.enableOnClickOutside();
|
||
|
}
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Track for disableOnClickOutside props changes and enable/disable click outside
|
||
|
*/
|
||
|
componentWillReceiveProps: function(nextProps) {
|
||
|
if (this.props.disableOnClickOutside && !nextProps.disableOnClickOutside) {
|
||
|
this.enableOnClickOutside();
|
||
|
} else if (!this.props.disableOnClickOutside && nextProps.disableOnClickOutside) {
|
||
|
this.disableOnClickOutside();
|
||
|
}
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Remove the document's event listeners
|
||
|
*/
|
||
|
componentWillUnmount: function() {
|
||
|
this.disableOnClickOutside();
|
||
|
this.__outsideClickHandler = false;
|
||
|
var pos = registeredComponents.indexOf(this);
|
||
|
if( pos>-1) {
|
||
|
// clean up so we don't leak memory
|
||
|
if (handlers[pos]) { handlers.splice(pos, 1); }
|
||
|
registeredComponents.splice(pos, 1);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Can be called to explicitly enable event listening
|
||
|
* for clicks and touches outside of this element.
|
||
|
*/
|
||
|
enableOnClickOutside: function() {
|
||
|
var fn = this.__outsideClickHandler;
|
||
|
if (typeof document !== 'undefined') {
|
||
|
var events = this.props.eventTypes || DEFAULT_EVENTS;
|
||
|
if (!events.forEach) {
|
||
|
events = [events];
|
||
|
}
|
||
|
events.forEach(function (eventName) {
|
||
|
document.addEventListener(eventName, fn);
|
||
|
});
|
||
|
}
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Can be called to explicitly disable event listening
|
||
|
* for clicks and touches outside of this element.
|
||
|
*/
|
||
|
disableOnClickOutside: function() {
|
||
|
var fn = this.__outsideClickHandler;
|
||
|
if (typeof document !== 'undefined') {
|
||
|
var events = this.props.eventTypes || DEFAULT_EVENTS;
|
||
|
if (!events.forEach) {
|
||
|
events = [events];
|
||
|
}
|
||
|
events.forEach(function (eventName) {
|
||
|
document.removeEventListener(eventName, fn);
|
||
|
});
|
||
|
}
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Pass-through render
|
||
|
*/
|
||
|
render: function() {
|
||
|
var passedProps = this.props;
|
||
|
var props = {};
|
||
|
Object.keys(this.props).forEach(function(key) {
|
||
|
if (key !== 'excludeScrollbar') {
|
||
|
props[key] = passedProps[key];
|
||
|
}
|
||
|
});
|
||
|
if (Component.prototype.isReactComponent) {
|
||
|
props.ref = 'instance';
|
||
|
}
|
||
|
props.disableOnClickOutside = this.disableOnClickOutside;
|
||
|
props.enableOnClickOutside = this.enableOnClickOutside;
|
||
|
return React.createElement(Component, props);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
// Add display name for React devtools
|
||
|
(function bindWrappedComponentName(c, wrapper) {
|
||
|
var componentName = c.displayName || c.name || 'Component';
|
||
|
wrapper.displayName = 'OnClickOutside(' + componentName + ')';
|
||
|
}(Component, wrapComponentWithOnClickOutsideHandling));
|
||
|
|
||
|
return wrapComponentWithOnClickOutsideHandling;
|
||
|
};
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* This function sets up the library in ways that
|
||
|
* work with the various modulde loading solutions
|
||
|
* used in JavaScript land today.
|
||
|
*/
|
||
|
function setupBinding(root, factory) {
|
||
|
if (typeof define === 'function' && define.amd) {
|
||
|
// AMD. Register as an anonymous module.
|
||
|
define(['react','react-dom','create-react-class'], function(React, ReactDom, createReactClass) {
|
||
|
if (!createReactClass) createReactClass = React.createClass;
|
||
|
return factory(root, React, ReactDom, createReactClass);
|
||
|
});
|
||
|
} else if (typeof exports === 'object') {
|
||
|
// Node. Note that this does not work with strict
|
||
|
// CommonJS, but only CommonJS-like environments
|
||
|
// that support module.exports
|
||
|
module.exports = factory(root, require('react'), require('react-dom'), require('create-react-class'));
|
||
|
} else {
|
||
|
// Browser globals (root is window)
|
||
|
var createReactClass = React.createClass ? React.createClass : window.createReactClass;
|
||
|
root.onClickOutside = factory(root, React, ReactDOM, createReactClass);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Make it all happen
|
||
|
setupBinding(root, setupHOC);
|
||
|
|
||
|
}(this));
|
||
|
|
||
|
|
||
|
|
||
|
//////////////////
|
||
|
// WEBPACK FOOTER
|
||
|
// ./~/react-onclickoutside/index.js
|
||
|
// module id = 2769
|
||
|
// module chunks = 4
|