import { CSSResultArray , html , PropertyValues , SpectrumElement , TemplateResult , } from '@spectrum-web-components/base' ; import { ifDefined } from '@spectrum-web-components/base/src/directives.js' ; import { property } from '@spectrum-web-components/base/src/decorators.js' ; import { reparentChildren } from '@spectrum-web-components/shared' ; import { firstFocusableIn } from '@spectrum-web-components/shared/src/first-focusable-in.js' ; import { Color , Scale } from '@spectrum-web-components/theme' ; import styles from './active-overlay.css.js' ; import { OverlayOpenCloseDetail , OverlayOpenDetail , Placement , TriggerInteractions , } from './overlay-types.js' ; import { applyMaxSize , createPopper , Instance , maxSize } from './popper.js' ; import { VirtualTrigger } from './VirtualTrigger.js' ; export interface PositionResult { arrowOffsetLeft : number ; arrowOffsetTop : number ; maxHeight : number ; placement : string ; positionLeft : number ; positionTop : number ; } type OverlayStateType = 'idle' | 'active' | 'visible' | 'hiding' | 'dispose' | 'disposed' ; type ContentAnimation = 'sp-overlay-fade-in' | 'sp-overlay-fade-out' ; const stateMachine = { initial : 'idle' , states : { idle : { on : { active : 'active' , } , } , active : { on : { visible : 'visible' , hiding : 'hiding' , idle : 'idle' , } , } , visible : { on : { hiding : 'hiding' , idle : 'idle' , } , } , hiding : { on : { dispose : 'dispose' , } , } , dispose : { on : { disposed : 'disposed' , } , } , disposed : { on : { } , } , } , } ; const stateTransition = ( state ? , event ? ) : OverlayStateType => { if ( ! state ) return stateMachine . initial ; if ( ! event ) return state ; return stateMachine . states [ state ] . on [ event ] || state ; } ; const parentOverlayOf = ( el ) : ActiveOverlay | null => { const closestOverlay = el . closest ( 'active-overlay' ) ; if ( closestOverlay ) { return closestOverlay ; } const rootNode = el . getRootNode ( ) as ShadowRoot ; if ( rootNode . host ) { return parentOverlayOf ( rootNode . host ) ; } return null ; } ; element slot export class ActiveOverlay extends SpectrumElement { public overlayContent ! : HTMLElement ; public overlayContentTip ? : HTMLElement ; public trigger ! : HTMLElement ; public virtualTrigger ? : VirtualTrigger ; private popper ? : Instance ; @ property ( ) public _state = stateTransition ( ) ; public get state ( ) : OverlayStateType { return this . _state ; } public set state ( state ) { const nextState = stateTransition ( this . state , state ) ; if ( nextState === this . state ) { return ; } this . _state = nextState ; if ( this . state === 'active' || this . state === 'visible' || this . state === 'hiding' ) { this . setAttribute ( 'state' , this . state ) ; } else { this . removeAttribute ( 'state' ) ; } } @ property ( { reflect : true , type : Boolean } ) public animating = false ; @ property ( { reflect : true } ) public placement ? : Placement ; @ property ( { attribute : false } ) public theme : { color ? : Color ; scale ? : Scale ; lang ? : string ; } = { } ; @ property ( { attribute : false } ) public receivesFocus ? : 'auto' ; public tabbingAway = false ; private originalPlacement ? : Placement ; private restoreContent ? : ( ) => Element [ ] ; prop @ property ( { attribute : 'data-popper-placement' } ) public dataPopperPlacement ? : Placement ; public focus ( ) { const firstFocusable = firstFocusableIn ( this ) ; if ( firstFocusable ) { firstFocusable . focus ( ) ; } else { super . focus ( ) ; } this . removeAttribute ( 'tabindex' ) ; } private get hasTheme ( ) : boolean { return ! ! this . theme . color || ! ! this . theme . scale || ! ! this . theme . lang ; } public offset = 6 ; public interaction : TriggerInteractions = 'hover' ; private positionAnimationFrame = 0 ; private timeout ? : number ; public static get styles ( ) : CSSResultArray { return [ styles ] ; } public constructor ( ) { super ( ) ; this . stealOverlayContentPromise = new Promise ( ( res ) => ( this . stealOverlayContentResolver = res ) ) ; } private _modalRoot ? : ActiveOverlay ; public get hasModalRoot ( ) : boolean { return ! ! this . _modalRoot ; } public feature ( ) { this . tabIndex = - 1 ; const parentOverlay = parentOverlayOf ( this . trigger ) ; const parentIsModal = parentOverlay && parentOverlay . slot === 'open' ; if ( this . interaction === 'modal' || parentIsModal || this . _modalRoot ) { this . slot = 'open' ; if ( this . _modalRoot ) { parentOverlay ?. feature ( ) ; } } } public obscure ( nextOverlayInteraction ) { if ( this . slot && nextOverlayInteraction === 'modal' ) { this . removeAttribute ( 'slot' ) ; if ( this . interaction !== 'modal' ) { const parentOverlay = parentOverlayOf ( this . trigger ) ; this . _modalRoot = parentOverlay ?. obscure ( nextOverlayInteraction ) ; return this . _modalRoot ; } return this ; } return undefined ; } public firstUpdated ( changedProperties ) { super . firstUpdated ( changedProperties ) ; if ( ! this . overlayContent ) return ; this . stealOverlayContent ( this . overlayContent ) ; if ( ! this . overlayContent || ! this . trigger ) return ; if ( this . placement && this . placement !== 'none' ) { this . popper = createPopper ( this . virtualTrigger || this . trigger , this , { placement : this . placement , modifiers : [ maxSize , applyMaxSize , { name : 'arrow' , options : { element : this . overlayContentTip , } , } , { name : 'offset' , options : { offset : [ 0 , this . offset ] , } , } , ] , } ) ; } this . state = 'active' ; document . addEventListener ( 'sp-update-overlays' , ( ) => { this . updateOverlayPosition ( ) ; this . state = 'visible' ; } ) ; this . feature ( ) ; this . updateOverlayPosition ( ) . then ( ( ) => this . applyContentAnimation ( 'sp-overlay-fade-in' ) ) . then ( ( ) => { if ( this . receivesFocus ) { this . focus ( ) ; } this . trigger . dispatchEvent ( new CustomEvent < OverlayOpenCloseDetail > ( 'sp-opened' , { bubbles : true , composed : true , cancelable : true , detail : { interaction : this . interaction , } , } ) ) ; } ) ; } private updateOverlayPopperPlacement ( ) { const activeWithContent = this . state === 'active' && this . overlayContent ; if ( ! activeWithContent ) return ; if ( this . dataPopperPlacement ) { this . overlayContent . setAttribute ( 'placement' , this . dataPopperPlacement ) ; } else if ( this . originalPlacement ) { this . overlayContent . setAttribute ( 'placement' , this . originalPlacement ) ; } else { this . overlayContent . removeAttribute ( 'placement' ) ; } } public updated ( changedProperties ) { if ( changedProperties . has ( 'dataPopperPlacement' ) ) { this . updateOverlayPopperPlacement ( ) ; } } private open ( openDetail ) { this . extractDetail ( openDetail ) ; } private extractDetail ( detail ) { this . overlayContent = detail . content ; this . overlayContentTip = detail . contentTip ; this . trigger = detail . trigger ; this . virtualTrigger = detail . virtualTrigger ; this . placement = detail . placement ; this . offset = detail . offset ; this . interaction = detail . interaction ; this . theme = detail . theme ; this . receivesFocus = detail . receivesFocus ; } public dispose ( ) { if ( this . state !== 'dispose' ) return ; if ( this . timeout ) { clearTimeout ( this . timeout ) ; delete this . timeout ; } if ( this . popper ) { this . popper . destroy ( ) ; this . popper = undefined ; } this . trigger . removeEventListener ( 'keydown' , this . handleInlineTriggerKeydown ) ; this . returnOverlayContent ( ) ; this . state = 'disposed' ; if ( this . willNotifyClosed ) { this . overlayContent . dispatchEvent ( new Event ( 'sp-overlay-closed' ) ) ; this . willNotifyClosed = false ; } } private stealOverlayContent ( element ) { this . originalPlacement = element . getAttribute ( 'placement' ) as Placement ; this . restoreContent = reparentChildren ( [ element ] , this , ( el ) => { const slotName = el . slot ; el . removeAttribute ( 'slot' ) ; return ( el ) => { el . slot = slotName ; } ; } ) ; this . stealOverlayContentResolver ( ) ; } private willNotifyClosed = false ; private returnOverlayContent ( ) { if ( ! this . restoreContent ) return ; const [ element ] = this . restoreContent ( ) ; this . restoreContent = undefined ; this . willNotifyClosed = true ; if ( this . originalPlacement ) { element . setAttribute ( 'placement' , this . originalPlacement ) ; delete this . originalPlacement ; } } public async updateOverlayPosition ( ) { await ( document . fonts ? document . fonts . ready : Promise . resolve ( ) ) ; if ( this . popper ) { await this . popper . update ( ) ; } } public async hide ( animated = true ) { this . state = 'hiding' ; if ( animated ) { await this . applyContentAnimation ( 'sp-overlay-fade-out' ) ; } this . state = 'dispose' ; } private schedulePositionUpdate ( ) { cancelAnimationFrame ( this . positionAnimationFrame ) ; this . positionAnimationFrame = requestAnimationFrame ( ( ) => this . updateOverlayPosition ( ) ) ; } private onSlotChange ( ) { this . schedulePositionUpdate ( ) ; } public handleInlineTriggerKeydown = ( event ) : void => { const { code , shiftKey } = event ; if ( code !== 'Tab' ) return ; if ( shiftKey ) { this . tabbingAway = true ; this . dispatchEvent ( new Event ( 'close' ) ) ; return ; } event . stopPropagation ( ) ; event . preventDefault ( ) ; this . focus ( ) ; } ; public connectedCallback ( ) { super . connectedCallback ( ) ; this . schedulePositionUpdate ( ) ; } public applyContentAnimation ( animation ) { return new Promise ( ( resolve ) : void => { const contents = this . shadowRoot . querySelector ( '#contents' ) as HTMLElement ; const doneHandler = ( event ) : void => { if ( animation !== event . animationName ) return ; contents . removeEventListener ( 'animationend' , doneHandler ) ; contents . removeEventListener ( 'animationcancel' , doneHandler ) ; this . animating = false ; resolve ( event . type === 'animationcancel' ) ; } ; contents . addEventListener ( 'animationend' , doneHandler ) ; contents . addEventListener ( 'animationcancel' , doneHandler ) ; contents . style . animationName = animation ; this . animating = true ; } ) ; } public renderTheme ( content ) { const { color , scale , lang } = this . theme ; return html `
            <sp-theme
                color=${ ifDefined ( color ) }
                scale=${ ifDefined ( scale ) }
                lang=${ ifDefined ( lang ) }
                part="theme"
            >
                ${ content }
            </sp-theme>
        ` ; } public render ( ) { const content = html `
            <div id="contents">
                <slot @slotchange=${ this . onSlotChange }></slot>
            </div>
        ` ; return this . hasTheme ? this . renderTheme ( content ) : content ; } public static create ( details ) { const overlay = new ActiveOverlay ( ) ; if ( details . content ) { overlay . open ( details ) ; } return overlay ; } private stealOverlayContentPromise = Promise . resolve ( ) ; private stealOverlayContentResolver ! : ( ) => void ; protected async getUpdateComplete ( ) { const complete = ( await super . getUpdateComplete ( ) ) as boolean ; await this . stealOverlayContentPromise ; return complete ; } }