Add dropdown.component.ts
Create the following file in your project.
import { Overlay, OverlayModule, OverlayPositionBuilder, type OverlayRef } from '@angular/cdk/overlay';
import { TemplatePortal } from '@angular/cdk/portal';
import { isPlatformBrowser } from '@angular/common';
import {
booleanAttribute,
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
inject,
input,
type OnDestroy,
output,
PLATFORM_ID,
signal,
type TemplateRef,
viewChild,
ViewContainerRef,
ViewEncapsulation,
} from '@angular/core';
import type { ClassValue } from 'clsx';
import { ZardIdDirective } from '@/shared/core/directives/id.directive';
import { mergeClasses } from '@/shared/utils/merge-classes';
import { dropdownContentVariants } from './dropdown.variants';
@Component({
selector: 'z-dropdown-menu',
imports: [OverlayModule, ZardIdDirective],
template: `
<!-- Dropdown Trigger -->
<div
#triggerContainer
zardId="dropdown-trigger"
#zId="zardId"
[id]="zId.id()"
data-slot="dropdown-menu-trigger"
(click)="toggle()"
(keydown.{enter,space}.prevent)="toggle()"
[attr.aria-haspopup]="'menu'"
[attr.aria-expanded]="isOpen()"
[attr.aria-disabled]="isDisabled()"
tabindex="0"
>
<ng-content select="[dropdown-trigger]" />
</div>
<!-- Template for overlay content -->
<ng-template #dropdownTemplate>
<div
[class]="contentClasses()"
role="menu"
data-slot="dropdown-menu-content"
[attr.data-state]="'open'"
(click)="onDropdownClick($event)"
(keydown.{arrowdown,arrowup,enter,space,escape,home,end}.prevent)="onDropdownKeydown($event)"
tabindex="-1"
>
<ng-content />
</div>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
host: {
class: 'relative inline-block text-left',
'data-slot': 'dropdown-menu',
'[attr.data-state]': 'isOpen() ? "open" : "closed"',
'(document:click)': 'onDocumentClick($event)',
},
exportAs: 'zDropdownMenu',
})
export class ZardDropdownMenuComponent implements OnDestroy {
private elementRef = inject(ElementRef);
private overlay = inject(Overlay);
private overlayPositionBuilder = inject(OverlayPositionBuilder);
private viewContainerRef = inject(ViewContainerRef);
private platformId = inject(PLATFORM_ID);
readonly dropdownTemplate = viewChild.required<TemplateRef<unknown>>('dropdownTemplate');
readonly triggerContainer = viewChild.required<ElementRef<HTMLElement>>('triggerContainer');
private overlayRef?: OverlayRef;
private portal?: TemplatePortal;
readonly class = input<ClassValue>('');
readonly disabled = input(false, { transform: booleanAttribute });
readonly zDisabled = input<boolean | undefined, unknown>(undefined, {
alias: 'zDisabled',
transform: value => (value === undefined ? undefined : booleanAttribute(value)),
});
readonly openChange = output<boolean>();
readonly isOpen = signal(false);
readonly focusedIndex = signal<number>(-1);
protected readonly isDisabled = computed(() => this.zDisabled() ?? this.disabled());
protected readonly contentClasses = computed(() => mergeClasses(dropdownContentVariants(), this.class()));
ngOnDestroy() {
this.destroyOverlay();
}
onDocumentClick(event: Event) {
if (!this.elementRef.nativeElement.contains(event.target as Node)) {
this.close();
}
}
onDropdownKeydown(e: Event) {
const items = this.getDropdownItems();
const { key } = e as KeyboardEvent;
switch (key) {
case 'ArrowDown':
this.navigateItems(1, items);
break;
case 'ArrowUp':
this.navigateItems(-1, items);
break;
case 'Enter':
case ' ':
this.selectFocusedItem(items);
break;
case 'Escape':
this.close();
this.focusTrigger();
break;
case 'Home':
this.focusFirstItem(items);
break;
case 'End':
this.focusLastItem(items);
break;
}
}
onDropdownClick(event: Event) {
const target = event.target as HTMLElement;
const item = target.closest<HTMLElement>(
'z-dropdown-menu-item, [z-dropdown-menu-item], z-dropdown-menu-checkbox-item, [z-dropdown-menu-checkbox-item], z-dropdown-menu-radio-item, [z-dropdown-menu-radio-item]',
);
if (!item || item.dataset['disabled'] !== undefined) {
return;
}
setTimeout(() => {
this.close();
this.focusTrigger();
}, 0);
}
toggle() {
if (this.isDisabled()) {
return;
}
if (this.isOpen()) {
this.close();
} else {
this.open();
}
}
open() {
if (this.isOpen()) {
return;
}
if (!this.overlayRef) {
this.createOverlay();
}
if (!this.overlayRef) {
return;
}
this.portal = new TemplatePortal(this.dropdownTemplate(), this.viewContainerRef);
this.overlayRef.attach(this.portal);
this.isOpen.set(true);
this.openChange.emit(true);
setTimeout(() => {
this.focusDropdown();
}, 0);
}
close() {
if (this.overlayRef?.hasAttached()) {
this.overlayRef.detach();
}
this.isOpen.set(false);
this.focusedIndex.set(-1);
this.openChange.emit(false);
}
private createOverlay() {
if (this.overlayRef) {
return;
}
if (isPlatformBrowser(this.platformId)) {
try {
const positionStrategy = this.overlayPositionBuilder
.flexibleConnectedTo(this.elementRef)
.withPositions([
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
offsetY: 4,
},
{
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',
offsetY: -4,
},
])
.withPush(false);
this.overlayRef = this.overlay.create({
positionStrategy,
hasBackdrop: false,
scrollStrategy: this.overlay.scrollStrategies.reposition(),
minWidth: 200,
maxHeight: 400,
});
} catch (error) {
console.error('Error creating overlay:', error);
}
}
}
private destroyOverlay() {
if (this.overlayRef) {
this.overlayRef.dispose();
this.overlayRef = undefined;
}
}
private getDropdownItems(): HTMLElement[] {
if (!this.overlayRef?.hasAttached()) {
return [];
}
const dropdownElement = this.overlayRef.overlayElement;
return Array.from(
dropdownElement.querySelectorAll<HTMLElement>(
'z-dropdown-menu-item, [z-dropdown-menu-item], z-dropdown-menu-checkbox-item, [z-dropdown-menu-checkbox-item], z-dropdown-menu-radio-item, [z-dropdown-menu-radio-item]',
),
).filter(item => item.dataset['disabled'] === undefined);
}
private navigateItems(direction: number, items: HTMLElement[]) {
if (items.length === 0) {
return;
}
const currentIndex = this.focusedIndex();
let nextIndex: number;
if (currentIndex === -1) {
nextIndex = direction > 0 ? 0 : items.length - 1;
} else {
nextIndex = currentIndex + direction;
if (nextIndex < 0) {
nextIndex = items.length - 1;
} else if (nextIndex >= items.length) {
nextIndex = 0;
}
}
this.focusedIndex.set(nextIndex);
this.updateItemFocus(items, nextIndex);
}
private selectFocusedItem(items: HTMLElement[]) {
const currentIndex = this.focusedIndex();
if (currentIndex >= 0 && currentIndex < items.length) {
const item = items[currentIndex];
item.click();
}
}
private focusFirstItem(items: HTMLElement[]) {
if (items.length > 0) {
this.focusedIndex.set(0);
this.updateItemFocus(items, 0);
}
}
private focusLastItem(items: HTMLElement[]) {
if (items.length > 0) {
const lastIndex = items.length - 1;
this.focusedIndex.set(lastIndex);
this.updateItemFocus(items, lastIndex);
}
}
private updateItemFocus(items: HTMLElement[], focusedIndex: number) {
items.forEach((item, index) => {
if (index === focusedIndex) {
item.focus();
item.setAttribute('data-highlighted', '');
} else {
item.removeAttribute('data-highlighted');
}
});
}
private focusDropdown() {
if (this.overlayRef?.hasAttached()) {
const dropdownElement = this.overlayRef.overlayElement.querySelector('[role="menu"]') as HTMLElement;
if (dropdownElement) {
dropdownElement.focus();
}
}
}
private focusTrigger() {
this.triggerContainer().nativeElement.focus();
}
}