@spaced-out/ui-design-system
Version:
Sense UI components library
229 lines (201 loc) • 6.38 kB
Flow
// @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;
}
}