src/popover/popover.tsx (413 lines of code) (raw):
/*
Copyright (c) Uber Technologies, Inc.
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
import * as React from 'react';
import FocusLock, { MoveFocusInside } from 'react-focus-lock';
import { getOverride, getOverrideProps } from '../helpers/overrides';
import {
ACCESSIBILITY_TYPE,
PLACEMENT,
TRIGGER_TYPE,
ANIMATE_OUT_TIME,
ANIMATE_IN_TIME,
POPOVER_MARGIN,
} from './constants';
import { Layer, TetherBehavior } from '../layer';
import {
Arrow as StyledArrow,
Body as StyledBody,
Inner as StyledInner,
Hidden,
} from './styled-components';
import { fromPopperPlacement } from './utils';
import defaultProps from './default-props';
import { useUID } from 'react-uid';
import type { AnchorProps, PopoverProps, PopoverPrivateState, SharedStylePropsArg } from './types';
import type { PopperDataObject, NormalizedOffsets } from '../layer/types';
class PopoverInner extends React.Component<PopoverProps, PopoverPrivateState> {
static defaultProps: Partial<PopoverProps> = defaultProps;
/* eslint-disable react/sort-comp */
animateInTimer?: ReturnType<typeof setTimeout> | undefined | null;
animateOutTimer?: ReturnType<typeof setTimeout> | undefined | null;
animateOutCompleteTimer?: ReturnType<typeof setTimeout> | undefined | null;
onMouseEnterTimer?: ReturnType<typeof setTimeout> | undefined | null;
onMouseLeaveTimer?: ReturnType<typeof setTimeout> | undefined | null;
anchorRef = React.createRef<HTMLElement>();
popperRef = React.createRef<HTMLElement>();
arrowRef = React.createRef<HTMLElement>();
/* eslint-enable react/sort-comp */
/**
* Yes our "Stateless" popover still has state. This is private state that
* customers shouldn't have to manage themselves, such as positioning and
* other internal flags for managing animation states.
*/
// @ts-ignore
state = this.getDefaultState(this.props);
componentDidMount() {
this.setState({ isMounted: true });
}
componentDidUpdate(prevProps: PopoverProps, prevState: PopoverPrivateState) {
this.init(prevProps, prevState);
if (
this.props.accessibilityType !== ACCESSIBILITY_TYPE.tooltip &&
this.props.autoFocus &&
!this.state.autoFocusAfterPositioning &&
this.popperRef.current !== null &&
this.popperRef.current.getBoundingClientRect().top > 0
) {
this.setState({ autoFocusAfterPositioning: true });
}
if (__DEV__) {
if (!this.anchorRef.current) {
// eslint-disable-next-line no-console
console.warn(
`[baseui][Popover] ref has not been passed to the Popper's anchor element.
See how to pass the ref to an anchor element in the Popover example
https://baseweb.design/components/popover/#anchor-ref-handling-example`
);
}
}
}
init(prevProps: PopoverProps, prevState: PopoverPrivateState) {
if (
this.props.isOpen !== prevProps.isOpen ||
this.state.isMounted !== prevState.isMounted ||
this.state.isLayerMounted !== prevState.isLayerMounted
) {
// Transition from closed to open.
if (this.props.isOpen && this.state.isLayerMounted) {
// Clear any existing timers (like previous animateOutCompleteTimer)
this.clearTimers();
return;
}
// Transition from open to closed.
if (!this.props.isOpen && prevProps.isOpen) {
this.animateOutTimer = setTimeout(this.animateOut, 20);
return;
}
}
}
componentWillUnmount() {
this.clearTimers();
}
getDefaultState(props: PopoverProps) {
return {
isAnimating: false,
arrowOffset: { left: 0, top: 0 },
popoverOffset: { left: 0, top: 0 },
placement: props.placement,
isMounted: false,
isLayerMounted: false,
autoFocusAfterPositioning: false,
};
}
animateIn = () => {
if (this.props.isOpen) {
this.setState({ isAnimating: true });
}
};
animateOut = () => {
if (!this.props.isOpen) {
this.setState({ isAnimating: true });
// Remove the popover from the DOM after animation finishes
this.animateOutCompleteTimer = setTimeout(() => {
this.setState({
isAnimating: false,
// Reset to ideal placement specified in props
// @ts-ignore
placement: this.props.placement,
});
}, this.props.animateOutTime || ANIMATE_OUT_TIME);
}
};
clearTimers() {
[
this.animateInTimer,
this.animateOutTimer,
this.animateOutCompleteTimer,
this.onMouseEnterTimer,
this.onMouseLeaveTimer,
].forEach((timerId) => {
if (timerId) {
clearTimeout(timerId);
}
});
}
onAnchorClick = (e: React.MouseEvent) => {
if (this.props.onClick) {
this.props.onClick(e);
}
};
onAnchorMouseEnter = (e: React.MouseEvent) => {
// First clear any existing close timers, this ensures that the user can
// move their mouse from the popover back to anchor without it hiding
if (this.onMouseLeaveTimer) {
clearTimeout(this.onMouseLeaveTimer);
}
this.triggerOnMouseEnterWithDelay(e);
};
onAnchorMouseLeave = (e: React.MouseEvent) => {
// Clear any existing open timer, otherwise popover could be stuck open
if (this.onMouseEnterTimer) {
clearTimeout(this.onMouseEnterTimer);
}
this.triggerOnMouseLeaveWithDelay(e);
};
onPopoverMouseEnter = () => {
if (this.onMouseLeaveTimer) {
clearTimeout(this.onMouseLeaveTimer);
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onPopoverMouseLeave = (e: React.MouseEvent<any>) => {
this.triggerOnMouseLeaveWithDelay(e);
};
onPopperUpdate = (normalizedOffsets: NormalizedOffsets, data: PopperDataObject) => {
const placement = fromPopperPlacement(data.placement) || PLACEMENT.top;
this.setState({
// @ts-ignore
arrowOffset: normalizedOffsets.arrow,
popoverOffset: normalizedOffsets.popper,
placement,
});
// Now that element has been positioned, we can animate it in
this.animateInTimer = setTimeout(this.animateIn, ANIMATE_IN_TIME);
return data;
};
triggerOnMouseLeaveWithDelay(e: React.MouseEvent) {
const { onMouseLeaveDelay } = this.props;
if (onMouseLeaveDelay) {
this.onMouseLeaveTimer = setTimeout(() => this.triggerOnMouseLeave(e), onMouseLeaveDelay);
return;
}
this.triggerOnMouseLeave(e);
}
triggerOnMouseLeave = (e: React.MouseEvent) => {
if (this.props.onMouseLeave) {
this.props.onMouseLeave(e);
}
};
triggerOnMouseEnterWithDelay(e: React.MouseEvent) {
const { onMouseEnterDelay } = this.props;
if (onMouseEnterDelay) {
this.onMouseEnterTimer = setTimeout(() => this.triggerOnMouseEnter(e), onMouseEnterDelay);
return;
}
this.triggerOnMouseEnter(e);
}
triggerOnMouseEnter = (e: React.MouseEvent) => {
if (this.props.onMouseEnter) {
this.props.onMouseEnter(e);
}
};
onDocumentClick = (evt: MouseEvent) => {
const target = evt.composedPath ? evt.composedPath()[0] : evt.target;
const popper = this.popperRef.current;
const anchor = this.anchorRef.current;
// Ignore document click if it came from popover or anchor
if (!popper || popper === target || (target instanceof Node && popper.contains(target))) {
return;
}
if (!anchor || anchor === target || (target instanceof Node && anchor.contains(target))) {
return;
}
if (this.props.onClickOutside) {
this.props.onClickOutside(evt);
}
};
isClickTrigger() {
return this.props.triggerType === TRIGGER_TYPE.click;
}
isHoverTrigger() {
return this.props.triggerType === TRIGGER_TYPE.hover;
}
isAccessibilityTypeMenu() {
return this.props.accessibilityType === ACCESSIBILITY_TYPE.menu;
}
isAccessibilityTypeTooltip() {
return this.props.accessibilityType === ACCESSIBILITY_TYPE.tooltip;
}
getAnchorIdAttr() {
const popoverId = this.getPopoverIdAttr();
return popoverId ? `${popoverId}__anchor` : null;
}
getPopoverIdAttr() {
return this.props.id || null;
}
getAnchorProps() {
const { isOpen } = this.props;
const anchorProps: AnchorProps = {
ref: this.anchorRef,
};
const popoverId = this.getPopoverIdAttr();
if (this.isAccessibilityTypeMenu()) {
const relationAttr = this.isClickTrigger() ? 'aria-controls' : 'aria-owns';
anchorProps[relationAttr] = isOpen ? popoverId : null;
anchorProps['aria-haspopup'] = true;
anchorProps['aria-expanded'] = Boolean(isOpen);
} else if (this.isAccessibilityTypeTooltip()) {
anchorProps.id = this.getAnchorIdAttr();
anchorProps['aria-describedby'] = isOpen ? popoverId : null;
}
if (this.isHoverTrigger()) {
anchorProps.onMouseEnter = this.onAnchorMouseEnter;
anchorProps.onMouseLeave = this.onAnchorMouseLeave;
// Make it focusable too
anchorProps.onBlur = this.props.onBlur;
anchorProps.onFocus = this.props.onFocus;
} else {
anchorProps.onClick = this.onAnchorClick;
// Make it focusable too
if (this.props.onBlur) {
anchorProps.onBlur = this.props.onBlur;
}
if (this.props.onFocus) {
anchorProps.onFocus = this.props.onFocus;
}
}
return anchorProps;
}
getPopoverBodyProps() {
const bodyProps: React.HTMLAttributes<'body'> = {};
const popoverId = this.getPopoverIdAttr();
if (this.isAccessibilityTypeMenu()) {
// @ts-ignore
bodyProps.id = popoverId;
} else if (this.isAccessibilityTypeTooltip()) {
// @ts-ignore
bodyProps.id = popoverId;
bodyProps.role = 'tooltip';
}
if (this.isHoverTrigger()) {
bodyProps.onMouseEnter = this.onPopoverMouseEnter;
bodyProps.onMouseLeave = this.onPopoverMouseLeave;
}
return bodyProps;
}
getSharedProps(): Omit<SharedStylePropsArg, 'children'> {
const { isOpen, showArrow, popoverMargin = POPOVER_MARGIN } = this.props;
const { isAnimating, arrowOffset, popoverOffset, placement } = this.state;
return {
$showArrow: !!showArrow,
$arrowOffset: arrowOffset,
$popoverOffset: popoverOffset,
// @ts-ignore
$placement: placement,
$isAnimating: isAnimating,
$animationDuration: this.props.animateOutTime || ANIMATE_OUT_TIME,
$isOpen: isOpen,
$popoverMargin: popoverMargin,
$isHoverTrigger: this.isHoverTrigger(),
};
}
getAnchorFromChildren() {
const { children } = this.props;
const childArray = React.Children.toArray(children);
if (childArray.length !== 1) {
// eslint-disable-next-line no-console
console.error(
`[baseui] Exactly 1 child must be passed to Popover/Tooltip, found ${childArray.length} children`
);
}
return childArray[0];
}
renderAnchor() {
const anchor = this.getAnchorFromChildren();
if (!anchor) {
return null;
}
const isValidElement = React.isValidElement(anchor);
const anchorProps = this.getAnchorProps();
if (typeof anchor === 'object' && isValidElement) {
return React.cloneElement(anchor, anchorProps);
}
return (
// @ts-ignore
<span key="popover-anchor" {...anchorProps}>
{anchor}
</span>
);
}
renderPopover(renderedContent: React.ReactNode) {
const { showArrow, overrides = {} } = this.props;
const { Arrow: ArrowOverride, Body: BodyOverride, Inner: InnerOverride } = overrides;
const Arrow = getOverride(ArrowOverride) || StyledArrow;
const Body = getOverride(BodyOverride) || StyledBody;
const Inner = getOverride(InnerOverride) || StyledInner;
const sharedProps = this.getSharedProps();
const bodyProps = this.getPopoverBodyProps();
return (
<Body
key="popover-body"
ref={this.popperRef}
data-baseweb={this.props['data-baseweb'] || 'popover'}
{...bodyProps}
{...sharedProps}
{...getOverrideProps(BodyOverride)}
>
{showArrow ? (
<Arrow
key="popover-arrow"
ref={this.arrowRef}
{...sharedProps}
{...getOverrideProps(ArrowOverride)}
/>
) : null}
<Inner {...sharedProps} {...getOverrideProps(InnerOverride)}>
{renderedContent}
</Inner>
</Body>
);
}
renderContent() {
const { content } = this.props;
return typeof content === 'function' ? content() : content;
}
render() {
const mountedAndOpen = this.state.isMounted && (this.props.isOpen || this.state.isAnimating);
const rendered = [this.renderAnchor()];
const renderedContent = mountedAndOpen || this.props.renderAll ? this.renderContent() : null;
const defaultPopperOptions = {
modifiers: {
preventOverflow: { enabled: !this.props.ignoreBoundary, padding: 0 },
},
};
// Only render popover on the browser (portals aren't supported server-side)
if (renderedContent) {
if (mountedAndOpen) {
rendered.push(
<Layer
key="new-layer"
mountNode={this.props.mountNode}
onEscape={this.props.onEsc}
onDocumentClick={this.isHoverTrigger() ? undefined : this.onDocumentClick}
isHoverLayer={this.isHoverTrigger()}
onMount={() => this.setState({ isLayerMounted: true })}
onUnmount={() => this.setState({ isLayerMounted: false })}
>
<TetherBehavior
anchorRef={this.anchorRef.current}
arrowRef={this.arrowRef.current}
popperRef={this.popperRef.current}
// Remove the `ignoreBoundary` prop in the next major version
// and have it replaced with the TetherBehavior props overrides
popperOptions={{
...defaultPopperOptions,
...this.props.popperOptions,
}}
onPopperUpdate={this.onPopperUpdate}
placement={this.state.placement}
>
{this.props.focusLock &&
this.props.accessibilityType !== ACCESSIBILITY_TYPE.tooltip ? (
<FocusLock
disabled={!this.props.focusLock}
noFocusGuards={false}
// see popover-focus-loop.scenario.js for why hover cannot return focus
returnFocus={!this.isHoverTrigger() && this.props.returnFocus}
autoFocus={this.state.autoFocusAfterPositioning}
// Allow focus to escape when UI is within an iframe
crossFrame={false}
focusOptions={this.props.focusOptions}
>
{this.renderPopover(renderedContent)}
</FocusLock>
) : (
<MoveFocusInside
disabled={!this.props.autoFocus || !this.state.autoFocusAfterPositioning}
>
{this.renderPopover(renderedContent)}
</MoveFocusInside>
)}
</TetherBehavior>
</Layer>
);
} else {
rendered.push(<Hidden key="hidden-layer">{renderedContent}</Hidden>);
}
}
return rendered;
}
}
// Remove when Popover is converted to a functional component.
const Popover = (
props: PopoverProps & {
innerRef?: React.Ref<HTMLElement>;
}
) => {
const { innerRef } = props;
const gID = useUID();
return (
<PopoverInner
id={props.id || gID}
// @ts-expect-error
ref={innerRef}
{...props}
/>
);
};
Popover.defaultProps = defaultProps;
export default Popover;
/* eslint-enable react/no-find-dom-node */