Skip to content

Instantly share code, notes, and snippets.

@foxyblocks
Last active August 4, 2018 22:02
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save foxyblocks/78348722edd275880dde54c549a0f155 to your computer and use it in GitHub Desktop.
Save foxyblocks/78348722edd275880dde54c549a0f155 to your computer and use it in GitHub Desktop.
ClickBoundary react component implementation used inside the Bugsnag dashboard.
// Author: Christian Schlensker for Bugsnag.
// @flow
import { omit } from 'lodash';
import * as React from 'react';
// Creates a React context to track track the tree of ClickBoundaries down the component hierarchy.
const NodeContext = React.createContext();
type Props = {
/**
* callback that is triggered for all click events that happen outside the <ClickBoundary>
*/
onClickOutside: ?(event: MouseEvent) => void,
children?: React.Node,
/**
* ref that will be used on the div element that gets rendered by the <ClickBoundary>
*/
innerRef?: (div: ?HTMLDivElement) => void,
};
/**
* ClickBoundary is very simple in how you use it, you render whatever you want
* inside it and it will tell you when the user clicks outside of it. By
* "outside" we mean outside the DOM hierarchy of its children. This component
* is special though in that it will consider the DOM of any descendant
* ClickBoundary in the component tree to also be inside itself, even if it is
* in a different physical DOM tree. This means that ClickBoundary can work
* across things like nested Portals that render their children to a new DOM
* root (as long as the content of the Portal is also wrapped in a
* <ClickBoundary>). It should also be noted that <ClickBoundary> will wrap its
* children in a <div>. Any extra props given to ClickBoundary will be passed
* to this div. If you need to attach a ref to this div you do so using the
* `innerRef` prop.
*
*
* @example ```javascript
*
* function Modal({ onClose, children}) {
*
* let content = (
* <ClickBoundary onClickOutside={onClose}>
* {children}
* </ClickBoundary>
* )
*
* return ReactDOM.createPortal(content, document.getElementById("portal"))}
* }
*
* function App() {
* return (
* <div>
* <span>Outside</span>
* <ClickBoundary onClickOutside={() => console.log('clicked outside')}>
* <span>Inside</span>
* <Modal onClose={() => console.log('modal wants to close')}>
* <span>Also inside</span>
* </Modal>
* </ClickBoundary>
* </div>
* )
* }
* ```
*
*
*
*/
export default class ClickBoundary extends React.PureComponent<Props> {
container: ?HTMLDivElement;
ancestor: ?ClickBoundary;
// eslint-disable-next-line react/sort-comp
descendants: Set<ClickBoundary> = new Set();
componentDidMount() {
if (this.ancestor) {
this.ancestor.addDescendant(this);
}
// setup the click events
window.removeEventListener('click', this.onClick);
window.addEventListener('click', this.onClick, true);
}
componentWillUnmount() {
if (this.ancestor) {
this.ancestor.removeDescendant(this);
}
// remove click event
window.removeEventListener('click', this.onClick);
}
onClick = (event: MouseEvent) => {
const shouldTrigger =
this.container &&
this.props.onClickOutside &&
event.target instanceof Node &&
!this.contains(event.target);
if (shouldTrigger && this.props.onClickOutside) {
// flow is requiring we check the existence of onClickOutside again
this.props.onClickOutside(event);
}
};
/**
* adds a component as one this component's descendants
*/
addDescendant = (descendant: ClickBoundary) => {
this.descendants.add(descendant);
};
/**
* removes a component from this component's descendants
*/
removeDescendant = (descendant: ClickBoundary) => {
this.descendants.delete(descendant);
};
/**
* Checks if this component or any of it's descendant components contains a given DOM node
*/
contains = (node: Node) =>
[...this.descendants].some(child => child.contains(node)) ||
(!!this.container && this.container.contains(node));
/**
* Happens during render, before componentDidMount. saves a reference to the container div
* and this component's ancestor node
*/
setup = (ancestorNode: ?ClickBoundary, div: ?HTMLDivElement) => {
if (this.ancestor && ancestorNode !== this.ancestor) {
// if for some reason the ancestor is different, we should remove this instance from the old
// ancestor before adding it to the new one
this.ancestor.removeDescendant(this);
}
this.ancestor = ancestorNode;
this.container = div;
if (this.props.innerRef) {
this.props.innerRef(div);
}
};
render() {
const passedProps = omit(this.props, 'innerRef', 'onClickOutside');
return (
<NodeContext.Consumer>
{(ancestorNode: ?ClickBoundary) => (
<NodeContext.Provider value={this}>
<div
ref={c => {
this.setup(ancestorNode, c);
}}
{...passedProps}
/>
</NodeContext.Provider>
)}
</NodeContext.Consumer>
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment