Files
ComponentTest/src/app/components/dropdown/dropdown.ts
T
2026-04-02 14:54:57 +09:00

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();
});
}