diff --git a/admin-ui/src/app/shared/components/dropdown/dropdown-item.directive.ts b/admin-ui/src/app/shared/components/dropdown/dropdown-item.directive.ts new file mode 100644 index 0000000000..d7eeb2d465 --- /dev/null +++ b/admin-ui/src/app/shared/components/dropdown/dropdown-item.directive.ts @@ -0,0 +1,17 @@ +import { Directive, HostListener } from '@angular/core'; + +import { DropdownComponent } from './dropdown.component'; + +@Directive({ + selector: '[vdrDropdownItem]', + // tslint:disable-next-line + host: { '[class.dropdown-item]': 'true' }, +}) +export class DropdownItemDirective { + constructor(private dropdown: DropdownComponent) {} + + @HostListener('click', ['$event']) + onDropdownItemClick(event: any): void { + this.dropdown.toggleOpen(); + } +} diff --git a/admin-ui/src/app/shared/components/dropdown/dropdown-menu.component.scss b/admin-ui/src/app/shared/components/dropdown/dropdown-menu.component.scss new file mode 100644 index 0000000000..fbfe9bf966 --- /dev/null +++ b/admin-ui/src/app/shared/components/dropdown/dropdown-menu.component.scss @@ -0,0 +1,13 @@ +.clear-backdrop { + background-color: hotpink; +} + +.dropdown.open > .dropdown-menu { + position: relative; + top: 0; +} + +:host { + opacity: 1; + transition: opacity 0.3s; +} diff --git a/admin-ui/src/app/shared/components/dropdown/dropdown-menu.component.ts b/admin-ui/src/app/shared/components/dropdown/dropdown-menu.component.ts new file mode 100644 index 0000000000..3ef0f444a5 --- /dev/null +++ b/admin-ui/src/app/shared/components/dropdown/dropdown-menu.component.ts @@ -0,0 +1,141 @@ +import { + ConnectedPosition, + HorizontalConnectionPos, + Overlay, + OverlayRef, + PositionStrategy, + VerticalConnectionPos, +} from '@angular/cdk/overlay'; +import { TemplatePortal } from '@angular/cdk/portal'; +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ContentChild, + ElementRef, + Input, + OnDestroy, + OnInit, + TemplateRef, + ViewChild, + ViewContainerRef, +} from '@angular/core'; +import { Subscription } from 'rxjs'; + +import { DropdownTriggerDirective } from './dropdown-trigger.directive'; +import { DropdownComponent } from './dropdown.component'; + +export type DropdownPosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; + +/** + * A dropdown menu modelled on the Clarity Dropdown component (https://v1.clarity.design/dropdowns). + * + * This was created because the Clarity implementation (at this time) does not handle edge detection. Instead + * we make use of the Angular CDK's Overlay module to manage the positioning. + * + * The API of this component (and its related Components & Directives) are based on the Clarity version, + * albeit only a subset which is currently used in this application. + */ +@Component({ + selector: 'vdr-dropdown-menu', + template: ` + + + + `, + styleUrls: ['./dropdown-menu.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DropdownMenuComponent implements AfterViewInit, OnInit, OnDestroy { + @Input('vdrPosition') private position: DropdownPosition = 'bottom-left'; + @ViewChild('menu') private menuTemplate: TemplateRef; + private menuPortal: TemplatePortal; + private overlayRef: OverlayRef; + private backdropClickSub: Subscription; + + constructor( + private overlay: Overlay, + private viewContainerRef: ViewContainerRef, + private dropdown: DropdownComponent, + ) {} + + ngOnInit(): void { + this.dropdown.onOpenChange(isOpen => { + if (isOpen) { + this.overlayRef.attach(this.menuPortal); + } else { + this.overlayRef.detach(); + } + }); + } + + ngAfterViewInit() { + this.overlayRef = this.overlay.create({ + hasBackdrop: true, + backdropClass: 'clear-backdrop', + positionStrategy: this.getPositionStrategy(), + }); + this.menuPortal = new TemplatePortal(this.menuTemplate, this.viewContainerRef); + this.backdropClickSub = this.overlayRef.backdropClick().subscribe(() => { + this.dropdown.toggleOpen(); + }); + } + + ngOnDestroy(): void { + this.overlayRef.dispose(); + if (this.backdropClickSub) { + this.backdropClickSub.unsubscribe(); + } + } + + private getPositionStrategy(): PositionStrategy { + const position: { [K in DropdownPosition]: ConnectedPosition } = { + ['top-left']: { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + }, + ['top-right']: { + originX: 'end', + originY: 'top', + overlayX: 'end', + overlayY: 'bottom', + }, + ['bottom-left']: { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + }, + ['bottom-right']: { + originX: 'end', + originY: 'bottom', + overlayX: 'end', + overlayY: 'top', + }, + }; + + const pos = position[this.position]; + + return this.overlay + .position() + .flexibleConnectedTo(this.dropdown.trigger) + .withPositions([pos, this.invertPosition(pos)]) + .withViewportMargin(12) + .withPush(true); + } + + /** Inverts an overlay position. */ + private invertPosition(pos: ConnectedPosition): ConnectedPosition { + const inverted = { ...pos }; + inverted.originY = pos.originY === 'top' ? 'bottom' : 'top'; + inverted.overlayY = pos.overlayY === 'top' ? 'bottom' : 'top'; + + return inverted; + } +} diff --git a/admin-ui/src/app/shared/components/dropdown/dropdown-trigger.directive.ts b/admin-ui/src/app/shared/components/dropdown/dropdown-trigger.directive.ts new file mode 100644 index 0000000000..249627fced --- /dev/null +++ b/admin-ui/src/app/shared/components/dropdown/dropdown-trigger.directive.ts @@ -0,0 +1,17 @@ +import { Directive, ElementRef, HostListener } from '@angular/core'; + +import { DropdownComponent } from './dropdown.component'; + +@Directive({ + selector: '[vdrDropdownTrigger]', +}) +export class DropdownTriggerDirective { + constructor(private dropdown: DropdownComponent, private elementRef: ElementRef) { + dropdown.setTriggerElement(this.elementRef); + } + + @HostListener('click', ['$event']) + onDropdownTriggerClick(event: any): void { + this.dropdown.toggleOpen(); + } +} diff --git a/admin-ui/src/app/shared/components/dropdown/dropdown.component.html b/admin-ui/src/app/shared/components/dropdown/dropdown.component.html new file mode 100644 index 0000000000..6dbc743063 --- /dev/null +++ b/admin-ui/src/app/shared/components/dropdown/dropdown.component.html @@ -0,0 +1 @@ + diff --git a/admin-ui/src/app/shared/components/dropdown/dropdown.component.scss b/admin-ui/src/app/shared/components/dropdown/dropdown.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/admin-ui/src/app/shared/components/dropdown/dropdown.component.ts b/admin-ui/src/app/shared/components/dropdown/dropdown.component.ts new file mode 100644 index 0000000000..965167f442 --- /dev/null +++ b/admin-ui/src/app/shared/components/dropdown/dropdown.component.ts @@ -0,0 +1,26 @@ +import { ChangeDetectionStrategy, Component, ElementRef } from '@angular/core'; + +@Component({ + selector: 'vdr-dropdown', + templateUrl: './dropdown.component.html', + styleUrls: ['./dropdown.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DropdownComponent { + private isOpen = false; + private onOpenChangeCallbacks: Array<(isOpen: boolean) => void> = []; + public trigger: ElementRef; + + toggleOpen() { + this.isOpen = !this.isOpen; + this.onOpenChangeCallbacks.forEach(fn => fn(this.isOpen)); + } + + onOpenChange(callback: (isOpen: boolean) => void) { + this.onOpenChangeCallbacks.push(callback); + } + + setTriggerElement(elementRef: ElementRef) { + this.trigger = elementRef; + } +} diff --git a/admin-ui/src/app/shared/shared.module.ts b/admin-ui/src/app/shared/shared.module.ts index 57f54aef90..adfd93e30c 100644 --- a/admin-ui/src/app/shared/shared.module.ts +++ b/admin-ui/src/app/shared/shared.module.ts @@ -1,3 +1,4 @@ +import { OverlayModule } from '@angular/cdk/overlay'; import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; @@ -21,6 +22,10 @@ import { CustomFieldControlComponent } from './components/custom-field-control/c import { CustomerLabelComponent } from './components/customer-label/customer-label.component'; import { DataTableColumnComponent } from './components/data-table/data-table-column.component'; import { DataTableComponent } from './components/data-table/data-table.component'; +import { DropdownItemDirective } from './components/dropdown/dropdown-item.directive'; +import { DropdownMenuComponent } from './components/dropdown/dropdown-menu.component'; +import { DropdownTriggerDirective } from './components/dropdown/dropdown-trigger.directive'; +import { DropdownComponent } from './components/dropdown/dropdown.component'; import { FacetValueChipComponent } from './components/facet-value-chip/facet-value-chip.component'; import { FacetValueSelectorComponent } from './components/facet-value-selector/facet-value-selector.component'; import { FormFieldControlDirective } from './components/form-field/form-field-control.directive'; @@ -54,6 +59,7 @@ const IMPORTS = [ NgSelectModule, NgxPaginationModule, TranslateModule, + OverlayModule, ]; const DECLARATIONS = [ @@ -90,6 +96,10 @@ const DECLARATIONS = [ SimpleDialogComponent, TitleInputComponent, SentenceCasePipe, + DropdownComponent, + DropdownMenuComponent, + DropdownTriggerDirective, + DropdownItemDirective, ]; @NgModule({ diff --git a/admin-ui/src/styles/styles.scss b/admin-ui/src/styles/styles.scss index 7a80027839..ff20fc89ec 100644 --- a/admin-ui/src/styles/styles.scss +++ b/admin-ui/src/styles/styles.scss @@ -7,3 +7,4 @@ @import "theme/theme"; @import "~@ng-select/ng-select/themes/default.theme.css"; +@import '~@angular/cdk/overlay-prebuilt.css';