import { ExportNs } from '../../esl-utils/environment/export-ns' ; import { ESC , SYSTEM_KEYS } from '../../esl-utils/dom/keys' ; import { CSSClassUtils } from '../../esl-utils/dom/class' ; import { bind } from '../../esl-utils/decorators/bind' ; import { defined , copyDefinedKeys } from '../../esl-utils/misc/object' ; import { sequentialUID } from '../../esl-utils/misc/uid' ; import { DeviceDetector } from '../../esl-utils/environment/device-detector' ; import { DelayedTask } from '../../esl-utils/async/delayed-task' ; import { ESLBaseElement , attr , jsonAttr , boolAttr } from '../../esl-base-element/core' ; /** Default Toggleable action params type definition */ export interface ToggleableActionParams { /** Initiator string identifier */ initiator ? : string ; /** Delay timeout for both show and hide actions */ delay ? : number ; /** Show delay timeout */ showDelay ? : number ; /** Hide delay timeout */ hideDelay ? : number ; /** Force action independently of current state of the Toggleable */ force ? : boolean ; /** Do not throw events on action */ silent ? : boolean ; /** Activate hover tracking to hide Toggleable */ trackHover ? : boolean ; /** Element activator of the action */ activator ? : HTMLElement | null ; /** Event that initiates the action */ event ? : Event ; /** Custom user data */ [ key ] : any ; } const activators = new WeakMap ( ) ; author @ ExportNs ( 'Toggleable' ) export class ESLToggleable extends ESLBaseElement { static is = 'esl-toggleable' ; static get observedAttributes ( ) : string [ ] { return [ 'open' , 'group' ] ; } /** CSS class to add on the body element */ @ attr ( ) public bodyClass : string ; /** CSS class to add when the Toggleable is active */ @ attr ( { defaultValue : 'open' } ) public activeClass : string ; /** Toggleable group meta information to organize groups */ @ attr ( { name : 'group' } ) public groupName : string ; /** Selector to mark inner close triggers */ @ attr ( { name : 'close-on' } ) public closeTrigger : string ; /** Disallow automatic id creation when it's empty */ @ boolAttr ( ) public noAutoId : boolean ; /** Close the Toggleable on ESC keyboard event */ @ boolAttr ( ) public closeOnEsc : boolean ; /** Close the Toggleable on a click/tap outside */ @ boolAttr ( ) public closeOnOutsideAction : boolean ; /** Initial params to pass to show/hide action on the start */ @ jsonAttr < ToggleableActionParams > ( { defaultValue : { force : true , initiator : 'init' } } ) public initialParams : ToggleableActionParams ; /** Default params to merge into passed action params */ @ jsonAttr < ToggleableActionParams > ( { defaultValue : { } } ) public defaultParams : ToggleableActionParams ; /** Hover params to pass from track hover listener */ @ jsonAttr < ToggleableActionParams > ( { defaultValue : { } } ) public trackHoverParams : ToggleableActionParams ; /** Marker of initially opened toggleable instance */ public initiallyOpened : boolean ; /** Inner state */ private _open : boolean = false ; /** Inner show/hide task manager instance */ protected _task : DelayedTask = new DelayedTask ( ) ; /** Marker for current hover listener state */ protected _trackHover : boolean = false ; /** Delay for track hover listeners actions */ protected _trackHoverDelay : number | undefined ; protected connectedCallback ( ) { super . connectedCallback ( ) ; if ( ! this . id && ! this . noAutoId ) { const tag = ( this . constructor as typeof ESLToggleable ) . is ; this . id = sequentialUID ( tag , tag + '-' ) ; } this . initiallyOpened = this . hasAttribute ( 'open' ) ; this . bindEvents ( ) ; this . setInitialState ( ) ; } protected disconnectedCallback ( ) { super . disconnectedCallback ( ) ; this . unbindEvents ( ) ; activators . delete ( this ) ; } protected attributeChangedCallback ( attrName , oldVal , newVal ) { if ( ! this . connected || newVal === oldVal ) return ; switch ( attrName ) { case 'open' : if ( this . open === this . hasAttribute ( 'open' ) ) return ; this . toggle ( this . open , { initiator : 'attribute' , showDelay : 0 , hideDelay : 0 } ) ; break ; case 'group' : this . $$fire ( 'change:group' , { detail : { oldGroupName : oldVal , newGroupName : newVal } } ) ; break ; } } /** Set initial state of the Toggleable */ protected setInitialState ( ) { if ( this . initialParams ) { this . toggle ( this . initiallyOpened , this . initialParams ) ; } } protected bindEvents ( ) { this . addEventListener ( 'click' , this . _onClick ) ; this . addEventListener ( 'keydown' , this . _onKeyboardEvent ) ; this . addEventListener ( 'esl:show:request' , this . _onShowRequest ) ; } protected unbindEvents ( ) { this . removeEventListener ( 'click' , this . _onClick ) ; this . removeEventListener ( 'keydown' , this . _onKeyboardEvent ) ; this . removeEventListener ( 'esl:show:request' , this . _onShowRequest ) ; this . bindOutsideEventTracking ( false ) ; this . bindHoverStateTracking ( false ) ; } /** Bind outside action event listeners */ protected bindOutsideEventTracking ( track ) { document . body . removeEventListener ( 'keydown' , this . _onOutsideAction , true ) ; document . body . removeEventListener ( 'mouseup' , this . _onOutsideAction , true ) ; document . body . removeEventListener ( 'touchend' , this . _onOutsideAction , true ) ; if ( track ) { document . body . addEventListener ( 'keydown' , this . _onOutsideAction , true ) ; document . body . addEventListener ( 'mouseup' , this . _onOutsideAction , true ) ; document . body . addEventListener ( 'touchend' , this . _onOutsideAction , true ) ; } } /** Bind hover events listeners for the Toggleable itself */ protected bindHoverStateTracking ( track , hideDelay ? ) { if ( ! DeviceDetector . hasHover ) return ; this . _trackHoverDelay = track && hideDelay !== undefined ? + hideDelay : undefined ; if ( this . _trackHover === track ) return ; this . _trackHover = track ; this . removeEventListener ( 'mouseenter' , this . _onMouseEnter ) ; this . removeEventListener ( 'mouseleave' , this . _onMouseLeave ) ; if ( this . _trackHover ) { this . addEventListener ( 'mouseenter' , this . _onMouseEnter ) ; this . addEventListener ( 'mouseleave' , this . _onMouseLeave ) ; } } /** Function to merge the result action params */ protected mergeDefaultParams ( params ? ) { return Object . assign ( { } , this . defaultParams , copyDefinedKeys ( params ) ) ; } /** Toggle the element state */ public toggle ( state = ! this . open , params ? ) { return state ? this . show ( params ) : this . hide ( params ) ; } /** Change the element state to active */ public show ( params ? ) { params = this . mergeDefaultParams ( params ) ; this . _task . put ( this . showTask . bind ( this , params ) , defined ( params . showDelay , params . delay ) ) ; this . bindOutsideEventTracking ( this . closeOnOutsideAction ) ; this . bindHoverStateTracking ( ! ! params . trackHover , defined ( params . hideDelay , params . delay ) ) ; return this ; } /** Change the element state to inactive */ public hide ( params ? ) { params = this . mergeDefaultParams ( params ) ; this . _task . put ( this . hideTask . bind ( this , params ) , defined ( params . hideDelay , params . delay ) ) ; this . bindOutsideEventTracking ( false ) ; this . bindHoverStateTracking ( ! ! params . trackHover , defined ( params . hideDelay , params . delay ) ) ; return this ; } /** Actual show task to execute by toggleable task manger ( DelayedTask out of the box)  protected showTask ( params ) { if ( ! params . force && this . open ) return ; if ( ! params . silent && ! this . $$fire ( 'before:show' , { detail : { params } } ) ) return ; this . activator = params . activator ; this . open = true ; this . onShow ( params ) ; if ( ! params . silent ) this . $$fire ( 'show' , { detail : { params } , cancelable : false } ) ; } /** Actual hide task to execute by toggleable task manger ( DelayedTask out of the box)  protected hideTask ( params ) { if ( ! params . force && ! this . open ) return ; if ( ! params . silent && ! this . $$fire ( 'before:hide' , { detail : { params } } ) ) return ; this . open = false ; this . onHide ( params ) ; this . bindOutsideEventTracking ( false ) ; if ( ! params . silent ) this . $$fire ( 'hide' , { detail : { params } , cancelable : false } ) ; } /**
     * Actions to execute on show toggleable.
     * Inner state and 'open' attribute are not affected and updated before `onShow` execution.
     * Adds CSS classes, update a11y and fire esl:refresh event by default.
     */ protected onShow ( params ) { CSSClassUtils . add ( this , this . activeClass ) ; CSSClassUtils . add ( document . body , this . bodyClass , this ) ; this . updateA11y ( ) ; this . $$fire ( 'esl:refresh' ) ; } /**
     * Actions to execute on hide toggleable.
     * Inner state and 'open' attribute are not affected and updated before `onShow` execution.
     * Removes CSS classes and update a11y by default.
     */ protected onHide ( params ) { CSSClassUtils . remove ( this , this . activeClass ) ; CSSClassUtils . remove ( document . body , this . bodyClass , this ) ; this . updateA11y ( ) ; } /** Active state marker */ public get open ( ) : boolean { return this . _open ; } public set open ( value ) { this . toggleAttribute ( 'open' , this . _open = value ) ; } /** Last component that has activated the element. Uses  ToggleableActionParams . activator public get activator ( ) : HTMLElement | null | undefined { return activators . get ( this ) ; } public set activator ( el ) { el ? activators . set ( this , el ) : activators . delete ( this ) ; } /** Returns the element to apply a11y attributes */ protected get $a11yTarget ( ) : HTMLElement | null { const target = this . getAttribute ( 'a11y-target' ) ; if ( target === 'none' ) return null ; return target ? this . querySelector ( target ) : this ; } /** Called on show and on hide actions to update a11y state accordingly */ protected updateA11y ( ) { const targetEl = this . $a11yTarget ; if ( ! targetEl ) return ; targetEl . setAttribute ( 'aria-hidden' , String ( ! this . _open ) ) ; } returns public isOutsideAction ( e ) { const target = e . target as HTMLElement ; if ( this . contains ( target ) ) return false ; if ( this . activator && this . activator . contains ( target ) ) return false ; return ! ( e instanceof KeyboardEvent && SYSTEM_KEYS . includes ( e . key ) ) ; } @ bind protected _onClick ( e ) { const target = e . target as HTMLElement ; if ( this . closeTrigger && target . closest ( this . closeTrigger ) ) { this . hide ( { initiator : 'close' , activator : target , event : e } ) ; } } @ bind protected _onOutsideAction ( e ) { if ( ! this . isOutsideAction ( e ) ) return ; this . hide ( { initiator : 'outsideaction' , hideDelay : 0 , event : e } ) ; } @ bind protected _onKeyboardEvent ( e ) { if ( this . closeOnEsc && e . key === ESC ) { this . hide ( { initiator : 'keyboard' , event : e } ) ; } } @ bind protected _onMouseEnter ( e ) { const hideDelay = this . _trackHoverDelay ; const baseParams = { initiator : 'mouseenter' , trackHover : true , activator : this . activator , event : e , hideDelay } ; this . show ( Object . assign ( baseParams , this . trackHoverParams ) ) ; } @ bind protected _onMouseLeave ( e ) { const hideDelay = this . _trackHoverDelay ; const baseParams = { initiator : 'mouseleave' , trackHover : true , activator : this . activator , event : e , hideDelay } ; this . hide ( Object . assign ( baseParams , this . trackHoverParams ) ) ; } /** Actions to execute on show request */ @ bind protected _onShowRequest ( ) { this . show ( ) ; } } declare global { export interface ESLLibrary { Toggleable : typeof ESLToggleable ; } export interface HTMLElementTagNameMap { 'esl-toggleable' : ESLToggleable ; } }