Make Dropdown to Process List Scroll Properly
This commit is contained in:
1 parent
0baba36f7f
commit
5de6d111f2
4 files changed
+110
-24
No files matched your search
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user