/* * 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. */ /** * @fileoverview This component is DEPRECATED, and the code is frozen. * All changes & bugfixes should be made to PopoverNext instead. */ import type { State as PopperState } from "@popperjs/core"; import classNames from "classnames"; import { Children, cloneElement, createElement, createRef } from "react"; import { Manager, type Modifier, Popper, type PopperChildrenProps, Reference, type ReferenceChildrenProps, } from "react-popper"; import { AbstractPureComponent, Classes, DISPLAYNAME_PREFIX, type HTMLDivProps, mergeRefs, refHandler, Utils, } from "../../common"; import * as Errors from "../../common/errors"; import { Overlay2 } from "../overlay2/overlay2"; import { ResizeSensor } from "../resize-sensor/resizeSensor"; import { matchReferenceWidthModifier } from "./customModifiers"; import { POPOVER_ARROW_SVG_SIZE, PopoverArrow } from "./popoverArrow"; import { positionToPlacement } from "./popoverPlacementUtils"; import { PopoverInteractionKind, type PopoverProps } from "./popoverProps"; import type { DefaultPopoverTargetHTMLProps, PopoverClickTargetHandlers, PopoverHoverTargetHandlers, } from "./popoverSharedProps"; import { getBasePlacement, getTransformOrigin } from "./popperUtils"; export interface PopoverState { hasDarkParent: boolean; // when an ESC keypress interaction closes the overlay, we want to force-enable `shouldReturnFocusOnClose` behavior isClosingViaEscapeKeypress: boolean; isOpen: boolean; } /** * Popover component, used to display a floating UI next to and tethered to a target element. * * @deprecated use `PopoverNext` instead * @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< T extends DefaultPopoverTargetHTMLProps = DefaultPopoverTargetHTMLProps, > extends AbstractPureComponent, PopoverState> { public static displayName = `${DISPLAYNAME_PREFIX}.Popover`; public static defaultProps: PopoverProps = { 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, }; public state: PopoverState = { hasDarkParent: false, isClosingViaEscapeKeypress: 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 */ public popoverElement: HTMLElement | null = null; /** Popover ref handler */ private popoverRef: React.RefCallback = refHandler(this, "popoverElement", this.props.popoverRef); /** * Target DOM element ref. * * N.B. this must be a ref object since we pass it to ``, which needs to know about the target * DOM element in order to observe its dimensions. * * @public for testing */ public targetRef = createRef(); /** * Overlay2 transition container element ref. */ private transitionContainerElement = createRef(); private cancelOpenTimeout?: () => void; // 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. private isMouseInTargetOrPopover = false; // a flag that indicates whether the target previously lost focus to another // element on the same page. private lostFocusOnSamePage = true; // Ensures the React 19 incompatibility warning fires at most once per instance. private didWarnReact19 = false; // Reference to the Poppper.scheduleUpdate() function, this changes every time the popper is mounted private popperScheduleUpdate?: () => Promise | null>; private isControlled = () => this.props.isOpen !== undefined; // arrow is disabled if minimal, or if the arrow modifier was explicitly disabled private isArrowEnabled = () => !this.props.minimal && this.props.modifiers?.arrow?.enabled !== false; private 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 private getPopoverElement() { return this.popoverElement?.querySelector(`.${Classes.POPOVER}`); } private getIsOpen(props: PopoverProps) { // disabled popovers should never be allowed to open. if (props.disabled) { return false; } else { return props.isOpen ?? props.defaultIsOpen!; } } public render() { const { disabled, placement, position = "auto", positioningStrategy } = this.props; const { isOpen } = this.state; if (this.getIsContentEmpty()) { // 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 }); } // Important: do not use since it has a bug when used in React 18 strict mode // see https://github.com/floating-ui/react-popper/pull/459 return ( {this.renderTarget} {this.renderPopover} ); } public componentDidMount() { this.updateDarkParent(); this.warnIfReact19(); } /** * Dev-only: warn that this deprecated component's react-popper dependency silently mis-positions * content under React 19 (worst under StrictMode). Steers consumers to PopoverNext. */ private warnIfReact19() { if (this.didWarnReact19 || Utils.isNodeEnv("production") || Utils.getReactMajorVersion() < 19) { return; } this.didWarnReact19 = true; console.warn(Errors.POPOVER_WARN_REACT19); } public componentDidUpdate(props: PopoverProps, state: PopoverState) { 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); } } protected validateProps(props: PopoverProps) { 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 = 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). */ public reposition = () => this.popperScheduleUpdate?.(); private renderTarget = ({ ref: popperChildRef }: ReferenceChildrenProps) => { const { children, className, disabled, 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"; } // N.B. react-popper has a wide type for this ref, but we can narrow it based on the source, // see https://github.com/floating-ui/react-popper/blob/beac280d61082852c4efc302be902911ce2d424c/src/Reference.js#L17 const ref = mergeRefs(popperChildRef as React.RefCallback, this.targetRef); const targetEventHandlers: PopoverHoverTargetHandlers | PopoverClickTargetHandlers = 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 = !this.getIsContentEmpty() && !disabled && openOnTargetFocus && isHoverInteractionKind ? 0 : undefined; const ownTargetProps = { // 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, [Classes.FILL]: fill, }), ref, ...targetEventHandlers, } satisfies React.HTMLProps; const childTargetProps = { "aria-expanded": isHoverInteractionKind ? undefined : isOpen, "aria-haspopup": this.props.interactionKind === PopoverInteractionKind.HOVER_TARGET_ONLY ? undefined : (this.props.popupKind ?? "menu"), } satisfies React.HTMLProps; const targetModifierClasses = { // this class is mainly useful for Blueprint