Make Dropdown to Process List Scroll Properly

This commit is contained in:
copecone committed 2026-04-20 11:59:00 +09:00
1 parent 0baba36f7f
commit 5de6d111f2
4 files changed
+110 -24

No files matched your search

+14 -8
View File
@@ -1,24 +1,30 @@
<ng-template #inputTemplate>
<div #searchInput class="dropdown-input" [class.hidden]="!isOpen()" [class.focus-mode]="inputFocusMode()"
contenteditable="true" role="textbox" aria-multiline="false" [aria-placeholder]="translation.common.selectOption" [aria-activedescendant]="ariaActiveDescendant()"
[attr.placeholder]="translation.common.selectOption" tabindex="-1" spellcheck="false"
(input)="onSearch($event)" (click)="handleInputClick($event)" (mousedown)="handleInputMouseDown()" (beforeinput)="preprocessInputContent($event)"></div>
</ng-template>
<div class="dropdown-box"> <div class="dropdown-box">
@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>
} }
@if (isOpen()) { @if (recreateKey() % 2 == 0) {<ng-container [ngTemplateOutlet]="inputTemplate" />}
<div #searchInput class="dropdown-input" [class.hidden]="!isOpen()" [class.focus-mode]="inputFocusMode()" @else {<ng-container [ngTemplateOutlet]="inputTemplate" />}
contenteditable="true" role="textbox" aria-multiline="false" [aria-placeholder]="translation.common.selectOption"
[attr.placeholder]="translation.common.selectOption" tabindex="-1" spellcheck="false" @if (!isOpen()) {
(input)="onSearch($event)" (click)="handleInputClick($event)" (mousedown)="handleInputMouseDown()" (beforeinput)="preprocessInputContent($event)">{{ this.initialInputText() }}</div>
} @else {
<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"> <div class="dropdown-container" tabindex="-1">
<ul class="dropdown-list" role="listbox" [id]="dropdownId + '-listbox'"> <ul class="dropdown-list" role="listbox" [id]="dropdownId + '-listbox'" tabindex="-1">
@for (option of filteredOptions(); track option.value; let i = $index) { @for (option of filteredOptions(); track option.value; let i = $index) {
<li <li
#optionItem
class="dropdown-item" class="dropdown-item"
role="option" role="option"
[id]="dropdownId + '-option-' + i" [id]="dropdownId + '-option-' + i"
+5 -1
View File
@@ -186,7 +186,7 @@ $default_animation_duration: 0.2s;
padding: 0 16px; padding: 0 16px;
position: relative; position: relative;
align-content: center; align-content: center;
transition: --hover-state var(--animation-duration) $animation_timing, --selected-state var(--animation-duration) $animation_timing; transition: --hover-state calc(var(--animation-duration) / 3) $animation_timing, --selected-state calc(var(--animation-duration) / 3) $animation_timing;
font-size: 16px; font-size: 16px;
background-color: color-mix( background-color: color-mix(
@@ -201,6 +201,10 @@ $default_animation_duration: 0.2s;
calc(var(--selected-state) * 100%) calc(var(--selected-state) * 100%)
); );
&.focused {
--animation-duration: 0;
}
&:hover, &.focused { &:hover, &.focused {
--hover-state: 1; --hover-state: 1;
} }
+57 -15
View File
@@ -11,9 +11,11 @@ import {
inject, inject,
effect, effect,
viewChild, viewChild,
viewChildren,
untracked, untracked,
PLATFORM_ID,
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule, isPlatformBrowser } from '@angular/common';
import { TranslationService } from '../../services/translation.service'; import { TranslationService } from '../../services/translation.service';
export interface DropdownOption { export interface DropdownOption {
@@ -32,8 +34,7 @@ export interface DropdownOption {
'aria-haspopup': 'listbox', 'aria-haspopup': 'listbox',
'[attr.aria-expanded]': 'isOpen()', '[attr.aria-expanded]': 'isOpen()',
'[attr.aria-controls]': "isOpen() ? dropdownId + '-listbox' : null", '[attr.aria-controls]': "isOpen() ? dropdownId + '-listbox' : null",
'[attr.aria-activedescendant]': '[attr.aria-activedescendant]': 'ariaActiveDescendant()',
"isOpen() && focusedIndex() >= 0 ? dropdownId + '-option-' + focusedIndex() : null",
'[attr.aria-label]': 'ariaLabel()', '[attr.aria-label]': 'ariaLabel()',
'[class.open]': 'isOpen()', '[class.open]': 'isOpen()',
tabindex: '0', tabindex: '0',
@@ -50,7 +51,9 @@ export interface DropdownOption {
}) })
export class Dropdown implements OnInit { export class Dropdown implements OnInit {
translation = inject(TranslationService).translation; translation = inject(TranslationService).translation;
private platformId = inject(PLATFORM_ID);
searchInput = viewChild<ElementRef<HTMLElement>>('searchInput'); searchInput = viewChild<ElementRef<HTMLElement>>('searchInput');
optionItems = viewChildren<ElementRef<HTMLElement>>('optionItem');
options = input<Array<DropdownOption | string>>([]); options = input<Array<DropdownOption | string>>([]);
placeholder = input<string>('Select an option'); placeholder = input<string>('Select an option');
@@ -64,6 +67,13 @@ export class Dropdown implements OnInit {
inputFocusMode = signal(false); inputFocusMode = signal(false);
focusedIndex = signal(-1); focusedIndex = signal(-1);
searchQuery = signal(''); searchQuery = signal('');
recreateKey = signal(0);
ariaActiveDescendant = computed(() => {
return this.isOpen() && this.focusedIndex() >= 0
? this.dropdownId + '-option-' + this.focusedIndex()
: null;
});
private static nextId = 0; private static nextId = 0;
private isLatestMouseDownInsideInput = false; private isLatestMouseDownInsideInput = false;
@@ -73,12 +83,31 @@ export class Dropdown implements OnInit {
constructor(private element: ElementRef) { constructor(private element: ElementRef) {
effect(() => this.filteredOptions()); effect(() => this.filteredOptions());
effect(() => {
const index = this.focusedIndex();
const isDropdownOpen = this.isOpen();
if (isDropdownOpen && index >= 0) {
requestAnimationFrame(() => {
this.scrollToIndex(index);
})
}
});
effect(() => { effect(() => {
const isDropdownOpen = this.isOpen(); const isDropdownOpen = this.isOpen();
if (!isDropdownOpen) { if (!isDropdownOpen) {
if (untracked(this.inputFocusMode)) this.inputFocusMode.set(false); if (untracked(this.inputFocusMode)) this.inputFocusMode.set(false);
this.searchQuery.set(''); this.searchQuery.set('');
const input = this.searchInput();
if (isPlatformBrowser(this.platformId) && input) {
let currentValue = untracked(this.normalizedOptions).find(
(o) => o.value === this.value(),
);
if (currentValue) input.nativeElement.innerText = currentValue.label;
}
} }
}); });
} }
@@ -93,6 +122,17 @@ export class Dropdown implements OnInit {
} }
} }
private scrollToIndex(index: number) {
if (!isPlatformBrowser(this.platformId)) return;
const items = this.optionItems();
if (items[index]) {
items[index].nativeElement.scrollIntoView({
block: 'nearest',
});
}
}
normalizedOptions = computed(() => { normalizedOptions = computed(() => {
return this.options().map((opt) => { return this.options().map((opt) => {
if (typeof opt === 'string') { if (typeof opt === 'string') {
@@ -112,30 +152,25 @@ export class Dropdown implements OnInit {
return opts.filter((o) => o.label.toLowerCase().normalize('NFKD').includes(query)); return opts.filter((o) => o.label.toLowerCase().normalize('NFKD').includes(query));
}); });
initialInputText = computed<string>(() => {
let currentValue = this.normalizedOptions().find((o) => o.value === this.value())?.label;
return currentValue ? currentValue : "";
})
toggle() { toggle() {
if (this.isLatestMouseDownInsideInput) { if (this.isLatestMouseDownInsideInput) {
this.isLatestMouseDownInsideInput = false; this.isLatestMouseDownInsideInput = false;
return; return;
} }
this.isOpen.update((v) => !v);
if (this.isOpen()) { if (this.isOpen()) {
this.close();
} else {
this.isOpen.set(true);
this.searchQuery.set(''); this.searchQuery.set('');
this.focusedIndex.set(this.normalizedOptions().findIndex((o) => o.value === this.value()));
const currentIndex = this.normalizedOptions().findIndex((o) => o.value === this.value()); requestAnimationFrame(() => {
this.focusedIndex.set(currentIndex);
setTimeout(() => {
const searchInputElement = this.searchInput()!.nativeElement; const searchInputElement = this.searchInput()!.nativeElement;
searchInputElement.focus(); searchInputElement.focus();
this.setCursorToEnd(searchInputElement); this.setCursorToEnd(searchInputElement);
}, 0); });
} }
} }
@@ -156,7 +191,13 @@ export class Dropdown implements OnInit {
} }
close() { close() {
const isFocusInside = this.element.nativeElement.contains(document.activeElement);
const isDropdownOpen = this.isOpen();
this.isOpen.set(false); this.isOpen.set(false);
if (isFocusInside) this.element.nativeElement.focus({ preventScroll: true });
if (isDropdownOpen) this.recreateKey.update((v) => (v + 1) % 65536);
this.focusedIndex.set(-1); this.focusedIndex.set(-1);
} }
@@ -248,7 +289,8 @@ export class Dropdown implements OnInit {
onDocumentClick(event: MouseEvent) { onDocumentClick(event: MouseEvent) {
if ( if (
!this.element.nativeElement.contains(event.target) && !this.element.nativeElement.contains(event.target) &&
!this.isLatestMouseDownInsideContainer !this.isLatestMouseDownInsideContainer &&
this.isOpen()
) { ) {
this.close(); this.close();
} }
+34
View File
@@ -20,6 +20,40 @@ export class MainPage {
{ label: 'Deutsch', value: 'DE' }, { label: 'Deutsch', value: 'DE' },
{ label: 'Italiano', value: 'IT' }, { label: 'Italiano', value: 'IT' },
{ label: '中文', value: 'CN' }, { label: '中文', value: 'CN' },
{ label: 'Français', value: 'FR' },
{ label: 'Español', value: 'ES' },
{ label: 'Português', value: 'PT' },
{ label: 'Русский', value: 'RU' },
{ label: 'العربية', value: 'AR' },
{ label: 'हिन्दी', value: 'HI' },
{ label: 'বাংলা', value: 'BN' },
{ label: 'Tiếng Việt', value: 'VI' },
{ label: 'Türkçe', value: 'TR' },
{ label: 'Polski', value: 'PL' },
{ label: 'Українська', value: 'UK' },
{ label: 'Nederlands', value: 'NL' },
{ label: 'Ελληνικά', value: 'EL' },
{ label: 'Magyar', value: 'HU' },
{ label: 'Čeština', value: 'CS' },
{ label: 'Română', value: 'RO' },
{ label: 'Svenska', value: 'SV' },
{ label: 'Dansk', value: 'DA' },
{ label: 'Suomi', value: 'FI' },
{ label: 'Norsk', value: 'NO' },
{ label: 'ไทย', value: 'TH' },
{ label: 'Bahasa Indonesia', value: 'ID' },
{ label: 'Bahasa Melayu', value: 'MS' },
{ label: 'Filipino', value: 'TL' },
{ label: 'עברית', value: 'HE' },
{ label: 'فارسی', value: 'FA' },
{ label: 'اردو', value: 'UR' },
{ label: 'தமிழ்', value: 'TA' },
{ label: 'తెలుగు', value: 'TE' },
{ label: 'ಕನ್ನಡ', value: 'KN' },
{ label: 'മലയാളം', value: 'ML' },
{ label: 'मराठी', value: 'MR' },
{ label: 'ગુજરાતી', value: 'GU' },
{ label: 'ਪੰਜਾਬੀ', value: 'PA' },
]; ];
selectedDropdownValue = signal<any>(null); selectedDropdownValue = signal<any>(null);