import { AfterViewInit , Component , ComponentFactoryResolver , ElementRef , HostBinding , HostListener , Injector , Input , NgZone , OnDestroy , OnInit , QueryList , Renderer2 , RendererStyleFlags2 , ViewChild , ViewChildren , } from '@angular/core' ; import { ActivatedRoute , RouterOutlet } from '@angular/router' ; import { IonContent , IonHeader , IonTitle , IonToolbar } from '@ionic/angular' ; import { merge , Observable , Subject } from 'rxjs' ; import { debounceTime , first , takeUntil } from 'rxjs/operators' ; import { DesignTokenHelper } from '@kirbydesign/core' ; import { KirbyAnimation } from '../../../animation/kirby-animation' ; import { PlatformService } from '../../../helpers/platform.service' ; import { WindowRef } from '../../../types/window-ref' ; import { ButtonComponent } from '../../button/button.component' ; import { ResizeObserverService } from '../../shared/resize-observer/resize-observer.service' ; import { ResizeObserverEntry } from '../../shared/resize-observer/types/resize-observer-entry' ; import { Modal } from '../services/modal.interfaces' ; import { ModalConfig } from './config/modal-config' ; import { COMPONENT_PROPS } from './config/modal-config.helper' ; @ Component ( { selector : 'kirby-modal-wrapper' , templateUrl : './modal-wrapper.component.html' , styleUrls : [ './modal-wrapper.component.scss' ] , providers : [ { provide : Modal , useExisting : ModalWrapperComponent } ] , } ) export class ModalWrapperComponent implements Modal , AfterViewInit , OnInit , OnDestroy { static readonly KEYBOARD_HIDE_DELAY_IN_MS = 100 ; scrollY : number = Math . abs ( this . windowRef . nativeWindow . scrollY ) ; private readonly VIEWPORT_RESIZE_DEBOUNCE_TIME = 100 ; set scrollDisabled ( disabled ) { this . ionContent . scrollY = ! disabled ; } @ Input ( ) config : ModalConfig ; componentPropsInjector : Injector ; @ ViewChildren ( ButtonComponent , { read : ElementRef } ) private toolbarButtonsQuery : QueryList < ElementRef < HTMLButtonElement > > ; @ ViewChild ( IonContent , { static : true } ) private ionContent : IonContent ; @ ViewChild ( IonContent , { static : true , read : ElementRef } ) private ionContentElement : ElementRef < HTMLIonContentElement > ; @ ViewChild ( IonHeader , { static : true , read : ElementRef } ) private ionHeaderElement : ElementRef < HTMLIonHeaderElement > ; @ ViewChild ( IonToolbar , { static : true , read : ElementRef } ) private ionToolbarElement : ElementRef < HTMLIonToolbarElement > ; @ ViewChild ( IonTitle , { static : true , read : ElementRef } ) private ionTitleElement : ElementRef < HTMLIonTitleElement > ; @ ViewChild ( RouterOutlet , { static : true } ) private routerOutlet : RouterOutlet ; private keyboardVisible = false ; private toolbarButtons : HTMLButtonElement [ ] = [ ] ; private delayedClose = ( ) => { } ; private delayedCloseTimeoutId ; private initialViewportHeight : number ; private viewportResized = false ; private ionModalElement ? : HTMLIonModalElement ; private readonly ionModalDidPresent = new Subject < void > ( ) ; readonly didPresent = this . ionModalDidPresent . toPromise ( ) ; private readonly ionModalWillDismiss = new Subject < void > ( ) ; readonly willClose = this . ionModalWillDismiss . toPromise ( ) ; private viewportResize = new Subject < void > ( ) ; private viewportResize$ = this . viewportResize . asObservable ( ) . pipe ( debounceTime ( this . VIEWPORT_RESIZE_DEBOUNCE_TIME ) ) ; private _mutationObserver : MutationObserver ; private get mutationObserver ( ) : MutationObserver { if ( ! this . _mutationObserver ) { this . _mutationObserver = this . createEmbeddedElementsMutationObserver ( ) ; } return this . _mutationObserver ; } private _intersectionObserver : IntersectionObserver ; private get intersectionObserver ( ) : IntersectionObserver { if ( ! this . _intersectionObserver ) { this . _intersectionObserver = this . createModalWrapperIntersectionObserver ( ) ; } return this . _intersectionObserver ; } private destroy$ = new Subject ( ) ; @ HostBinding ( 'class.drawer' ) get _isDrawer ( ) { return this . config . flavor === 'drawer' ; } willClose$ = this . ionModalWillDismiss . pipe ( first ( ) ) ; constructor ( private injector , private elementRef , private renderer , private zone , private resizeObserverService , private componentFactoryResolver , private windowRef , private platform ) { this . setViewportHeight ( ) ; this . observeViewportResize ( ) ; } ngOnInit ( ) { this . ionModalElement = this . elementRef . nativeElement . closest ( 'ion-modal' ) ; this . initializeSizing ( ) ; this . initializeModalRoute ( ) ; this . listenForIonModalDidPresent ( ) ; this . listenForIonModalWillDismiss ( ) ; this . initializeResizeModalToModalWrapper ( ) ; this . componentPropsInjector = Injector . create ( { providers : [ { provide : COMPONENT_PROPS , useValue : this . config . componentProps } ] , parent : this . injector , } ) ; } private initializeResizeModalToModalWrapper ( ) { if ( this . config . flavor === 'drawer' && this . config . interactWithBackground ) { merge ( this . ionModalDidPresent , this . viewportResize$ ) . pipe ( takeUntil ( this . destroy$ ) ) . subscribe ( ( ) => { setTimeout ( ( ) => { const domRect = this . elementRef . nativeElement . getBoundingClientRect ( ) ; const document = this . windowRef . nativeWindow . document . documentElement ; const right = document . clientWidth - domRect . right ; this . renderer . setStyle ( this . ionModalElement , 'top' , `${ domRect . top }px` ) ; this . renderer . setStyle ( this . ionModalElement , 'left' , `${ domRect . left }px` ) ; this . renderer . setStyle ( this . ionModalElement , 'right' , `${ right }px` ) ; } ) ; } ) ; } } private initializeSizing ( ) { if ( this . config . size === 'full-height' ) return ; this . patchScrollElementSize ( ) ; this . observeHeaderResize ( ) ; this . observeModalFullHeight ( ) ; } private initializeModalRoute ( ) { if ( this . config . modalRoute ) { this . onSiblingModalRouteActivated ( this . config . siblingModalRouteActivated$ ) ; this . routerOutlet . activateWith ( this . config . modalRoute , this . componentFactoryResolver ) ; } } private onSiblingModalRouteActivated ( siblingModalRouteActivated$ ? ) { if ( ! siblingModalRouteActivated$ ) return ; siblingModalRouteActivated$ . pipe ( takeUntil ( this . willClose$ ) ) . subscribe ( ( route ) => { if ( this . routerOutlet . isActivated ) { this . mutationObserver . disconnect ( ) ; this . routerOutlet . deactivate ( ) ; this . clearEmbeddedElements ( ) ; } this . routerOutlet . activateWith ( route , this . componentFactoryResolver ) ; this . checkForEmbeddedElements ( ) ; } ) ; } private patchScrollElementSize ( ) { const supportsCssShadowParts = 'part' in HTMLElement . prototype ; if ( supportsCssShadowParts ) return ; this . ionContent . getScrollElement ( ) . then ( ( scrollElement ) => { this . renderer . setStyle ( scrollElement , 'height' , '100%' ) ; this . renderer . setStyle ( scrollElement , 'position' , 'relative' ) ; if ( this . config . flavor === 'drawer' ) { this . renderer . setStyle ( scrollElement , 'transition' , 'padding-bottom ' + DesignTokenHelper . softKeyboardTransitionLeave ) ; } } ) ; } private observeModalFullHeight ( ) { const ionModalWrapper = this . elementRef . nativeElement . closest < HTMLElement > ( '.modal-wrapper' ) ; if ( ! ionModalWrapper ) return ; this . didPresent . then ( ( ) => { this . intersectionObserver . observe ( ionModalWrapper ) ; } ) ; } ngAfterViewInit ( ) { if ( this . toolbarButtonsQuery ) { this . toolbarButtons = this . toolbarButtonsQuery . map ( ( buttonRef ) => buttonRef . nativeElement ) ; } this . checkForEmbeddedElements ( ) ; } private checkForEmbeddedElements ( ) { this . moveEmbeddedElements ( ) ; this . observeEmbeddedElements ( ) ; } private observeHeaderResize ( ) { this . resizeObserverService . observe ( this . ionHeaderElement . nativeElement , ( entry ) => { const [ property , pixelValue ] = [ '--header-height' , `${ entry . contentRect . height }px` ] ; this . setCssVar ( this . elementRef . nativeElement , property , pixelValue ) ; } ) ; } private moveEmbeddedElements ( ) { const parentElement = this . getEmbeddedComponentElement ( ) ; if ( parentElement ) { Object . entries ( this . elementToParentMap ) . forEach ( ( [ tagName , getNewParent ] ) => { const embeddedElement = parentElement . querySelector < HTMLElement > ( tagName ) ; if ( embeddedElement ) { this . moveChild ( embeddedElement , getNewParent ( ) ) ; } } ) ; } } private listenForIonModalDidPresent ( ) { if ( this . ionModalElement ) { this . ionModalElement . addEventListener ( 'ionModalDidPresent' , ( ) => { this . ionModalDidPresent . next ( ) ; this . ionModalDidPresent . complete ( ) ; } ) ; } } private listenForIonModalWillDismiss ( ) { if ( this . ionModalElement ) { this . ionModalElement . addEventListener ( 'ionModalWillDismiss' , ( ) => { this . ionModalWillDismiss . next ( ) ; this . ionModalWillDismiss . complete ( ) ; } ) ; } } scrollToTop ( scrollDuration ? ) { this . ionContent . scrollToTop ( scrollDuration || 0 ) ; } scrollToBottom ( scrollDuration ? ) { this . ionContent . scrollToBottom ( scrollDuration || 0 ) ; } async close ( data ? ) { if ( ! this . ionModalElement ) { return ; } if ( ! this . keyboardVisible || ! this . viewportResized ) { clearTimeout ( this . delayedCloseTimeoutId ) ; await this . ionModalElement . dismiss ( data ) ; return ; } this . blurActiveElement ( ) ; return new Promise < void > ( ( resolve ) => { this . delayedClose = async ( ) => { await this . ionModalElement . dismiss ( data ) ; resolve ( ) ; } ; this . delayedCloseTimeoutId = setTimeout ( this . delayedClose , ModalWrapperComponent . KEYBOARD_HIDE_DELAY_IN_MS ) ; } ) ; } @ HostListener ( 'window:focus' ) @ HostListener ( 'window:focusout' ) onFocusChange ( ) { this . windowRef . nativeWindow . scrollTo ( { top : this . scrollY } ) ; } @ HostListener ( 'window:ionKeyboardDidShow' , [ '$event.detail.keyboardHeight' ] ) @ HostListener ( 'window:keyboardWillShow' , [ '$event.keyboardHeight' ] ) _onKeyboardShow ( keyboardHeight ) { this . setKeyboardVisibility ( keyboardHeight ) ; } @ HostListener ( 'window:ionKeyboardDidHide' ) @ HostListener ( 'window:keyboardWillHide' ) _onKeyboardHide ( ) { this . setKeyboardVisibility ( 0 ) ; } private toggleContentMaxHeight ( freeze ) { const shouldToggleMaxHeight = this . config . flavor === 'modal' && this . platform . isPhabletOrBigger ( ) ; if ( ! shouldToggleMaxHeight ) return ; const style = 'max-height' ; const contentElement = this . ionContentElement . nativeElement ; this . zone . run ( ( ) => { if ( freeze ) { const contentHeight = contentElement . offsetHeight ; this . renderer . setStyle ( contentElement , style , `${ contentHeight }px` ) ; } else { this . renderer . removeStyle ( contentElement , style ) ; } } ) ; } private setKeyboardVisibility ( keyboardHeight ) { const keyboardAlreadyVisible = keyboardHeight > 0 && this . keyboardVisible ; const keyboardAlreadyHidden = keyboardHeight === 0 && ! this . keyboardVisible ; if ( keyboardAlreadyVisible || keyboardAlreadyHidden ) return ; this . keyboardVisible = keyboardHeight > 0 ; this . toggleContentMaxHeight ( this . keyboardVisible ) ; this . setKeyboardOverlap ( keyboardHeight ) ; } private getKeyboardOverlap ( keyboardHeight , element ) { if ( keyboardHeight <= 0 || ! element ) return 0 ; const distanceFromViewportBottomToElement = Math . floor ( this . windowRef . nativeWindow . innerHeight - element . getBoundingClientRect ( ) . bottom ) ; return Math . max ( keyboardHeight - distanceFromViewportBottomToElement , 0 ) ; } private setCssVar ( element , property , value ) { this . zone . run ( ( ) => this . renderer . setStyle ( element , property , value , RendererStyleFlags2 . DashCase ) ) ; } private toggleCssClass ( element , klass , condition ) { this . zone . run ( ( ) => condition ? this . renderer . addClass ( element , klass ) : this . renderer . removeClass ( element , klass ) ) ; } private setKeyboardOverlap ( keyboardHeight ) { this . toggleCssClass ( this . elementRef . nativeElement , 'keyboard-visible' , keyboardHeight > 0 ) ; const keyboardOverlap = this . getKeyboardOverlap ( keyboardHeight , this . elementRef . nativeElement ) ; let snapFooterToKeyboard = false ; const embeddedFooterElement = this . getEmbeddedFooterElement ( ) ; if ( embeddedFooterElement ) { this . setCssVar ( embeddedFooterElement , '--keyboard-offset' , `${ keyboardOverlap }px` ) ; snapFooterToKeyboard = embeddedFooterElement . classList . contains ( 'snap-to-keyboard' ) ; } const contentElement = this . ionContentElement . nativeElement ; const contentKeyboardOffset = snapFooterToKeyboard ? keyboardOverlap : this . getKeyboardOverlap ( keyboardHeight , contentElement ) ; this . setCssVar ( contentElement , '--keyboard-offset' , `${ contentKeyboardOffset }px` ) ; } onHeaderTouchStart ( event ) { if ( this . keyboardVisible ) { const isToolbarButtonTouch = this . toolbarButtons . some ( ( button ) => { return ( event . target instanceof HTMLElement && ( event . target === button || button . contains ( event . target ) ) ) ; } ) ; if ( ! isToolbarButtonTouch ) { this . blurActiveElement ( ) ; } } } @ HostListener ( 'window:resize' ) _onWindowResize ( ) { this . setViewportHeight ( ) ; } private setViewportHeight ( ) { const vh = ( this . windowRef . nativeWindow . innerHeight * 0.01 ) . toFixed ( 2 ) ; this . setCssVar ( this . elementRef . nativeElement , '--vh' , `${ vh }px` ) ; } private observeViewportResize ( ) { this . resizeObserverService . observe ( this . windowRef . nativeWindow . document . body , ( entry ) => this . onViewportResize ( entry ) ) ; } private onViewportResize ( entry ) { if ( ! this . initialViewportHeight ) { this . initialViewportHeight = entry . contentRect . height ; return ; } this . viewportResized = entry . contentRect . height !== this . initialViewportHeight ; if ( ! this . viewportResized ) { if ( this . delayedCloseTimeoutId ) { clearTimeout ( this . delayedCloseTimeoutId ) ; this . delayedClose ( ) ; } } this . viewportResize . next ( ) ; } blurActiveElement ( ) { const BLUR_TARGET_SELECTOR = 'input, textarea' ; if ( this . keyboardVisible ) { if ( document . activeElement instanceof HTMLElement && document . activeElement . matches ( BLUR_TARGET_SELECTOR ) ) { document . activeElement . blur ( ) ; } } } private readonly elementToParentMap : { [ key ] : ( ) => HTMLElement ; } = { 'KIRBY-MODAL-FOOTER' : ( ) => this . elementRef . nativeElement , 'KIRBY-PAGE-TITLE' : ( ) => this . ionTitleElement . nativeElement , 'KIRBY-PAGE-PROGRESS' : ( ) => this . ionToolbarElement . nativeElement , } ; private clearEmbeddedElements ( ) { Object . entries ( this . elementToParentMap ) . forEach ( ( [ tagName , getParent ] ) => { const embeddedElement = getParent ( ) . querySelector < HTMLElement > ( `:scope > ${ tagName }` ) ; this . removeChild ( embeddedElement ) ; } ) ; } private getEmbeddedComponentElement ( ) { return ! ! this . config . modalRoute ? this . ionContentElement . nativeElement . lastElementChild : this . ionContentElement . nativeElement . firstElementChild ; } private getEmbeddedFooterElement ( ) { return this . elementRef . nativeElement . querySelector < HTMLElement > ( 'kirby-modal-footer' ) ; } private moveChild ( child , newParent ) { this . renderer . removeChild ( child . parentElement , child ) ; this . renderer . appendChild ( newParent , child ) ; if ( child . tagName === 'KIRBY-MODAL-FOOTER' ) { this . resizeObserverService . observe ( child , ( entry ) => { const [ property , pixelValue ] = [ '--footer-height' , `${ Math . floor ( entry . contentRect . height ) }px` , ] ; this . setCssVar ( this . elementRef . nativeElement , property , pixelValue ) ; } ) ; } } private removeChild ( child ? ) { if ( ! ! child ) { this . renderer . removeChild ( child . parentElement , child ) ; } } private observeEmbeddedElements ( ) { const parentElement = this . getEmbeddedComponentElement ( ) ; this . mutationObserver . observe ( parentElement , { childList : true , } ) ; } private createEmbeddedElementsMutationObserver ( ) { const observedElements = Object . keys ( this . elementToParentMap ) ; const callback = ( mutations ) => { const addedNodes = mutations . filter ( ( mutation ) => mutation . type === 'childList' ) . map ( ( mutation ) => { return Array . from ( mutation . addedNodes ) . filter ( ( node ) => observedElements . includes ( node . nodeName ) ) ; } ) ; const addedElements = Array . prototype . concat ( ... addedNodes ) . filter ( ( node ) : node is HTMLElement => node instanceof HTMLElement ) ; addedElements . forEach ( ( addedElement ) => { const newParentElement = this . elementToParentMap [ addedElement . nodeName ] ( ) ; this . moveChild ( addedElement , newParentElement ) ; } ) ; } ; return new MutationObserver ( callback ) ; } private createModalWrapperIntersectionObserver ( ) { const callback = ( entries ) => { const entry = entries [ 0 ] ; const isTouchingViewport = entry . intersectionRatio < 1 ; if ( isTouchingViewport ) { this . renderer . addClass ( entry . target , 'full-height' ) ; } else { this . renderer . removeClass ( entry . target , 'full-height' ) ; } } ; const root = this . windowRef . nativeWindow . frameElement ? ( this . windowRef . nativeWindow . document as any ) : undefined ; const options = { rootMargin : '0px 0px -1px 0px' , root , threshold : [ 0.99 , 1 ] , } ; return new IntersectionObserver ( callback , options ) ; } ngOnDestroy ( ) { if ( this . routerOutlet . isActivated ) { this . routerOutlet . deactivate ( ) ; } this . mutationObserver . disconnect ( ) ; delete this . _mutationObserver ; this . intersectionObserver . disconnect ( ) ; delete this . _intersectionObserver ; if ( this . resizeObserverService ) { this . resizeObserverService . unobserve ( this . windowRef . nativeWindow . document . body ) ; this . resizeObserverService . unobserve ( this . ionHeaderElement . nativeElement ) ; this . resizeObserverService . unobserve ( this . getEmbeddedFooterElement ( ) ) ; } this . destroy$ . next ( ) ; this . destroy$ . complete ( ) ; } }