import { Component, input, model, output, signal, HostListener, ElementRef, computed, OnInit } from '@angular/core' import { CommonModule } from '@angular/common' export interface DropdownOption { label: string; value: any; } @Component({ selector: 'dropdown', standalone: true, imports: [CommonModule], templateUrl: './dropdown.html', styleUrl: './dropdown.scss', host: { role: 'combobox', 'aria-haspopup': 'listbox', '[attr.aria-expanded]': 'isOpen()', '[attr.aria-controls]': "isOpen() ? dropdownId + '-listbox' : null", '[attr.aria-activedescendant]': "isOpen() && focusedIndex() >= 0 ? dropdownId + '-option-' + focusedIndex() : null", '[attr.aria-label]': 'ariaLabel()', '[class.open]': 'isOpen()', tabindex: '0', '(click)': 'toggle()', '(keydown.enter)': 'onEnter($event)', '(keydown.space)': 'onSpaceDown($event)', '(keydown.escape)': 'close()', '(keydown.arrowdown)': 'onArrowDown($event)', '(keydown.arrowup)': 'onArrowUp($event)', }, }) export class Dropdown implements OnInit { options = input>([]); placeholder = input('Select an option'); ariaLabel = input(undefined); icon = input(); defaultIndex = input(); value = model(null); selectionChange = output(); isOpen = signal(false); focusedIndex = signal(-1); private static nextId = 0; dropdownId = `dropdown-${Dropdown.nextId++}`; constructor(private el: ElementRef) {} ngOnInit() { const opts = this.normalizedOptions(); const dIndex = this.defaultIndex(); const currentValue = this.value(); if (currentValue === null && dIndex !== undefined && dIndex >= 0 && dIndex < opts.length) { this.value.set(opts[dIndex].value); } } normalizedOptions = computed(() => { return this.options().map((opt) => { if (typeof opt === 'string') { return { label: opt, value: opt }; } return opt; }); }); toggle() { this.isOpen.update((v) => !v); if (this.isOpen()) { this.focusedIndex.set(this.normalizedOptions().findIndex((o) => o.value === this.value())); } } close() { this.isOpen.set(false); this.focusedIndex.set(-1); } selectOption(option: DropdownOption) { this.value.set(option.value); this.selectionChange.emit(option.value); this.close(); } onEnter(event: Event) { event.preventDefault(); if (this.isOpen()) { const currentIndex = this.focusedIndex(); if (currentIndex >= 0 && currentIndex < this.normalizedOptions().length) { this.selectOption(this.normalizedOptions()[currentIndex]); } } else { this.toggle(); } } onSpaceDown(event: Event) { event.preventDefault(); if (this.isOpen()) { const currentIndex = this.focusedIndex(); if (currentIndex >= 0 && currentIndex < this.normalizedOptions().length) { this.selectOption(this.normalizedOptions()[currentIndex]); } } else { this.toggle(); } } onArrowDown(event: Event) { event.preventDefault(); if (!this.isOpen()) { this.toggle(); return; } this.focusedIndex.update((i) => { if (i === -1) return 0; return (i + 1) % this.normalizedOptions().length; }); } onArrowUp(event: Event) { event.preventDefault(); if (!this.isOpen()) { this.toggle(); return; } this.focusedIndex.update((i) => { if (i <= 0) return this.normalizedOptions().length - 1; return i - 1; }); } @HostListener('document:click', ['$event']) onDocumentClick(event: MouseEvent) { if (!this.el.nativeElement.contains(event.target)) { this.close(); } } selectedLabel = computed(() => { const selected = this.normalizedOptions().find((o) => o.value === this.value()); return selected ? selected.label : this.placeholder(); }); }