// @flow strict import * as React from 'react'; import invariant from 'invariant'; import {pageHeight} from './dom'; export type ChildProps = { onOpen: () => void, isOpen: boolean, height: ?number, pageBottom: ?number, cancelNext: () => void, clickAway: () => void, anchorRef: (?HTMLElement) => mixed, }; export type ClickAwayProps = { closeOnEscapeKeypress?: boolean, children: (props: ChildProps) => React.Node, shouldCancel: (event: MouseEvent) => boolean, onChange?: (isOpen: boolean) => mixed, }; type ClickAwayState = { isOpen: boolean, height: ?number, pageBottom: ?number, }; // TODO(Nishant): Make this a functional component export class ClickAway extends React.Component { static defaultProps: { shouldCancel: () => boolean, closeOnEscapeKeypress?: boolean, } = { shouldCancel: () => false, closeOnEscapeKeypress: true, }; state: ClickAwayState = { isOpen: false, height: null, pageBottom: null, }; el: ?HTMLElement = null; cancelNext: boolean = false; componentDidMount() { if (this.el) { this.setState({ height: this.el.offsetHeight, pageBottom: this.pageBottom(), }); } } componentDidUpdate(prevProps: ClickAwayProps, prevState: ClickAwayState) { const {isOpen} = this.state; if (prevState.isOpen !== isOpen) { if (this.state.isOpen) { window.document.addEventListener('click', this.handleCloseClick); if (this.props.closeOnEscapeKeypress) { window.document.addEventListener( 'keyup', this.handleCloseOnEscapeKeypress, ); } } else { window.document.removeEventListener('click', this.handleCloseClick); window.document.removeEventListener( 'keyup', this.handleCloseOnEscapeKeypress, ); } } } componentWillUnmount() { window.document.removeEventListener('click', this.handleCloseClick); window.document.removeEventListener( 'keyup', this.handleCloseOnEscapeKeypress, ); } render(): React.Node { const {height, isOpen, pageBottom} = this.state; return this.props.children({ onOpen: this.handleOpenClick, isOpen, height, pageBottom, cancelNext: this.cancelNextClickAway, clickAway: this.forceClose, anchorRef: (el) => (this.el = el), }); } handleOpenClick: () => void = () => { // NOTE (kyle): we recalculate the position on click because sibling and niece components // could have changed. let {pageBottom} = this.state; if (this.el) { pageBottom = this.pageBottom(); } this.setState( { isOpen: true, pageBottom, }, this.handleOnChange, ); }; handleCloseClick: (evt: MouseEvent) => void = (evt: MouseEvent) => { if (this.props.shouldCancel(evt)) { return; } if (this.cancelNext === true) { this.cancelNext = false; } else { this.setState( { isOpen: false, }, this.handleOnChange, ); } }; handleCloseOnEscapeKeypress: ( evt?: SyntheticKeyboardEvent, ) => void = (evt?: SyntheticKeyboardEvent) => { if (evt?.key === 'Escape') { this.forceClose(); } }; forceClose: () => void = () => { this.setState({isOpen: false}, this.handleOnChange); }; cancelNextClickAway: () => void = () => { this.cancelNext = true; }; handleOnChange: () => mixed = () => this.props.onChange && this.props.onChange(this.state.isOpen); pageBottom(): $FlowFixMe { invariant(this.el, 'pageBottom() requires that this.el not be null'); const bottomBound = this.el ? this.el.getBoundingClientRect().bottom : 0; return pageHeight() - bottomBound + window.scrollY; } }