LogoRectangle UI

Dropdown

This is a component that represents a dropdown menu.

  • A dropdown lets users select one option from a list.
  • It supports a placeholder and a currently selected value.
  • Great for simple single-select choices when search is not needed.

Preview

Without default value:
With default value:

Usage

import { Component, signal } from "@angular/core";
import { DropdownComponent } from "@/components/dropdown/dropdown.component";
import { DropdownItemComponent } from "@/components/dropdown/dropdown.item.component";
import { DropdownModel } from "@/components/dropdown/dropdown.model";

@Component({
  selector: "rui-dropdown-demo",
  template: `
    <div class="flex w-64 flex-col gap-4">
      <div class="flex flex-col gap-2">
        <span>Without default value:</span>
        <rui-dropdown [placeholder]="'Select a Pokémon..'" [(selectedItem)]="selectedPokemon1">
          @for (p of allPokemons; track p.id) {
            <rui-dropdown-item [item]="p" (itemSelected)="handlePokemonSelected($event)">
              {{ p.label }}
            </rui-dropdown-item>
          }
        </rui-dropdown>
      </div>
      <div class="flex flex-col gap-2">
        <span>With default value:</span>
        <rui-dropdown [placeholder]="'Select a Pokémon'" [(selectedItem)]="selectedPokemon2">
          @for (p of allPokemons; track p.id) {
            <rui-dropdown-item [item]="p" (itemSelected)="handlePokemonSelected($event)">
              {{ p.label }}
            </rui-dropdown-item>
          }
        </rui-dropdown>
      </div>
    </div>
  `,
  imports: [DropdownComponent, DropdownItemComponent],
})
export class DropdownDemoComponent {
  allPokemons: DropdownModel[] = [
    { id: "1", label: "Pikachu" },
    { id: "2", label: "Bulbasaur" },
    { id: "3", label: "Charmander" },
    { id: "4", label: "Squirtle" },
    { id: "5", label: "Snorlax" },
    { id: "6", label: "Magikarp" },
    { id: "7", label: "Dragonite" },
  ];

  // Regular property binding
  selectedPokemon1: DropdownModel | undefined = undefined;

  // Signal binding
  selectedPokemon2 = signal<DropdownModel | undefined>(this.allPokemons[0]);

  handlePokemonSelected(pokemon: DropdownModel) {
    console.log("Selected Pokémon:", pokemon.label);
  }
}

Source Code

import {
  afterRender,
  booleanAttribute,
  ChangeDetectionStrategy,
  Component,
  ContentChildren,
  ElementRef,
  HostListener,
  Input,
  model,
  QueryList,
} from "@angular/core";
import { NgClass } from "@angular/common";
import { matArrowDropDown, matArrowDropUp } from "@ng-icons/material-icons/baseline";
import { IconComponent } from "@/components/icon/icon.component";
import { DropdownModel } from "@/components/dropdown/dropdown.model";
import { DropdownItemComponent } from "@/components/dropdown/dropdown.item.component";

const DROPDOWN_BACKGROUND =
  "border-[1px] border-primary-400 bg-primary-100 hover:bg-primary-200 active:bg-primary-200 dark:border-primary-800 dark:bg-primary-900 dark:hover:bg-primary-900/50 dark:active:bg-primary-900/50";
const DROPDOWN_TEXT =
  "cursor-pointer select-none text-sm font-semibold text-primary-900 dark:text-primary-100";
const DROPDOWN_LAYOUT = "flex w-full items-center justify-between rounded-lg px-2 py-2";
const DROPDOWN_ANIMATION = "transition-colors duration-200 ease-in-out";
const EMPTY_OPTION_TEXT = "text-primary-700/70 dark:text-primary-300/70";

@Component({
  selector: "rui-dropdown",
  imports: [NgClass, IconComponent],
  template: `
    <div class="relative w-full">
      <button type="button" [ngClass]="styleClasses" (click)="toggleDropdown()">
        <span class="px-2" [ngClass]="!selectedItem() ? placeholderTextClasses : []">
          {{ selectedItem()?.label ?? placeholder }}
        </span>
        <rui-icon
          class="scale-110"
          [icon]="isExpanded ? matArrowDropUp : matArrowDropDown"></rui-icon>
      </button>
      @if (isExpanded) {
        <ul
          class="absolute left-0 z-10 mt-1 max-h-60 w-full overflow-y-auto overflow-x-hidden rounded-lg border-[1px] border-primary-300 dark:border-primary-800">
          @if (!required) {
            <li class="list-none">
              <button
                type="button"
                [ngClass]="emptyOptionClasses"
                (click)="clearSelection()"
                aria-label="Clear selection">
                {{ emptyOptionLabel }}
              </button>
            </li>
          }
          <ng-content select="rui-dropdown-item"></ng-content>
        </ul>
      }
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DropdownComponent {
  @ContentChildren(DropdownItemComponent) items: QueryList<DropdownItemComponent> | undefined;

  /**
   * The placeholder text to display when no option is selected.
   */
  @Input() placeholder: string = "Select an option";

  /**
   * Whether selecting an empty value is disallowed.
   */
  @Input({ transform: booleanAttribute }) required: boolean = false;

  /**
   * Label shown for the empty option when the dropdown is not required.
   */
  @Input() emptyOptionLabel: string = "None";

  /**
   * The currently selected item.
   */
  selectedItem = model<DropdownModel | undefined>();

  /**
   * The dropdown state.
   */
  isExpanded = false;

  /**
   * Whether the component has finished initial rendering.
   */
  finishedRendering = false;

  constructor(private _elementRef: ElementRef) {
    afterRender(() => {
      this.finishedRendering = true;
      this.items?.forEach((item) => {
        item.itemSelected.subscribe((model) => {
          this.selectedItem.set(model);
          this.isExpanded = false;
        });
      });
    });
  }

  /**
   * Toggles the dropdown state.
   */
  toggleDropdown() {
    this.isExpanded = !this.isExpanded;
  }

  clearSelection() {
    this.selectedItem.set(undefined);
    this.isExpanded = false;
  }

  protected readonly matArrowDropUp = matArrowDropUp;
  protected readonly matArrowDropDown = matArrowDropDown;

  protected readonly styleClasses: string[] = [
    DROPDOWN_BACKGROUND,
    DROPDOWN_TEXT,
    DROPDOWN_LAYOUT,
    DROPDOWN_ANIMATION,
  ];
  protected readonly emptyOptionClasses: string[] = [
    "flex w-full items-center px-4 py-2 text-sm font-semibold transition-colors duration-200 ease-in-out",
    "bg-primary-100 hover:bg-primary-200 dark:bg-primary-900 dark:hover:bg-primary-800",
    EMPTY_OPTION_TEXT,
  ];
  protected readonly placeholderTextClasses: string[] = [
    "text-primary-700/70",
    "dark:text-primary-300/70",
  ];

  @HostListener("document:click", ["$event"])
  onDocumentClick(event: MouseEvent) {
    const clickedInside = this._elementRef.nativeElement.contains(event.target);
    if (!clickedInside) {
      this.isExpanded = false;
    }
  }
}
Copyright © 2026 Jarrett Huang | MIT License | Github