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