/// /// Copyright © 2016-2021 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. /// You may obtain a copy of the License at /// /// http://www.apache.org/licenses/LICENSE-2.0 /// /// Unless required by applicable law or agreed to in writing, software /// distributed under the License is distributed on an "AS IS" BASIS, /// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. /// See the License for the specific language governing permissions and /// limitations under the License. /// import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ComponentFactory, ComponentFactoryResolver, ComponentRef, Directive, ElementRef, EventEmitter, Injectable, Injector, Input, OnChanges, OnDestroy, OnInit, Optional, Output, Renderer2, SimpleChanges, TemplateRef, Type, ViewChild, ViewContainerRef, ViewEncapsulation } from '@angular/core'; import { Direction, Directionality } from '@angular/cdk/bidi'; import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectedOverlayPositionChange, ConnectionPositionPair } from '@angular/cdk/overlay'; import { Subject, Subscription } from 'rxjs'; import { DEFAULT_POPOVER_POSITIONS, getPlacementName, popoverMotion, PopoverPlacement, PopoverWithTrigger, POSITION_MAP, PropertyMapping } from '@shared/components/popover.models'; import { distinctUntilChanged, takeUntil } from 'rxjs/operators'; import { isNotEmptyStr, onParentScrollOrWindowResize } from '@core/utils'; import { HelpMarkdownComponent } from '@shared/components/help-markdown.component'; export type TbPopoverTrigger = 'click' | 'focus' | 'hover' | null; @Directive({ selector: '[tb-popover]', exportAs: 'tbPopover', host: { '[class.tb-popover-open]': 'visible' } }) export class TbPopoverDirective implements OnChanges, OnDestroy, AfterViewInit { // tslint:disable:no-input-rename @Input('tbPopoverContent') content?: string | TemplateRef; @Input('tbPopoverTrigger') trigger?: TbPopoverTrigger = 'hover'; @Input('tbPopoverPlacement') placement?: string | string[] = 'top'; @Input('tbPopoverOrigin') origin?: ElementRef; @Input('tbPopoverVisible') visible?: boolean; @Input('tbPopoverMouseEnterDelay') mouseEnterDelay?: number; @Input('tbPopoverMouseLeaveDelay') mouseLeaveDelay?: number; @Input('tbPopoverOverlayClassName') overlayClassName?: string; @Input('tbPopoverOverlayStyle') overlayStyle?: { [klass: string]: any }; @Input() tbPopoverBackdrop = false; // tslint:disable-next-line:no-output-rename @Output('tbPopoverVisibleChange') readonly visibleChange = new EventEmitter(); componentFactory: ComponentFactory = this.resolver.resolveComponentFactory(TbPopoverComponent); component?: TbPopoverComponent; private readonly destroy$ = new Subject(); private readonly triggerDisposables: Array<() => void> = []; private delayTimer?; private internalVisible = false; constructor( private elementRef: ElementRef, private hostView: ViewContainerRef, private resolver: ComponentFactoryResolver, private renderer: Renderer2 ) {} ngOnChanges(changes: SimpleChanges): void { const { trigger } = changes; if (trigger && !trigger.isFirstChange()) { this.registerTriggers(); } if (this.component) { this.updatePropertiesByChanges(changes); } } ngAfterViewInit(): void { this.registerTriggers(); } ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); this.clearTogglingTimer(); this.removeTriggerListeners(); } show(): void { if (!this.component) { this.createComponent(); } this.component?.show(); } hide(): void { this.component?.hide(); } updatePosition(): void { if (this.component) { this.component.updatePosition(); } } private createComponent(): void { const componentRef = this.hostView.createComponent(this.componentFactory); this.component = componentRef.instance; this.renderer.removeChild( this.renderer.parentNode(this.elementRef.nativeElement), componentRef.location.nativeElement ); this.component.setOverlayOrigin({ elementRef: this.origin || this.elementRef }); this.initProperties(); this.component.tbVisibleChange .pipe(distinctUntilChanged(), takeUntil(this.destroy$)) .subscribe((visible: boolean) => { this.internalVisible = visible; this.visibleChange.emit(visible); }); } private registerTriggers(): void { // When the method gets invoked, all properties has been synced to the dynamic component. // After removing the old API, we can just check the directive's own `nzTrigger`. const el = this.elementRef.nativeElement; const trigger = this.trigger; this.removeTriggerListeners(); if (trigger === 'hover') { let overlayElement: HTMLElement; this.triggerDisposables.push( this.renderer.listen(el, 'mouseenter', () => { this.delayEnterLeave(true, true, this.mouseEnterDelay); }) ); this.triggerDisposables.push( this.renderer.listen(el, 'mouseleave', () => { this.delayEnterLeave(true, false, this.mouseLeaveDelay); if (this.component?.overlay.overlayRef && !overlayElement) { overlayElement = this.component.overlay.overlayRef.overlayElement; this.triggerDisposables.push( this.renderer.listen(overlayElement, 'mouseenter', () => { this.delayEnterLeave(false, true, this.mouseEnterDelay); }) ); this.triggerDisposables.push( this.renderer.listen(overlayElement, 'mouseleave', () => { this.delayEnterLeave(false, false, this.mouseLeaveDelay); }) ); } }) ); } else if (trigger === 'focus') { this.triggerDisposables.push(this.renderer.listen(el, 'focusin', () => this.show())); this.triggerDisposables.push(this.renderer.listen(el, 'focusout', () => this.hide())); } else if (trigger === 'click') { this.triggerDisposables.push( this.renderer.listen(el, 'click', (e: MouseEvent) => { e.preventDefault(); if (this.component?.visible) { this.hide(); } else { this.show(); } }) ); } // Else do nothing because user wants to control the visibility programmatically. } private updatePropertiesByChanges(changes: SimpleChanges): void { this.updatePropertiesByKeys(Object.keys(changes)); } private updatePropertiesByKeys(keys?: string[]): void { const mappingProperties: PropertyMapping = { // common mappings content: ['tbContent', () => this.content], trigger: ['tbTrigger', () => this.trigger], placement: ['tbPlacement', () => this.placement], visible: ['tbVisible', () => this.visible], mouseEnterDelay: ['tbMouseEnterDelay', () => this.mouseEnterDelay], mouseLeaveDelay: ['tbMouseLeaveDelay', () => this.mouseLeaveDelay], overlayClassName: ['tbOverlayClassName', () => this.overlayClassName], overlayStyle: ['tbOverlayStyle', () => this.overlayStyle], tbPopoverBackdrop: ['tbBackdrop', () => this.tbPopoverBackdrop] }; (keys || Object.keys(mappingProperties).filter(key => !key.startsWith('directive'))).forEach( (property: any) => { if (mappingProperties[property]) { const [name, valueFn] = mappingProperties[property]; this.updateComponentValue(name, valueFn()); } } ); this.component?.updateByDirective(); } private initProperties(): void { this.updatePropertiesByKeys(); } private updateComponentValue(key: string, value: any): void { if (typeof value !== 'undefined') { // @ts-ignore this.component[key] = value; } } private delayEnterLeave(isOrigin: boolean, isEnter: boolean, delay: number = -1): void { if (this.delayTimer) { this.clearTogglingTimer(); } else if (delay > 0) { this.delayTimer = setTimeout(() => { this.delayTimer = undefined; isEnter ? this.show() : this.hide(); }, delay * 1000); } else { // `isOrigin` is used due to the tooltip will not hide immediately // (may caused by the fade-out animation). isEnter && isOrigin ? this.show() : this.hide(); } } private removeTriggerListeners(): void { this.triggerDisposables.forEach(dispose => dispose()); this.triggerDisposables.length = 0; } private clearTogglingTimer(): void { if (this.delayTimer) { clearTimeout(this.delayTimer); this.delayTimer = undefined; } } } @Injectable() export class TbPopoverService { private popoverWithTriggers: PopoverWithTrigger[] = []; componentFactory: ComponentFactory = this.resolver.resolveComponentFactory(TbPopoverComponent); constructor(private resolver: ComponentFactoryResolver) { } hasPopover(trigger: Element): boolean { const res = this.findPopoverByTrigger(trigger); return res !== null; } hidePopover(trigger: Element): boolean { const component: TbPopoverComponent = this.findPopoverByTrigger(trigger); if (component && component.tbVisible) { component.hide(); return true; } else { return false; } } displayPopover(trigger: Element, renderer: Renderer2, hostView: ViewContainerRef, componentType: Type, preferredPlacement: PopoverPlacement = 'top', hideOnClickOutside = true, injector?: Injector, context?: any, popoverStyle: any = {}, style?: any): TbPopoverComponent { const componentRef = hostView.createComponent(this.componentFactory); const component = componentRef.instance; this.popoverWithTriggers.push({ trigger, popoverComponent: component }); renderer.removeChild( renderer.parentNode(trigger), componentRef.location.nativeElement ); const originElementRef = new ElementRef(trigger); component.setOverlayOrigin({ elementRef: originElementRef }); component.tbPlacement = preferredPlacement; component.tbComponentFactory = this.resolver.resolveComponentFactory(componentType); component.tbComponentInjector = injector; component.tbComponentContext = context; component.tbPopoverInnerStyle = popoverStyle; component.tbComponentStyle = style; component.tbHideOnClickOutside = hideOnClickOutside; component.tbVisibleChange.subscribe((visible: boolean) => { if (!visible) { component.tbAnimationDone.subscribe(() => { componentRef.destroy(); }); } }); component.tbDestroy.subscribe(() => { this.removePopoverByComponent(component); }); component.show(); return component; } toggleHelpPopover(trigger: Element, renderer: Renderer2, hostView: ViewContainerRef, helpId: string, visibleFn: (visible: boolean) => void, readyFn: (ready: boolean) => void) { if (this.hasPopover(trigger)) { this.hidePopover(trigger); } else { readyFn(false); const injector = Injector.create({ parent: hostView.injector, providers: [] }); const componentRef = hostView.createComponent(this.componentFactory); const component = componentRef.instance; this.popoverWithTriggers.push({ trigger, popoverComponent: component }); renderer.removeChild( renderer.parentNode(trigger), componentRef.location.nativeElement ); const originElementRef = new ElementRef(trigger); component.tbAnimationState = 'void'; component.tbOverlayStyle = { opacity: '0' }; component.setOverlayOrigin({ elementRef: originElementRef }); component.tbPlacement = 'bottom'; component.tbComponentFactory = this.resolver.resolveComponentFactory(HelpMarkdownComponent); component.tbComponentInjector = injector; component.tbComponentContext = { helpId, visible: true }; component.tbHideOnClickOutside = true; component.tbVisibleChange.subscribe((visible: boolean) => { if (!visible) { visibleFn(false); component.tbAnimationDone.subscribe(() => { componentRef.destroy(); }); } }); component.tbDestroy.subscribe(() => { this.removePopoverByComponent(component); }); const showHelpMarkdownComponent = () => { component.tbOverlayStyle = { opacity: '1' }; component.tbAnimationState = 'active'; component.updatePosition(); readyFn(true); setTimeout(() => { component.updatePosition(); }); }; const setupHelpMarkdownComponent = (helpMarkdownComponent: HelpMarkdownComponent) => { if (helpMarkdownComponent.isMarkdownReady) { showHelpMarkdownComponent(); } else { helpMarkdownComponent.markdownReady.subscribe(() => { showHelpMarkdownComponent(); }); } }; if (component.tbComponentRef) { setupHelpMarkdownComponent(component.tbComponentRef.instance); } else { component.tbComponentChange.subscribe((helpMarkdownComponentRef) => { setupHelpMarkdownComponent(helpMarkdownComponentRef.instance); }); } component.show(); visibleFn(true); } } private findPopoverByTrigger(trigger: Element): TbPopoverComponent | null { const res = this.popoverWithTriggers.find(val => this.elementsAreEqualOrDescendant(trigger, val.trigger)); if (res) { return res.popoverComponent; } else { return null; } } private removePopoverByComponent(component: TbPopoverComponent): void { const index = this.popoverWithTriggers.findIndex(val => val.popoverComponent === component); if (index > -1) { this.popoverWithTriggers.splice(index, 1); } } private elementsAreEqualOrDescendant(element1: Element, element2: Element): boolean { return element1 === element2 || element1.contains(element2) || element2.contains(element1); } } @Component({ selector: 'tb-popover', exportAs: 'tbPopoverComponent', animations: [popoverMotion], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, styleUrls: ['./popover.component.scss'], template: `
` }) export class TbPopoverComponent implements OnDestroy, OnInit { @ViewChild('overlay', { static: false }) overlay!: CdkConnectedOverlay; @ViewChild('popoverRoot', { static: false }) popoverRoot!: ElementRef; tbContent: string | TemplateRef | null = null; tbComponentFactory: ComponentFactory | null = null; tbComponentRef: ComponentRef | null = null; tbComponentContext: any; tbComponentInjector: Injector | null = null; tbComponentStyle: { [klass: string]: any } = {}; tbOverlayClassName!: string; tbOverlayStyle: { [klass: string]: any } = {}; tbPopoverInnerStyle: { [klass: string]: any } = {}; tbBackdrop = false; tbMouseEnterDelay?: number; tbMouseLeaveDelay?: number; tbHideOnClickOutside = true; tbAnimationState = 'active'; tbVisibleChange = new Subject(); tbAnimationDone = new Subject(); tbComponentChange = new Subject>(); tbDestroy = new Subject(); set tbVisible(value: boolean) { const visible = value; if (this.visible !== visible) { this.visible = visible; this.tbVisibleChange.next(visible); } } get tbVisible(): boolean { return this.visible; } visible = false; set tbHidden(value: boolean) { const hidden = value; if (this.hidden !== hidden) { this.hidden = hidden; if (this.hidden) { this.renderer.setStyle(this.popoverRoot.nativeElement, 'width', this.popoverRoot.nativeElement.offsetWidth + 'px'); this.renderer.setStyle(this.popoverRoot.nativeElement, 'height', this.popoverRoot.nativeElement.offsetHeight + 'px'); } else { setTimeout(() => { this.renderer.removeStyle(this.popoverRoot.nativeElement, 'width'); this.renderer.removeStyle(this.popoverRoot.nativeElement, 'height'); }); } this.updateStyles(); this.cdr.markForCheck(); } } get tbHidden(): boolean { return this.hidden; } hidden = false; lastIsIntersecting = true; set tbTrigger(value: TbPopoverTrigger) { this.trigger = value; } get tbTrigger(): TbPopoverTrigger { return this.trigger; } protected trigger: TbPopoverTrigger = 'hover'; set tbPlacement(value: PopoverPlacement | PopoverPlacement[]) { if (typeof value === 'string') { this.positions = [POSITION_MAP[value], ...DEFAULT_POPOVER_POSITIONS]; } else { const preferredPosition = value.map(placement => POSITION_MAP[placement]); this.positions = [...preferredPosition, ...DEFAULT_POPOVER_POSITIONS]; } } get hasBackdrop(): boolean { return this.tbTrigger === 'click' ? this.tbBackdrop : false; } preferredPlacement: PopoverPlacement = 'top'; origin!: CdkOverlayOrigin; public dir: Direction = 'ltr'; classMap: { [klass: string]: any } = {}; positions: ConnectionPositionPair[] = [...DEFAULT_POPOVER_POSITIONS]; private parentScrollSubscription: Subscription = null; private intersectionObserver = new IntersectionObserver((entries) => { if (this.lastIsIntersecting !== entries[0].isIntersecting) { this.lastIsIntersecting = entries[0].isIntersecting; this.updateStyles(); this.cdr.markForCheck(); } }, {threshold: [0.5]}); constructor( public cdr: ChangeDetectorRef, private renderer: Renderer2, @Optional() private directionality: Directionality ) {} ngOnInit(): void { this.directionality.change?.pipe(takeUntil(this.tbDestroy)).subscribe((direction: Direction) => { this.dir = direction; this.cdr.detectChanges(); }); this.dir = this.directionality.value; } ngOnDestroy(): void { if (this.parentScrollSubscription) { this.parentScrollSubscription.unsubscribe(); this.parentScrollSubscription = null; } if (this.origin) { const el = this.origin.elementRef.nativeElement; this.intersectionObserver.unobserve(el); } this.intersectionObserver.disconnect(); this.intersectionObserver = null; this.tbVisibleChange.complete(); this.tbAnimationDone.complete(); this.tbDestroy.next(); this.tbDestroy.complete(); } closeButtonClick($event: Event) { if ($event) { $event.preventDefault(); $event.stopPropagation(); } this.hide(); } show(): void { if (this.tbVisible) { return; } if (!this.isEmpty()) { this.tbVisible = true; this.tbVisibleChange.next(true); this.cdr.detectChanges(); } if (this.origin && this.overlay && this.overlay.overlayRef) { if (this.overlay.overlayRef.getDirection() === 'rtl') { this.overlay.overlayRef.setDirection('ltr'); } const el = this.origin.elementRef.nativeElement; this.parentScrollSubscription = onParentScrollOrWindowResize(el).subscribe(() => { this.overlay.overlayRef.updatePosition(); }); this.intersectionObserver.observe(el); } } hide(): void { if (!this.tbVisible) { return; } if (this.parentScrollSubscription) { this.parentScrollSubscription.unsubscribe(); this.parentScrollSubscription = null; } if (this.origin) { const el = this.origin.elementRef.nativeElement; this.intersectionObserver.unobserve(el); } this.tbVisible = false; this.tbVisibleChange.next(false); this.cdr.detectChanges(); } updateByDirective(): void { this.updateStyles(); this.cdr.detectChanges(); Promise.resolve().then(() => { this.updatePosition(); this.updateVisibilityByContent(); }); } updatePosition(): void { if (this.origin && this.overlay && this.overlay.overlayRef) { this.overlay.overlayRef.updatePosition(); } } onPositionChange(position: ConnectedOverlayPositionChange): void { this.preferredPlacement = getPlacementName(position); this.updateStyles(); this.cdr.detectChanges(); } updateStyles(): void { this.classMap = { [this.tbOverlayClassName]: true, [`tb-popover-placement-${this.preferredPlacement}`]: true, ['tb-popover-hidden']: this.tbHidden || !this.lastIsIntersecting }; } setOverlayOrigin(origin: CdkOverlayOrigin): void { this.origin = origin; this.cdr.markForCheck(); } onClickOutside(event: MouseEvent): void { if (this.tbHideOnClickOutside && !this.origin.elementRef.nativeElement.contains(event.target) && this.tbTrigger !== null) { this.hide(); } } onComponentChange(component: ComponentRef) { this.tbComponentRef = component; this.tbComponentChange.next(component); } animationDone() { this.tbAnimationDone.next(); } private updateVisibilityByContent(): void { if (this.isEmpty()) { this.hide(); } } private isEmpty(): boolean { return (this.tbComponentFactory instanceof ComponentFactory || this.tbContent instanceof TemplateRef) ? false : !isNotEmptyStr(this.tbContent); } }