Skip to content

Commit c02b46c

Browse files
Jens Vannerumalanorth
authored andcommitted
110088: new implementation for keyboard support in dropdowns
1 parent eee7267 commit c02b46c

2 files changed

Lines changed: 103 additions & 33 deletions

File tree

src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
(keydown)="selectOnKeyDown($event, sdRef)">
2828
</div>
2929

30-
<div ngbDropdownMenu
30+
<div #dropdownMenu ngbDropdownMenu
3131
class="dropdown-menu scrollable-dropdown-menu w-100"
3232
[attr.aria-label]="model.placeholder">
3333
<div class="scrollable-menu"
@@ -41,7 +41,8 @@
4141
[scrollWindow]="false">
4242

4343
<button class="dropdown-item disabled" *ngIf="optionsList && optionsList.length == 0">{{'form.no-results' | translate}}</button>
44-
<button class="dropdown-item collection-item text-truncate" *ngFor="let listEntry of optionsList"
44+
<button class="dropdown-item collection-item text-truncate" *ngFor="let listEntry of optionsList; let i = index"
45+
[class.active]="i === selectedIndex"
4546
(keydown.enter)="onSelect(listEntry); sdRef.close()" (mousedown)="onSelect(listEntry); sdRef.close()"
4647
title="{{ listEntry.display }}" role="option"
4748
[attr.id]="listEntry.display == (currentValue|async) ? ('combobox_' + id + '_selected') : null">

src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts

Lines changed: 100 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
1-
import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
1+
import {
2+
ChangeDetectorRef,
3+
Component,
4+
EventEmitter,
5+
Input,
6+
OnInit,
7+
Output,
8+
ViewChild,
9+
ElementRef
10+
} from '@angular/core';
211
import { UntypedFormGroup } from '@angular/forms';
312

413
import { Observable, of as observableOf } from 'rxjs';
5-
import { catchError, distinctUntilChanged, map, tap } from 'rxjs/operators';
14+
import { catchError, map, tap } from 'rxjs/operators';
615
import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap';
716
import { DynamicFormLayoutService, DynamicFormValidationService } from '@ng-dynamic-forms/core';
817

@@ -28,6 +37,8 @@ import { FormFieldMetadataValueObject } from '../../../models/form-field-metadat
2837
templateUrl: './dynamic-scrollable-dropdown.component.html'
2938
})
3039
export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyComponent implements OnInit {
40+
@ViewChild('dropdownMenu', { read: ElementRef }) dropdownMenu: ElementRef;
41+
3142
@Input() bindId = true;
3243
@Input() group: UntypedFormGroup;
3344
@Input() model: DynamicScrollableDropdownModel;
@@ -40,6 +51,9 @@ export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyCom
4051
public loading = false;
4152
public pageInfo: PageInfo;
4253
public optionsList: any;
54+
public inputText: string = null;
55+
public selectedIndex = 0;
56+
public acceptableKeys = ['Space', 'NumpadMultiply', 'NumpadAdd', 'NumpadSubtract', 'NumpadDecimal', 'Semicolon', 'Equal', 'Comma', 'Minus', 'Period', 'Quote', 'Backquote'];
4357

4458
constructor(protected vocabularyService: VocabularyService,
4559
protected cdr: ChangeDetectorRef,
@@ -54,32 +68,26 @@ export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyCom
5468
*/
5569
ngOnInit() {
5670
this.updatePageInfo(this.model.maxOptions, 1);
57-
this.vocabularyService.getVocabularyEntries(this.model.vocabularyOptions, this.pageInfo).pipe(
71+
this.loadOptions();
72+
}
73+
74+
loadOptions() {
75+
this.loading = true;
76+
this.vocabularyService.getVocabularyEntriesByValue(this.inputText, false, this.model.vocabularyOptions, this.pageInfo).pipe(
5877
getFirstSucceededRemoteDataPayload(),
59-
catchError(() => observableOf(buildPaginatedList(
60-
new PageInfo(),
61-
[]
62-
))
63-
))
64-
.subscribe((list: PaginatedList<VocabularyEntry>) => {
65-
this.optionsList = list.page;
66-
if (this.model.value) {
67-
this.setCurrentValue(this.model.value, true);
68-
}
69-
70-
this.updatePageInfo(
71-
list.pageInfo.elementsPerPage,
72-
list.pageInfo.currentPage,
73-
list.pageInfo.totalElements,
74-
list.pageInfo.totalPages
75-
);
76-
this.cdr.detectChanges();
77-
});
78-
79-
this.group.get(this.model.id).valueChanges.pipe(distinctUntilChanged())
80-
.subscribe((value) => {
81-
this.setCurrentValue(value);
82-
});
78+
catchError(() => observableOf(buildPaginatedList(new PageInfo(), []))),
79+
tap(() => this.loading = false)
80+
).subscribe((list: PaginatedList<VocabularyEntry>) => {
81+
this.optionsList = list.page;
82+
this.updatePageInfo(
83+
list.pageInfo.elementsPerPage,
84+
list.pageInfo.currentPage,
85+
list.pageInfo.totalElements,
86+
list.pageInfo.totalPages
87+
);
88+
this.selectedIndex = 0;
89+
this.cdr.detectChanges();
90+
});
8391
}
8492

8593
/**
@@ -94,10 +102,30 @@ export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyCom
94102
openDropdown(sdRef: NgbDropdown) {
95103
if (!this.model.readOnly) {
96104
this.group.markAsUntouched();
105+
this.inputText = null;
106+
this.updatePageInfo(this.model.maxOptions, 1);
107+
this.loadOptions();
97108
sdRef.open();
98109
}
99110
}
100111

112+
navigateDropdown(event: KeyboardEvent) {
113+
if (event.key === 'ArrowDown') {
114+
this.selectedIndex = Math.min(this.selectedIndex + 1, this.optionsList.length - 1);
115+
} else if (event.key === 'ArrowUp') {
116+
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
117+
}
118+
this.scrollToSelected();
119+
}
120+
121+
scrollToSelected() {
122+
const dropdownItems = this.dropdownMenu.nativeElement.querySelectorAll('.dropdown-item');
123+
const selectedItem = dropdownItems[this.selectedIndex];
124+
if (selectedItem) {
125+
selectedItem.scrollIntoView({ block: 'nearest' });
126+
}
127+
}
128+
101129
/**
102130
* KeyDown handler to allow toggling the dropdown via keyboard
103131
* @param event KeyboardEvent
@@ -106,13 +134,54 @@ export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyCom
106134
selectOnKeyDown(event: KeyboardEvent, sdRef: NgbDropdown) {
107135
const keyName = event.key;
108136

109-
if (keyName === ' ' || keyName === 'Enter') {
137+
if (keyName === 'Enter') {
110138
event.preventDefault();
111139
event.stopPropagation();
112-
sdRef.toggle();
140+
if (sdRef.isOpen()) {
141+
this.onSelect(this.optionsList[this.selectedIndex]);
142+
sdRef.close();
143+
} else {
144+
sdRef.open();
145+
}
113146
} else if (keyName === 'ArrowDown' || keyName === 'ArrowUp') {
114-
this.openDropdown(sdRef);
147+
event.preventDefault();
148+
event.stopPropagation();
149+
this.navigateDropdown(event);
150+
} else if (keyName === 'Backspace') {
151+
this.removeKeyFromInput();
152+
} else if (this.isAcceptableKey(keyName)) {
153+
this.addKeyToInput(keyName);
154+
}
155+
}
156+
157+
addKeyToInput(keyName: string) {
158+
if (this.inputText === null) {
159+
this.inputText = '';
160+
}
161+
this.inputText += keyName;
162+
// When a new key is added, we need to reset the page info
163+
this.updatePageInfo(this.model.maxOptions, 1);
164+
this.loadOptions();
165+
}
166+
167+
removeKeyFromInput() {
168+
if (this.inputText !== null) {
169+
this.inputText = this.inputText.slice(0, -1);
170+
if (this.inputText === '') {
171+
this.inputText = null;
172+
}
173+
this.loadOptions();
174+
}
175+
}
176+
177+
178+
isAcceptableKey(keyPress: string): boolean {
179+
// allow all letters and numbers
180+
if (keyPress.length === 1 && keyPress.match(/^[a-zA-Z0-9]*$/)) {
181+
return true;
115182
}
183+
// Some other characters like space, dash, etc should be allowed as well
184+
return this.acceptableKeys.includes(keyPress);
116185
}
117186

118187
/**
@@ -127,7 +196,7 @@ export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyCom
127196
this.pageInfo.totalElements,
128197
this.pageInfo.totalPages
129198
);
130-
this.vocabularyService.getVocabularyEntries(this.model.vocabularyOptions, this.pageInfo).pipe(
199+
this.vocabularyService.getVocabularyEntriesByValue(this.inputText, false, this.model.vocabularyOptions, this.pageInfo).pipe(
131200
getFirstSucceededRemoteDataPayload(),
132201
catchError(() => observableOf(buildPaginatedList(
133202
new PageInfo(),

0 commit comments

Comments
 (0)