import classNames from "classnames" ; import * as React from "react" ; import { findDOMNode } from "react-dom" ; import { polyfill } from "react-lifecycles-compat" ; import { CSSTransition , TransitionGroup } from "react-transition-group" ; import { CSSTransitionProps } from "react-transition-group/CSSTransition" ; import { AbstractPureComponent2 , Classes , Keys } from "../../common" ; import { DISPLAYNAME_PREFIX , HTMLDivProps , Props } from "../../common/props" ; import { isFunction , LifecycleCompatPolyfill } from "../../common/utils" ; import { Portal } from "../portal/portal" ; export type OverlayableProps = IOverlayableProps ; deprecated export interface IOverlayableProps extends IOverlayLifecycleProps { default autoFocus ? : boolean ; default canEscapeKeyClose ? : boolean ; default enforceFocus ? : boolean ; default lazy ? : boolean ; default shouldReturnFocusOnClose ? : boolean ; default transitionDuration ? : number ; default usePortal ? : boolean ; /**
     * Space-delimited string of class names applied to the `Portal` element if
     * `usePortal={true}`.
     */ portalClassName ? : string ; default portalContainer ? : HTMLElement ; /**
     * A callback that is invoked when user interaction causes the overlay to close, such as
     * clicking on the overlay or pressing the `esc` key (if enabled).
     *
     * Receives the event from the user's interaction, if there was an event (generally either a
     * mouse or key event). Note that, since this component is controlled by the `isOpen` prop, it
     * will not actually close itself until that prop becomes `false`.
     */ onClose ? : ( event : React . SyntheticEvent < HTMLElement > ) => void ; } export type OverlayLifecycleProps = IOverlayLifecycleProps ; export interface IOverlayLifecycleProps { /**
     * Lifecycle method invoked just before the CSS _close_ transition begins on
     * a child. Receives the DOM element of the child being closed.
     */ onClosing ? : ( node : HTMLElement ) => void ; /**
     * Lifecycle method invoked just after the CSS _close_ transition ends but
     * before the child has been removed from the DOM. Receives the DOM element
     * of the child being closed.
     */ onClosed ? : ( node : HTMLElement ) => void ; /**
     * Lifecycle method invoked just after mounting the child in the DOM but
     * just before the CSS _open_ transition begins. Receives the DOM element of
     * the child being opened.
     */ onOpening ? : ( node : HTMLElement ) => void ; /**
     * Lifecycle method invoked just after the CSS _open_ transition ends.
     * Receives the DOM element of the child being opened.
     */ onOpened ? : ( node : HTMLElement ) => void ; } export type BackdropProps = IBackdropProps ; export interface IBackdropProps { /** CSS class names to apply to backdrop element. */ backdropClassName ? : string ; /** HTML props for the backdrop element. */ backdropProps ? : React . HTMLProps < HTMLDivElement > ; default canOutsideClickClose ? : boolean ; default hasBackdrop ? : boolean ; } export type OverlayProps = IOverlayProps ; deprecated export interface IOverlayProps extends OverlayableProps , IBackdropProps , Props { /**
     * Toggles the visibility of the overlay and its children.
     * This prop is required because the component is controlled.
     */ isOpen : boolean ; default transitionName ? : string ; } export interface IOverlayState { hasEverOpened ? : boolean ; } @ ( polyfill as LifecycleCompatPolyfill < OverlayProps , any > ) export class Overlay extends AbstractPureComponent2 < OverlayProps , IOverlayState > { public static displayName = `${ DISPLAYNAME_PREFIX }.Overlay` ; public static defaultProps : OverlayProps = { autoFocus : true , backdropProps : { } , canEscapeKeyClose : true , canOutsideClickClose : true , enforceFocus : true , hasBackdrop : true , isOpen : false , lazy : true , shouldReturnFocusOnClose : true , transitionDuration : 300 , transitionName : Classes . OVERLAY , usePortal : true , } ; public static getDerivedStateFromProps ( { isOpen : hasEverOpened } ) { if ( hasEverOpened ) { return { hasEverOpened } ; } return null ; } private static openStack : Overlay [ ] = [ ] ; private static getLastOpened = ( ) => Overlay . openStack [ Overlay . openStack . length - 1 ] ; private isAutoFocusing = false ; private lastActiveElementBeforeOpened : Element | null | undefined ; public state : IOverlayState = { hasEverOpened : this . props . isOpen , } ; public containerElement : HTMLElement | null = null ; private startFocusTrapElement : HTMLDivElement | null = null ; private endFocusTrapElement : HTMLDivElement | null = null ; private refHandlers = { container : ( ref ) => ( this . containerElement = findDOMNode ( ref ) as HTMLElement ) , endFocusTrap : ( ref ) => ( this . endFocusTrapElement = ref ) , startFocusTrap : ( ref ) => ( this . startFocusTrapElement = ref ) , } ; public render ( ) { if ( this . props . lazy && ! this . state . hasEverOpened ) { return null ; } const { autoFocus , children , className , enforceFocus , usePortal , isOpen } = this . props ; const childrenWithTransitions = isOpen ? React . Children . map ( children , this . maybeRenderChild ) ?? [ ] : [ ] ; const maybeBackdrop = this . maybeRenderBackdrop ( ) ; if ( maybeBackdrop !== null ) { childrenWithTransitions . unshift ( maybeBackdrop ) ; } if ( isOpen && ( autoFocus || enforceFocus ) && childrenWithTransitions . length > 0 ) { childrenWithTransitions . unshift ( this . renderDummyElement ( "__start" , { className : Classes . OVERLAY_START_FOCUS_TRAP , onFocus : this . handleStartFocusTrapElementFocus , onKeyDown : this . handleStartFocusTrapElementKeyDown , ref : this . refHandlers . startFocusTrap , } ) ) ; if ( enforceFocus ) { childrenWithTransitions . push ( this . renderDummyElement ( "__end" , { className : Classes . OVERLAY_END_FOCUS_TRAP , onFocus : this . handleEndFocusTrapElementFocus , ref : this . refHandlers . endFocusTrap , } ) ) ; } } const containerClasses = classNames ( Classes . OVERLAY , { [ Classes . OVERLAY_OPEN ] : isOpen , [ Classes . OVERLAY_INLINE ] : ! usePortal , } , className ) ; const transitionGroup = ( < TransitionGroup appear = { true } aria - live = "polite" className = { containerClasses } component = "div" onKeyDown = { this . handleKeyDown } ref = { this . refHandlers . container } > { childrenWithTransitions } < /TransitionGroup>); if ( usePortal ) { return ( < Portal className = { this . props . portalClassName } container = { this . props . portalContainer } > { transitionGroup } < /Portal>); } else { return transitionGroup ; } } public componentDidMount ( ) { if ( this . props . isOpen ) { this . overlayWillOpen ( ) ; } } public componentDidUpdate ( prevProps ) { if ( prevProps . isOpen && ! this . props . isOpen ) { this . overlayWillClose ( ) ; } else if ( ! prevProps . isOpen && this . props . isOpen ) { this . overlayWillOpen ( ) ; } } public componentWillUnmount ( ) { this . overlayWillClose ( ) ; } public internal public bringFocusInsideOverlay ( ) { return this . requestAnimationFrame ( ( ) => { if ( this . containerElement == null || document . activeElement == null || ! this . props . isOpen ) { return ; } const isFocusOutsideModal = ! this . containerElement . contains ( document . activeElement ) ; if ( isFocusOutsideModal ) { this . startFocusTrapElement ?. focus ( { preventScroll : true } ) ; this . isAutoFocusing = false ; } } ) ; } private maybeRenderChild = ( child ? ) => { if ( isFunction ( child ) ) { child = child ( ) ; } if ( child == null ) { return null ; } const decoratedChild = typeof child === "object" ? ( React . cloneElement ( child as React . ReactElement , { className : classNames ( ( child as React . ReactElement ) . props . className , Classes . OVERLAY_CONTENT ) , } ) ) : ( < span className = { Classes . OVERLAY_CONTENT } > { child } < /span>); const { onOpening , onOpened , onClosing , transitionDuration , transitionName } = this . props ; const CSSTransitionImplicit = CSSTransition as new ( props ) => CSSTransition < undefined > ; return ( < CSSTransitionImplicit classNames = { transitionName } onEntering = { onOpening } onEntered = { onOpened } onExiting = { onClosing } onExited = { this . handleTransitionExited } timeout = { transitionDuration } addEndListener = { this . handleTransitionAddEnd } > { decoratedChild } < /CSSTransitionImplicit>); } ; private maybeRenderBackdrop ( ) { const { backdropClassName , backdropProps , hasBackdrop , isOpen , transitionDuration , transitionName , } = this . props ; if ( hasBackdrop && isOpen ) { return ( < CSSTransition classNames = { transitionName } key = "__backdrop" timeout = { transitionDuration } addEndListener = { this . handleTransitionAddEnd } > < div { ... backdropProps } className = { classNames ( Classes . OVERLAY_BACKDROP , backdropClassName , backdropProps ?. className ) } onMouseDown = { this . handleBackdropMouseDown } / > < /CSSTransition>); } else { return null ; } } private renderDummyElement ( key , props ) { const { transitionDuration , transitionName } = this . props ; return ( < CSSTransition classNames = { transitionName } key = { key } addEndListener = { this . handleTransitionAddEnd } timeout = { transitionDuration } unmountOnExit = { true } > < div tabIndex = { 0 } { ... props } /> < /CSSTransition>); } /**
     * Ensures repeatedly pressing shift+tab keeps focus inside the Overlay. Moves focus to
     * the `endFocusTrapElement` or the first keyboard-focusable element in the Overlay (excluding
     * the `startFocusTrapElement`), depending on whether the element losing focus is inside the
     * Overlay.
     */ private handleStartFocusTrapElementFocus = ( e ) => { if ( ! this . props . enforceFocus || this . isAutoFocusing ) { return ; } if ( e . relatedTarget != null && this . containerElement ! . contains ( e . relatedTarget as Element ) && e . relatedTarget !== this . endFocusTrapElement ) { this . endFocusTrapElement ?. focus ( { preventScroll : true } ) ; } } ; /**
     * Wrap around to the end of the dialog if `enforceFocus` is enabled.
     */ private handleStartFocusTrapElementKeyDown = ( e ) => { if ( ! this . props . enforceFocus ) { return ; } if ( e . shiftKey && e . which === Keys . TAB ) { const lastFocusableElement = this . getKeyboardFocusableElements ( ) . pop ( ) ; if ( lastFocusableElement != null ) { lastFocusableElement . focus ( ) ; } else { this . endFocusTrapElement ?. focus ( { preventScroll : true } ) ; } } } ; /**
     * Ensures repeatedly pressing tab keeps focus inside the Overlay. Moves focus to the
     * `startFocusTrapElement` or the last keyboard-focusable element in the Overlay (excluding the
     * `startFocusTrapElement`), depending on whether the element losing focus is inside the
     * Overlay.
     */ private handleEndFocusTrapElementFocus = ( e ) => { if ( e . relatedTarget != null && this . containerElement ! . contains ( e . relatedTarget as Element ) && e . relatedTarget !== this . startFocusTrapElement ) { const firstFocusableElement = this . getKeyboardFocusableElements ( ) . shift ( ) ; if ( ! this . isAutoFocusing && firstFocusableElement != null && firstFocusableElement !== e . relatedTarget ) { firstFocusableElement . focus ( ) ; } else { this . startFocusTrapElement ?. focus ( { preventScroll : true } ) ; } } else { const lastFocusableElement = this . getKeyboardFocusableElements ( ) . pop ( ) ; if ( lastFocusableElement != null ) { lastFocusableElement . focus ( ) ; } else { this . startFocusTrapElement ?. focus ( { preventScroll : true } ) ; } } } ; private getKeyboardFocusableElements ( ) { const focusableElements = this . containerElement !== null ? Array . from ( this . containerElement . querySelectorAll ( [ 'a[href]:not([tabindex="-1"])' , 'button:not([disabled]):not([tabindex="-1"])' , 'details:not([tabindex="-1"])' , 'input:not([disabled]):not([tabindex="-1"])' , 'select:not([disabled]):not([tabindex="-1"])' , 'textarea:not([disabled]):not([tabindex="-1"])' , '[tabindex]:not([tabindex="-1"])' , ] . join ( "," ) ) ) : [ ] ; return focusableElements . filter ( el => ! el . classList . contains ( Classes . OVERLAY_START_FOCUS_TRAP ) && ! el . classList . contains ( Classes . OVERLAY_END_FOCUS_TRAP ) ) ; } private overlayWillClose ( ) { document . removeEventListener ( "focus" , this . handleDocumentFocus , true ) ; document . removeEventListener ( "mousedown" , this . handleDocumentClick ) ; const { openStack } = Overlay ; const stackIndex = openStack . indexOf ( this ) ; if ( stackIndex !== - 1 ) { openStack . splice ( stackIndex , 1 ) ; if ( openStack . length > 0 ) { const lastOpenedOverlay = Overlay . getLastOpened ( ) ; if ( lastOpenedOverlay . props . autoFocus && lastOpenedOverlay . props . enforceFocus ) { lastOpenedOverlay . bringFocusInsideOverlay ( ) ; document . addEventListener ( "focus" , lastOpenedOverlay . handleDocumentFocus , true ) ; } } if ( openStack . filter ( o => o . props . usePortal && o . props . hasBackdrop ) . length === 0 ) { document . body . classList . remove ( Classes . OVERLAY_OPEN ) ; } } } private overlayWillOpen ( ) { const { getLastOpened , openStack } = Overlay ; if ( openStack . length > 0 ) { document . removeEventListener ( "focus" , getLastOpened ( ) . handleDocumentFocus , true ) ; } openStack . push ( this ) ; if ( this . props . autoFocus ) { this . isAutoFocusing = true ; this . bringFocusInsideOverlay ( ) ; } if ( this . props . enforceFocus ) { document . addEventListener ( "focus" , this . handleDocumentFocus , true ) ; } if ( this . props . canOutsideClickClose && ! this . props . hasBackdrop ) { document . addEventListener ( "mousedown" , this . handleDocumentClick ) ; } if ( this . props . hasBackdrop && this . props . usePortal ) { document . body . classList . add ( Classes . OVERLAY_OPEN ) ; } this . lastActiveElementBeforeOpened = document . activeElement ; } private handleTransitionExited = ( node ) => { if ( this . props . shouldReturnFocusOnClose && this . lastActiveElementBeforeOpened instanceof HTMLElement ) { this . lastActiveElementBeforeOpened . focus ( ) ; } this . props . onClosed ?. ( node ) ; } ; private handleBackdropMouseDown = ( e ) => { const { backdropProps , canOutsideClickClose , enforceFocus , onClose } = this . props ; if ( canOutsideClickClose ) { onClose ?. ( e ) ; } if ( enforceFocus ) { this . bringFocusInsideOverlay ( ) ; } backdropProps ?. onMouseDown ?. ( e ) ; } ; private handleDocumentClick = ( e ) => { const { canOutsideClickClose , isOpen , onClose } = this . props ; const eventTarget = ( e . composed ? e . composedPath ( ) [ 0 ] : e . target ) as HTMLElement ; const stackIndex = Overlay . openStack . indexOf ( this ) ; const isClickInThisOverlayOrDescendant = Overlay . openStack . slice ( stackIndex ) . some ( ( { containerElement : elem } ) => { return elem && elem . contains ( eventTarget ) && ! elem . isSameNode ( eventTarget ) ; } ) ; if ( isOpen && ! isClickInThisOverlayOrDescendant && canOutsideClickClose ) { onClose ?. ( e as any ) ; } } ; /**
     * When multiple Overlays are open, this event handler is only active for the most recently
     * opened one to avoid Overlays competing with each other for focus.
     */ private handleDocumentFocus = ( e ) => { const eventTarget = e . composed ? e . composedPath ( ) [ 0 ] : e . target ; if ( this . props . enforceFocus && this . containerElement != null && eventTarget instanceof Node && ! this . containerElement . contains ( eventTarget as HTMLElement ) ) { e . preventDefault ( ) ; e . stopImmediatePropagation ( ) ; this . bringFocusInsideOverlay ( ) ; } } ; private handleKeyDown = ( e ) => { const { canEscapeKeyClose , onClose } = this . props ; if ( e . which === Keys . ESCAPE && canEscapeKeyClose ) { onClose ?. ( e ) ; e . preventDefault ( ) ; } } ; private handleTransitionAddEnd = ( ) => { } ; }