Dropdown

Displays a menu to the user — such as a set of actions or functions — triggered by a button.

PreviousNext
import { Component } from '@angular/core';
 
import { ZardButtonComponent } from '../../button/button.component';
import { ZardDividerComponent } from '../../divider/divider.component';
import { ZardDropdownModule } from '../dropdown.module';
 
@Component({
  selector: 'z-dropdown-demo',
  imports: [ZardDropdownModule, ZardButtonComponent, ZardDividerComponent],
  template: `
    <button type="button" z-button zType="outline" z-dropdown [zDropdownMenu]="menu">Open</button>
 
    <z-dropdown-menu-content #menu="zDropdownMenuContent" class="w-56">
      <z-dropdown-menu-label>My Account</z-dropdown-menu-label>
 
      <z-dropdown-menu-item (click)="onProfile()">
        Profile
        <z-dropdown-menu-shortcut>⇧⌘P</z-dropdown-menu-shortcut>
      </z-dropdown-menu-item>
 
      <z-dropdown-menu-item (click)="onBilling()">
        Billing
        <z-dropdown-menu-shortcut>⌘B</z-dropdown-menu-shortcut>
      </z-dropdown-menu-item>
 
      <z-dropdown-menu-item (click)="onSettings()">
        Settings
        <z-dropdown-menu-shortcut>⌘S</z-dropdown-menu-shortcut>
      </z-dropdown-menu-item>
 
      <z-dropdown-menu-item (click)="onKeyboardShortcuts()">
        Keyboard shortcuts
        <z-dropdown-menu-shortcut>⌘K</z-dropdown-menu-shortcut>
      </z-dropdown-menu-item>
 
      <z-divider zSpacing="sm" class="-mx-1" />
 
      <z-dropdown-menu-item (click)="onTeam()">Team</z-dropdown-menu-item>
 
      <z-dropdown-menu-item (click)="onNewTeam()">
        New Team
        <z-dropdown-menu-shortcut>⌘+T</z-dropdown-menu-shortcut>
      </z-dropdown-menu-item>
 
      <z-divider zSpacing="sm" class="-mx-1" />
 
      <z-dropdown-menu-item (click)="onGitHub()">GitHub</z-dropdown-menu-item>
      <z-dropdown-menu-item (click)="onSupport()">Support</z-dropdown-menu-item>
      <z-dropdown-menu-item disabled="true">API</z-dropdown-menu-item>
 
      <z-divider zSpacing="sm" class="-mx-1" />
 
      <z-dropdown-menu-item (click)="onLogout()">
        Log out
        <z-dropdown-menu-shortcut>⇧⌘Q</z-dropdown-menu-shortcut>
      </z-dropdown-menu-item>
    </z-dropdown-menu-content>
  `,
})
export class ZardDropdownDemoComponent {
  onProfile() {
    console.log('Profile clicked');
  }
 
  onBilling() {
    console.log('Billing clicked');
  }
 
  onSettings() {
    console.log('Settings clicked');
  }
 
  onKeyboardShortcuts() {
    console.log('Keyboard shortcuts clicked');
  }
 
  onTeam() {
    console.log('Team clicked');
  }
 
  onNewTeam() {
    console.log('New Team clicked');
  }
 
  onGitHub() {
    console.log('GitHub clicked');
  }
 
  onSupport() {
    console.log('Support clicked');
  }
 
  onLogout() {
    console.log('Log out clicked');
  }
}
 

Installation

1

Run the CLI

Use the CLI to add the component to your project.

npx @ngzard/ui@latest add dropdown
1

Add the component files

Create the component directory structure and add the following files to your project.

dropdown.component.ts
dropdown.component.ts
import { Overlay, OverlayModule, OverlayPositionBuilder, type OverlayRef } from '@angular/cdk/overlay';
import { TemplatePortal } from '@angular/cdk/portal';
import { isPlatformBrowser } from '@angular/common';
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  ElementRef,
  inject,
  input,
  type OnDestroy,
  type OnInit,
  output,
  PLATFORM_ID,
  signal,
  type TemplateRef,
  viewChild,
  ViewContainerRef,
  ViewEncapsulation,
} from '@angular/core';
 
import type { ClassValue } from 'clsx';
 
import { dropdownContentVariants } from './dropdown.variants';
import { mergeClasses, transform } from '../../shared/utils/utils';
import { checkForProperZardInitialization } from '../core/provider/providezard';
 
@Component({
  selector: 'z-dropdown-menu',
  imports: [OverlayModule],
  template: `
    <!-- Dropdown Trigger -->
    <div class="trigger-container" (click)="toggle()" (keydown.{enter,space}.prevent)="toggle()" tabindex="0">
      <ng-content select="[dropdown-trigger]" />
    </div>
 
    <!-- Template for overlay content -->
    <ng-template #dropdownTemplate>
      <div
        [class]="contentClasses()"
        role="menu"
        [attr.data-state]="'open'"
        (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',
    '[attr.data-state]': 'isOpen() ? "open" : "closed"',
    '(document:click)': 'onDocumentClick($event)',
  },
  exportAs: 'zDropdownMenu',
})
export class ZardDropdownMenuComponent implements OnInit, 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');
 
  private overlayRef?: OverlayRef;
  private portal?: TemplatePortal;
 
  readonly class = input<ClassValue>('');
  readonly disabled = input(false, { transform });
 
  readonly openChange = output<boolean>();
 
  readonly isOpen = signal(false);
  readonly focusedIndex = signal<number>(-1);
 
  protected readonly contentClasses = computed(() => mergeClasses(dropdownContentVariants(), this.class()));
 
  constructor() {
    checkForProperZardInitialization();
  }
 
  ngOnInit() {
    setTimeout(() => {
      this.createOverlay();
    });
  }
 
  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;
    }
  }
 
  toggle() {
    if (this.disabled()) {
      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();
      this.focusFirstItem(this.getDropdownItems());
    }, 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]'),
    ).filter(item => item.dataset['disabled'] === undefined);
  }
 
  private navigateItems(direction: number, items: HTMLElement[]) {
    if (items.length === 0) {
      return;
    }
 
    const currentIndex = this.focusedIndex();
    let 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() {
    const trigger = this.elementRef.nativeElement.querySelector('.trigger-container');
    if (trigger) {
      trigger.focus();
    }
  }
}
 
dropdown.variants.ts
dropdown.variants.ts
import { cva, type VariantProps } from 'class-variance-authority';
 
export const dropdownContentVariants = cva(
  'bg-popover text-popover-foreground z-50 min-w-[200px] overflow-y-auto rounded-md border py-1 px-1 shadow-md',
);
 
export const dropdownItemVariants = cva(
  'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
  {
    variants: {
      variant: {
        default: '',
        destructive:
          'text-destructive hover:bg-destructive/10 focus:bg-destructive/10 dark:hover:bg-destructive/20 dark:focus:bg-destructive/20 focus:text-destructive',
      },
      inset: {
        true: 'pl-8',
        false: '',
      },
    },
    defaultVariants: {
      variant: 'default',
      inset: false,
    },
  },
);
 
export const dropdownLabelVariants = cva(
  'relative flex items-center px-2 py-1.5 text-sm font-medium text-muted-foreground',
  {
    variants: {
      inset: {
        true: 'pl-8',
        false: '',
      },
    },
    defaultVariants: {
      inset: false,
    },
  },
);
 
export const dropdownShortcutVariants = cva('ml-auto text-xs tracking-widest text-muted-foreground');
 
export type ZardDropdownItemVariants = VariantProps<typeof dropdownItemVariants>;
export type ZardDropdownLabelVariants = VariantProps<typeof dropdownLabelVariants>;
 
dropdown-item.component.ts
dropdown-item.component.ts
import { ChangeDetectionStrategy, Component, computed, inject, input, ViewEncapsulation } from '@angular/core';
 
import type { ClassValue } from 'clsx';
 
import { ZardDropdownService } from './dropdown.service';
import { dropdownItemVariants, type ZardDropdownItemVariants } from './dropdown.variants';
import { mergeClasses, transform } from '../../shared/utils/utils';
 
@Component({
  selector: 'z-dropdown-menu-item, [z-dropdown-menu-item]',
  template: `
    <ng-content />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  host: {
    '[class]': 'classes()',
    '[attr.data-disabled]': 'disabled() || null',
    '[attr.data-variant]': 'variant()',
    '[attr.data-inset]': 'inset() || null',
    '[attr.aria-disabled]': 'disabled()',
    '(click.prevent-with-stop)': 'onClick()',
    role: 'menuitem',
    tabindex: '-1',
  },
  exportAs: 'zDropdownMenuItem',
})
export class ZardDropdownMenuItemComponent {
  private readonly dropdownService = inject(ZardDropdownService);
 
  readonly variant = input<ZardDropdownItemVariants['variant']>('default');
  readonly inset = input(false, { transform });
  readonly disabled = input(false, { transform });
  readonly class = input<ClassValue>('');
 
  onClick() {
    if (this.disabled()) {
      return;
    }
 
    // Fechar dropdown após click
    setTimeout(() => {
      this.dropdownService.close();
    }, 0);
  }
 
  protected readonly classes = computed(() =>
    mergeClasses(
      dropdownItemVariants({
        variant: this.variant(),
        inset: this.inset(),
      }),
      this.class(),
    ),
  );
}
 
dropdown-label.component.ts
dropdown-label.component.ts
import { ChangeDetectionStrategy, Component, computed, input, ViewEncapsulation } from '@angular/core';
 
import type { ClassValue } from 'clsx';
 
import { dropdownLabelVariants } from './dropdown.variants';
import { mergeClasses, transform } from '../../shared/utils/utils';
 
@Component({
  selector: 'z-dropdown-menu-label, [z-dropdown-menu-label]',
  template: `
    <ng-content />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  host: {
    '[class]': 'classes()',
    '[attr.data-inset]': 'inset() || null',
  },
  exportAs: 'zDropdownMenuLabel',
})
export class ZardDropdownMenuLabelComponent {
  readonly inset = input(false, { transform });
  readonly class = input<ClassValue>('');
 
  protected readonly classes = computed(() =>
    mergeClasses(
      dropdownLabelVariants({
        inset: this.inset(),
      }),
      this.class(),
    ),
  );
}
 
dropdown-menu-content.component.ts
dropdown-menu-content.component.ts
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  input,
  type TemplateRef,
  viewChild,
  ViewEncapsulation,
} from '@angular/core';
 
import type { ClassValue } from 'clsx';
 
import { dropdownContentVariants } from './dropdown.variants';
import { mergeClasses } from '../../shared/utils/utils';
 
@Component({
  selector: 'z-dropdown-menu-content',
  template: `
    <ng-template #contentTemplate>
      <div [class]="contentClasses()" role="menu" tabindex="-1" aria-orientation="vertical">
        <ng-content />
      </div>
    </ng-template>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  host: {
    '[style.display]': '"none"',
  },
  exportAs: 'zDropdownMenuContent',
})
export class ZardDropdownMenuContentComponent {
  readonly contentTemplate = viewChild.required<TemplateRef<unknown>>('contentTemplate');
 
  readonly class = input<ClassValue>('');
 
  protected readonly contentClasses = computed(() => mergeClasses(dropdownContentVariants(), this.class()));
}
 
dropdown-shortcut.component.ts
dropdown-shortcut.component.ts
import { ChangeDetectionStrategy, Component, computed, input, ViewEncapsulation } from '@angular/core';
 
import type { ClassValue } from 'clsx';
 
import { dropdownShortcutVariants } from './dropdown.variants';
import { mergeClasses } from '../../shared/utils/utils';
 
@Component({
  selector: 'z-dropdown-menu-shortcut, [z-dropdown-menu-shortcut]',
  template: `
    <ng-content />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  host: {
    '[class]': 'classes()',
  },
  exportAs: 'zDropdownMenuShortcut',
})
export class ZardDropdownMenuShortcutComponent {
  readonly class = input<ClassValue>('');
 
  protected readonly classes = computed(() => mergeClasses(dropdownShortcutVariants(), this.class()));
}
 
dropdown-trigger.directive.ts
dropdown-trigger.directive.ts
import { Directive, ElementRef, inject, input, type OnInit, ViewContainerRef } from '@angular/core';
 
import type { ZardDropdownMenuContentComponent } from './dropdown-menu-content.component';
import { ZardDropdownService } from './dropdown.service';
import { checkForProperZardInitialization } from '../core/provider/providezard';
 
@Directive({
  selector: '[z-dropdown], [zDropdown]',
  host: {
    '[attr.tabindex]': '0',
    '[attr.role]': '"button"',
    '[attr.aria-haspopup]': '"menu"',
    '[attr.aria-expanded]': 'dropdownService.isOpen()',
    '[attr.aria-disabled]': 'zDisabled()',
    '(click.prevent-with-stop)': 'onClick()',
    '(mouseenter)': 'onHoverToggle()',
    '(mouseleave)': 'onHoverToggle()',
    '(keydown.{enter,space}.prevent-with-stop)': 'toggleDropdown()',
    '(keydown.arrowdown.prevent)': 'openDropdown()',
  },
  exportAs: 'zDropdown',
})
export class ZardDropdownDirective implements OnInit {
  private readonly elementRef = inject(ElementRef);
  private readonly viewContainerRef = inject(ViewContainerRef);
  protected readonly dropdownService = inject(ZardDropdownService);
 
  readonly zDropdownMenu = input<ZardDropdownMenuContentComponent>();
  readonly zTrigger = input<'click' | 'hover'>('click');
  readonly zDisabled = input<boolean>(false);
 
  constructor() {
    checkForProperZardInitialization();
  }
 
  ngOnInit() {
    // Ensure button has proper accessibility attributes
    const element = this.elementRef.nativeElement;
    if (!element.hasAttribute('aria-label') && !element.hasAttribute('aria-labelledby')) {
      const label = element.textContent?.trim();
      element.setAttribute('aria-label', label?.length ? label : 'Open menu');
    }
  }
 
  protected onClick() {
    if (this.zTrigger() !== 'click') {
      return;
    }
 
    this.toggleDropdown();
  }
 
  protected onHoverToggle() {
    if (this.zTrigger() !== 'hover') {
      return;
    }
 
    this.toggleDropdown();
  }
 
  protected toggleDropdown() {
    if (this.zDisabled()) {
      return;
    }
 
    const menuContent = this.zDropdownMenu();
    if (menuContent) {
      this.dropdownService.toggle(this.elementRef, menuContent.contentTemplate(), this.viewContainerRef);
    }
  }
 
  protected openDropdown() {
    if (this.zDisabled()) {
      return;
    }
 
    const menuContent = this.zDropdownMenu();
    if (menuContent && !this.dropdownService.isOpen()) {
      this.dropdownService.toggle(this.elementRef, menuContent.contentTemplate(), this.viewContainerRef);
    }
  }
}
 
dropdown.module.ts
dropdown.module.ts
import { OverlayModule } from '@angular/cdk/overlay';
import { NgModule } from '@angular/core';
 
import { ZardDropdownMenuItemComponent } from './dropdown-item.component';
import { ZardDropdownMenuLabelComponent } from './dropdown-label.component';
import { ZardDropdownMenuContentComponent } from './dropdown-menu-content.component';
import { ZardDropdownMenuShortcutComponent } from './dropdown-shortcut.component';
import { ZardDropdownDirective } from './dropdown-trigger.directive';
import { ZardDropdownMenuComponent } from './dropdown.component';
 
const DROPDOWN_COMPONENTS = [
  ZardDropdownMenuComponent,
  ZardDropdownMenuItemComponent,
  ZardDropdownMenuLabelComponent,
  ZardDropdownMenuShortcutComponent,
  ZardDropdownMenuContentComponent,
  ZardDropdownDirective,
];
 
@NgModule({
  imports: [OverlayModule, ...DROPDOWN_COMPONENTS],
  exports: [...DROPDOWN_COMPONENTS],
})
export class ZardDropdownModule {}
 
dropdown.service.ts
dropdown.service.ts
import { Overlay, OverlayPositionBuilder, type OverlayRef } from '@angular/cdk/overlay';
import { TemplatePortal } from '@angular/cdk/portal';
import { isPlatformBrowser } from '@angular/common';
import {
  type ElementRef,
  inject,
  Injectable,
  PLATFORM_ID,
  Renderer2,
  RendererFactory2,
  signal,
  type TemplateRef,
  type ViewContainerRef,
} from '@angular/core';
 
import { filter, type Subscription } from 'rxjs';
 
import { noopFun } from '../../shared/utils/utils';
 
@Injectable({
  providedIn: 'root',
})
export class ZardDropdownService {
  private readonly overlay = inject(Overlay);
  private readonly overlayPositionBuilder = inject(OverlayPositionBuilder);
  private readonly platformId = inject(PLATFORM_ID);
  private readonly rendererFactory = inject(RendererFactory2);
 
  private overlayRef?: OverlayRef;
  private portal?: TemplatePortal;
  private triggerElement?: ElementRef;
  private renderer!: Renderer2;
  private readonly focusedIndex = signal<number>(-1);
  private outsideClickSubscription!: Subscription;
  private unlisten: () => void = noopFun;
 
  readonly isOpen = signal(false);
 
  constructor() {
    this.renderer = this.rendererFactory.createRenderer(null, null);
  }
 
  toggle(triggerElement: ElementRef, template: TemplateRef<unknown>, viewContainerRef: ViewContainerRef) {
    if (this.isOpen()) {
      this.close();
    } else {
      this.open(triggerElement, template, viewContainerRef);
    }
  }
 
  private open(triggerElement: ElementRef, template: TemplateRef<unknown>, viewContainerRef: ViewContainerRef) {
    if (this.isOpen()) {
      this.close();
    }
 
    this.triggerElement = triggerElement;
    this.createOverlay(triggerElement);
 
    if (!this.overlayRef) {
      return;
    }
 
    this.portal = new TemplatePortal(template, viewContainerRef);
    this.overlayRef.attach(this.portal);
 
    // Setup keyboard navigation
    setTimeout(() => {
      this.setupKeyboardNavigation();
      this.focusFirstItem();
    }, 0);
 
    // Close on outside click
    this.outsideClickSubscription = this.overlayRef
      .outsidePointerEvents()
      .pipe(filter(event => !triggerElement.nativeElement.contains(event.target)))
      .subscribe(() => {
        this.close();
      });
    this.isOpen.set(true);
  }
 
  close() {
    if (this.overlayRef?.hasAttached()) {
      this.overlayRef.detach();
    }
    this.focusedIndex.set(-1);
    this.unlisten();
    this.destroyOverlay();
    this.isOpen.set(false);
  }
 
  private createOverlay(triggerElement: ElementRef) {
    if (this.overlayRef) {
      this.destroyOverlay();
    }
 
    const positionStrategy = this.overlayPositionBuilder
      .flexibleConnectedTo(triggerElement)
      .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,
    });
  }
 
  private destroyOverlay() {
    this.overlayRef?.dispose();
    this.overlayRef = undefined;
    this.outsideClickSubscription?.unsubscribe();
  }
 
  private setupKeyboardNavigation() {
    if (!this.overlayRef?.hasAttached() || !isPlatformBrowser(this.platformId)) {
      return;
    }
 
    const dropdownElement = this.overlayRef.overlayElement.querySelector('[role="menu"]') as HTMLElement;
    if (!dropdownElement) {
      return;
    }
 
    this.unlisten = this.renderer.listen(
      dropdownElement,
      'keydown.{arrowdown,arrowup,enter,space,escape,home,end}.prevent',
      (event: KeyboardEvent) => {
        const items = this.getDropdownItems();
 
        switch (event.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.triggerElement?.nativeElement.focus();
            break;
          case 'Home':
            this.focusItemAtIndex(items, 0);
            break;
          case 'End':
            this.focusItemAtIndex(items, items.length - 1);
            break;
        }
      },
    );
 
    // Focus dropdown container
    dropdownElement.focus();
  }
 
  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]'),
    ).filter(item => item.dataset['disabled'] === undefined);
  }
 
  private navigateItems(direction: number, items: HTMLElement[]) {
    if (items.length === 0) {
      return;
    }
 
    const currentIndex = this.focusedIndex();
    let nextIndex = currentIndex + direction;
 
    if (nextIndex < 0) {
      nextIndex = items.length - 1;
    } else if (nextIndex >= items.length) {
      nextIndex = 0;
    }
 
    this.focusItemAtIndex(items, nextIndex);
  }
 
  private focusItemAtIndex(items: HTMLElement[], index: number) {
    if (index >= 0 && index < items.length) {
      this.focusedIndex.set(index);
      this.updateItemFocus(items, index);
    }
  }
 
  private focusFirstItem() {
    const items = this.getDropdownItems();
    if (items.length > 0) {
      this.focusItemAtIndex(items, 0);
    }
  }
 
  private selectFocusedItem(items: HTMLElement[]) {
    const currentIndex = this.focusedIndex();
    if (currentIndex >= 0 && currentIndex < items.length) {
      const item = items[currentIndex];
      item.click();
    }
  }
 
  private updateItemFocus(items: HTMLElement[], focusedIndex: number) {
    for (let index = 0; index < items.length; index++) {
      const item = items[index];
      if (index === focusedIndex) {
        item.focus();
        item.dataset['highlighted'] = '';
      } else {
        delete item.dataset['highlighted'];
      }
    }
  }
}
 

Examples

default

import { Component } from '@angular/core';
 
import { ZardButtonComponent } from '../../button/button.component';
import { ZardDividerComponent } from '../../divider/divider.component';
import { ZardDropdownModule } from '../dropdown.module';
 
@Component({
  selector: 'z-dropdown-demo',
  imports: [ZardDropdownModule, ZardButtonComponent, ZardDividerComponent],
  template: `
    <button type="button" z-button zType="outline" z-dropdown [zDropdownMenu]="menu">Open</button>
 
    <z-dropdown-menu-content #menu="zDropdownMenuContent" class="w-56">
      <z-dropdown-menu-label>My Account</z-dropdown-menu-label>
 
      <z-dropdown-menu-item (click)="onProfile()">
        Profile
        <z-dropdown-menu-shortcut>⇧⌘P</z-dropdown-menu-shortcut>
      </z-dropdown-menu-item>
 
      <z-dropdown-menu-item (click)="onBilling()">
        Billing
        <z-dropdown-menu-shortcut>⌘B</z-dropdown-menu-shortcut>
      </z-dropdown-menu-item>
 
      <z-dropdown-menu-item (click)="onSettings()">
        Settings
        <z-dropdown-menu-shortcut>⌘S</z-dropdown-menu-shortcut>
      </z-dropdown-menu-item>
 
      <z-dropdown-menu-item (click)="onKeyboardShortcuts()">
        Keyboard shortcuts
        <z-dropdown-menu-shortcut>⌘K</z-dropdown-menu-shortcut>
      </z-dropdown-menu-item>
 
      <z-divider zSpacing="sm" class="-mx-1" />
 
      <z-dropdown-menu-item (click)="onTeam()">Team</z-dropdown-menu-item>
 
      <z-dropdown-menu-item (click)="onNewTeam()">
        New Team
        <z-dropdown-menu-shortcut>⌘+T</z-dropdown-menu-shortcut>
      </z-dropdown-menu-item>
 
      <z-divider zSpacing="sm" class="-mx-1" />
 
      <z-dropdown-menu-item (click)="onGitHub()">GitHub</z-dropdown-menu-item>
      <z-dropdown-menu-item (click)="onSupport()">Support</z-dropdown-menu-item>
      <z-dropdown-menu-item disabled="true">API</z-dropdown-menu-item>
 
      <z-divider zSpacing="sm" class="-mx-1" />
 
      <z-dropdown-menu-item (click)="onLogout()">
        Log out
        <z-dropdown-menu-shortcut>⇧⌘Q</z-dropdown-menu-shortcut>
      </z-dropdown-menu-item>
    </z-dropdown-menu-content>
  `,
})
export class ZardDropdownDemoComponent {
  onProfile() {
    console.log('Profile clicked');
  }
 
  onBilling() {
    console.log('Billing clicked');
  }
 
  onSettings() {
    console.log('Settings clicked');
  }
 
  onKeyboardShortcuts() {
    console.log('Keyboard shortcuts clicked');
  }
 
  onTeam() {
    console.log('Team clicked');
  }
 
  onNewTeam() {
    console.log('New Team clicked');
  }
 
  onGitHub() {
    console.log('GitHub clicked');
  }
 
  onSupport() {
    console.log('Support clicked');
  }
 
  onLogout() {
    console.log('Log out clicked');
  }
}
 

API

z-dropdown (Directive) Directive

A dropdown trigger directive that handles dropdown interactions.

Properties

Property Description Type Default
[zDropdownMenu] Reference to dropdown menu content ZardDropdownMenuContentComponent -
[zTrigger] Trigger type for dropdown 'click' | 'hover' 'click'
[zDisabled] Disables the dropdown trigger boolean false

z-dropdown-menu Component

Legacy dropdown component with built-in overlay management.

Properties

Property Description Type Default
[class] Additional CSS classes ClassValue ''
[disabled] Disables the dropdown boolean false

Events

Event Description Type
(openChange) Emitted when dropdown state changes boolean

Content Projection Slots

Slot Description
[dropdown-trigger] Element that triggers the dropdown
<default> Dropdown items content

z-dropdown-menu-content Component

Container for dropdown menu items with proper accessibility attributes.

Properties

Property Description Type Default
[class] Additional CSS classes ClassValue ''

z-dropdown-menu-item Component

Individual clickable items within the dropdown menu.

Properties

Property Description Type Default
[variant] Visual variant of the item 'default' | 'destructive' 'default'
[inset] Adds left padding for alignment boolean false
[disabled] Disables the dropdown item boolean false
[class] Additional CSS classes ClassValue ''

z-dropdown-menu-label Component

Label component for grouping dropdown items.

Properties

Property Description Type Default
[inset] Adds left padding for alignment boolean false
[class] Additional CSS classes ClassValue ''

z-dropdown-menu-shortcut Component

Component for displaying keyboard shortcuts in dropdown items.

Properties

Property Description Type Default
[class] Additional CSS classes ClassValue ''

ZardDropdownService Service

Global service for managing dropdown state and interactions.

Methods

Method Description Parameters
toggle() Toggles dropdown visibility triggerElement: ElementRef, template: TemplateRef<unknown>, viewContainerRef: ViewContainerRef
open() Opens the dropdown triggerElement: ElementRef, template: TemplateRef<unknown>, viewContainerRef: ViewContainerRef
close() Closes the dropdown -

Properties

Property Description Type
isOpen() Current dropdown state Signal<boolean>