UNPKG

igniteui-angular-sovn

Version:

Ignite UI for Angular is a dependency-free Angular toolkit for building modern web apps

1,007 lines (936 loc) 40.7 kB
import { AnimationReferenceMetadata } from '@angular/animations'; import { DOCUMENT } from '@angular/common'; import { ApplicationRef, ComponentFactory, ComponentFactoryResolver, ComponentRef, ElementRef, EventEmitter, Inject, Injectable, Injector, NgZone, OnDestroy, Type, ViewContainerRef } from '@angular/core'; import { fromEvent, Subject, Subscription } from 'rxjs'; import { filter, takeUntil } from 'rxjs/operators'; import { fadeIn, fadeOut, IAnimationParams, scaleInHorLeft, scaleInHorRight, scaleInVerBottom, scaleInVerTop, scaleOutHorLeft, scaleOutHorRight, scaleOutVerBottom, scaleOutVerTop, slideInBottom, slideInTop, slideOutBottom, slideOutTop } from '../../animations/main'; import { PlatformUtil } from '../../core/utils'; import { IgxOverlayOutletDirective } from '../../directives/toggle/toggle.directive'; import { IgxAngularAnimationService } from '../animation/angular-animation-service'; import { AnimationService } from '../animation/animation'; import { AutoPositionStrategy } from './position/auto-position-strategy'; import { ConnectedPositioningStrategy } from './position/connected-positioning-strategy'; import { ContainerPositionStrategy } from './position/container-position-strategy'; import { ElasticPositionStrategy } from './position/elastic-position-strategy'; import { GlobalPositionStrategy } from './position/global-position-strategy'; import { IPositionStrategy } from './position/IPositionStrategy'; import { NoOpScrollStrategy } from './scroll/NoOpScrollStrategy'; import { AbsolutePosition, HorizontalAlignment, OverlayAnimationEventArgs, OverlayCancelableEventArgs, OverlayClosingEventArgs, OverlayEventArgs, OverlayInfo, OverlaySettings, Point, PositionSettings, RelativePosition, RelativePositionStrategy, VerticalAlignment } from './utilities'; /** * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/overlay-main) * The overlay service allows users to show components on overlay div above all other elements in the page. */ @Injectable({ providedIn: 'root' }) export class IgxOverlayService implements OnDestroy { /** * Emitted just before the overlay content starts to open. * ```typescript * opening(event: OverlayCancelableEventArgs){ * const opening = event; * } * ``` */ public opening = new EventEmitter<OverlayCancelableEventArgs>(); /** * Emitted after the overlay content is opened and all animations are finished. * ```typescript * opened(event: OverlayEventArgs){ * const opened = event; * } * ``` */ public opened = new EventEmitter<OverlayEventArgs>(); /** * Emitted just before the overlay content starts to close. * ```typescript * closing(event: OverlayCancelableEventArgs){ * const closing = event; * } * ``` */ public closing = new EventEmitter<OverlayClosingEventArgs>(); /** * Emitted after the overlay content is closed and all animations are finished. * ```typescript * closed(event: OverlayEventArgs){ * const closed = event; * } * ``` */ public closed = new EventEmitter<OverlayEventArgs>(); /** * Emitted before the content is appended to the overlay. * ```typescript * contentAppending(event: OverlayEventArgs){ * const contentAppending = event; * } * ``` */ public contentAppending = new EventEmitter<OverlayEventArgs>(); /** * Emitted after the content is appended to the overlay, and before animations are started. * ```typescript * contentAppended(event: OverlayEventArgs){ * const contentAppended = event; * } * ``` */ public contentAppended = new EventEmitter<OverlayEventArgs>(); /** * Emitted just before the overlay animation start. * ```typescript * animationStarting(event: OverlayAnimationEventArgs){ * const animationStarting = event; * } * ``` */ public animationStarting = new EventEmitter<OverlayAnimationEventArgs>(); private _componentId = 0; private _overlayInfos: OverlayInfo[] = []; private _overlayElement: HTMLElement; private _document: Document; private _keyPressEventListener: Subscription; private destroy$ = new Subject<boolean>(); private _cursorStyleIsSet = false; private _cursorOriginalValue: string; private _defaultSettings: OverlaySettings = { excludeFromOutsideClick: [], positionStrategy: new GlobalPositionStrategy(), scrollStrategy: new NoOpScrollStrategy(), modal: true, closeOnOutsideClick: true, closeOnEscape: false }; constructor( private _factoryResolver: ComponentFactoryResolver, private _appRef: ApplicationRef, private _injector: Injector, @Inject(DOCUMENT) private document: any, private _zone: NgZone, protected platformUtil: PlatformUtil, @Inject(IgxAngularAnimationService)private animationService: AnimationService) { this._document = this.document; } /** * Creates overlay settings with global or container position strategy and preset position settings * * @param position Preset position settings. Default position is 'center' * @param outlet The outlet container to attach the overlay to * @returns Non-modal overlay settings based on Global or Container position strategy and the provided position. */ public static createAbsoluteOverlaySettings( position?: AbsolutePosition, outlet?: IgxOverlayOutletDirective | ElementRef): OverlaySettings { const positionSettings = this.createAbsolutePositionSettings(position); const strategy = outlet ? new ContainerPositionStrategy(positionSettings) : new GlobalPositionStrategy(positionSettings); const overlaySettings: OverlaySettings = { positionStrategy: strategy, scrollStrategy: new NoOpScrollStrategy(), modal: false, closeOnOutsideClick: true, outlet }; return overlaySettings; } /** * Creates overlay settings with auto, connected or elastic position strategy and preset position settings * * @param target Attaching target for the component to show * @param strategy The relative position strategy to be applied to the overlay settings. Default is Auto positioning strategy. * @param position Preset position settings. By default the element is positioned below the target, left aligned. * @returns Non-modal overlay settings based on the provided target, strategy and position. */ public static createRelativeOverlaySettings( target: Point | HTMLElement, position?: RelativePosition, strategy?: RelativePositionStrategy): OverlaySettings { const positionSettings = this.createRelativePositionSettings(position); const overlaySettings: OverlaySettings = { target, positionStrategy: this.createPositionStrategy(strategy, positionSettings), scrollStrategy: new NoOpScrollStrategy(), modal: false, closeOnOutsideClick: true }; return overlaySettings; } private static createAbsolutePositionSettings(position: AbsolutePosition): PositionSettings { let positionSettings: PositionSettings; switch (position) { case AbsolutePosition.Bottom: positionSettings = { horizontalDirection: HorizontalAlignment.Center, verticalDirection: VerticalAlignment.Bottom, openAnimation: slideInBottom, closeAnimation: slideOutBottom }; break; case AbsolutePosition.Top: positionSettings = { horizontalDirection: HorizontalAlignment.Center, verticalDirection: VerticalAlignment.Top, openAnimation: slideInTop, closeAnimation: slideOutTop }; break; case AbsolutePosition.Center: default: positionSettings = { horizontalDirection: HorizontalAlignment.Center, verticalDirection: VerticalAlignment.Middle, openAnimation: fadeIn, closeAnimation: fadeOut }; } return positionSettings; } private static createRelativePositionSettings(position: RelativePosition): PositionSettings { let positionSettings: PositionSettings; switch (position) { case RelativePosition.Above: positionSettings = { horizontalStartPoint: HorizontalAlignment.Center, verticalStartPoint: VerticalAlignment.Top, horizontalDirection: HorizontalAlignment.Center, verticalDirection: VerticalAlignment.Top, openAnimation: scaleInVerBottom, closeAnimation: scaleOutVerBottom, }; break; case RelativePosition.Below: positionSettings = { horizontalStartPoint: HorizontalAlignment.Center, verticalStartPoint: VerticalAlignment.Bottom, horizontalDirection: HorizontalAlignment.Center, verticalDirection: VerticalAlignment.Bottom, openAnimation: scaleInVerTop, closeAnimation: scaleOutVerTop }; break; case RelativePosition.After: positionSettings = { horizontalStartPoint: HorizontalAlignment.Right, verticalStartPoint: VerticalAlignment.Middle, horizontalDirection: HorizontalAlignment.Right, verticalDirection: VerticalAlignment.Middle, openAnimation: scaleInHorLeft, closeAnimation: scaleOutHorLeft }; break; case RelativePosition.Before: positionSettings = { horizontalStartPoint: HorizontalAlignment.Left, verticalStartPoint: VerticalAlignment.Middle, horizontalDirection: HorizontalAlignment.Left, verticalDirection: VerticalAlignment.Middle, openAnimation: scaleInHorRight, closeAnimation: scaleOutHorRight }; break; case RelativePosition.Default: default: positionSettings = { horizontalStartPoint: HorizontalAlignment.Left, verticalStartPoint: VerticalAlignment.Bottom, horizontalDirection: HorizontalAlignment.Right, verticalDirection: VerticalAlignment.Bottom, openAnimation: scaleInVerTop, closeAnimation: scaleOutVerTop, }; break; } return positionSettings; } private static createPositionStrategy(strategy: RelativePositionStrategy, positionSettings: PositionSettings): IPositionStrategy { switch (strategy) { case RelativePositionStrategy.Connected: return new ConnectedPositioningStrategy(positionSettings); case RelativePositionStrategy.Elastic: return new ElasticPositionStrategy(positionSettings); case RelativePositionStrategy.Auto: default: return new AutoPositionStrategy(positionSettings); } } /** * Generates Id. Provide this Id when call `show(id)` method * * @param component ElementRef to show in overlay * @param settings Display settings for the overlay, such as positioning and scroll/close behavior. * @returns Id of the created overlay. Valid until `detach` is called. */ public attach(element: ElementRef, settings?: OverlaySettings): string; /** * Generates Id. Provide this Id when call `show(id)` method * * @param component Component Type to show in overlay * @param settings Display settings for the overlay, such as positioning and scroll/close behavior. * @param moduleRef Optional reference to an object containing Injector and ComponentFactoryResolver * that can resolve the component's factory * @returns Id of the created overlay. Valid until `detach` is called. * @deprecated deprecated in 14.0.0. Use the `attach(component, viewContainerRef, settings)` overload */ public attach( component: Type<any>, settings?: OverlaySettings, moduleRef?: { injector: Injector, componentFactoryResolver: ComponentFactoryResolver }): string; /** * Generates an Id. Provide this Id when calling the `show(id)` method * * @param component Component Type to show in overlay * @param viewContainerRef Reference to the container where created component's host view will be inserted * @param settings Display settings for the overlay, such as positioning and scroll/close behavior. */ public attach(component: Type<any>, viewContainerRef: ViewContainerRef, settings?: OverlaySettings): string; public attach( componentOrElement: ElementRef | Type<any>, viewContainerRefOrSettings?: ViewContainerRef | OverlaySettings, moduleRefOrSettings?: { injector: Injector, componentFactoryResolver: ComponentFactoryResolver } | OverlaySettings): string { const info: OverlayInfo = this.getOverlayInfo(componentOrElement, this.getUserViewContainerOrModuleRef(viewContainerRefOrSettings, moduleRefOrSettings)); if (!info) { console.warn('Overlay was not able to attach provided component!'); return null; } info.id = (this._componentId++).toString(); info.visible = false; const settings = Object.assign({}, this._defaultSettings, this.getUserOverlaySettings(viewContainerRefOrSettings, moduleRefOrSettings)); // Emit the contentAppending event before appending the content const eventArgs = { id: info.id, elementRef: info.elementRef, componentRef: info.componentRef, settings }; this.contentAppending.emit(eventArgs); // Append the content to the overlay info.settings = eventArgs.settings; this._overlayInfos.push(info); info.hook = this.placeElementHook(info.elementRef.nativeElement); const elementRect = info.elementRef.nativeElement.getBoundingClientRect(); info.initialSize = { width: elementRect.width, height: elementRect.height }; this.moveElementToOverlay(info); this.contentAppended.emit({ id: info.id, componentRef: info.componentRef }); info.settings.scrollStrategy.initialize(this._document, this, info.id); info.settings.scrollStrategy.attach(); this.addOutsideClickListener(info); this.addResizeHandler(); this.addCloseOnEscapeListener(info); this.buildAnimationPlayers(info); return info.id; } /** * Remove overlay with the provided id. * * @param id Id of the overlay to remove * ```typescript * this.overlay.detach(id); * ``` */ public detach(id: string) { const info: OverlayInfo = this.getOverlayById(id); if (!info) { console.warn('igxOverlay.detach was called with wrong id: ', id); return; } info.detached = true; this.finishAnimations(info); info.settings.scrollStrategy.detach(); this.removeOutsideClickListener(info); this.removeResizeHandler(); this.cleanUp(info); } /** * Remove all the overlays. * ```typescript * this.overlay.detachAll(); * ``` */ public detachAll() { for (let i = this._overlayInfos.length; i--;) { this.detach(this._overlayInfos[i].id); } } /** * Shows the overlay for provided id. * * @param id Id to show overlay for * @param settings Display settings for the overlay, such as positioning and scroll/close behavior. */ public show(id: string, settings?: OverlaySettings): void { const info: OverlayInfo = this.getOverlayById(id); if (!info) { console.warn('igxOverlay.show was called with wrong id: ', id); return; } const eventArgs: OverlayCancelableEventArgs = { id, componentRef: info.componentRef, cancel: false }; this.opening.emit(eventArgs); if (eventArgs.cancel) { return; } if (settings) { // TODO: update attach } this.updateSize(info); info.settings.positionStrategy.position( info.elementRef.nativeElement.parentElement, { width: info.initialSize.width, height: info.initialSize.height }, document, true, info.settings.target); this.addModalClasses(info); if (info.settings.positionStrategy.settings.openAnimation) { // TODO: should we build players again. This was already done in attach!!! // this.buildAnimationPlayers(info); this.playOpenAnimation(info); } else { // to eliminate flickering show the element just before opened fires info.wrapperElement.style.visibility = ''; info.visible = true; this.opened.emit({ id: info.id, componentRef: info.componentRef }); } } /** * Hides the component with the ID provided as a parameter. * ```typescript * this.overlay.hide(id); * ``` */ public hide(id: string, event?: Event) { this._hide(id, event); } /** * Hides all the components and the overlay. * ```typescript * this.overlay.hideAll(); * ``` */ public hideAll() { for (let i = this._overlayInfos.length; i--;) { this.hide(this._overlayInfos[i].id); } } /** * Repositions the component with ID provided as a parameter. * * @param id Id to reposition overlay for * ```typescript * this.overlay.reposition(id); * ``` */ public reposition(id: string) { const overlayInfo = this.getOverlayById(id); if (!overlayInfo || !overlayInfo.settings) { console.error('Wrong id provided in overlay.reposition method. Id: ' + id); return; } if (!overlayInfo.visible) { return; } const contentElement = overlayInfo.elementRef.nativeElement.parentElement; const contentElementRect = contentElement.getBoundingClientRect(); overlayInfo.settings.positionStrategy.position( contentElement, { width: contentElementRect.width, height: contentElementRect.height }, this._document, false, overlayInfo.settings.target); } /** * Offsets the content along the corresponding axis by the provided amount * * @param id Id to offset overlay for * @param deltaX Amount of offset in horizontal direction * @param deltaY Amount of offset in vertical direction * ```typescript * this.overlay.setOffset(id, deltaX, deltaY); * ``` */ public setOffset(id: string, deltaX: number, deltaY: number) { const info: OverlayInfo = this.getOverlayById(id); if (!info) { return; } info.transformX += deltaX; info.transformY += deltaY; const transformX = info.transformX; const transformY = info.transformY; const translate = `translate(${transformX}px, ${transformY}px)`; info.elementRef.nativeElement.parentElement.style.transform = translate; } /** @hidden */ public repositionAll = () => { for (let i = this._overlayInfos.length; i--;) { this.reposition(this._overlayInfos[i].id); } }; /** @hidden */ public ngOnDestroy(): void { this.destroy$.next(true); this.destroy$.complete(); } /** @hidden @internal */ public getOverlayById(id: string): OverlayInfo { if (!id) { return null; } const info = this._overlayInfos.find(e => e.id === id); return info; } private _hide(id: string, event?: Event) { const info: OverlayInfo = this.getOverlayById(id); if (!info) { console.warn('igxOverlay.hide was called with wrong id: ', id); return; } const eventArgs: OverlayClosingEventArgs = { id, componentRef: info.componentRef, cancel: false, event }; this.closing.emit(eventArgs); if (eventArgs.cancel) { return; } this.removeModalClasses(info); if (info.settings.positionStrategy.settings.closeAnimation) { this.playCloseAnimation(info, event); } else { this.closeDone(info); } } private getUserOverlaySettings( viewContainerRefOrSettings?: ViewContainerRef | OverlaySettings, moduleRefOrSettings?: { injector: Injector, componentFactoryResolver: ComponentFactoryResolver } | OverlaySettings): OverlaySettings { let result: OverlaySettings | undefined; if (viewContainerRefOrSettings && !(viewContainerRefOrSettings instanceof ViewContainerRef)) { result = viewContainerRefOrSettings; return result; } if (moduleRefOrSettings && !('injector' in moduleRefOrSettings && 'componentFactoryResolver' in moduleRefOrSettings)) { result = moduleRefOrSettings; } return result; } private getUserViewContainerOrModuleRef( viewContainerRefOrSettings?: ViewContainerRef | OverlaySettings, moduleRefOrSettings?: { injector: Injector, componentFactoryResolver: ComponentFactoryResolver } | OverlaySettings ): ViewContainerRef | { injector: Injector, componentFactoryResolver: ComponentFactoryResolver } | undefined { let result: ViewContainerRef | { injector: Injector, componentFactoryResolver: ComponentFactoryResolver } | undefined; if (viewContainerRefOrSettings instanceof ViewContainerRef) { result = viewContainerRefOrSettings; } if (moduleRefOrSettings && 'injector' in moduleRefOrSettings && 'componentFactoryResolver' in moduleRefOrSettings) { result = moduleRefOrSettings; } return result; } private getOverlayInfo( component: ElementRef | Type<any>, viewContainerRef?: { injector: Injector, componentFactoryResolver: ComponentFactoryResolver } | ViewContainerRef): OverlayInfo | null { const info: OverlayInfo = { ngZone: this._zone, transformX: 0, transformY: 0 }; if (component instanceof ElementRef) { info.elementRef = component; } else { let dynamicComponent: ComponentRef<any>; if (viewContainerRef instanceof ViewContainerRef) { dynamicComponent = viewContainerRef.createComponent(component); } else { let dynamicFactory: ComponentFactory<any>; const factoryResolver = viewContainerRef ? viewContainerRef.componentFactoryResolver : this._factoryResolver; try { dynamicFactory = factoryResolver.resolveComponentFactory(component); } catch (error) { console.error(error); return null; } const injector = viewContainerRef ? viewContainerRef.injector : this._injector; dynamicComponent = dynamicFactory.create(injector); this._appRef.attachView(dynamicComponent.hostView); } if (dynamicComponent.onDestroy) { dynamicComponent.onDestroy(() => { if (!info.detached && this._overlayInfos.indexOf(info) !== -1) { this.detach(info.id); } }) } // If the element is newly created from a Component, it is wrapped in 'ng-component' tag - we do not want that. const element = dynamicComponent.location.nativeElement; info.elementRef = { nativeElement: element }; info.componentRef = dynamicComponent; } return info; } private placeElementHook(element: HTMLElement): HTMLElement { if (!element.parentElement) { return null; } const hook = this._document.createElement('div'); hook.style.display = 'none'; element.parentElement.insertBefore(hook, element); return hook; } private moveElementToOverlay(info: OverlayInfo) { info.wrapperElement = this.getWrapperElement(); const contentElement = this.getContentElement(info.wrapperElement, info.settings.modal); this.getOverlayElement(info).appendChild(info.wrapperElement); contentElement.appendChild(info.elementRef.nativeElement); } private getWrapperElement(): HTMLElement { const wrapper: HTMLElement = this._document.createElement('div'); wrapper.classList.add('igx-overlay__wrapper'); return wrapper; } private getContentElement(wrapperElement: HTMLElement, modal: boolean): HTMLElement { const content: HTMLElement = this._document.createElement('div'); if (modal) { content.classList.add('igx-overlay__content--modal'); content.addEventListener('click', (ev: Event) => { ev.stopPropagation(); }); } else { content.classList.add('igx-overlay__content'); } content.addEventListener('scroll', (ev: Event) => { ev.stopPropagation(); }); // hide element to eliminate flickering. Show the element exactly before animation starts wrapperElement.style.visibility = 'hidden'; wrapperElement.appendChild(content); return content; } private getOverlayElement(info: OverlayInfo): HTMLElement { if (info.settings.outlet) { return info.settings.outlet.nativeElement || info.settings.outlet; } if (!this._overlayElement) { this._overlayElement = this._document.createElement('div'); this._overlayElement.classList.add('igx-overlay'); this._document.body.appendChild(this._overlayElement); } return this._overlayElement; } private updateSize(info: OverlayInfo) { if (info.componentRef) { // if we are positioning component this is first time it gets visible // and we can finally get its size info.componentRef.changeDetectorRef.detectChanges(); info.initialSize = info.elementRef.nativeElement.getBoundingClientRect(); } // set content div width only if element to show has width if (info.initialSize.width !== 0) { info.elementRef.nativeElement.parentElement.style.width = info.initialSize.width + 'px'; } } private closeDone(info: OverlayInfo) { info.visible = false; if (info.wrapperElement) { // to eliminate flickering show the element just before animation start info.wrapperElement.style.visibility = 'hidden'; } if (!info.closeAnimationDetaching) { this.closed.emit({ id: info.id, componentRef: info.componentRef, event: info.event }); } delete info.event; } private cleanUp(info: OverlayInfo) { const child: HTMLElement = info.elementRef.nativeElement; const outlet = this.getOverlayElement(info); // if same element is shown in other overlay outlet will not contain // the element and we should not remove it form outlet if (outlet.contains(child)) { outlet.removeChild(child.parentNode.parentNode); } if (info.componentRef) { this._appRef.detachView(info.componentRef.hostView); info.componentRef.destroy(); delete info.componentRef; } if (info.hook) { info.hook.parentElement.insertBefore(info.elementRef.nativeElement, info.hook); info.hook.parentElement.removeChild(info.hook); delete info.hook; } const index = this._overlayInfos.indexOf(info); this._overlayInfos.splice(index, 1); // this._overlayElement.parentElement check just for tests that manually delete the element if (this._overlayInfos.length === 0) { if (this._overlayElement && this._overlayElement.parentElement) { this._overlayElement.parentElement.removeChild(this._overlayElement); this._overlayElement = null; } this.removeCloseOnEscapeListener(); } // clean all the resources attached to info delete info.elementRef; delete info.settings; delete info.initialSize; info.openAnimationDetaching = true; info.openAnimationPlayer?.destroy(); delete info.openAnimationPlayer; info.closeAnimationDetaching = true; info.closeAnimationPlayer?.destroy(); delete info.closeAnimationPlayer; delete info.ngZone; delete info.wrapperElement; info = null; } private playOpenAnimation(info: OverlayInfo) { // if there is opening animation already started do nothing if (info.openAnimationPlayer?.hasStarted()) { return; } if (info.closeAnimationPlayer?.hasStarted()) { const position = info.closeAnimationPlayer.position; info.closeAnimationPlayer.reset(); info.openAnimationPlayer.init(); info.openAnimationPlayer.position = 1 - position; } this.animationStarting.emit({ id: info.id, animationPlayer: info.openAnimationPlayer, animationType: 'open' }); // to eliminate flickering show the element just before animation start info.wrapperElement.style.visibility = ''; info.visible = true; info.openAnimationPlayer.play(); } private playCloseAnimation(info: OverlayInfo, event?: Event) { // if there is closing animation already started do nothing if (info.closeAnimationPlayer?.hasStarted()) { return; } if (info.openAnimationPlayer?.hasStarted()) { const position = info.openAnimationPlayer.position; info.openAnimationPlayer.reset(); info.closeAnimationPlayer.init(); info.closeAnimationPlayer.position = 1 - position; } this.animationStarting.emit({ id: info.id, animationPlayer: info.closeAnimationPlayer, animationType: 'close' }); info.event = event; info.closeAnimationPlayer.play(); } // TODO: check if applyAnimationParams will work with complex animations private applyAnimationParams(wrapperElement: HTMLElement, animationOptions: AnimationReferenceMetadata) { if (!animationOptions) { wrapperElement.style.transitionDuration = '0ms'; return; } if (!animationOptions.options || !animationOptions.options.params) { return; } const params = animationOptions.options.params as IAnimationParams; if (params.duration) { wrapperElement.style.transitionDuration = params.duration; } if (params.easing) { wrapperElement.style.transitionTimingFunction = params.easing; } } private documentClicked = (ev: MouseEvent) => { // if we get to modal overlay just return - we should not close anything under it // if we get to non-modal overlay do the next: // 1. Check it has close on outside click. If not go on to next overlay; // 2. If true check if click is on the element. If it is on the element we have closed // already all previous non-modal with close on outside click elements, so we return. If // not close the overlay and check next for (let i = this._overlayInfos.length; i--;) { const info = this._overlayInfos[i]; if (info.settings.modal) { return; } if (info.settings.closeOnOutsideClick) { const target = ev.composed ? ev.composedPath()[0] : ev.target; const overlayElement = info.elementRef.nativeElement; // check if the click is on the overlay element or on an element from the exclusion list, and if so do not close the overlay const excludeElements = info.settings.excludeFromOutsideClick ? [...info.settings.excludeFromOutsideClick, overlayElement] : [overlayElement]; const isInsideClick: boolean = excludeElements.some(e => e.contains(target as Node)); if (isInsideClick) { return; // if the click is outside click, but close animation has started do nothing } else if (!(info.closeAnimationPlayer?.hasStarted())) { this._hide(info.id, ev); } } } }; private addOutsideClickListener(info: OverlayInfo) { if (info.settings.closeOnOutsideClick) { if (info.settings.modal) { fromEvent(info.elementRef.nativeElement.parentElement.parentElement, 'click') .pipe(takeUntil(this.destroy$)) .subscribe((e: Event) => this._hide(info.id, e)); } else if ( // if all overlays minus closing overlays equals one add the handler this._overlayInfos.filter(x => x.settings.closeOnOutsideClick && !x.settings.modal).length - this._overlayInfos.filter(x => x.settings.closeOnOutsideClick && !x.settings.modal && x.closeAnimationPlayer?.hasStarted()).length === 1) { // click event is not fired on iOS. To make element "clickable" we are // setting the cursor to pointer if (this.platformUtil.isIOS && !this._cursorStyleIsSet) { this._cursorOriginalValue = this._document.body.style.cursor; this._document.body.style.cursor = 'pointer'; this._cursorStyleIsSet = true; } this._document.addEventListener('click', this.documentClicked, true); } } } private removeOutsideClickListener(info: OverlayInfo) { if (info.settings.modal === false) { let shouldRemoveClickEventListener = true; this._overlayInfos.forEach(o => { if (o.settings.modal === false && o.id !== info.id) { shouldRemoveClickEventListener = false; } }); if (shouldRemoveClickEventListener) { if (this._cursorStyleIsSet) { this._document.body.style.cursor = this._cursorOriginalValue; this._cursorOriginalValue = ''; this._cursorStyleIsSet = false; } this._document.removeEventListener('click', this.documentClicked, true); } } } private addResizeHandler() { const closingOverlaysCount = this._overlayInfos .filter(o => o.closeAnimationPlayer?.hasStarted()) .length; if (this._overlayInfos.length - closingOverlaysCount === 1) { this._document.defaultView.addEventListener('resize', this.repositionAll); } } private removeResizeHandler() { const closingOverlaysCount = this._overlayInfos .filter(o => o.closeAnimationPlayer?.hasStarted()) .length; if (this._overlayInfos.length - closingOverlaysCount === 1) { this._document.defaultView.removeEventListener('resize', this.repositionAll); } } private addCloseOnEscapeListener(info: OverlayInfo) { if (info.settings.closeOnEscape && !this._keyPressEventListener) { this._keyPressEventListener = fromEvent(this._document, 'keydown').pipe( filter((ev: KeyboardEvent) => ev.key === 'Escape' || ev.key === 'Esc') ).subscribe((ev) => { const visibleOverlays = this._overlayInfos.filter(o => o.visible); if (visibleOverlays.length < 1) { return; } const targetOverlayInfo = visibleOverlays[visibleOverlays.length - 1]; if (targetOverlayInfo.visible && targetOverlayInfo.settings.closeOnEscape) { this.hide(targetOverlayInfo.id, ev); } }); } } private removeCloseOnEscapeListener() { if (this._keyPressEventListener) { this._keyPressEventListener.unsubscribe(); this._keyPressEventListener = null; } } private addModalClasses(info: OverlayInfo) { if (info.settings.modal) { const wrapperElement = info.elementRef.nativeElement.parentElement.parentElement; wrapperElement.classList.remove('igx-overlay__wrapper'); this.applyAnimationParams(wrapperElement, info.settings.positionStrategy.settings.openAnimation); requestAnimationFrame(() => { wrapperElement.classList.add('igx-overlay__wrapper--modal'); }); } } private removeModalClasses(info: OverlayInfo) { if (info.settings.modal) { const wrapperElement = info.elementRef.nativeElement.parentElement.parentElement; this.applyAnimationParams(wrapperElement, info.settings.positionStrategy.settings.closeAnimation); wrapperElement.classList.remove('igx-overlay__wrapper--modal'); wrapperElement.classList.add('igx-overlay__wrapper'); } } private buildAnimationPlayers(info: OverlayInfo) { if (info.settings.positionStrategy.settings.openAnimation) { info.openAnimationPlayer = this.animationService .buildAnimation(info.settings.positionStrategy.settings.openAnimation, info.elementRef.nativeElement); info.openAnimationPlayer.animationEnd .pipe(takeUntil(this.destroy$)) .subscribe(() => this.openAnimationDone(info)); } if (info.settings.positionStrategy.settings.closeAnimation) { info.closeAnimationPlayer = this.animationService .buildAnimation(info.settings.positionStrategy.settings.closeAnimation, info.elementRef.nativeElement); info.closeAnimationPlayer.animationEnd .pipe(takeUntil(this.destroy$)) .subscribe(() => this.closeAnimationDone(info)); } } private openAnimationDone(info: OverlayInfo) { if (!info.openAnimationDetaching) { this.opened.emit({ id: info.id, componentRef: info.componentRef }); } if (info.openAnimationPlayer) { info.openAnimationPlayer.reset(); } if (info.closeAnimationPlayer?.hasStarted()) { info.closeAnimationPlayer.reset(); } } private closeAnimationDone(info: OverlayInfo) { if (info.closeAnimationPlayer) { info.closeAnimationPlayer.reset(); } if (info.openAnimationPlayer?.hasStarted()) { info.openAnimationPlayer.reset(); } this.closeDone(info); } private finishAnimations(info: OverlayInfo) { // // TODO: should we emit here opened or closed events if (info.openAnimationPlayer?.hasStarted()) { info.openAnimationPlayer.finish(); } if (info.closeAnimationPlayer?.hasStarted()) { info.closeAnimationPlayer.finish(); } } }