Add Dropdown Search Feature

This commit is contained in:
copecone committed 2026-04-19 18:51:46 +09:00
1 parent 7a0af6cc4a
commit 3f33c0dfa5
7 files changed
+166 -24

No files matched your search

+10 -1
View File
@@ -2,13 +2,21 @@
@if (icon()) { @if (icon()) {
<span class="material-symbols-outlined icon-24 prefix-icon">{{ icon() }}</span> <span class="material-symbols-outlined icon-24 prefix-icon">{{ icon() }}</span>
} }
<div #searchInput class="dropdown-input" [class.hidden]="!isOpen()"
contenteditable="true" role="textbox" aria-multiline="false" [aria-placeholder]="translation.common.selectOption" tabindex="-1" spellcheck="false"
(input)="onSearch($event)"></div>
@if (!isOpen()) {
<span class="placeholder">{{ selectedLabel() }}</span> <span class="placeholder">{{ selectedLabel() }}</span>
}
<span class="material-symbols-outlined icon-24 chevron" [class.open]="isOpen()">keyboard_arrow_down</span> <span class="material-symbols-outlined icon-24 chevron" [class.open]="isOpen()">keyboard_arrow_down</span>
</div> </div>
@if (isOpen()) { @if (isOpen()) {
<div class="dropdown-container">
<ul class="dropdown-list" role="listbox" [id]="dropdownId + '-listbox'"> <ul class="dropdown-list" role="listbox" [id]="dropdownId + '-listbox'">
@for (option of normalizedOptions(); track option.value; let i = $index) { @for (option of filteredOptions(); track option.value; let i = $index) {
<li <li
class="dropdown-item" class="dropdown-item"
role="option" role="option"
@@ -21,4 +29,5 @@
</li> </li>
} }
</ul> </ul>
</div>
} }
+51 -4
View File
@@ -38,6 +38,11 @@ $default_animation_duration: 0.2s;
cursor: pointer; cursor: pointer;
max-width: 100%; max-width: 100%;
&:focus-within {
outline: 2px solid list.nth(library.$brand_palette, 5);
outline-offset: 2px;
}
} }
.dropdown-box { .dropdown-box {
@@ -55,12 +60,28 @@ $default_animation_duration: 0.2s;
} }
.placeholder { .placeholder {
text-overflow: ellipsis;
}
.dropdown-input:focus-visible {
outline: none;
}
.dropdown-input.hidden {
position: absolute;
width: 0;
height: 0;
opacity: 0;
visibility: hidden;
}
.placeholder, .dropdown-input {
flex-grow: 1; flex-grow: 1;
font-size: 18px; font-size: 18px;
height: 24px; height: 24px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
user-select: none; user-select: none;
align-content: center; align-content: center;
@@ -78,22 +99,48 @@ $default_animation_duration: 0.2s;
} }
} }
.dropdown-list { .dropdown-container {
position: absolute; position: absolute;
top: calc(100% + 4px); top: calc(100% + 4px);
left: 0; left: 0;
width: 100%; width: 100%;
padding: 0; padding: 0;
margin: 0; margin: 0;
list-style: none;
background-color: light-dark(oklch(1 0 0), oklch(0 0 0)); background-color: light-dark(oklch(1 0 0), oklch(0 0 0));
border-radius: $border_radius; border-radius: $border_radius;
z-index: 1000; z-index: 1000;
max-height: 240px; max-height: 240px;
overflow-y: auto; overflow-y: hidden;
box-shadow: 0 4px 12px oklch(0 0 0 / 10%); // rgba(0, 0, 0, 0.1) box-shadow: 0 4px 12px oklch(0 0 0 / 10%); // rgba(0, 0, 0, 0.1)
animation: slideIn var(--animation-duration) $animation_timing; animation: slideIn var(--animation-duration) $animation_timing;
.dropdown-list {
padding: 0;
margin: 0;
list-style: none;
width: 100%;
max-height: inherit;
overflow-y: auto;
scrollbar-color: light-dark(oklch(0 0 0 / 10%), oklch(1 0 0 / 20%)) transparent;
&::-webkit-scrollbar {
width: 12px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
border: 2px solid transparent;
background-clip: padding-box;
background-color: light-dark(oklch(0 0 0 / 10%), oklch(1 0 0 / 20%));
border-radius: 10px;
}
}
&::after { &::after {
content: ""; content: "";
position: absolute; position: absolute;
+99 -19
View File
@@ -1,6 +1,20 @@
import { Component, input, model, output, signal, HostListener, ElementRef, computed, OnInit import {
} from '@angular/core' Component,
input,
model,
output,
signal,
HostListener,
ElementRef,
computed,
OnInit,
inject,
effect,
viewChild,
untracked,
} from '@angular/core';
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { TranslationService } from '../../services/translation.service';
export interface DropdownOption { export interface DropdownOption {
label: string; label: string;
@@ -24,6 +38,7 @@ export interface DropdownOption {
'[class.open]': 'isOpen()', '[class.open]': 'isOpen()',
tabindex: '0', tabindex: '0',
'(click)': 'toggle()', '(click)': 'toggle()',
'(keydown)': 'onKeyDown($event)',
'(keydown.enter)': 'onEnter($event)', '(keydown.enter)': 'onEnter($event)',
'(keydown.space)': 'onSpaceDown($event)', '(keydown.space)': 'onSpaceDown($event)',
'(keydown.escape)': 'close()', '(keydown.escape)': 'close()',
@@ -32,6 +47,9 @@ export interface DropdownOption {
}, },
}) })
export class Dropdown implements OnInit { export class Dropdown implements OnInit {
translation = inject(TranslationService).translation;
searchInput = viewChild<ElementRef<HTMLElement>>('searchInput');
options = input<Array<DropdownOption | string>>([]); options = input<Array<DropdownOption | string>>([]);
placeholder = input<string>('Select an option'); placeholder = input<string>('Select an option');
ariaLabel = input<string | undefined>(undefined); ariaLabel = input<string | undefined>(undefined);
@@ -41,12 +59,28 @@ export class Dropdown implements OnInit {
selectionChange = output<any>(); selectionChange = output<any>();
isOpen = signal(false); isOpen = signal(false);
inputFocusMode = signal(false);
focusedIndex = signal(-1); focusedIndex = signal(-1);
searchQuery = signal('');
private static nextId = 0; private static nextId = 0;
dropdownId = `dropdown-${Dropdown.nextId++}`; dropdownId = `dropdown-${Dropdown.nextId++}`;
constructor(private el: ElementRef) {} constructor(private element: ElementRef) {
effect(() => this.filteredOptions())
effect(() => {
const isDropdownOpen = this.isOpen();
if (!isDropdownOpen) {
if (untracked(this.inputFocusMode)) this.inputFocusMode.set(false);
this.searchQuery.set('');
let currentValue = untracked(this.normalizedOptions).find((o) => o.value === this.value());
if (currentValue) this.searchInput()!.nativeElement.innerText = currentValue.label;
}
});
}
ngOnInit() { ngOnInit() {
const opts = this.normalizedOptions(); const opts = this.normalizedOptions();
@@ -67,10 +101,28 @@ export class Dropdown implements OnInit {
}); });
}); });
filteredOptions = computed(() => {
const query = this.searchQuery().toLowerCase().trim();
const opts = this.normalizedOptions();
if (!this.inputFocusMode()) return opts;
if (!query) return opts;
return opts.filter((o) => o.label.toLowerCase().includes(query));
});
toggle() { toggle() {
this.isOpen.update((v) => !v); this.isOpen.update((v) => !v);
if (this.isOpen()) { if (this.isOpen()) {
this.searchQuery.set('');
this.focusedIndex.set(this.normalizedOptions().findIndex((o) => o.value === this.value())); this.focusedIndex.set(this.normalizedOptions().findIndex((o) => o.value === this.value()));
requestAnimationFrame(() => {
const searchInputElement = this.searchInput()!.nativeElement;
searchInputElement.focus();
this.setCursorToEnd(searchInputElement);
})
} }
} }
@@ -85,28 +137,25 @@ export class Dropdown implements OnInit {
this.close(); this.close();
} }
onEnter(event: Event) { onConfirm(event: Event) {
event.preventDefault(); event.preventDefault()
if (this.isOpen()) { if (this.isOpen()) {
const currentIndex = this.focusedIndex(); const currentIndex = this.focusedIndex();
if (currentIndex >= 0 && currentIndex < this.normalizedOptions().length) { const options = this.filteredOptions();
this.selectOption(this.normalizedOptions()[currentIndex]); if (currentIndex >= 0 && currentIndex < options.length) {
this.selectOption(options[currentIndex]);
} }
} else { } else {
this.toggle(); this.toggle();
} }
} }
onEnter(event: Event) { this.onConfirm(event) }
onSpaceDown(event: Event) { onSpaceDown(event: Event) {
event.preventDefault(); if (this.isOpen() && this.inputFocusMode()) return;
if (this.isOpen()) { this.onConfirm(event)
const currentIndex = this.focusedIndex();
if (currentIndex >= 0 && currentIndex < this.normalizedOptions().length) {
this.selectOption(this.normalizedOptions()[currentIndex]);
}
} else {
this.toggle();
}
} }
onArrowDown(event: Event) { onArrowDown(event: Event) {
@@ -115,9 +164,13 @@ export class Dropdown implements OnInit {
this.toggle(); this.toggle();
return; return;
} }
this.focusedIndex.update((i) => { this.focusedIndex.update((i) => {
const options = this.filteredOptions();
if (options.length === 0) return -1;
if (i === -1) return 0; if (i === -1) return 0;
return (i + 1) % this.normalizedOptions().length; return (i + 1) % options.length;
}); });
} }
@@ -127,21 +180,48 @@ export class Dropdown implements OnInit {
this.toggle(); this.toggle();
return; return;
} }
this.focusedIndex.update((i) => { this.focusedIndex.update((i) => {
if (i <= 0) return this.normalizedOptions().length - 1; const options = this.filteredOptions();
if (options.length === 0) return -1;
if (i <= 0) return options.length - 1;
return i - 1; return i - 1;
}); });
} }
onKeyDown(event: KeyboardEvent) {
if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey || event.key == 'Tab') return;
if (!this.isOpen() || this.inputFocusMode()) return;
this.inputFocusMode.set(true);
}
@HostListener('document:click', ['$event']) @HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent) { onDocumentClick(event: MouseEvent) {
if (!this.el.nativeElement.contains(event.target)) { if (!this.element.nativeElement.contains(event.target)) {
this.close(); this.close();
} }
} }
onSearch(event: Event) {
const text = (event.target as HTMLElement).innerText;
this.focusedIndex.set(0);
this.searchQuery.set(text);
}
selectedLabel = computed(() => { selectedLabel = computed(() => {
const selected = this.normalizedOptions().find((o) => o.value === this.value()); const selected = this.normalizedOptions().find((o) => o.value === this.value());
return selected ? selected.label : this.placeholder(); return selected ? selected.label : this.placeholder();
}); });
private setCursorToEnd(element: HTMLElement) {
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(element);
range.collapse(false);
sel?.removeAllRanges();
sel?.addRange(range);
}
} }
+1
View File
@@ -18,6 +18,7 @@ export const EN = {
signup: 'Signup', signup: 'Signup',
}, },
common: { common: {
selectOption: 'Select Option...',
close: 'Close', close: 'Close',
save: 'Save', save: 'Save',
}, },
+1
View File
@@ -20,6 +20,7 @@ export const JP: Translation = {
signup: '新規登録', signup: '新規登録',
}, },
common: { common: {
selectOption: '選択...',
close: '閉じる', close: '閉じる',
save: '保存', save: '保存',
}, },
+1
View File
@@ -20,6 +20,7 @@ export const KR: Translation = {
signup: '회원가입', signup: '회원가입',
}, },
common: { common: {
selectOption: '선택...',
close: '닫기', close: '닫기',
save: '저장', save: '저장',
}, },
+3
View File
@@ -17,6 +17,9 @@ export class MainPage {
{ label: 'English', value: 'EN' }, { label: 'English', value: 'EN' },
{ label: '한국어', value: 'KR' }, { label: '한국어', value: 'KR' },
{ label: '日本語', value: 'JP' }, { label: '日本語', value: 'JP' },
{ label: 'Deutsch', value: 'DE' },
{ label: 'Italiano', value: 'IT' },
{ label: '中文', value: 'CN' },
]; ];
selectedDropdownValue = signal<any>(null); selectedDropdownValue = signal<any>(null);