Last active
August 4, 2018 22:02
-
-
Save foxyblocks/78348722edd275880dde54c549a0f155 to your computer and use it in GitHub Desktop.
ClickBoundary react component implementation used inside the Bugsnag dashboard.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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