@blueprintjs/core
Version:
Core styles & components
522 lines • 26.2 kB
JavaScript
/*
* Copyright 2021 Palantir Technologies, Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import classNames from "classnames";
import * as React from "react";
import { Manager, Popper, Reference } from "react-popper";
import { AbstractPureComponent, Classes, DISPLAYNAME_PREFIX, refHandler, Utils } from "../../common";
import * as Errors from "../../common/errors";
import { Overlay } from "../overlay/overlay";
import { ResizeSensor } from "../resize-sensor/resizeSensor";
// eslint-disable-next-line import/no-cycle
import { Tooltip } from "../tooltip/tooltip";
import { matchReferenceWidthModifier } from "./customModifiers";
import { POPOVER_ARROW_SVG_SIZE, PopoverArrow } from "./popoverArrow";
import { positionToPlacement } from "./popoverPlacementUtils";
import { getBasePlacement, getTransformOrigin } from "./popperUtils";
export const PopoverInteractionKind = {
CLICK: "click",
CLICK_TARGET_ONLY: "click-target",
HOVER: "hover",
HOVER_TARGET_ONLY: "hover-target",
};
/**
* Popover component, used to display a floating UI next to and tethered to a target element.
*
* @template T target element props interface. Consumers wishing to stay in sync with Blueprint's default target HTML
* props interface should use the `DefaultPopoverTargetHTMLProps` type (although this is already the default type for
* this type param).
* @see https://blueprintjs.com/docs/#core/components/popover
*/
export class Popover extends AbstractPureComponent {
static displayName = `${DISPLAYNAME_PREFIX}.Popover`;
static defaultProps = {
boundary: "clippingParents",
captureDismiss: false,
defaultIsOpen: false,
disabled: false,
fill: false,
hasBackdrop: false,
hoverCloseDelay: 300,
hoverOpenDelay: 150,
inheritDarkTheme: true,
interactionKind: PopoverInteractionKind.CLICK,
matchTargetWidth: false,
minimal: false,
openOnTargetFocus: true,
// N.B. we don't set a default for `placement` or `position` here because that would trigger
// a warning in validateProps if the other prop is specified by a user of this component
positioningStrategy: "absolute",
renderTarget: undefined,
shouldReturnFocusOnClose: false,
targetTagName: "span",
transitionDuration: 300,
usePortal: true,
};
state = {
hasDarkParent: false,
isOpen: this.getIsOpen(this.props),
};
/**
* DOM element that contains the popover.
* When `usePortal={true}`, this element will be portaled outside the usual DOM flow,
* so this reference can be very useful for testing.
*
* @public for testing
*/
popoverElement = null;
/** Popover ref handler */
popoverRef = refHandler(this, "popoverElement", this.props.popoverRef);
/**
* Target DOM element ref.
*
* @public for testing
*/
targetRef = React.createRef();
cancelOpenTimeout;
// a flag that lets us detect mouse movement between the target and popover,
// now that mouseleave is triggered when you cross the gap between the two.
isMouseInTargetOrPopover = false;
// a flag that indicates whether the target previously lost focus to another
// element on the same page.
lostFocusOnSamePage = true;
// Reference to the Poppper.scheduleUpdate() function, this changes every time the popper is mounted
popperScheduleUpdate;
isControlled = () => this.props.isOpen !== undefined;
// arrow is disabled if minimal, or if the arrow modifier was explicitly disabled
isArrowEnabled = () => !this.props.minimal && this.props.modifiers?.arrow?.enabled !== false;
isHoverInteractionKind = () => {
return (this.props.interactionKind === PopoverInteractionKind.HOVER ||
this.props.interactionKind === PopoverInteractionKind.HOVER_TARGET_ONLY);
};
// popper innerRef gives us a handle on the transition container, since that's what we render as the overlay child,
// so if we want to look at our actual popover element, we need to reach inside a bit
getPopoverElement() {
return this.popoverElement?.querySelector(`.${Classes.POPOVER}`);
}
getIsOpen(props) {
// disabled popovers should never be allowed to open.
if (props.disabled) {
return false;
}
else {
return props.isOpen ?? props.defaultIsOpen;
}
}
render() {
const { disabled, content, placement, position = "auto", positioningStrategy } = this.props;
const { isOpen } = this.state;
const isContentEmpty = content == null || (typeof content === "string" && content.trim() === "");
if (isContentEmpty) {
// need to do this check in render(), because `isOpen` is derived from
// state, and state can't necessarily be accessed in validateProps.
if (!disabled && isOpen !== false && !Utils.isNodeEnv("production")) {
console.warn(Errors.POPOVER_WARN_EMPTY_CONTENT);
}
// just render the target without a content overlay if there is no content to display
return this.renderTarget({ ref: noop });
}
return (React.createElement(Manager, null,
React.createElement(Reference, { innerRef: this.targetRef }, this.renderTarget),
React.createElement(Popper, { innerRef: this.popoverRef, placement: placement ?? positionToPlacement(position), strategy: positioningStrategy, modifiers: this.getPopperModifiers() }, this.renderPopover)));
}
componentDidMount() {
this.updateDarkParent();
}
componentDidUpdate(props, state) {
super.componentDidUpdate(props, state);
this.updateDarkParent();
const nextIsOpen = this.getIsOpen(this.props);
if (this.props.isOpen != null && nextIsOpen !== this.state.isOpen) {
this.setOpenState(nextIsOpen);
// tricky: setOpenState calls setState only if this.props.isOpen is
// not controlled, so we need to invoke setState manually here.
this.setState({ isOpen: nextIsOpen });
}
else if (this.props.disabled && this.state.isOpen && this.props.isOpen == null) {
// special case: close an uncontrolled popover when disabled is set to true
this.setOpenState(false);
}
}
validateProps(props) {
if (props.isOpen == null && props.onInteraction != null) {
console.warn(Errors.POPOVER_WARN_UNCONTROLLED_ONINTERACTION);
}
if (props.hasBackdrop && !props.usePortal) {
console.warn(Errors.POPOVER_WARN_HAS_BACKDROP_INLINE);
}
if (props.hasBackdrop && props.interactionKind !== PopoverInteractionKind.CLICK) {
console.warn(Errors.POPOVER_HAS_BACKDROP_INTERACTION);
}
if (props.placement !== undefined && props.position !== undefined) {
console.warn(Errors.POPOVER_WARN_PLACEMENT_AND_POSITION_MUTEX);
}
const childrenCount = React.Children.count(props.children);
const hasRenderTargetProp = props.renderTarget !== undefined;
const hasTargetPropsProp = props.targetProps !== undefined;
if (childrenCount === 0 && !hasRenderTargetProp) {
console.warn(Errors.POPOVER_REQUIRES_TARGET);
}
if (childrenCount > 1) {
console.warn(Errors.POPOVER_WARN_TOO_MANY_CHILDREN);
}
if (childrenCount > 0 && hasRenderTargetProp) {
console.warn(Errors.POPOVER_WARN_DOUBLE_TARGET);
}
if (hasRenderTargetProp && hasTargetPropsProp) {
console.warn(Errors.POPOVER_WARN_TARGET_PROPS_WITH_RENDER_TARGET);
}
}
/**
* Instance method to instruct the `Popover` to recompute its position.
*
* This method should only be used if you are updating the target in a way
* that does not cause it to re-render, such as changing its _position_
* without changing its _size_ (since `Popover` already repositions when it
* detects a resize).
*/
reposition = () => this.popperScheduleUpdate?.();
renderTarget = ({ ref }) => {
const { children, className, fill, openOnTargetFocus, renderTarget } = this.props;
const { isOpen } = this.state;
const isControlled = this.isControlled();
const isHoverInteractionKind = this.isHoverInteractionKind();
let { targetTagName } = this.props;
if (fill) {
targetTagName = "div";
}
const targetEventHandlers = isHoverInteractionKind
? {
// HOVER handlers
onBlur: this.handleTargetBlur,
onContextMenu: this.handleTargetContextMenu,
onFocus: this.handleTargetFocus,
onMouseEnter: this.handleMouseEnter,
onMouseLeave: this.handleMouseLeave,
}
: {
// CLICK needs only one handler
onClick: this.handleTargetClick,
// For keyboard accessibility, trigger the same behavior as a click event upon pressing ENTER/SPACE
onKeyDown: this.handleKeyDown,
};
// Ensure target is focusable if relevant prop enabled
const targetTabIndex = openOnTargetFocus && isHoverInteractionKind ? 0 : undefined;
const ownTargetProps = {
"aria-haspopup": this.props.popupKind ??
(this.props.interactionKind === PopoverInteractionKind.HOVER_TARGET_ONLY
? undefined
: "true"),
// N.B. this.props.className is passed along to renderTarget even though the user would have access to it.
// If, instead, renderTarget is undefined and the target is provided as a child, this.props.className is
// applied to the generated target wrapper element.
className: classNames(className, Classes.POPOVER_TARGET, {
[Classes.POPOVER_OPEN]: isOpen,
// this class is mainly useful for button targets
[Classes.ACTIVE]: isOpen && !isControlled && !isHoverInteractionKind,
}),
ref,
...targetEventHandlers,
};
const targetModifierClasses = {
// this class is mainly useful for Blueprint <Button> targets; we should only apply it for
// uncontrolled popovers when they are opened by a user interaction
[Classes.ACTIVE]: isOpen && !isControlled && !isHoverInteractionKind,
// similarly, this class is mainly useful for targets like <Button>, <InputGroup>, etc.
[Classes.FILL]: fill,
};
let target;
if (renderTarget !== undefined) {
target = renderTarget({
...ownTargetProps,
className: classNames(ownTargetProps.className, targetModifierClasses),
// if the consumer renders a tooltip target, it's their responsibility to disable that tooltip
// when *this* popover is open
isOpen,
tabIndex: targetTabIndex,
});
}
else {
const childTarget = Utils.ensureElement(React.Children.toArray(children)[0]);
if (childTarget === undefined) {
return null;
}
const clonedTarget = React.cloneElement(childTarget, {
className: classNames(childTarget.props.className, targetModifierClasses),
// force disable single Tooltip child when popover is open
disabled: isOpen && Utils.isElementOfType(childTarget, Tooltip) ? true : childTarget.props.disabled,
tabIndex: childTarget.props.tabIndex ?? targetTabIndex,
});
const wrappedTarget = React.createElement(targetTagName, {
...ownTargetProps,
...this.props.targetProps,
}, clonedTarget);
target = wrappedTarget;
}
// N.B. we must attach the ref ('wrapped' with react-popper functionality) to the DOM element here and
// let ResizeSensor know about it
return (React.createElement(ResizeSensor, { targetRef: this.targetRef, onResize: this.reposition }, target));
};
renderPopover = (popperProps) => {
const { interactionKind, shouldReturnFocusOnClose, usePortal } = this.props;
const { isOpen } = this.state;
// compute an appropriate transform origin so the scale animation points towards target
const transformOrigin = getTransformOrigin(popperProps.placement, this.isArrowEnabled() ? popperProps.arrowProps.style : undefined);
// need to update our reference to this function on every render as it will change.
this.popperScheduleUpdate = popperProps.update;
const popoverHandlers = {
// always check popover clicks for dismiss class
onClick: this.handlePopoverClick,
// treat ENTER/SPACE keys the same as a click for accessibility
onKeyDown: event => Utils.isKeyboardClick(event) && this.handlePopoverClick(event),
};
if (interactionKind === PopoverInteractionKind.HOVER ||
(!usePortal && interactionKind === PopoverInteractionKind.HOVER_TARGET_ONLY)) {
popoverHandlers.onMouseEnter = this.handleMouseEnter;
popoverHandlers.onMouseLeave = this.handleMouseLeave;
}
const basePlacement = getBasePlacement(popperProps.placement);
const popoverClasses = classNames(Classes.POPOVER, {
[Classes.DARK]: this.props.inheritDarkTheme && this.state.hasDarkParent,
[Classes.MINIMAL]: this.props.minimal,
[Classes.POPOVER_CAPTURING_DISMISS]: this.props.captureDismiss,
[Classes.POPOVER_MATCH_TARGET_WIDTH]: this.props.matchTargetWidth,
[Classes.POPOVER_REFERENCE_HIDDEN]: popperProps.isReferenceHidden === true,
[Classes.POPOVER_POPPER_ESCAPED]: popperProps.hasPopperEscaped === true,
}, `${Classes.POPOVER_CONTENT_PLACEMENT}-${basePlacement}`, this.props.popoverClassName);
const defaultAutoFocus = this.isHoverInteractionKind() ? false : undefined;
return (React.createElement(Overlay, { autoFocus: this.props.autoFocus ?? defaultAutoFocus, backdropClassName: Classes.POPOVER_BACKDROP, backdropProps: this.props.backdropProps, canEscapeKeyClose: this.props.canEscapeKeyClose, canOutsideClickClose: this.props.interactionKind === PopoverInteractionKind.CLICK, enforceFocus: this.props.enforceFocus, hasBackdrop: this.props.hasBackdrop, isOpen: isOpen, onClose: this.handleOverlayClose, onClosed: this.props.onClosed, onClosing: this.props.onClosing, onOpened: this.props.onOpened, onOpening: this.props.onOpening, transitionDuration: this.props.transitionDuration, transitionName: Classes.POPOVER, usePortal: this.props.usePortal, portalClassName: this.props.portalClassName, portalContainer: this.props.portalContainer, portalStopPropagationEvents: this.props.portalStopPropagationEvents,
// if hover interaction, it doesn't make sense to take over focus control
shouldReturnFocusOnClose: this.isHoverInteractionKind() ? false : shouldReturnFocusOnClose },
React.createElement("div", { className: Classes.POPOVER_TRANSITION_CONTAINER, ref: popperProps.ref, style: popperProps.style },
React.createElement(ResizeSensor, { onResize: this.reposition },
React.createElement("div", { className: popoverClasses, style: { transformOrigin }, ref: this.popoverRef, ...popoverHandlers },
this.isArrowEnabled() && (React.createElement(PopoverArrow, { arrowProps: popperProps.arrowProps, placement: popperProps.placement })),
React.createElement("div", { className: Classes.POPOVER_CONTENT }, this.props.content))))));
};
getPopperModifiers() {
const { matchTargetWidth, modifiers, modifiersCustom } = this.props;
const popperModifiers = [
{
enabled: this.isArrowEnabled(),
name: "arrow",
...modifiers?.arrow,
},
{
name: "computeStyles",
...modifiers?.computeStyles,
options: {
adaptive: true,
// We disable the built-in gpuAcceleration so that
// Popper.js will return us easy to interpolate values
// (top, left instead of transform: translate3d)
// We'll then use these values to generate the needed
// css transform values blended with the react-spring values
gpuAcceleration: false,
...modifiers?.computeStyles?.options,
},
},
{
enabled: this.isArrowEnabled(),
name: "offset",
...modifiers?.offset,
options: {
offset: [0, POPOVER_ARROW_SVG_SIZE / 2],
...modifiers?.offset?.options,
},
},
{
name: "flip",
...modifiers?.flip,
options: {
boundary: this.props.boundary,
rootBoundary: this.props.rootBoundary,
...modifiers?.flip?.options,
},
},
{
name: "preventOverflow",
...modifiers?.preventOverflow,
options: {
boundary: this.props.boundary,
rootBoundary: this.props.rootBoundary,
...modifiers?.preventOverflow?.options,
},
},
];
if (matchTargetWidth) {
popperModifiers.push(matchReferenceWidthModifier);
}
if (modifiersCustom !== undefined) {
popperModifiers.push(...modifiersCustom);
}
return popperModifiers;
}
handleTargetFocus = (e) => {
if (this.props.openOnTargetFocus && this.isHoverInteractionKind()) {
if (e.relatedTarget == null && !this.lostFocusOnSamePage) {
// ignore this focus event -- the target was already focused but the page itself
// lost focus (e.g. due to switching tabs).
return;
}
this.handleMouseEnter(e);
}
};
handleTargetBlur = (e) => {
if (this.props.openOnTargetFocus && this.isHoverInteractionKind()) {
if (e.relatedTarget != null) {
// if the next element to receive focus is within the popover, we'll want to leave the
// popover open.
if (e.relatedTarget !== this.popoverElement &&
!this.isElementInPopover(e.relatedTarget)) {
this.handleMouseLeave(e);
}
}
else {
this.handleMouseLeave(e);
}
}
this.lostFocusOnSamePage = e.relatedTarget != null;
};
handleTargetContextMenu = (e) => {
// we assume that when someone prevents the default interaction on this event (a browser native context menu),
// they are showing a custom context menu (as ContextMenu2 does); in this case, we should close this popover/tooltip
if (e.defaultPrevented) {
this.setOpenState(false, e);
}
};
handleMouseEnter = (e) => {
this.isMouseInTargetOrPopover = true;
// if we're entering the popover, and the mode is set to be HOVER_TARGET_ONLY, we want to manually
// trigger the mouse leave event, as hovering over the popover shouldn't count.
if (!this.props.usePortal &&
this.isElementInPopover(e.target) &&
this.props.interactionKind === PopoverInteractionKind.HOVER_TARGET_ONLY &&
!this.props.openOnTargetFocus) {
this.handleMouseLeave(e);
}
else if (!this.props.disabled) {
// only begin opening popover when it is enabled
this.setOpenState(true, e, this.props.hoverOpenDelay);
}
};
handleMouseLeave = (e) => {
this.isMouseInTargetOrPopover = false;
// wait until the event queue is flushed, because we want to leave the
// popover open if the mouse entered the popover immediately after
// leaving the target (or vice versa).
this.setTimeout(() => {
if (this.isMouseInTargetOrPopover) {
return;
}
// user-configurable closing delay is helpful when moving mouse from target to popover
this.setOpenState(false, e, this.props.hoverCloseDelay);
});
};
handlePopoverClick = (e) => {
const eventTarget = e.target;
const eventPopover = eventTarget.closest(`.${Classes.POPOVER}`);
const eventPopoverV1 = eventTarget.closest(`.${Classes.POPOVER}`);
const isEventFromSelf = (eventPopover ?? eventPopoverV1) === this.getPopoverElement();
const isEventPopoverCapturing = eventPopover?.classList.contains(Classes.POPOVER_CAPTURING_DISMISS) ??
eventPopoverV1?.classList.contains(Classes.POPOVER_CAPTURING_DISMISS) ??
false;
// an OVERRIDE inside a DISMISS does not dismiss, and a DISMISS inside an OVERRIDE will dismiss.
const dismissElement = eventTarget.closest(`.${Classes.POPOVER_DISMISS}, .${Classes.POPOVER_DISMISS_OVERRIDE}`);
// dismiss selectors from the "V1" version of Popover in the core package
// we expect these to be rendered by MenuItem, which at this point has no knowledge of Popover
// this can be removed once Popover is merged into core in v5.0
const dismissElementV1 = eventTarget.closest(`.${Classes.POPOVER_DISMISS}, .${Classes.POPOVER_DISMISS_OVERRIDE}`);
const shouldDismiss = dismissElement?.classList.contains(Classes.POPOVER_DISMISS) ??
dismissElementV1?.classList.contains(Classes.POPOVER_DISMISS) ??
false;
const isDisabled = eventTarget.closest(`:disabled, .${Classes.DISABLED}`) != null;
if (shouldDismiss && !isDisabled && (!isEventPopoverCapturing || isEventFromSelf)) {
this.setOpenState(false, e);
}
};
handleOverlayClose = (e) => {
if (this.targetRef.current == null || e === undefined) {
return;
}
const event = (e.nativeEvent ?? e);
const eventTarget = (event.composed ? event.composedPath()[0] : event.target);
// if click was in target, target event listener will handle things, so don't close
if (!Utils.elementIsOrContains(this.targetRef.current, eventTarget) || e.nativeEvent instanceof KeyboardEvent) {
this.setOpenState(false, e);
}
};
handleKeyDown = (e) => {
const isKeyboardClick = Utils.isKeyboardClick(e);
// For keyboard accessibility, trigger the same behavior as a click event upon pressing ENTER/SPACE
if (isKeyboardClick) {
this.handleTargetClick(e);
}
};
handleTargetClick = (e) => {
// Target element(s) may fire simulated click event upon pressing ENTER/SPACE, which we should ignore
// see: https://github.com/palantir/blueprint/issues/5775
const shouldIgnoreClick = this.state.isOpen && this.isSimulatedButtonClick(e);
if (!shouldIgnoreClick) {
// ensure click did not originate from within inline popover before closing
if (!this.props.disabled && !this.isElementInPopover(e.target)) {
if (this.props.isOpen == null) {
this.setState(prevState => ({ isOpen: !prevState.isOpen }));
}
else {
this.setOpenState(!this.props.isOpen, e);
}
}
}
};
isSimulatedButtonClick = (e) => {
return !e.isTrusted && e.target.matches(`.${Classes.BUTTON}`);
};
// a wrapper around setState({ isOpen }) that will call props.onInteraction instead when in controlled mode.
// starts a timeout to delay changing the state if a non-zero duration is provided.
setOpenState(isOpen, e, timeout) {
// cancel any existing timeout because we have new state
this.cancelOpenTimeout?.();
if (timeout !== undefined && timeout > 0) {
this.cancelOpenTimeout = this.setTimeout(() => this.setOpenState(isOpen, e), timeout);
}
else {
if (this.props.isOpen == null) {
this.setState({ isOpen });
}
else {
this.props.onInteraction?.(isOpen, e);
}
if (!isOpen) {
// non-null assertion because the only time `e` is undefined is when in controlled mode
// or the rare special case in uncontrolled mode when the `disabled` flag is toggled true
this.props.onClose?.(e);
}
}
}
updateDarkParent() {
if (this.props.usePortal && this.state.isOpen) {
const hasDarkParent = this.targetRef.current != null && this.targetRef.current.closest(`.${Classes.DARK}`) != null;
this.setState({ hasDarkParent });
}
}
isElementInPopover(element) {
return this.getPopoverElement()?.contains(element) ?? false;
}
}
function noop() {
// no-op
}
//# sourceMappingURL=popover.js.map