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