148 lines
3.9 KiB
TypeScript
148 lines
3.9 KiB
TypeScript
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<Array<DropdownOption | string>>([]);
|
|
placeholder = input<string>('Select an option');
|
|
ariaLabel = input<string | undefined>(undefined);
|
|
icon = input<string>();
|
|
defaultIndex = input<number>();
|
|
value = model<any>(null);
|
|
selectionChange = output<any>();
|
|
|
|
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();
|
|
});
|
|
}
|