thingsboard/ui-ngx/src/app/shared/components/popover.component.ts

638 lines
20 KiB
TypeScript
Raw Normal View History

///
2023-01-31 10:43:56 +02:00
/// Copyright © 2016-2023 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,
2021-10-07 10:34:43 +03:00
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ComponentFactory,
ComponentFactoryResolver,
ComponentRef,
Directive,
ElementRef,
2021-10-07 10:34:43 +03:00
EventEmitter,
Injector,
Input,
OnChanges,
OnDestroy,
OnInit,
Optional,
Output,
Renderer2,
SimpleChanges,
TemplateRef,
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,
2021-10-07 10:34:43 +03:00
popoverMotion,
PopoverPlacement,
POSITION_MAP,
PropertyMapping
} from '@shared/components/popover.models';
2022-06-01 16:35:20 +03:00
import { distinctUntilChanged, take, takeUntil } from 'rxjs/operators';
2021-10-07 10:34:43 +03:00
import { isNotEmptyStr, onParentScrollOrWindowResize } from '@core/utils';
import { animate, AnimationBuilder, AnimationMetadata, style } from '@angular/animations';
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<void>;
@Input('tbPopoverTrigger') trigger?: TbPopoverTrigger = 'hover';
@Input('tbPopoverPlacement') placement?: string | string[] = 'top';
@Input('tbPopoverOrigin') origin?: ElementRef<HTMLElement>;
@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<boolean>();
componentFactory: ComponentFactory<TbPopoverComponent> = this.resolver.resolveComponentFactory(TbPopoverComponent);
component?: TbPopoverComponent;
private readonly destroy$ = new Subject<void>();
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;
}
}
}
@Component({
selector: 'tb-popover',
exportAs: 'tbPopoverComponent',
animations: [popoverMotion],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
styleUrls: ['./popover.component.scss'],
template: `
<ng-template
#overlay="cdkConnectedOverlay"
cdkConnectedOverlay
[cdkConnectedOverlayHasBackdrop]="hasBackdrop"
[cdkConnectedOverlayOrigin]="origin"
[cdkConnectedOverlayPositions]="positions"
[cdkConnectedOverlayOpen]="visible"
[cdkConnectedOverlayPush]="true"
(overlayOutsideClick)="onClickOutside($event)"
(detach)="hide()"
(positionChange)="onPositionChange($event)"
>
<div #popoverRoot [@popoverMotion]="tbAnimationState"
(@popoverMotion.done)="animationDone()">
<div
#popover
class="tb-popover"
[class.tb-popover-rtl]="dir === 'rtl'"
[ngClass]="classMap"
[ngStyle]="tbOverlayStyle"
>
<div class="tb-popover-content">
<div class="tb-popover-arrow">
<span class="tb-popover-arrow-content"></span>
</div>
<div class="tb-popover-inner" [ngStyle]="tbPopoverInnerStyle" role="tooltip">
<div *ngIf="tbShowCloseButton" class="tb-popover-close-button" (click)="closeButtonClick($event)">×</div>
<div style="width: 100%; height: 100%;">
<div class="tb-popover-inner-content">
<ng-container *ngIf="tbContent">
<ng-container *tbStringTemplateOutlet="tbContent">{{ tbContent }}</ng-container>
</ng-container>
<ng-container *ngIf="tbComponentFactory"
[tbComponentOutlet]="tbComponentFactory"
[tbComponentInjector]="tbComponentInjector"
[tbComponentOutletContext]="tbComponentContext"
(componentChange)="onComponentChange($event)"
[tbComponentStyle]="tbComponentStyle">
</ng-container>
</div>
</div>
</div>
</div>
</div>
</div>
</ng-template>
`
})
export class TbPopoverComponent implements OnDestroy, OnInit {
@ViewChild('overlay', { static: false }) overlay!: CdkConnectedOverlay;
@ViewChild('popoverRoot', { static: false }) popoverRoot!: ElementRef<HTMLElement>;
@ViewChild('popover', { static: false }) popover!: ElementRef<HTMLElement>;
tbContent: string | TemplateRef<void> | null = null;
tbComponentFactory: ComponentFactory<any> | null = null;
tbComponentRef: ComponentRef<any> | 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;
tbShowCloseButton = true;
tbAnimationState = 'active';
tbVisibleChange = new Subject<boolean>();
tbAnimationDone = new Subject<void>();
tbComponentChange = new Subject<ComponentRef<any>>();
tbDestroy = new Subject<void>();
set tbVisible(value: boolean) {
const visible = value;
if (this.visible !== visible) {
this.visible = visible;
this.tbVisibleChange.next(visible);
}
}
get tbVisible(): boolean {
2022-06-01 16:35:20 +03:00
return this.visible && this.tbAnimationState === 'active';
}
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,
private animationBuilder: AnimationBuilder,
@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);
}
2022-06-01 16:35:20 +03:00
this.tbAnimationState = 'void';
this.cdr.detectChanges();
2022-06-01 16:35:20 +03:00
this.tbAnimationDone.pipe(take(1)).subscribe(() => {
this.tbVisible = false;
this.cdr.detectChanges();
});
}
updateByDirective(): void {
this.updateStyles();
this.cdr.detectChanges();
Promise.resolve().then(() => {
this.updatePosition();
this.updateVisibilityByContent();
});
}
resize(width: string, height: string, animationDurationMs?: number) {
if (animationDurationMs && animationDurationMs > 0) {
const prevWidth = this.popover.nativeElement.offsetWidth;
const prevHeight = this.popover.nativeElement.offsetHeight;
const animationMetadata: AnimationMetadata[] = [style({width: prevWidth + 'px', height: prevHeight + 'px'}),
animate(animationDurationMs + 'ms', style({width, height}))];
const factory = this.animationBuilder.build(animationMetadata);
const player = factory.create(this.popover.nativeElement);
player.play();
const resize$ = new ResizeObserver(() => {
this.updatePosition();
});
resize$.observe(this.popover.nativeElement);
player.onDone(() => {
player.destroy();
resize$.disconnect();
this.setSize(width, height);
});
} else {
this.setSize(width, height);
}
}
private setSize(width: string, height: string) {
this.renderer.setStyle(this.popover.nativeElement, 'width', width);
this.renderer.setStyle(this.popover.nativeElement, 'height', height);
this.updatePosition();
}
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 = {
[`tb-popover-placement-${this.preferredPlacement}`]: true,
['tb-popover-hidden']: this.tbHidden || !this.lastIsIntersecting
};
if (this.tbOverlayClassName) {
this.classMap[this.tbOverlayClassName] = true;
}
}
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) {
if (!this.isTopOverlay(event.target as Element)) {
this.hide();
}
}
}
onComponentChange(component: ComponentRef<any>) {
this.tbComponentRef = component;
this.tbComponentChange.next(component);
}
animationDone() {
this.tbAnimationDone.next();
}
private isTopOverlay(targetElement: Element): boolean {
const target = $(targetElement);
if (target.parents('.cdk-overlay-container').length) {
let targetOverlayContainerChild: JQuery<Element>;
if (target.hasClass('cdk-overlay-backdrop')) {
targetOverlayContainerChild = target;
} else {
targetOverlayContainerChild = target.parents('.cdk-overlay-pane').parent();
}
const currentOverlayContainerChild = $(this.overlay.overlayRef.overlayElement).parent();
return targetOverlayContainerChild.index() > currentOverlayContainerChild.index();
}
return false;
}
private updateVisibilityByContent(): void {
if (this.isEmpty()) {
this.hide();
}
}
private isEmpty(): boolean {
return (this.tbComponentFactory instanceof ComponentFactory || this.tbContent instanceof TemplateRef)
? false : !isNotEmptyStr(this.tbContent);
}
}