import { ChangeDetectorRef, Component, Directive, ElementRef, EventEmitter, HostListener, Injector, Input, OnChanges, OnDestroy, Output, SimpleChanges, SkipSelf, TemplateRef, ViewChild, ViewContainerRef } from "@angular/core"; import {TemplatePortal} from "@angular/cdk/portal"; import {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 "@marcj/estdlib"; @Component({ selector: 'dui-dropdown', template: `
`, host: { '[class.overlay]': 'overlay !== false', }, styleUrls: ['./dropdow.component.scss'] }) export class DropdownComponent implements OnChanges, OnDestroy { public isOpen = false; public overlayRef?: OverlayRef; protected lastFocusWatcher?: Subscription; @Input() host?: HTMLElement | ElementRef; @Input() allowedFocus: (HTMLElement | ElementRef)[] | (HTMLElement | ElementRef) = []; /** * For debugging purposes. */ @Input() keepOpen?: true; @Input() height?: number | string; @Input() width?: number | string; @Input() minWidth?: number | string; @Input() minHeight?: number | string; @Input() maxWidth?: number | string; @Input() maxHeight?: number | string; @Input() scrollbars: boolean = true; /** * Whether the dropdown aligns to the horizontal center. */ @Input() center: boolean = false; /** * Whether is styled as overlay */ @Input() overlay: boolean | '' = false; @Input() show?: boolean; @Output() showChange = new EventEmitter(); @Output() shown = new EventEmitter(); @Output() hidden = new EventEmitter(); @ViewChild('dropdownTemplate', {static: false}) dropdownTemplate!: TemplateRef; @ViewChild('dropdown', {static: false}) dropdown!: ElementRef; constructor( protected overlayService: Overlay, protected injector: Injector, protected registry: WindowRegistry, protected viewContainerRef: ViewContainerRef, protected cd: ChangeDetectorRef, @SkipSelf() protected cdParent: ChangeDetectorRef, ) { } ngOnChanges(changes: SimpleChanges): void { if (changes.show) { if (this.show === true) this.open(); if (this.show === false) this.close(); } } ngOnDestroy(): void { this.close(); } @HostListener('window:keyup', ['$event']) public key(event: KeyboardEvent) { if (this.isOpen && event.key.toLowerCase() === 'escape') { this.close(); } } public toggle(target?: HTMLElement | ElementRef | MouseEvent) { if (this.isOpen) { this.close(); } else { this.open(target); } } public open(target?: HTMLElement | ElementRef | MouseEvent) { 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([ { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', }, { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top', } ]); ; } else { position = overlay .position() .flexibleConnectedTo(target) .withFlexibleDimensions(false) .withViewportMargin(12) .withPush(true) .withDefaultOffsetY(this.overlay !== false ? 15 : 0) .withPositions([ { 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); 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); setTimeout(() => { if (this.overlayRef) { this.overlayRef.updatePosition(); } }, 250); } const normalizedAllowedFocus = isArray(this.allowedFocus) ? this.allowedFocus : (this.allowedFocus ? [this.allowedFocus] : []); const allowedFocus = normalizedAllowedFocus.map(v => v instanceof ElementRef ? v.nativeElement : v) as HTMLElement[]; if (this.show === undefined) { this.dropdown.nativeElement.focus(); this.lastFocusWatcher = focusWatcher(this.dropdown.nativeElement, [...allowedFocus, target as any]).subscribe(() => { if (!this.keepOpen) { this.close(); } }); } } public focus() { this.dropdown.nativeElement.focus(); } public close() { if (!this.isOpen) { return; } this.isOpen = false; if (this.overlayRef) { this.overlayRef.dispose(); delete this.overlayRef; } this.cd.detectChanges(); this.hidden.emit(); this.showChange.emit(false); } } /** * A directive to open the given dropdown on regular left click. */ @Directive({ 'selector': '[openDropdown]', }) export class OpenDropdownDirective { @Input() openDropdown?: DropdownComponent; constructor(protected elementRef: ElementRef) { } @HostListener('click') onClick() { if (this.openDropdown) { this.openDropdown.toggle(this.elementRef); } } } /** * A directive to open the given dropdown upon right click / context menu. */ @Directive({ 'selector': '[contextDropdown]', }) export class ContextDropdownDirective { @Input() contextDropdown?: DropdownComponent; @HostListener('contextmenu', ['$event']) onClick($event: MouseEvent) { if (this.contextDropdown && $event.button === 2) { this.contextDropdown.close(); $event.preventDefault(); $event.stopPropagation(); this.contextDropdown.open($event); } } } @Component({ selector: 'dui-dropdown-splitter', template: `
`, styles: [` :host { display: block; padding: 4px 0; } div { border-top: 1px solid var(--line-color-light); } `] }) export class DropdownSplitterComponent { } @Component({ selector: 'dui-dropdown-item', template: ` `, host: { '[class.selected]': 'selected !== false', '[class.disabled]': 'disabled !== false', }, styleUrls: ['./dropdown-item.component.scss'] }) export class DropdownItemComponent { @Input() selected = false; @Input() disabled: boolean | '' = false; @Input() closeOnClick: boolean = true; constructor(protected dropdown: DropdownComponent) { } @HostListener('click') onClick() { if (this.closeOnClick) { this.dropdown.close(); } } }