import { FocusOrigin } from '@angular/cdk/a11y' ; import { BooleanInput , coerceBooleanProperty } from '@angular/cdk/coercion' ; import { DOWN_ARROW , LEFT_ARROW , RIGHT_ARROW , UP_ARROW } from '@angular/cdk/keycodes' ; import { FlexibleConnectedPositionStrategy , Overlay , OverlayRef } from '@angular/cdk/overlay' ; import { TemplatePortal } from '@angular/cdk/portal' ; import { ContentChildren , Directive , ElementRef , HostListener , Input , OnDestroy , OnInit , Optional , QueryList , Self , ViewContainerRef } from '@angular/core' ; import { combineLatest , fromEvent , merge , Observable , of , Subject , timer } from 'rxjs' ; import { debounceTime , filter , take , takeUntil } from 'rxjs/operators' ; import { AnchorPlacement } from '../../../common/overlay/anchor-placement' ; import { FocusIndicator , FocusIndicatorOriginService , FocusIndicatorService } from '../../../directives/accessibility/index' ; import { OverlayPlacementService } from '../../../services/overlay-placement/index' ; import { MenuItemComponent } from '../menu-item/menu-item.component' ; import { MenuComponent } from '../menu/menu.component' ; @ Directive ( { selector : '[uxMenuTriggerFor]' , exportAs : 'ux-menu-trigger' , host : { '[attr.disabled]' : 'disabled ? true : null' , '[attr.aria-haspopup]' : '!!menu' , '[attr.aria-expanded]' : 'menu?.isMenuOpen' , '[attr.aria-controls]' : 'ariaControls' } } ) export class MenuTriggerDirective implements OnInit , OnDestroy { /** Access the menu we should show */ @ Input ( 'uxMenuTriggerFor' ) menu : MenuComponent ; /** Determine if we should disable the trigger */ @ Input ( ) disabled : boolean = false ; /** Optionally specify the menu's parent element */ @ Input ( 'uxMenuParent' ) parent : ElementRef ; /** Determine if the menu should close when it loses focus */ @ Input ( ) set closeOnBlur ( value ) { this . _closeOnBlur = coerceBooleanProperty ( value ) ; } get closeOnBlur ( ) : boolean { return this . _closeOnBlur ; } /** Reference to the portal based off the MenuCompont templateRef */ private _portal : TemplatePortal ; /** Store the reference to the overlay */ private _overlayRef ? : OverlayRef ; /** Get the aria controls for accessibility */ get ariaControls ( ) : string | null { return this . menu ?. isMenuOpen ? this . menu ?. innerId : null ; } /** Store the instance of the focus indicator */ private _focusIndicator : FocusIndicator ; /** Automatically unsubscribe on directive destroy */ private readonly _onDestroy$ = new Subject < void > ( ) ; /** Reference to the menu should close when it loses focus */ private _closeOnBlur : boolean = false ; /** Determine if this triggers a submenu */ private get _isSubmenuTrigger ( ) : boolean { return ! ! this . _parentMenu ; } /** Determine if this is the root trigger */ private get _isRootTrigger ( ) : boolean { return ! this . _isSubmenuTrigger ; } private readonly _debounceTime : number = 50 ; @ ContentChildren ( MenuTriggerDirective ) menuTriggers : QueryList < MenuTriggerDirective > ; /** If this is a submenu we want to know when the mouse leaves the items or parent item */ private get _menuShouldClose ( ) : Observable < boolean [ ] > { if ( ! this . _isSubmenuTrigger ) { return of ( ) ; } return combineLatest ( [ this . menu . _isHovering$ , this . menu . _isFocused$ , this . _menuItem . isHovered$ , this . menu . _isExpanded , this . _menuItem . isFocused$ ] ) . pipe ( debounceTime ( 50 ) , filter ( ( [ isHovered , isFocused , isItemHovered , isExpanded , isItemFocused ] ) => ! isHovered && ! isFocused && ! isItemHovered && ! isExpanded && ! isItemFocused ) ) ; } constructor ( private readonly _overlay , private readonly _elementRef , private readonly _viewContainerRef , private readonly _focusOrigin , private readonly _focusIndicatorService , private readonly _overlayPlacement , @ Optional ( ) private readonly _parentMenu , @ Optional ( ) @ Self ( ) private readonly _menuItem ) { } ngOnInit ( ) { this . _focusIndicator = this . _focusIndicatorService . monitor ( this . _elementRef . nativeElement ) ; if ( this . _isSubmenuTrigger ) { this . menu . _isSubMenu = true ; this . menu . placement = this . getSubMenuPlacement ( this . menu . placement ) ; } this . menu . opened . pipe ( takeUntil ( this . _onDestroy$ ) ) . subscribe ( ( ) => this . menuDidOpen ( ) ) ; this . menu . _closeAll$ . pipe ( takeUntil ( this . _onDestroy$ ) ) . subscribe ( origin => this . closeMenu ( origin , true ) ) ; this . menu . _onKeydown$ . pipe ( takeUntil ( this . _onDestroy$ ) ) . subscribe ( event => this . onMenuKeydown ( event ) ) ; combineLatest ( [ this . menu . _placement$ , this . menu . _alignment$ ] ) . pipe ( takeUntil ( this . _onDestroy$ ) ) . subscribe ( ( ) => { if ( this . _isSubmenuTrigger ) { this . menu . placement = this . getSubMenuPlacement ( this . menu . placement ) ; } this . getOverlay ( true ) ; } ) ; } ngOnDestroy ( ) { this . destroyMenu ( ) ; this . _onDestroy$ . next ( ) ; this . _onDestroy$ . complete ( ) ; } /** Open the menu */ openMenu ( ) { if ( this . menu . isMenuOpen || this . disabled ) { return ; } const overlayRef = this . getOverlay ( ) ; const portal = this . getPortal ( ) ; if ( overlayRef . hasAttached ( ) ) { return ; } overlayRef . attach ( portal ) ; this . menu . _setMenuOpen ( true ) ; if ( this . _menuItem ) { timer ( this . _debounceTime ) . pipe ( takeUntil ( this . _onDestroy$ ) ) . subscribe ( ( ) => this . _menuItem . isExpanded$ . next ( true ) ) ; } this . menu . _menuItemClick . pipe ( take ( 1 ) , takeUntil ( this . _onDestroy$ ) ) . subscribe ( origin => this . closeMenu ( origin , true ) ) ; this . didMenuClose ( ) . pipe ( take ( 1 ) , takeUntil ( this . _onDestroy$ ) ) . subscribe ( ( ) => this . closeMenu ( ) ) ; if ( this . _isSubmenuTrigger ) { this . menu . closing . pipe ( take ( 1 ) , takeUntil ( this . _onDestroy$ ) ) . subscribe ( ( ) => this . destroyMenu ( ) ) ; } else { this . menu . closed . pipe ( take ( 1 ) , takeUntil ( this . _onDestroy$ ) ) . subscribe ( ( ) => this . destroyMenu ( ) ) ; } if ( this . closeOnBlur ) { fromEvent ( this . _overlayRef . hostElement , 'focusout' ) . pipe ( takeUntil ( this . _onDestroy$ ) ) . subscribe ( ( ) => this . closeOnFocusout ( ) ) ; } } /** Close a menu or submenu */ closeMenu ( origin ? , closeParents = false ) { this . menu . _setMenuOpen ( false ) ; if ( this . _menuItem ) { this . _menuItem . isExpanded$ . next ( false ) ; } if ( closeParents && this . _parentMenu ) { this . _parentMenu . _closeAll$ . next ( origin ) ; } if ( this . _isRootTrigger ) { this . _focusIndicator . focus ( origin ) ; } return this . menu . closed ; } /** Toggle the open state of a menu */ @ HostListener ( 'click' , [ '$event' ] ) @ HostListener ( 'keydown.enter' , [ '$event' ] ) toggleMenu ( event ? ) { if ( this . _isSubmenuTrigger ) { return ; } if ( ! this . menu . _isAnimating ) { const origin = event instanceof KeyboardEvent ? 'keyboard' : 'mouse' ; this . _focusOrigin . setOrigin ( event ? origin : 'program' ) ; this . menu . isMenuOpen ? this . closeMenu ( origin , true ) : this . openMenu ( ) ; } if ( event ) { event . preventDefault ( ) ; } } /** Submenus should be opened by hovering on the menu item */ @ HostListener ( 'mouseenter' ) _onMouseEnter ( ) { if ( this . _isSubmenuTrigger && ! this . _parentMenu . _isAnimating ) { this . openMenu ( ) ; } } @ HostListener ( 'mousemove' ) _onMouseMove ( ) { if ( this . _isSubmenuTrigger && ! this . _parentMenu . _isAnimating ) { setTimeout ( ( ) => { this . openMenu ( ) ; } , this . _debounceTime ) ; } } /** Pressing the escape key should close all menus */ @ HostListener ( 'document:keydown.escape' ) _onEscape ( ) { if ( this . menu . isMenuOpen ) { this . closeMenu ( ) ; if ( this . _isRootTrigger ) { this . _focusIndicator . focus ( 'keyboard' ) ; } } } /** Handle keyboard events for opening submenus */ @ HostListener ( 'keydown' , [ '$event' ] ) _onKeydown ( event ) { if ( this . menu . placement === 'right' && event . keyCode === RIGHT_ARROW || this . menu . placement === 'left' && event . keyCode === LEFT_ARROW || this . menu . placement === 'top' && event . keyCode === UP_ARROW || this . menu . placement === 'bottom' && event . keyCode === DOWN_ARROW ) { this . _focusOrigin . setOrigin ( 'keyboard' ) ; if ( this . menu . isMenuOpen ) { this . menu . _keyManager . setFocusOrigin ( 'keyboard' ) . setFirstItemActive ( ) ; } else { this . openMenu ( ) ; } event . preventDefault ( ) ; } } /** Blurring the trigger should check if the menu has focus and close it if not */ @ HostListener ( 'blur' ) _onBlur ( ) { if ( this . closeOnBlur ) { this . closeOnFocusout ( ) ; } } /** Remove the menu from the DOM */ private destroyMenu ( ) { if ( ! this . _overlayRef ) { return ; } this . _overlayRef . detach ( ) ; } /** Create an overlay or return an existing instance */ private getOverlay ( recreateOverlay = false ) { if ( this . _overlayRef && ! recreateOverlay ) { return this . _overlayRef ; } const strategy = this . _overlay . position ( ) . flexibleConnectedTo ( this . _elementRef ) . withFlexibleDimensions ( false ) . withPush ( false ) ; this . _overlayRef = this . _overlay . create ( { hasBackdrop : ! this . _isSubmenuTrigger , backdropClass : 'cdk-overlay-transparent-backdrop' , scrollStrategy : this . _overlay . scrollStrategies . reposition ( { scrollThrottle : 0 } ) , positionStrategy : strategy } ) ; this . _overlayPlacement . updatePosition ( this . _overlayRef , this . menu . placement , this . menu . alignment ) ; const position = this . _overlayRef . getConfig ( ) . positionStrategy as FlexibleConnectedPositionStrategy ; position . withPositions ( position . positions . map ( ( pos ) => { return { ... pos , panelClass : this . menuAnimation ( pos . originY ) } ; } ) ) ; return this . _overlayRef ; } /** Create a Template portal if one does not already exist (or the template has changed) */ private getPortal ( ) { if ( ! this . _portal || this . menu . templateRef !== this . _portal . templateRef ) { this . _portal = new TemplatePortal ( this . menu . templateRef , this . _viewContainerRef ) ; } return this . _portal ; } /** Determine the direction of the animation. */ private menuAnimation ( originY ) { if ( ( this . menu . placement === 'top' || this . menu . placement === 'bottom' ) && originY === 'top' && ! this . _isSubmenuTrigger ) { return 'ux-menu-placement-top' ; } return null ; } /** Get an observable that emits on any of the triggers that close a menu */ private didMenuClose ( ) { return merge ( this . _overlayRef . backdropClick ( ) , this . _overlayRef . detachments ( ) , this . _parentMenu ? this . _parentMenu . closing : of ( ) , this . _menuShouldClose ) ; } /** When the menu opens we want to focus the first item in the list */ private menuDidOpen ( ) { if ( this . _focusOrigin . getOrigin ( ) === 'keyboard' ) { this . menu . _keyManager . setFocusOrigin ( 'keyboard' ) . setFirstItemActive ( ) ; } } /** Handle keypresses in submenus where we may want to close them */ private onMenuKeydown ( event ) { if ( this . _parentMenu ) { if ( this . menu . placement === 'right' && event . keyCode === LEFT_ARROW || this . menu . placement === 'left' && event . keyCode === RIGHT_ARROW || this . menu . placement === 'top' && event . keyCode === DOWN_ARROW || this . menu . placement === 'bottom' && event . keyCode === UP_ARROW ) { this . closeMenu ( ) ; this . _menuItem . focus ( 'keyboard' ) ; } } } /** Check whether the overlay has focus */ private hasFocus ( ) { let check = false ; document . querySelectorAll ( '.cdk-overlay-container .ux-menu' ) . forEach ( el => { if ( el . contains ( document . activeElement ) ) { check = true ; } } ) ; return check ; } /** Close the menu if there is no element focused */ private closeOnFocusout ( ) { if ( this . menu . isMenuOpen ) { setTimeout ( ( ) => { if ( ! this . hasFocus ( ) ) { this . closeMenu ( undefined , true ) ; } } , this . _debounceTime ) ; } } private getSubMenuPlacement ( placement ) { return placement === 'left' ? 'left' : 'right' ; } static ngAcceptInputType_closeOnBlur : BooleanInput ; }