import React from "react" /** Props for ClickOutHandler. */ export interface ClickOutHandlerProps { /** Called when the user clicks outside the wrapped element. */ onClickOut: (event: MouseEvent | TouchEvent) => void /** Element or elements to watch for outside clicks. */ children: React.ReactNode } /** Watches for document clicks outside of its child element without using ReactDOM.findDOMNode. */ export default class ClickOutHandler extends React.Component { private element: Element | null = null private addListenersTimeout: number | null = null private insideClick = false componentDidMount() { this.addListenersTimeout = window.setTimeout(this.addListeners, 0) } componentWillUnmount() { if (this.addListenersTimeout != null) { window.clearTimeout(this.addListenersTimeout) this.addListenersTimeout = null } this.removeListeners() } /** Add document listeners after the current click finishes bubbling. */ private addListeners = () => { document.addEventListener("click", this.handleDocumentClick) document.addEventListener("touchend", this.handleDocumentClick) } /** Remove document listeners. */ private removeListeners = () => { document.removeEventListener("click", this.handleDocumentClick) document.removeEventListener("touchend", this.handleDocumentClick) } /** Handles document events by checking whether they happened outside the child element. */ private handleDocumentClick = (event: MouseEvent | TouchEvent) => { const target = event.target if ( this.insideClick || this.eventPathContainsElement(event) || !this.element || !(target instanceof Node) || this.element.contains(target) ) { this.insideClick = false return } this.insideClick = false this.props.onClickOut(event) } /** Checks the original event path, which still contains nodes removed during React event handling. */ private eventPathContainsElement(event: MouseEvent | TouchEvent) { if (!this.element || typeof event.composedPath !== "function") { return false } return event.composedPath().includes(this.element) } /** Marks the current click as internal before child handlers can re-render the clicked node. */ private handleInsideClickCapture = () => { this.insideClick = true } /** Stores the DOM element rendered by the child. */ private setElement = (element: Element | null) => { this.element = element instanceof Element ? element : null } /** Stores the DOM element and forwards the ref to the wrapped child if it had one. */ private setChildRef = (originalRef: React.Ref | undefined, element: Element | null) => { this.setElement(element) if (typeof originalRef === "function") { originalRef(element) } else if (originalRef && typeof originalRef === "object") { const mutableRef = originalRef as React.MutableRefObject mutableRef.current = element } } /** Calls a child capture handler after marking this interaction as internal. */ private chainCaptureHandler = (handler?: (event: T) => void) => { return (event: T) => { this.handleInsideClickCapture() if (handler) { handler(event) } } } render() { const children = React.Children.toArray(this.props.children) if (children.length === 1 && React.isValidElement(children[0])) { const child = children[0] as React.ReactElement const originalRef = ((child.props as any).ref ?? (child as any).ref) as React.Ref | undefined return React.cloneElement(child, { ref: (element: Element | null) => this.setChildRef(originalRef, element), onClickCapture: this.chainCaptureHandler(child.props.onClickCapture), onTouchStartCapture: this.chainCaptureHandler(child.props.onTouchStartCapture) } as any) } return (
{this.props.children}
) } }