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