UNPKG

@spaced-out/ui-design-system

Version:
229 lines (201 loc) 6.38 kB
// @flow strict import * as React from 'react'; import invariant from 'invariant'; import {pageHeight} from '../dom'; type BoundaryRefType<T> = { current: ?T, }; type TriggerRefType<T> = { current: ?T, }; export type ChildProps = { onOpen: () => void, isOpen: boolean, height: ?number, pageBottom: ?number, clickAway: () => void, boundaryRef: BoundaryRefType<?HTMLElement>, triggerRef: TriggerRefType<?HTMLElement>, }; export type ClickAwayRefType = ?{ current: ?{ forceClose: () => void, forceOpen: () => void, }, }; export type ClickAwayProps = { closeOnEscapeKeypress?: boolean, children: (props: ChildProps) => React.Node, onChange?: (isOpen: boolean) => mixed, clickAwayRef?: ClickAwayRefType, /** * When containsNestedFloatingPortals is true, prevents ClickAway from closing the dropdown if the click target * is inside a floating portal root element. * * This is necessary for nested dropdowns rendered using floating portals, * such as via `@floating-ui/react`. Floating portals render elements outside * the normal DOM tree (often directly into `document.body` or another container) * using `ReactDOM.createPortal`. As a result, these nested dropdowns do not * appear inside the parent dropdown's `boundaryRef` or `triggerRef` subtree. * * To address this, the floating portal ensures that all nested dropdowns * are mounted into a **shared root element** — effectively maintaining a * common DOM ancestry. This allows parent dropdowns to detect when clicks * occur within any of their nested children, even if rendered via portals. * The shared root element is the closest parent element that has a `data-floating-ui-portal` attribute. * Because of this, the ClickAway logic will not close the parent menu if the click target is inside boundaryRef's parentElement. * * Enabling `containsNestedFloatingPortals` allows the ClickAway logic to account * for this shared root and avoids incorrectly closing the parent menu when * interacting with nested dropdowns. */ containsNestedFloatingPortals?: boolean, }; type ClickAwayState = { isOpen: boolean, height: ?number, pageBottom: ?number, }; // TODO(Nishant): Make this a functional component export class ClickAway extends React.Component<ClickAwayProps, ClickAwayState> { static defaultProps: { containsNestedFloatingPortals?: boolean, closeOnEscapeKeypress?: boolean, } = { containsNestedFloatingPortals: false, closeOnEscapeKeypress: true, }; state: ClickAwayState = { isOpen: false, height: null, pageBottom: null, }; el: ?HTMLElement = null; boundaryRef: BoundaryRefType<?HTMLElement> = React.createRef(); triggerRef: TriggerRefType<?HTMLElement> = React.createRef(); 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, { capture: true, }); if (this.props.closeOnEscapeKeypress) { window.document.addEventListener( 'keyup', this.handleCloseOnEscapeKeypress, ); } } else { window.document.removeEventListener('click', this.handleCloseClick, { capture: true, }); window.document.removeEventListener( 'keyup', this.handleCloseOnEscapeKeypress, ); } } } componentWillUnmount() { window.document.removeEventListener('click', this.handleCloseClick, { capture: true, }); window.document.removeEventListener( 'keyup', this.handleCloseOnEscapeKeypress, ); } render(): React.Node { const {height, isOpen, pageBottom} = this.state; const {clickAwayRef} = this.props; if (clickAwayRef) { clickAwayRef.current = { forceClose: this.forceClose, forceOpen: this.handleOpenClick, }; } return this.props.children({ onOpen: this.handleOpenClick, isOpen, height, pageBottom, clickAway: this.forceClose, boundaryRef: this.boundaryRef, triggerRef: this.triggerRef, }); } 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: !this.state.isOpen, pageBottom, }, this.handleOnChange, ); }; handleCloseClick: (evt: MouseEvent) => void = (evt: MouseEvent) => { if ( evt.target instanceof Node && this.props.containsNestedFloatingPortals && this.boundaryRef.current?.parentElement && (this.boundaryRef.current.parentElement === evt.target || this.boundaryRef.current.parentElement.contains(evt.target)) ) { return; } if ( evt.target instanceof Node && this.boundaryRef && (this.boundaryRef === evt.target || this.boundaryRef.current?.contains(evt.target)) ) { return; } if ( evt.target instanceof Node && this.triggerRef && (this.triggerRef === evt.target || this.triggerRef.current?.contains(evt.target)) ) { return; } this.setState( { isOpen: false, }, this.handleOnChange, ); }; handleCloseOnEscapeKeypress: ( evt?: SyntheticKeyboardEvent<EventTarget>, ) => void = (evt?: SyntheticKeyboardEvent<EventTarget>) => { if (evt?.key === 'Escape') { this.forceClose(); } }; forceClose: () => void = () => { this.setState({isOpen: false}, this.handleOnChange); }; 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; } }