Skip to content

Commit

Permalink
110088: new implementation for keyboard support in dropdowns
Browse files Browse the repository at this point in the history
  • Loading branch information
Jens Vannerum authored and alanorth committed Jan 23, 2024
1 parent b2cae3b commit 2d0d6dd
Show file tree
Hide file tree
Showing 2 changed files with 103 additions and 33 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
(keydown)="selectOnKeyDown($event, sdRef)">
</div>

<div ngbDropdownMenu
<div #dropdownMenu ngbDropdownMenu
class="dropdown-menu scrollable-dropdown-menu w-100"
[attr.aria-label]="model.placeholder">
<div class="scrollable-menu"
Expand All @@ -41,7 +41,8 @@
[scrollWindow]="false">

<button class="dropdown-item disabled" *ngIf="optionsList && optionsList.length == 0">{{'form.no-results' | translate}}</button>
<button class="dropdown-item collection-item text-truncate" *ngFor="let listEntry of optionsList"
<button class="dropdown-item collection-item text-truncate" *ngFor="let listEntry of optionsList; let i = index"
[class.active]="i === selectedIndex"
(keydown.enter)="onSelect(listEntry); sdRef.close()" (mousedown)="onSelect(listEntry); sdRef.close()"
title="{{ listEntry.display }}" role="option"
[attr.id]="listEntry.display == (currentValue|async) ? ('combobox_' + id + '_selected') : null">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import {
ChangeDetectorRef,
Component,
EventEmitter,
Input,
OnInit,
Output,
ViewChild,
ElementRef
} from '@angular/core';
import { UntypedFormGroup } from '@angular/forms';

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

Expand All @@ -28,6 +37,8 @@ import { FormFieldMetadataValueObject } from '../../../models/form-field-metadat
templateUrl: './dynamic-scrollable-dropdown.component.html'
})
export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyComponent implements OnInit {
@ViewChild('dropdownMenu', { read: ElementRef }) dropdownMenu: ElementRef;

@Input() bindId = true;
@Input() group: UntypedFormGroup;
@Input() model: DynamicScrollableDropdownModel;
Expand All @@ -40,6 +51,9 @@ export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyCom
public loading = false;
public pageInfo: PageInfo;
public optionsList: any;
public inputText: string = null;
public selectedIndex = 0;
public acceptableKeys = ['Space', 'NumpadMultiply', 'NumpadAdd', 'NumpadSubtract', 'NumpadDecimal', 'Semicolon', 'Equal', 'Comma', 'Minus', 'Period', 'Quote', 'Backquote'];

constructor(protected vocabularyService: VocabularyService,
protected cdr: ChangeDetectorRef,
Expand All @@ -54,32 +68,26 @@ export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyCom
*/
ngOnInit() {
this.updatePageInfo(this.model.maxOptions, 1);
this.vocabularyService.getVocabularyEntries(this.model.vocabularyOptions, this.pageInfo).pipe(
this.loadOptions();
}

loadOptions() {
this.loading = true;
this.vocabularyService.getVocabularyEntriesByValue(this.inputText, false, this.model.vocabularyOptions, this.pageInfo).pipe(
getFirstSucceededRemoteDataPayload(),
catchError(() => observableOf(buildPaginatedList(
new PageInfo(),
[]
))
))
.subscribe((list: PaginatedList<VocabularyEntry>) => {
this.optionsList = list.page;
if (this.model.value) {
this.setCurrentValue(this.model.value, true);
}

this.updatePageInfo(
list.pageInfo.elementsPerPage,
list.pageInfo.currentPage,
list.pageInfo.totalElements,
list.pageInfo.totalPages
);
this.cdr.detectChanges();
});

this.group.get(this.model.id).valueChanges.pipe(distinctUntilChanged())
.subscribe((value) => {
this.setCurrentValue(value);
});
catchError(() => observableOf(buildPaginatedList(new PageInfo(), []))),

Check warning on line 78 in src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L78 was not covered by tests
tap(() => this.loading = false)
).subscribe((list: PaginatedList<VocabularyEntry>) => {
this.optionsList = list.page;
this.updatePageInfo(
list.pageInfo.elementsPerPage,
list.pageInfo.currentPage,
list.pageInfo.totalElements,
list.pageInfo.totalPages
);
this.selectedIndex = 0;
this.cdr.detectChanges();
});
}

/**
Expand All @@ -94,10 +102,30 @@ export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyCom
openDropdown(sdRef: NgbDropdown) {
if (!this.model.readOnly) {
this.group.markAsUntouched();
this.inputText = null;
this.updatePageInfo(this.model.maxOptions, 1);
this.loadOptions();
sdRef.open();
}
}

navigateDropdown(event: KeyboardEvent) {
if (event.key === 'ArrowDown') {
this.selectedIndex = Math.min(this.selectedIndex + 1, this.optionsList.length - 1);

Check warning on line 114 in src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L114 was not covered by tests
} else if (event.key === 'ArrowUp') {
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);

Check warning on line 116 in src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L116 was not covered by tests
}
this.scrollToSelected();

Check warning on line 118 in src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L118 was not covered by tests
}

scrollToSelected() {
const dropdownItems = this.dropdownMenu.nativeElement.querySelectorAll('.dropdown-item');
const selectedItem = dropdownItems[this.selectedIndex];

Check warning on line 123 in src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts

View check run for this annotation

Codecov / codecov/patch

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

Added lines #L122 - L123 were not covered by tests
if (selectedItem) {
selectedItem.scrollIntoView({ block: 'nearest' });

Check warning on line 125 in src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L125 was not covered by tests
}
}

/**
* KeyDown handler to allow toggling the dropdown via keyboard
* @param event KeyboardEvent
Expand All @@ -106,13 +134,54 @@ export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyCom
selectOnKeyDown(event: KeyboardEvent, sdRef: NgbDropdown) {
const keyName = event.key;

if (keyName === ' ' || keyName === 'Enter') {
if (keyName === 'Enter') {
event.preventDefault();
event.stopPropagation();
sdRef.toggle();
if (sdRef.isOpen()) {
this.onSelect(this.optionsList[this.selectedIndex]);
sdRef.close();

Check warning on line 142 in src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts

View check run for this annotation

Codecov / codecov/patch

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

Added lines #L141 - L142 were not covered by tests
} else {
sdRef.open();

Check warning on line 144 in src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L144 was not covered by tests
}
} else if (keyName === 'ArrowDown' || keyName === 'ArrowUp') {
this.openDropdown(sdRef);
event.preventDefault();
event.stopPropagation();
this.navigateDropdown(event);

Check warning on line 149 in src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts

View check run for this annotation

Codecov / codecov/patch

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

Added lines #L147 - L149 were not covered by tests
} else if (keyName === 'Backspace') {
this.removeKeyFromInput();

Check warning on line 151 in src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L151 was not covered by tests
} else if (this.isAcceptableKey(keyName)) {
this.addKeyToInput(keyName);

Check warning on line 153 in src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L153 was not covered by tests
}
}

addKeyToInput(keyName: string) {
if (this.inputText === null) {
this.inputText = '';

Check warning on line 159 in src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L159 was not covered by tests
}
this.inputText += keyName;

Check warning on line 161 in src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L161 was not covered by tests
// When a new key is added, we need to reset the page info
this.updatePageInfo(this.model.maxOptions, 1);
this.loadOptions();

Check warning on line 164 in src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts

View check run for this annotation

Codecov / codecov/patch

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

Added lines #L163 - L164 were not covered by tests
}

removeKeyFromInput() {
if (this.inputText !== null) {
this.inputText = this.inputText.slice(0, -1);

Check warning on line 169 in src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L169 was not covered by tests
if (this.inputText === '') {
this.inputText = null;

Check warning on line 171 in src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L171 was not covered by tests
}
this.loadOptions();

Check warning on line 173 in src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L173 was not covered by tests
}
}


isAcceptableKey(keyPress: string): boolean {
// allow all letters and numbers
if (keyPress.length === 1 && keyPress.match(/^[a-zA-Z0-9]*$/)) {
return true;

Check warning on line 181 in src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L181 was not covered by tests
}
// Some other characters like space, dash, etc should be allowed as well
return this.acceptableKeys.includes(keyPress);

Check warning on line 184 in src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L184 was not covered by tests
}

/**
Expand All @@ -127,7 +196,7 @@ export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyCom
this.pageInfo.totalElements,
this.pageInfo.totalPages
);
this.vocabularyService.getVocabularyEntries(this.model.vocabularyOptions, this.pageInfo).pipe(
this.vocabularyService.getVocabularyEntriesByValue(this.inputText, false, this.model.vocabularyOptions, this.pageInfo).pipe(
getFirstSucceededRemoteDataPayload(),
catchError(() => observableOf(buildPaginatedList(
new PageInfo(),
Expand Down

0 comments on commit 2d0d6dd

Please sign in to comment.