@deepkit/desktop-ui
Version:
Library for desktop UI widgets in Angular 10+
606 lines (515 loc) • 19.5 kB
text/typescript
/*
* Deepkit Framework
* Copyright (C) 2021 Deepkit UG, Marc J. Schmidt
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the MIT License.
*
* You should have received a copy of the MIT License along with this program.
*/
import {
AfterViewInit,
ChangeDetectorRef,
Component,
Directive,
ElementRef,
EventEmitter,
HostListener,
Injector,
Input,
OnChanges,
OnDestroy,
Optional,
Output,
SimpleChanges,
SkipSelf,
TemplateRef,
ViewChild,
ViewContainerRef,
} from '@angular/core';
import { TemplatePortal } from '@angular/cdk/portal';
import { ConnectedPosition, Overlay, OverlayConfig, OverlayRef, PositionStrategy } from '@angular/cdk/overlay';
import { Subscription } from 'rxjs';
import { WindowRegistry } from '../window/window-state';
import { focusWatcher } from '../../core/utils';
import { isArray } from '@deepkit/core';
import { OverlayStack, OverlayStackItem, ReactiveChangeDetectionModule, unsubscribe } from '../app';
import { ButtonComponent } from './button.component';
export class DropdownComponent implements OnChanges, OnDestroy, AfterViewInit {
public isOpen = false;
public overlayRef?: OverlayRef;
protected lastFocusWatcher?: Subscription;
host?: HTMLElement | ElementRef;
allowedFocus: (HTMLElement | ElementRef)[] | (HTMLElement | ElementRef) = [];
/**
* For debugging purposes.
*/
keepOpen?: true;
height?: number | string;
width?: number | string;
minWidth?: number | string;
minHeight?: number | string;
maxWidth?: number | string;
maxHeight?: number | string;
scrollbars: boolean = true;
/**
* Whether the dropdown aligns to the horizontal center.
*/
center: boolean = false;
/**
* Whether is styled as overlay
*/
overlay: boolean | '' = false;
show?: boolean;
connectedPositions: ConnectedPosition[] = [];
showChange = new EventEmitter<boolean>();
shown = new EventEmitter();
hidden = new EventEmitter();
dropdownTemplate!: TemplateRef<any>;
dropdown!: ElementRef<HTMLElement>;
container?: TemplateRef<any> | undefined;
relativeToInitiator?: HTMLElement;
protected lastOverlayStackItem?: OverlayStackItem;
constructor(
protected overlayService: Overlay,
protected injector: Injector,
protected overlayStack: OverlayStack,
protected viewContainerRef: ViewContainerRef,
protected cd: ChangeDetectorRef,
protected cdParent: ChangeDetectorRef,
protected registry?: WindowRegistry,
) {
}
ngOnChanges(changes: SimpleChanges): void {
if (changes.show && this.dropdownTemplate) {
if (this.show === true) this.open();
if (this.show === false) this.close();
}
}
ngAfterViewInit() {
if (this.show === true) this.open();
if (this.show === false) this.close();
}
ngOnDestroy(): void {
this.close();
}
public key(event: KeyboardEvent) {
if (!this.keepOpen && this.isOpen && event.key.toLowerCase() === 'escape' && this.lastOverlayStackItem && this.lastOverlayStackItem.isLast()) {
this.close();
}
}
public toggle(target?: HTMLElement | ElementRef | MouseEvent) {
if (this.isOpen) {
this.close();
} else {
this.open(target);
}
}
public setContainer(container: TemplateRef<any> | undefined) {
this.container = container;
}
public open(target?: HTMLElement | ElementRef | MouseEvent | 'center', initiator?: HTMLElement | ElementRef | { x: number, y: number, width: number, height: number }) {
if (this.lastFocusWatcher) {
this.lastFocusWatcher.unsubscribe();
}
if (!target) {
target = this.host!;
}
target = target instanceof ElementRef ? target.nativeElement : target;
if (!target) {
throw new Error('No target or host specified for dropdown');
}
let position: PositionStrategy | undefined;
//this is necessary for multi-window environments, but doesn't work yet.
// const document = this.registry.getCurrentViewContainerRef().element.nativeElement.ownerDocument;
// const overlayContainer = new OverlayContainer(document);
// const overlayContainer = new OverlayContainer(document);
// const overlay = new Overlay(
// this.injector.get(ScrollStrategyOptions),
// overlayContainer,
// this.injector.get(ComponentFactoryResolver),
// new OverlayPositionBuilder(this.injector.get(ViewportRuler), document, this.injector.get(Platform), overlayContainer),
// this.injector.get(OverlayKeyboardDispatcher),
// this.injector,
// this.injector.get(NgZone),
// document,
// this.injector.get(Directionality),
// );
const overlay = this.overlayService;
if (target instanceof MouseEvent) {
const mousePosition = { x: target.pageX, y: target.pageY };
position = overlay
.position()
.flexibleConnectedTo(mousePosition)
.withFlexibleDimensions(false)
.withViewportMargin(12)
.withPush(true)
.withDefaultOffsetY(this.overlay !== false ? 15 : 0)
.withPositions([
...this.connectedPositions,
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
},
{
originX: 'end',
originY: 'bottom',
overlayX: 'end',
overlayY: 'top',
}
]);
} else if (target === 'center') {
position = overlay
.position()
.global().centerHorizontally().centerVertically();
} else {
position = overlay
.position()
.flexibleConnectedTo(target)
.withFlexibleDimensions(false)
.withViewportMargin(12)
.withPush(true)
.withDefaultOffsetY(this.overlay !== false ? 15 : 0)
.withPositions([
...this.connectedPositions,
{
originX: this.center ? 'center' : 'start',
originY: 'bottom',
overlayX: this.center ? 'center' : 'start',
overlayY: 'top',
},
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
},
{
originX: 'end',
originY: 'bottom',
overlayX: 'end',
overlayY: 'top',
}
]);
}
if (this.overlayRef) {
this.overlayRef.updatePositionStrategy(position);
this.overlayRef.updatePosition();
} else {
this.isOpen = true;
const options: OverlayConfig = {
minWidth: 50,
maxWidth: 450,
maxHeight: '90%',
hasBackdrop: false,
scrollStrategy: overlay.scrollStrategies.reposition(),
positionStrategy: position
};
if (this.width) options.width = this.width;
if (this.height) options.height = this.height;
if (this.minWidth) options.minWidth = this.minWidth;
if (this.minHeight) options.minHeight = this.minHeight;
if (this.maxWidth) options.maxWidth = this.maxWidth;
if (this.maxHeight) options.maxHeight = this.maxHeight;
this.overlayRef = overlay.create(options);
if (!this.dropdownTemplate) throw new Error('No dropdownTemplate set');
const portal = new TemplatePortal(this.dropdownTemplate, this.viewContainerRef);
this.overlayRef!.attach(portal);
this.cd.detectChanges();
this.overlayRef!.updatePosition();
this.shown.emit();
this.showChange.emit(true);
// console.log('this.overlayRef', initiator, this.overlayRef.overlayElement);
this.setInitiator(initiator);
if (this.relativeToInitiator) {
const overlayElement = this.overlayRef.overlayElement;
const rect = this.getInitiatorRelativeRect();
overlayElement.style.transformOrigin = '0 0';
overlayElement.style.transform = `translate(${rect.x}px, ${rect.y}px) scale(${rect.width}, ${rect.height})`;
setTimeout(() => {
overlayElement.style.transition = `transform 0.1s ease-in`;
overlayElement.style.transform = `translate(0, 0) scale(1, 1)`;
}, 1);
}
setTimeout(() => {
if (this.overlayRef) this.overlayRef.updatePosition();
}, 0);
setTimeout(() => {
if (this.overlayRef) this.overlayRef.updatePosition();
}, 50);
if (this.lastOverlayStackItem) this.lastOverlayStackItem.release();
this.lastOverlayStackItem = this.overlayStack.register(this.overlayRef.hostElement);
}
const normalizedAllowedFocus = isArray(this.allowedFocus) ? this.allowedFocus : (this.allowedFocus ? [this.allowedFocus] : []);
const allowedFocus = normalizedAllowedFocus.map(v => v instanceof ElementRef ? v.nativeElement : v) as HTMLElement[];
allowedFocus.push(this.overlayRef.hostElement);
if (this.show === undefined) {
this.overlayRef.hostElement.focus();
this.lastFocusWatcher = focusWatcher(this.dropdown.nativeElement, [...allowedFocus, target as any], (element) => {
//if the element is a dialog as well, we don't close
if (!element) return false;
if (this.lastOverlayStackItem) {
//when there's a overlay above ours we keep it open
if (!this.lastOverlayStackItem.isLast()) return true;
}
return false;
}).subscribe(() => {
if (!this.keepOpen) {
this.close();
ReactiveChangeDetectionModule.tick();
}
});
}
}
public setInitiator(initiator?: HTMLElement | ElementRef | { x: number, y: number, width: number, height: number }) {
if (!this.overlayRef) return;
initiator = initiator instanceof ElementRef ? initiator.nativeElement : initiator;
initiator = initiator instanceof HTMLElement ? initiator : undefined;
this.relativeToInitiator = initiator;
}
protected getInitiatorRelativeRect() {
const initiator = this.relativeToInitiator?.getBoundingClientRect();
if (!this.overlayRef || !initiator) return { x: 0, y: 0, width: 1, height: 1 };
const overlayElement = this.overlayRef.overlayElement;
const overlayRect = overlayElement.getBoundingClientRect();
return {
x: initiator.x - overlayRect.x,
y: initiator.y - overlayRect.y,
width: initiator.width / overlayRect.width,
height: initiator.height / overlayRect.height
};
}
public focus() {
if (!this.dropdown) return;
this.dropdown.nativeElement.focus();
}
public close() {
if (!this.isOpen) return;
if (this.lastOverlayStackItem) this.lastOverlayStackItem.release();
this.isOpen = false;
if (this.relativeToInitiator && this.overlayRef) {
const overlayElement = this.overlayRef.overlayElement;
const rect = this.getInitiatorRelativeRect();
overlayElement.style.transition = `transform 0.1s ease-out`;
overlayElement.style.transform = `translate(${rect.x}px, ${rect.y}px) scale(${rect.width}, ${rect.height})`;
this.relativeToInitiator = undefined;
const transitionEnd = () => {
if (this.overlayRef) {
this.overlayRef.dispose();
this.overlayRef = undefined;
}
this.cd.detectChanges();
this.hidden.emit();
this.showChange.emit(false);
overlayElement.removeEventListener('transitionend', transitionEnd);
}
overlayElement.addEventListener('transitionend', transitionEnd);
} else {
if (this.overlayRef) {
this.overlayRef.dispose();
this.overlayRef = undefined;
}
this.cd.detectChanges();
this.hidden.emit();
this.showChange.emit(false);
}
}
}
/**
* A directive to open the given dropdown on regular left click.
*/
export class OpenDropdownDirective implements AfterViewInit, OnDestroy {
openDropdown?: DropdownComponent;
openSub?: Subscription;
hiddenSub?: Subscription;
constructor(
protected elementRef: ElementRef,
protected button?: ButtonComponent,
) {
}
ngOnDestroy() {
}
ngAfterViewInit() {
if (this.button && this.openDropdown) {
this.openSub = this.openDropdown.shown.subscribe(() => {
if (this.button) this.button.active = true;
});
this.hiddenSub = this.openDropdown.hidden.subscribe(() => {
if (this.button) this.button.active = false;
});
}
}
onClick() {
if (this.openDropdown) {
this.openDropdown.toggle(this.elementRef);
}
}
}
/**
* A directive to open the given dropdown on mouseenter, and closes automatically on mouseleave.
* Dropdown keeps open when mouse enters the dropdown
*/
export class OpenDropdownHoverDirective implements OnDestroy {
openDropdownHover?: DropdownComponent;
/**
* In milliseconds.
*/
openDropdownHoverCloseTimeout: number = 80;
openSub?: Subscription;
hiddenSub?: Subscription;
lastHide?: any;
constructor(
protected elementRef: ElementRef,
) {
}
ngOnDestroy() {
}
onHover() {
clearTimeout(this.lastHide);
this.lastHide = undefined;
if (this.openDropdownHover && !this.openDropdownHover.isOpen) {
this.openDropdownHover.open(this.elementRef);
if (this.openDropdownHover.overlayRef) {
this.openDropdownHover.overlayRef.hostElement.addEventListener('mouseenter', () => {
this.onHover();
});
this.openDropdownHover.overlayRef.hostElement.addEventListener('mouseleave', () => {
this.onLeave();
});
}
}
}
onLeave() {
this.lastHide = setTimeout(() => {
if (this.openDropdownHover && this.lastHide) this.openDropdownHover.close();
}, this.openDropdownHoverCloseTimeout);
}
}
/**
* A directive to open the given dropdown upon right click / context menu.
*/
export class ContextDropdownDirective {
contextDropdown?: DropdownComponent;
onClick($event: MouseEvent) {
if (this.contextDropdown && $event.button === 2) {
this.contextDropdown.close();
$event.preventDefault();
$event.stopPropagation();
this.contextDropdown.open($event);
}
}
}
export class DropdownSplitterComponent {
}
/**
* This directive is necessary if you want to load and render the dialog content
* only when opening the dialog. Without it, it is immediately rendered, which can cause
* performance and injection issues.
*
* ```typescript
* <dui-dropdown>
* <ng-container *dropdownContainer>
* Dynamically created upon dropdown instantiation.
* </ng-container>
* </dui-dropdown>
*
* ```
*/
export class DropdownContainerDirective {
constructor(protected dropdown: DropdownComponent, public template: TemplateRef<any>) {
this.dropdown.setContainer(this.template);
}
}
export class DropdownItemComponent {
selected = false;
disabled: boolean | '' = false;
closeOnClick: boolean = true;
constructor(protected dropdown: DropdownComponent) {
}
onClick() {
if (this.closeOnClick) {
this.dropdown.close();
}
}
}