diff --git a/src/app/components/dropdown/dropdown.html b/src/app/components/dropdown/dropdown.html
index 17a0789..b10e209 100644
--- a/src/app/components/dropdown/dropdown.html
+++ b/src/app/components/dropdown/dropdown.html
@@ -2,23 +2,32 @@
@if (icon()) {
{{ icon() }}
}
- {{ selectedLabel() }}
+
+
+
+ @if (!isOpen()) {
+ {{ selectedLabel() }}
+ }
keyboard_arrow_down
@if (isOpen()) {
-
- @for (option of normalizedOptions(); track option.value; let i = $index) {
- -
- {{ option.label }}
-
- }
-
+
+
+ @for (option of filteredOptions(); track option.value; let i = $index) {
+ -
+ {{ option.label }}
+
+ }
+
+
}
diff --git a/src/app/components/dropdown/dropdown.scss b/src/app/components/dropdown/dropdown.scss
index 01e4133..644fc78 100644
--- a/src/app/components/dropdown/dropdown.scss
+++ b/src/app/components/dropdown/dropdown.scss
@@ -38,6 +38,11 @@ $default_animation_duration: 0.2s;
cursor: pointer;
max-width: 100%;
+
+ &:focus-within {
+ outline: 2px solid list.nth(library.$brand_palette, 5);
+ outline-offset: 2px;
+ }
}
.dropdown-box {
@@ -55,12 +60,28 @@ $default_animation_duration: 0.2s;
}
.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;
font-size: 18px;
height: 24px;
white-space: nowrap;
overflow: hidden;
- text-overflow: ellipsis;
user-select: none;
align-content: center;
@@ -78,22 +99,48 @@ $default_animation_duration: 0.2s;
}
}
-.dropdown-list {
+.dropdown-container {
position: absolute;
top: calc(100% + 4px);
left: 0;
width: 100%;
padding: 0;
margin: 0;
- list-style: none;
background-color: light-dark(oklch(1 0 0), oklch(0 0 0));
border-radius: $border_radius;
z-index: 1000;
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)
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 {
content: "";
position: absolute;
diff --git a/src/app/components/dropdown/dropdown.ts b/src/app/components/dropdown/dropdown.ts
index d7475b0..7a0283c 100644
--- a/src/app/components/dropdown/dropdown.ts
+++ b/src/app/components/dropdown/dropdown.ts
@@ -1,6 +1,20 @@
-import { Component, input, model, output, signal, HostListener, ElementRef, computed, OnInit
-} from '@angular/core'
+import {
+ Component,
+ input,
+ model,
+ output,
+ signal,
+ HostListener,
+ ElementRef,
+ computed,
+ OnInit,
+ inject,
+ effect,
+ viewChild,
+ untracked,
+} from '@angular/core';
import { CommonModule } from '@angular/common'
+import { TranslationService } from '../../services/translation.service';
export interface DropdownOption {
label: string;
@@ -24,6 +38,7 @@ export interface DropdownOption {
'[class.open]': 'isOpen()',
tabindex: '0',
'(click)': 'toggle()',
+ '(keydown)': 'onKeyDown($event)',
'(keydown.enter)': 'onEnter($event)',
'(keydown.space)': 'onSpaceDown($event)',
'(keydown.escape)': 'close()',
@@ -32,6 +47,9 @@ export interface DropdownOption {
},
})
export class Dropdown implements OnInit {
+ translation = inject(TranslationService).translation;
+ searchInput = viewChild>('searchInput');
+
options = input>([]);
placeholder = input('Select an option');
ariaLabel = input(undefined);
@@ -41,12 +59,28 @@ export class Dropdown implements OnInit {
selectionChange = output();
isOpen = signal(false);
+ inputFocusMode = signal(false);
focusedIndex = signal(-1);
+ searchQuery = signal('');
private static nextId = 0;
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() {
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() {
this.isOpen.update((v) => !v);
if (this.isOpen()) {
+ this.searchQuery.set('');
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();
}
- onEnter(event: Event) {
- event.preventDefault();
+ onConfirm(event: Event) {
+ event.preventDefault()
+
if (this.isOpen()) {
const currentIndex = this.focusedIndex();
- if (currentIndex >= 0 && currentIndex < this.normalizedOptions().length) {
- this.selectOption(this.normalizedOptions()[currentIndex]);
+ const options = this.filteredOptions();
+ if (currentIndex >= 0 && currentIndex < options.length) {
+ this.selectOption(options[currentIndex]);
}
} else {
this.toggle();
}
}
+ onEnter(event: Event) { this.onConfirm(event) }
+
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();
- }
+ if (this.isOpen() && this.inputFocusMode()) return;
+ this.onConfirm(event)
}
onArrowDown(event: Event) {
@@ -115,9 +164,13 @@ export class Dropdown implements OnInit {
this.toggle();
return;
}
+
this.focusedIndex.update((i) => {
+ const options = this.filteredOptions();
+ if (options.length === 0) return -1;
+
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();
return;
}
+
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;
});
}
+ 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'])
onDocumentClick(event: MouseEvent) {
- if (!this.el.nativeElement.contains(event.target)) {
+ if (!this.element.nativeElement.contains(event.target)) {
this.close();
}
}
+ onSearch(event: Event) {
+ const text = (event.target as HTMLElement).innerText;
+
+ this.focusedIndex.set(0);
+ this.searchQuery.set(text);
+ }
+
selectedLabel = computed(() => {
const selected = this.normalizedOptions().find((o) => o.value === this.value());
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);
+ }
}
diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts
index 6eda799..a3f161f 100644
--- a/src/app/i18n/en.ts
+++ b/src/app/i18n/en.ts
@@ -18,6 +18,7 @@ export const EN = {
signup: 'Signup',
},
common: {
+ selectOption: 'Select Option...',
close: 'Close',
save: 'Save',
},
diff --git a/src/app/i18n/jp.ts b/src/app/i18n/jp.ts
index 3b21f6d..779a8b2 100644
--- a/src/app/i18n/jp.ts
+++ b/src/app/i18n/jp.ts
@@ -20,6 +20,7 @@ export const JP: Translation = {
signup: '新規登録',
},
common: {
+ selectOption: '選択...',
close: '閉じる',
save: '保存',
},
diff --git a/src/app/i18n/kr.ts b/src/app/i18n/kr.ts
index 026ff22..ce217ae 100644
--- a/src/app/i18n/kr.ts
+++ b/src/app/i18n/kr.ts
@@ -20,6 +20,7 @@ export const KR: Translation = {
signup: '회원가입',
},
common: {
+ selectOption: '선택...',
close: '닫기',
save: '저장',
},
diff --git a/src/app/pages/main/main.ts b/src/app/pages/main/main.ts
index 36fb22b..4bc55f7 100644
--- a/src/app/pages/main/main.ts
+++ b/src/app/pages/main/main.ts
@@ -17,6 +17,9 @@ export class MainPage {
{ label: 'English', value: 'EN' },
{ label: '한국어', value: 'KR' },
{ label: '日本語', value: 'JP' },
+ { label: 'Deutsch', value: 'DE' },
+ { label: 'Italiano', value: 'IT' },
+ { label: '中文', value: 'CN' },
];
selectedDropdownValue = signal(null);