Add Dropdown Search Feature
This commit is contained in:
1 parent
7a0af6cc4a
commit
3f33c0dfa5
7 files changed
+166
-24
No files matched your search
@@ -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>
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,7 @@ export const EN = {
|
|||||||
signup: 'Signup',
|
signup: 'Signup',
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
|
selectOption: 'Select Option...',
|
||||||
close: 'Close',
|
close: 'Close',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const JP: Translation = {
|
|||||||
signup: '新規登録',
|
signup: '新規登録',
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
|
selectOption: '選択...',
|
||||||
close: '閉じる',
|
close: '閉じる',
|
||||||
save: '保存',
|
save: '保存',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const KR: Translation = {
|
|||||||
signup: '회원가입',
|
signup: '회원가입',
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
|
selectOption: '선택...',
|
||||||
close: '닫기',
|
close: '닫기',
|
||||||
save: '저장',
|
save: '저장',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user