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';