import { ConnectionPositionPair , Overlay , OverlayRef , ScrollDispatcher } from '@angular/cdk/overlay' ; import { ComponentPortal } from '@angular/cdk/portal' ; import { ChangeDetectorRef , Directive , ElementRef , EventEmitter , Input , OnChanges , OnDestroy , OnInit , Output , Renderer2 , SimpleChanges , TemplateRef , ViewContainerRef } from '@angular/core' ; import { fromEvent , Subject } from 'rxjs' ; import { filter , takeUntil } from 'rxjs/operators' ; import { AnchorAlignment , AnchorPlacement } from '../../common/overlay/index' ; import { OverlayPlacementService } from '../../services/overlay-placement/index' ; import { TooltipComponent } from './tooltip.component' ; import { TooltipService } from './tooltip.service' ; @ Directive ( { selector : '[uxTooltip]' , exportAs : 'ux-tooltip' } ) export class TooltipDirective implements OnInit , OnChanges , OnDestroy { /** Contains the content of the tooltip or a TemplateRef for more detailed content */ @ Input ( 'uxTooltip' ) content : string | TemplateRef < any > ; /** Allow the tooltip to be conditionally disabled */ @ Input ( 'tooltipDisabled' ) disabled : boolean ; /** All the user to add a custom class to the tooltip */ @ Input ( 'tooltipClass' ) customClass : string = '' ; /** All the user to add a role to the tooltip - default is tooltip */ @ Input ( 'tooltipRole' ) role : string = 'tooltip' ; /** Provide the TemplateRef a context object */ @ Input ( 'tooltipContext' ) context : any = { } ; /** Delay the showing of the tooltip by a number of miliseconds */ @ Input ( 'tooltipDelay' ) delay : number = 0 ; /** Programmatically show and hide the tooltip */ @ Input ( ) isOpen : boolean = false ; /** Customize how the tooltip should be positioned relative to the element */ @ Input ( ) placement : AnchorPlacement = 'top' ; /** Specify the fallback placement if the specified placement cannot be used */ @ Input ( ) fallbackPlacement : AnchorPlacement ; /** Customize the position of the callout */ @ Input ( ) alignment : AnchorAlignment = 'center' ; /** Specify which events should show the tooltip */ @ Input ( ) showTriggers : OverlayTrigger [ ] = [ 'mouseenter' , 'focus' ] ; /** Specify which events should hide the tooltip */ @ Input ( ) hideTriggers : OverlayTrigger [ ] = [ 'mouseleave' , 'blur' ] ; /** Emits an event when the tooltip is shown */ @ Output ( ) shown = new EventEmitter < void > ( ) ; /** Emits a event when the tooltip is hidden */ @ Output ( ) hidden = new EventEmitter < void > ( ) ; /** Allow two way binding to track the visibility of the tooltip */ @ Output ( ) isOpenChange = new EventEmitter < boolean > ( ) ; /** Keep track of the tooltip visibility */ isVisible : boolean = false ; /** The name of the css class to use for the tooltip direction */ positionClass = this . placement ; /** Define the overlay class */ protected _overlayClass : string = 'ux-tooltip-pane' ; /** A reference to the CDK portal containing the overlay */ protected _portal : ComponentPortal < TooltipComponent > ; /** A reference to the overlay the tooltip will be inserted into */ protected _overlayRef : OverlayRef ; /** A reference to the instance of the tooltip component when created */ protected _instance : TooltipComponent ; /** This will emit when the directive is destroyed allowing us to unsubscribe all subscriptions automatically */ protected _onDestroy = new Subject < void > ( ) ; /** Store the timeout interval for cancelation */ private _showTimeoutId : number = null ; /** Internally store the type of this component - usual for distinctions when extending this class */ protected _type : string = 'tooltip' ; constructor ( protected _elementRef , protected _viewContainerRef , protected _overlay , protected _scrollDispatcher , private _changeDetectorRef , private _renderer , private _tooltipService , private _overlayFallback ) { } /** Set up the triggers and bind to the show/hide events to keep visibility in sync */ ngOnInit ( ) { fromEvent ( this . _elementRef . nativeElement , 'click' ) . pipe ( takeUntil ( this . _onDestroy ) ) . subscribe ( this . onClick . bind ( this ) ) ; fromEvent ( this . _elementRef . nativeElement , 'mouseenter' ) . pipe ( takeUntil ( this . _onDestroy ) ) . subscribe ( this . onMouseEnter . bind ( this ) ) ; fromEvent ( this . _elementRef . nativeElement , 'mouseleave' ) . pipe ( takeUntil ( this . _onDestroy ) ) . subscribe ( this . onMouseLeave . bind ( this ) ) ; fromEvent ( this . _elementRef . nativeElement , 'focus' ) . pipe ( takeUntil ( this . _onDestroy ) ) . subscribe ( this . onFocus . bind ( this ) ) ; fromEvent ( this . _elementRef . nativeElement , 'blur' ) . pipe ( takeUntil ( this . _onDestroy ) ) . subscribe ( this . onBlur . bind ( this ) ) ; this . _tooltipService . shown$ . pipe ( filter ( ( ) => this . _type === 'tooltip' ) , filter ( tooltip => tooltip !== this . _instance ) , takeUntil ( this . _onDestroy ) ) . subscribe ( this . hide . bind ( this ) ) ; if ( this . isOpen ) { this . show ( ) ; } } /**
     * We need to send input changes to the tooltip component
     * We can't use setters as they may trigger before tooltip initialised and can't resend once initialised
     **/ ngOnChanges ( changes ) { if ( changes . isOpen && ! changes . isOpen . firstChange && changes . isOpen . currentValue !== this . isVisible ) { changes . isOpen . currentValue ? this . show ( ) : this . hide ( ) ; } if ( changes . placement ) { this . destroyOverlay ( ) ; } if ( this . _instance && changes . placement ) { this . _instance . setPlacement ( changes . placement . currentValue ) ; } if ( this . _instance && changes . alignment ) { this . _instance . setAlignment ( changes . alignment . currentValue ) ; } if ( this . _instance && changes . content ) { this . _instance . setContent ( changes . content . currentValue ) ; } if ( this . _instance && changes . customClass ) { this . _instance . setClass ( changes . customClass . currentValue ) ; } if ( this . _instance && changes . context ) { this . _instance . setContext ( changes . context . currentValue ) ; } if ( this . _instance && changes . role ) { this . _instance . setContext ( changes . role . currentValue ) ; } } /** Ensure we clean up after ourselves */ ngOnDestroy ( ) { if ( this . _overlayRef ) { this . _overlayRef . dispose ( ) ; this . _instance = null ; } this . cancelTooltip ( ) ; this . _onDestroy . next ( ) ; this . _onDestroy . complete ( ) ; } /** Make the tooltip open */ show ( ) { if ( this . disabled || this . isVisible || this . _showTimeoutId || ! this . content ) { return ; } this . _showTimeoutId = window . setTimeout ( ( ) => { const overlayRef = this . createOverlay ( ) ; this . _portal = this . createPortal ( ) ; this . _instance = this . createInstance ( overlayRef ) ; this . _instance . reposition$ . pipe ( takeUntil ( this . _onDestroy ) ) . subscribe ( this . reposition . bind ( this ) ) ; this . isVisible = true ; this . reposition ( ) ; this . shown . emit ( ) ; this . isOpenChange . next ( true ) ; this . _showTimeoutId = null ; this . _tooltipService . shown$ . next ( this . _instance ) ; this . _changeDetectorRef . detectChanges ( ) ; } , this . delay ) ; } /** If a tooltip exists and is visible, hide it */ hide ( ) { if ( this . _showTimeoutId ) { window . clearTimeout ( this . _showTimeoutId ) ; this . _showTimeoutId = null ; return ; } if ( this . _overlayRef && this . _overlayRef . hasAttached ( ) ) { this . _overlayRef . detach ( ) ; } this . setAriaDescribedBy ( null ) ; this . _instance = null ; this . isVisible = false ; this . hidden . emit ( ) ; this . isOpenChange . next ( false ) ; this . _changeDetectorRef . detectChanges ( ) ; } /** Toggle the visibility of the tooltip */ toggle ( ) { this . isVisible ? this . hide ( ) : this . show ( ) ; } /** Recalculate the position of the popover */ reposition ( ) { if ( this . isVisible && this . _overlayRef ) { this . _overlayRef . updatePosition ( ) ; } } /** Create an instance from the overlay ref - allows overriding and additional logic here */ protected createInstance ( overlayRef ) { const instance = overlayRef . attach ( this . _portal ) . instance as TooltipComponent ; instance . setContent ( this . content ) ; instance . setPlacement ( this . placement ) ; instance . setAlignment ( this . alignment ) ; instance . setClass ( this . customClass ) ; instance . setContext ( this . context ) ; instance . setRole ( this . role ) ; this . setAriaDescribedBy ( instance . id ) ; return instance ; } /** Create the component portal - allows overriding to allow other portals eg. popovers */ protected createPortal ( ) { return this . _portal || new ComponentPortal ( TooltipComponent , this . _viewContainerRef ) ; } /** Create the overlay and set up the scroll handling behavior */ private createOverlay ( ) { if ( this . _overlayRef ) { return this . _overlayRef ; } const strategy = this . _overlay . position ( ) . flexibleConnectedTo ( this . _elementRef ) . withFlexibleDimensions ( false ) . withPush ( false ) ; this . _overlayRef = this . _overlay . create ( { positionStrategy : strategy , panelClass : this . _overlayClass , scrollStrategy : this . _overlay . scrollStrategies . reposition ( { scrollThrottle : 0 } ) , hasBackdrop : false } ) ; this . _overlayFallback . updatePosition ( this . _overlayRef , this . placement , this . alignment , this . fallbackPlacement ) ; strategy . positionChanges . subscribe ( positionChange => { const currentPosition = positionChange . connectionPair ; this . getPositionClass ( currentPosition ) ; } ) ; return this . _overlayRef ; } /** Recreate the overlay ref using the updated origin and overlay positions */ private destroyOverlay ( ) { if ( this . _overlayRef && this . _overlayRef . hasAttached ( ) ) { this . _overlayRef . detach ( ) ; } if ( this . _overlayRef ) { this . _overlayRef . dispose ( ) ; this . _overlayRef = null ; } this . isVisible = false ; } private getPositionClass ( currentPosition ) { let positionClass = this . placement ; if ( currentPosition . originX === 'center' ) { positionClass = currentPosition . originY === 'top' ? 'top' : 'bottom' ; } else if ( currentPosition . originY === 'center' ) { positionClass = currentPosition . originX === 'start' ? 'left' : 'right' ; } this . _instance . positionClass = positionClass ; this . _changeDetectorRef . detectChanges ( ) ; } /**
     * Simple utility method - because IE doesn't support array.includes
     * And it isn't included in the core-js/es6 polyfills which are the
     * only ones required by Angular and guaranteed to be there
     **/ protected includes < T > ( array , value ) { return Array . isArray ( array ) && ! ! array . find ( item => item === value ) ; } /** Handle the click event - show or hide accordingly */ protected onClick ( _ ) { if ( ! this . isVisible && this . includes ( this . showTriggers , 'click' ) && this . _showTimeoutId === null ) { return this . show ( ) ; } if ( this . isVisible && this . includes ( this . hideTriggers , 'click' ) && this . _showTimeoutId === null ) { return this . hide ( ) ; } if ( ! this . isVisible && this . includes ( this . hideTriggers , 'click' ) && this . _showTimeoutId !== null ) { return this . cancelTooltip ( ) ; } } /** Handle the mouse enter event - show or hide accordingly */ protected onMouseEnter ( _ ) { if ( this . isVisible || ! this . includes ( this . showTriggers , 'mouseenter' ) ) { return ; } this . show ( ) ; } /** Handle the mouse leave event - show or hide accordingly */ protected onMouseLeave ( _ ) { if ( ! this . isVisible && this . includes ( this . hideTriggers , 'mouseleave' ) && this . _showTimeoutId !== null ) { return this . cancelTooltip ( ) ; } if ( ! this . isVisible || ! this . includes ( this . hideTriggers , 'mouseleave' ) ) { return ; } this . hide ( ) ; } /** Handle the focus event - show or hide accordingly */ protected onFocus ( _ ) { if ( this . isVisible || ! this . includes ( this . showTriggers , 'focus' ) ) { return ; } this . show ( ) ; } /** Handle the blur event - show or hide accordingly */ protected onBlur ( _ ) { if ( ! this . isVisible && this . includes ( this . hideTriggers , 'blur' ) && this . _showTimeoutId !== null ) { return this . cancelTooltip ( ) ; } if ( ! this . isVisible || ! this . includes ( this . hideTriggers , 'blur' ) ) { return ; } this . hide ( ) ; } /** Programmatically update the aria-describedby property */ protected setAriaDescribedBy ( id ) { if ( id === null ) { this . _renderer . removeAttribute ( this . _elementRef . nativeElement , 'aria-describedby' ) ; } else { this . _renderer . setAttribute ( this . _elementRef . nativeElement , 'aria-describedby' , id ) ; } } /** Cancel any pending tooltip (waiting on delay ellapsing) */ private cancelTooltip ( ) { if ( this . _showTimeoutId !== null ) { window . clearTimeout ( this . _showTimeoutId ) ; this . _showTimeoutId = null ; } } } export type OverlayTrigger = 'click' | 'clickoutside' | 'escape' | 'mouseenter' | 'focus' | 'mouseleave' | 'blur' ;