1/*
2 * Copyright 2017 Palantir Technologies, Inc. All rights reserved.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *     http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17import classNames from "classnames";
18import { ModifierFn } from "popper.js";
19import * as React from "react";
20import { polyfill } from "react-lifecycles-compat";
21import { Manager, Popper, PopperChildrenProps, Reference, ReferenceChildrenProps } from "react-popper";
22
23import { AbstractPureComponent2, Classes, IRef, refHandler, setRef } from "../../common";
24import * as Errors from "../../common/errors";
25import { DISPLAYNAME_PREFIX, HTMLDivProps } from "../../common/props";
26import * as Utils from "../../common/utils";
27import { Overlay } from "../overlay/overlay";
28import { ResizeSensor } from "../resize-sensor/resizeSensor";
29// eslint-disable-next-line import/no-cycle
30import { Tooltip } from "../tooltip/tooltip";
31import { PopoverArrow } from "./popoverArrow";
32import { positionToPlacement } from "./popoverMigrationUtils";
33import { IPopoverSharedProps, PopperModifiers } from "./popoverSharedProps";
34import { arrowOffsetModifier, getTransformOrigin } from "./popperUtils";
35
36export const PopoverInteractionKind = {
37    CLICK: "click" as "click",
38    CLICK_TARGET_ONLY: "click-target" as "click-target",
39    HOVER: "hover" as "hover",
40    HOVER_TARGET_ONLY: "hover-target" as "hover-target",
41};
42// eslint-disable-next-line @typescript-eslint/no-redeclare
43export type PopoverInteractionKind = typeof PopoverInteractionKind[keyof typeof PopoverInteractionKind];
44
45export interface IPopoverProps extends IPopoverSharedProps {
46    /** HTML props for the backdrop element. Can be combined with `backdropClassName`. */
47    backdropProps?: React.HTMLProps<HTMLDivElement>;
48
49    /**
50     * The content displayed inside the popover. This can instead be provided as
51     * the _second_ element in `children` (first is `target`).
52     */
53    content?: string | JSX.Element;
54
55    /**
56     * Whether the wrapper and target should take up the full width of their container.
57     * Note that supplying `true` for this prop will force  `targetTagName="div"` and
58     * `wrapperTagName="div"`.
59     */
60    fill?: boolean;
61
62    /**
63     * The kind of interaction that triggers the display of the popover.
64     *
65     * @default PopoverInteractionKind.CLICK
66     */
67    interactionKind?: PopoverInteractionKind;
68
69    /**
70     * Enables an invisible overlay beneath the popover that captures clicks and
71     * prevents interaction with the rest of the document until the popover is
72     * closed. This prop is only available when `interactionKind` is
73     * `PopoverInteractionKind.CLICK`. When popovers with backdrop are opened,
74     * they become focused.
75     *
76     * @default false
77     */
78    hasBackdrop?: boolean;
79
80    /**
81     * Ref supplied to the `Classes.POPOVER` element.
82     */
83    popoverRef?: IRef<HTMLElement>;
84
85    /**
86     * The target to which the popover content is attached. This can instead be
87     * provided as the _first_ element in `children`.
88     */
89    target?: string | JSX.Element;
90}
91
92export interface IPopoverState {
93    transformOrigin: string;
94    isOpen: boolean;
95    hasDarkParent: boolean;
96}
97
98/** @deprecated use { Popover2 } from "@blueprintjs/popover2" */
99@polyfill
100export class Popover extends AbstractPureComponent2<IPopoverProps, IPopoverState> {
101    public static displayName = `${DISPLAYNAME_PREFIX}.Popover`;
102
103    // eslint-disable-next-line deprecation/deprecation
104    private popoverRef = Utils.createReactRef<HTMLDivElement>();
105
106    public static defaultProps: IPopoverProps = {
107        boundary: "scrollParent",
108        captureDismiss: false,
109        defaultIsOpen: false,
110        disabled: false,
111        fill: false,
112        hasBackdrop: false,
113        hoverCloseDelay: 300,
114        hoverOpenDelay: 150,
115        inheritDarkTheme: true,
116        interactionKind: PopoverInteractionKind.CLICK,
117        minimal: false,
118        modifiers: {},
119        openOnTargetFocus: true,
120        // N.B. we don't set a default for `placement` or `position` here because that would trigger
121        // a warning in validateProps if the other prop is specified by a user of this component
122        targetTagName: "span",
123        transitionDuration: 300,
124        usePortal: true,
125        wrapperTagName: "span",
126    };
127
128    /**
129     * DOM element that contains the popover.
130     * When `usePortal={true}`, this element will be portaled outside the usual DOM flow,
131     * so this reference can be very useful for testing.
132     */
133    public popoverElement: HTMLElement | null = null;
134
135    /** DOM element that contains the target. */
136    public targetElement: HTMLElement | null = null;
137
138    public state: IPopoverState = {
139        hasDarkParent: false,
140        isOpen: this.getIsOpen(this.props),
141        transformOrigin: "",
142    };
143
144    private cancelOpenTimeout?: () => void;
145
146    // a flag that lets us detect mouse movement between the target and popover,
147    // now that mouseleave is triggered when you cross the gap between the two.
148    private isMouseInTargetOrPopover = false;
149
150    // a flag that indicates whether the target previously lost focus to another
151    // element on the same page.
152    private lostFocusOnSamePage = true;
153
154    // Reference to the Poppper.scheduleUpdate() function, this changes every time the popper is mounted
155    private popperScheduleUpdate?: () => void;
156
157    private handlePopoverRef: IRef<HTMLElement> = refHandler(this, "popoverElement", this.props.popoverRef);
158
159    private handleTargetRef = (ref: HTMLElement | null) => (this.targetElement = ref);
160
161    public render() {
162        // rename wrapper tag to begin with uppercase letter so it's recognized
163        // as JSX component instead of intrinsic element. but because of its
164        // type, tsc actually recognizes that it is _any_ intrinsic element, so
165        // it can typecheck the HTML props!!
166        const { className, disabled, fill, placement, position = "auto" } = this.props;
167        const { isOpen } = this.state;
168        let { wrapperTagName } = this.props;
169        if (fill) {
170            wrapperTagName = "div";
171        }
172
173        const isContentEmpty = Utils.ensureElement(this.understandChildren().content) == null;
174        // need to do this check in render(), because `isOpen` is derived from
175        // state, and state can't necessarily be accessed in validateProps.
176        if (isContentEmpty && !disabled && isOpen !== false && !Utils.isNodeEnv("production")) {
177            console.warn(Errors.POPOVER_WARN_EMPTY_CONTENT);
178        }
179
180        const wrapperClasses = classNames(Classes.POPOVER_WRAPPER, className, {
181            [Classes.FILL]: fill,
182        });
183
184        const wrapper = React.createElement(
185            wrapperTagName!,
186            { className: wrapperClasses },
187            <Reference innerRef={this.handleTargetRef}>{this.renderTarget}</Reference>,
188            <Overlay
189                autoFocus={this.props.autoFocus}
190                backdropClassName={Classes.POPOVER_BACKDROP}
191                backdropProps={this.props.backdropProps}
192                canEscapeKeyClose={this.props.canEscapeKeyClose}
193                canOutsideClickClose={this.props.interactionKind === PopoverInteractionKind.CLICK}
194                className={this.props.portalClassName}
195                enforceFocus={this.props.enforceFocus}
196                hasBackdrop={this.props.hasBackdrop}
197                isOpen={isOpen && !isContentEmpty}
198                onClose={this.handleOverlayClose}
199                onClosed={this.props.onClosed}
200                onClosing={this.props.onClosing}
201                onOpened={this.props.onOpened}
202                onOpening={this.props.onOpening}
203                transitionDuration={this.props.transitionDuration}
204                transitionName={Classes.POPOVER}
205                usePortal={this.props.usePortal}
206                portalContainer={this.props.portalContainer}
207            >
208                <Popper
209                    innerRef={this.handlePopoverRef}
210                    placement={placement ?? positionToPlacement(position)}
211                    modifiers={this.getPopperModifiers()}
212                >
213                    {this.renderPopover}
214                </Popper>
215            </Overlay>,
216        );
217
218        return <Manager>{wrapper}</Manager>;
219    }
220
221    public componentDidMount() {
222        this.updateDarkParent();
223    }
224
225    public componentDidUpdate(prevProps: IPopoverProps, prevState: IPopoverState) {
226        super.componentDidUpdate(prevProps, prevState);
227
228        if (prevProps.popoverRef !== this.props.popoverRef) {
229            setRef(prevProps.popoverRef, null);
230            this.handlePopoverRef = refHandler(this, "popoverElement", this.props.popoverRef);
231            setRef(this.props.popoverRef, this.popoverElement);
232        }
233
234        this.updateDarkParent();
235
236        const nextIsOpen = this.getIsOpen(this.props);
237
238        if (this.props.isOpen != null && nextIsOpen !== this.state.isOpen) {
239            this.setOpenState(nextIsOpen);
240            // tricky: setOpenState calls setState only if this.props.isOpen is
241            // not controlled, so we need to invoke setState manually here.
242            this.setState({ isOpen: nextIsOpen });
243        } else if (this.props.disabled && this.state.isOpen && this.props.isOpen == null) {
244            // special case: close an uncontrolled popover when disabled is set to true
245            this.setOpenState(false);
246        }
247    }
248
249    /**
250     * Instance method to instruct the `Popover` to recompute its position.
251     *
252     * This method should only be used if you are updating the target in a way
253     * that does not cause it to re-render, such as changing its _position_
254     * without changing its _size_ (since `Popover` already repositions when it
255     * detects a resize).
256     */
257    public reposition = () => this.popperScheduleUpdate?.();
258
259    protected validateProps(props: IPopoverProps & { children?: React.ReactNode }) {
260        if (props.isOpen == null && props.onInteraction != null) {
261            console.warn(Errors.POPOVER_WARN_UNCONTROLLED_ONINTERACTION);
262        }
263        if (props.hasBackdrop && !props.usePortal) {
264            console.warn(Errors.POPOVER_WARN_HAS_BACKDROP_INLINE);
265        }
266        if (props.hasBackdrop && props.interactionKind !== PopoverInteractionKind.CLICK) {
267            console.error(Errors.POPOVER_HAS_BACKDROP_INTERACTION);
268        }
269        if (props.placement !== undefined && props.position !== undefined) {
270            console.warn(Errors.POPOVER_WARN_PLACEMENT_AND_POSITION_MUTEX);
271        }
272
273        const childrenCount = React.Children.count(props.children);
274        const hasContentProp = props.content !== undefined;
275        const hasTargetProp = props.target !== undefined;
276
277        if (childrenCount === 0 && !hasTargetProp) {
278            console.error(Errors.POPOVER_REQUIRES_TARGET);
279        }
280        if (childrenCount > 2) {
281            console.warn(Errors.POPOVER_WARN_TOO_MANY_CHILDREN);
282        }
283        if (childrenCount > 0 && hasTargetProp) {
284            console.warn(Errors.POPOVER_WARN_DOUBLE_TARGET);
285        }
286        if (childrenCount === 2 && hasContentProp) {
287            console.warn(Errors.POPOVER_WARN_DOUBLE_CONTENT);
288        }
289    }
290
291    private updateDarkParent() {
292        if (this.props.usePortal && this.state.isOpen) {
293            const hasDarkParent = this.targetElement != null && this.targetElement.closest(`.${Classes.DARK}`) != null;
294            this.setState({ hasDarkParent });
295        }
296    }
297
298    private renderPopover = (popperProps: PopperChildrenProps) => {
299        const { usePortal, interactionKind } = this.props;
300        const { transformOrigin } = this.state;
301
302        // Need to update our reference to this on every render as it will change.
303        this.popperScheduleUpdate = popperProps.scheduleUpdate;
304
305        const popoverHandlers: HTMLDivProps = {
306            // always check popover clicks for dismiss class
307            onClick: this.handlePopoverClick,
308        };
309        if (
310            interactionKind === PopoverInteractionKind.HOVER ||
311            (!usePortal && interactionKind === PopoverInteractionKind.HOVER_TARGET_ONLY)
312        ) {
313            popoverHandlers.onMouseEnter = this.handleMouseEnter;
314            popoverHandlers.onMouseLeave = this.handleMouseLeave;
315        }
316
317        const popoverClasses = classNames(
318            Classes.POPOVER,
319            {
320                [Classes.DARK]: this.props.inheritDarkTheme && this.state.hasDarkParent,
321                [Classes.MINIMAL]: this.props.minimal,
322                [Classes.POPOVER_CAPTURING_DISMISS]: this.props.captureDismiss,
323            },
324            this.props.popoverClassName,
325        );
326
327        return (
328            <div className={Classes.TRANSITION_CONTAINER} ref={popperProps.ref} style={popperProps.style}>
329                <ResizeSensor onResize={this.reposition}>
330                    <div
331                        className={popoverClasses}
332                        style={{ transformOrigin }}
333                        ref={this.popoverRef}
334                        {...popoverHandlers}
335                    >
336                        {this.isArrowEnabled() && (
337                            <PopoverArrow arrowProps={popperProps.arrowProps} placement={popperProps.placement} />
338                        )}
339                        <div className={Classes.POPOVER_CONTENT}>{this.understandChildren().content}</div>
340                    </div>
341                </ResizeSensor>
342            </div>
343        );
344    };
345
346    private renderTarget = (referenceProps: ReferenceChildrenProps) => {
347        const { fill, openOnTargetFocus, targetClassName, targetProps = {} } = this.props;
348        const { isOpen } = this.state;
349        const isControlled = this.isControlled();
350        const isHoverInteractionKind = this.isHoverInteractionKind();
351        let { targetTagName } = this.props;
352        if (fill) {
353            targetTagName = "div";
354        }
355
356        const finalTargetProps: React.HTMLProps<HTMLElement> = isHoverInteractionKind
357            ? {
358                  // HOVER handlers
359                  onBlur: this.handleTargetBlur,
360                  onFocus: this.handleTargetFocus,
361                  onMouseEnter: this.handleMouseEnter,
362                  onMouseLeave: this.handleMouseLeave,
363              }
364            : {
365                  // CLICK needs only one handler
366                  onClick: this.handleTargetClick,
367              };
368        finalTargetProps["aria-haspopup"] = "true";
369        finalTargetProps.className = classNames(
370            Classes.POPOVER_TARGET,
371            { [Classes.POPOVER_OPEN]: isOpen },
372            targetProps.className,
373            targetClassName,
374        );
375        finalTargetProps.ref = referenceProps.ref;
376
377        const rawTarget = Utils.ensureElement(this.understandChildren().target);
378
379        if (rawTarget === undefined) {
380            return null;
381        }
382
383        const rawTabIndex = rawTarget.props.tabIndex;
384        // ensure target is focusable if relevant prop enabled
385        const tabIndex = rawTabIndex == null && openOnTargetFocus && isHoverInteractionKind ? 0 : rawTabIndex;
386        const clonedTarget: JSX.Element = React.cloneElement(rawTarget, {
387            className: classNames(rawTarget.props.className, {
388                // this class is mainly useful for button targets; we should only apply it for uncontrolled popovers
389                // when they are opened by a user interaction
390                [Classes.ACTIVE]: isOpen && !isControlled && !isHoverInteractionKind,
391            }),
392            // force disable single Tooltip child when popover is open (BLUEPRINT-552)
393            /* eslint-disable-next-line deprecation/deprecation */
394            disabled: isOpen && Utils.isElementOfType(rawTarget, Tooltip) ? true : rawTarget.props.disabled,
395            tabIndex,
396        });
397        const target = React.createElement(
398            targetTagName!,
399            {
400                ...targetProps,
401                ...finalTargetProps,
402            },
403            clonedTarget,
404        );
405
406        return <ResizeSensor onResize={this.reposition}>{target}</ResizeSensor>;
407    };
408
409    // content and target can be specified as props or as children. this method
410    // normalizes the two approaches, preferring child over prop.
411    private understandChildren() {
412        const { children, content: contentProp, target: targetProp } = this.props;
413        // #validateProps asserts that 1 <= children.length <= 2 so content is optional
414        const [targetChild, contentChild] = React.Children.toArray(children);
415        return {
416            content: contentChild == null ? contentProp : contentChild,
417            target: targetChild == null ? targetProp : targetChild,
418        };
419    }
420
421    private isControlled = () => this.props.isOpen !== undefined;
422
423    private getIsOpen(props: IPopoverProps) {
424        // disabled popovers should never be allowed to open.
425        if (props.disabled) {
426            return false;
427        } else if (props.isOpen != null) {
428            return props.isOpen;
429        } else {
430            return props.defaultIsOpen!;
431        }
432    }
433
434    private getPopperModifiers(): PopperModifiers {
435        const { boundary, modifiers } = this.props;
436        const { flip = {}, preventOverflow = {} } = modifiers!;
437        return {
438            ...modifiers,
439            arrowOffset: {
440                enabled: this.isArrowEnabled(),
441                fn: arrowOffsetModifier,
442                order: 510,
443            },
444            flip: { boundariesElement: boundary, ...flip },
445            preventOverflow: { boundariesElement: boundary, ...preventOverflow },
446            updatePopoverState: {
447                enabled: true,
448                fn: this.updatePopoverState,
449                order: 900,
450            },
451        };
452    }
453
454    private handleTargetFocus = (e: React.FocusEvent<HTMLElement>) => {
455        if (this.props.openOnTargetFocus && this.isHoverInteractionKind()) {
456            if (e.relatedTarget == null && !this.lostFocusOnSamePage) {
457                // ignore this focus event -- the target was already focused but the page itself
458                // lost focus (e.g. due to switching tabs).
459                return;
460            }
461            this.handleMouseEnter((e as unknown) as React.MouseEvent<HTMLElement>);
462        }
463        this.props.targetProps?.onFocus?.(e);
464    };
465
466    private handleTargetBlur = (e: React.FocusEvent<HTMLElement>) => {
467        if (this.props.openOnTargetFocus && this.isHoverInteractionKind()) {
468            // if the next element to receive focus is within the popover, we'll want to leave the
469            // popover open. e.relatedTarget ought to tell us the next element to receive focus, but if the user just
470            // clicked on an element which is not focusable (either by default or with a tabIndex attribute),
471            // it won't be set. So, we filter those out here and assume that a click handler somewhere else will
472            // close the popover if necessary.
473            if (e.relatedTarget != null && !this.isElementInPopover(e.relatedTarget as HTMLElement)) {
474                this.handleMouseLeave((e as unknown) as React.MouseEvent<HTMLElement>);
475            }
476        }
477        this.lostFocusOnSamePage = e.relatedTarget != null;
478        this.props.targetProps?.onBlur?.(e);
479    };
480
481    private handleMouseEnter = (e: React.MouseEvent<HTMLElement>) => {
482        this.isMouseInTargetOrPopover = true;
483
484        // if we're entering the popover, and the mode is set to be HOVER_TARGET_ONLY, we want to manually
485        // trigger the mouse leave event, as hovering over the popover shouldn't count.
486        if (
487            !this.props.usePortal &&
488            this.isElementInPopover(e.target as Element) &&
489            this.props.interactionKind === PopoverInteractionKind.HOVER_TARGET_ONLY &&
490            !this.props.openOnTargetFocus
491        ) {
492            this.handleMouseLeave(e);
493        } else if (!this.props.disabled) {
494            // only begin opening popover when it is enabled
495            this.setOpenState(true, e, this.props.hoverOpenDelay);
496        }
497        this.props.targetProps?.onMouseEnter?.(e);
498    };
499
500    private handleMouseLeave = (e: React.MouseEvent<HTMLElement>) => {
501        this.isMouseInTargetOrPopover = false;
502
503        // wait until the event queue is flushed, because we want to leave the
504        // popover open if the mouse entered the popover immediately after
505        // leaving the target (or vice versa).
506        this.setTimeout(() => {
507            if (this.isMouseInTargetOrPopover) {
508                return;
509            }
510            // user-configurable closing delay is helpful when moving mouse from target to popover
511            this.setOpenState(false, e, this.props.hoverCloseDelay);
512        });
513        this.props.targetProps?.onMouseLeave?.(e);
514    };
515
516    private handlePopoverClick = (e: React.MouseEvent<HTMLElement>) => {
517        const eventTarget = e.target as HTMLElement;
518        const eventPopover = eventTarget.closest(`.${Classes.POPOVER}`);
519        const isEventFromSelf = eventPopover === this.popoverRef.current;
520        const isEventPopoverCapturing = eventPopover?.classList.contains(Classes.POPOVER_CAPTURING_DISMISS);
521        // an OVERRIDE inside a DISMISS does not dismiss, and a DISMISS inside an OVERRIDE will dismiss.
522        const dismissElement = eventTarget.closest(`.${Classes.POPOVER_DISMISS}, .${Classes.POPOVER_DISMISS_OVERRIDE}`);
523        const shouldDismiss = dismissElement != null && dismissElement.classList.contains(Classes.POPOVER_DISMISS);
524        const isDisabled = eventTarget.closest(`:disabled, .${Classes.DISABLED}`) != null;
525        if (shouldDismiss && !isDisabled && (!isEventPopoverCapturing || isEventFromSelf)) {
526            this.setOpenState(false, e);
527        }
528    };
529
530    private handleOverlayClose = (e?: React.SyntheticEvent<HTMLElement>) => {
531        if (this.targetElement === null || e === undefined) {
532            return;
533        }
534
535        const eventTarget = e.target as HTMLElement;
536        // if click was in target, target event listener will handle things, so don't close
537        if (!Utils.elementIsOrContains(this.targetElement, eventTarget) || e.nativeEvent instanceof KeyboardEvent) {
538            this.setOpenState(false, e);
539        }
540    };
541
542    private handleTargetClick = (e: React.MouseEvent<HTMLElement>) => {
543        // ensure click did not originate from within inline popover before closing
544        if (!this.props.disabled && !this.isElementInPopover(e.target as HTMLElement)) {
545            if (this.props.isOpen == null) {
546                this.setState(prevState => ({ isOpen: !prevState.isOpen }));
547            } else {
548                this.setOpenState(!this.props.isOpen, e);
549            }
550        }
551        this.props.targetProps?.onClick?.(e);
552    };
553
554    // a wrapper around setState({isOpen}) that will call props.onInteraction instead when in controlled mode.
555    // starts a timeout to delay changing the state if a non-zero duration is provided.
556    private setOpenState(isOpen: boolean, e?: React.SyntheticEvent<HTMLElement>, timeout?: number) {
557        // cancel any existing timeout because we have new state
558        this.cancelOpenTimeout?.();
559        if (timeout !== undefined && timeout > 0) {
560            this.cancelOpenTimeout = this.setTimeout(() => this.setOpenState(isOpen, e), timeout);
561        } else {
562            if (this.props.isOpen == null) {
563                this.setState({ isOpen });
564            } else {
565                this.props.onInteraction?.(isOpen, e);
566            }
567            if (!isOpen) {
568                // non-null assertion because the only time `e` is undefined is when in controlled mode
569                // or the rare special case in uncontrolled mode when the `disabled` flag is toggled true
570                this.props.onClose?.(e!);
571            }
572        }
573    }
574
575    private isArrowEnabled() {
576        const { minimal, modifiers } = this.props;
577        // omitting `arrow` from `modifiers` uses Popper default, which does show an arrow.
578        return !minimal && (modifiers?.arrow == null || modifiers.arrow.enabled);
579    }
580
581    private isElementInPopover(element: Element) {
582        return this.popoverElement?.contains(element);
583    }
584
585    private isHoverInteractionKind() {
586        return (
587            this.props.interactionKind === PopoverInteractionKind.HOVER ||
588            this.props.interactionKind === PopoverInteractionKind.HOVER_TARGET_ONLY
589        );
590    }
591
592    /** Popper modifier that updates React state (for style properties) based on latest data. */
593    private updatePopoverState: ModifierFn = data => {
594        // always set string; let shouldComponentUpdate determine if update is necessary
595        this.setState({ transformOrigin: getTransformOrigin(data) });
596        return data;
597    };
598}
599