projects/swimlane/ngx-ui/src/lib/components/select/select-dropdown.component.ts
import { coerceNumberProperty } from '@angular/cdk/coercion';
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
Input,
Output,
TemplateRef,
ViewChild
} from '@angular/core';
import { InViewportDirective } from 'ng-in-viewport';
import { debounceable } from '../../decorators/debounceable/debounceable.decorator';
import { CoerceBooleanProperty } from '../../utils/coerce/coerce-boolean';
import { KeyboardKeys } from '../../enums/keyboard-keys.enum';
import { containsFilter } from './contains-filter.util';
import { SelectDropdownOption } from './select-dropdown-option.interface';
@Component({
exportAs: 'ngxSelectDropdown',
selector: 'ngx-select-dropdown',
templateUrl: './select-dropdown.component.html',
host: {
class: 'ngx-select-dropdown',
'[class.groupings]': 'groupBy'
},
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SelectDropdownComponent implements AfterViewInit {
@Input() selected: any[];
@Input() identifier: any;
@Input() filterPlaceholder: string;
@Input() filterEmptyPlaceholder: string;
@Input() emptyPlaceholder: string;
@Input() allowAdditionsText: string | TemplateRef<any> = 'Add Value';
@Input()
@CoerceBooleanProperty()
tagging: boolean;
@Input()
@CoerceBooleanProperty()
allowAdditions: boolean;
@Input()
@CoerceBooleanProperty()
filterable: boolean;
@Input()
@CoerceBooleanProperty()
filterCaseSensitive = false;
@Input()
get focusIndex() {
return this._focusIndex;
}
set focusIndex(val: number) {
this._focusIndex = coerceNumberProperty(val);
this.focusElement(this._focusIndex);
}
@Input()
get filterQuery() {
return this._filterQuery;
}
set filterQuery(val: string) {
this._filterQuery = val;
this.groups = this.calculateGroups(this.groupBy, this.options, val);
}
@Input()
get groupBy() {
return this._groupBy;
}
set groupBy(val: string) {
this._groupBy = val;
this.groups = this.calculateGroups(val, this.options);
}
@Input() groupByTemplate: TemplateRef<unknown>;
@Input()
get options() {
return this._options;
}
set options(val) {
this.groups = this.calculateGroups(this.groupBy, val);
this._options = val;
}
@Output() keyup = new EventEmitter<{ event: KeyboardEvent; value?: string }>();
@Output() selection = new EventEmitter<SelectDropdownOption>();
@Output() deselection = new EventEmitter<SelectDropdownOption>();
@Output() keyboardSelection = new EventEmitter<SelectDropdownOption>();
@Output() keyboardDeselection = new EventEmitter<SelectDropdownOption>();
@Output() close = new EventEmitter<boolean | undefined>();
@Output() clearQueryFilter = new EventEmitter<void>();
@ViewChild('filterInput')
readonly filterInput?: ElementRef<HTMLInputElement>;
@ViewChild(InViewportDirective)
readonly inViewport: InViewportDirective;
get element() {
return this.elementRef.nativeElement;
}
get isNotTemplate() {
return !(typeof this.allowAdditionsText === 'object' && this.allowAdditionsText instanceof TemplateRef);
}
groups: any[];
filterQueryIsInOptions = false;
private _options: SelectDropdownOption[];
private _groupBy: string;
private _filterQuery: string;
private _focusIndex: number;
constructor(private readonly elementRef: ElementRef, private readonly cdr: ChangeDetectorRef) {}
ngAfterViewInit(): void {
if (this.filterable && !this.tagging) {
setTimeout(() => {
this.filterInput.nativeElement.focus();
}, 50);
} else {
// focusIndex has to be set to 0, because arrows won't work if it's set to -1.
this.focusIndex = 0;
}
}
isSelected(option: SelectDropdownOption): boolean {
if (!this.selected || !this.selected.length) return false;
const idx = this.selected.findIndex(o => {
if (this.identifier) return o[this.identifier] === option.value[this.identifier];
return o === option.value;
});
return idx > -1;
}
@debounceable(500)
updateFilterQueryIsInOptions() {
this.filterQueryIsInOptions = this.options.some(o => o.name.toLowerCase() === this.filterQuery.toLowerCase());
this.cdr.markForCheck();
}
clearFilter(filterInput: HTMLInputElement) {
filterInput.value = '';
this.filterQuery = '';
this.updateFilterQueryIsInOptions();
this.cdr.markForCheck();
this.clearQueryFilter.emit();
}
onInputKeyUp(event: KeyboardEvent): void {
event.preventDefault();
event.stopPropagation();
const key = event.key;
const value = (event.target as any).value;
if (key === (KeyboardKeys.ESCAPE as any)) {
this.close.emit(true);
} else if (event.key === (KeyboardKeys.ARROW_DOWN as any)) {
++this.focusIndex;
}
if (this.filterQuery !== value) {
this.filterQuery = value;
}
this.updateFilterQueryIsInOptions();
this.keyup.emit({ event, value });
}
onOptionClick(option: SelectDropdownOption) {
if (this.isSelected(option)) {
this.deselection.emit(option);
} else {
this.selection.emit(option);
}
}
onOptionKeyDown(event: KeyboardEvent, option?: SelectDropdownOption): void {
event.preventDefault();
event.stopPropagation();
switch (event.code) {
case KeyboardKeys.ESCAPE:
return this.close.emit(true);
case KeyboardKeys.ARROW_DOWN:
return this.focusNext();
case KeyboardKeys.ARROW_UP:
return this.focusPrev();
case KeyboardKeys.ENTER:
// Enter may trigger dropdown close
return this.isSelected(option) ? this.deselection.emit(option) : this.selection.emit(option);
case KeyboardKeys.SPACE:
// Space does not trigger dropdown close
return this.isSelected(option) ? this.keyboardDeselection.emit(option) : this.keyboardSelection.emit(option);
}
}
focusNext() {
const options = this.options;
const len = options.length;
if (this.focusIndex < len - 1) {
for (let i = this.focusIndex + 1; i < len; i++) {
if (!options[i].disabled && !options[i].hidden) {
this.focusIndex = i;
break;
}
}
}
}
focusPrev() {
const options = this.options;
if (this.focusIndex > 0) {
for (let i = this.focusIndex - 1; i >= 0; i--) {
if (!options[i].disabled && !options[i].hidden) {
this.focusIndex = i;
break;
}
}
}
}
onAddClicked(event: Event, value: any): void {
event.preventDefault();
event.stopPropagation();
this.selection.emit({ value, name: value });
(event.target as any).value = '';
this.close.emit();
}
onShiftEnterKeyDown(event) {
if (this.allowAdditions) {
this.onAddClicked(event, this.filterQuery);
}
}
// When the filterable select input has focus, tab event opens the next popover if the next control is ngx-select.
onTabKeyDown(event) {
event.preventDefault();
event.stopPropagation();
}
focusOn(index: number): void {
if (index < 0) index = this.options.length + index;
this.focusIndex = index;
this.cdr.markForCheck();
}
private focusElement(index: number): void {
const elements = this.element.getElementsByClassName('ngx-select-dropdown-option');
const element = elements[index];
if (element) {
setTimeout(() => element.focus(), 5);
}
}
private calculateGroups(groupBy: string, options: any[], filter?: string): any[] {
if (!options) return [];
const filterOptions = { filterCaseSensitive: this.filterCaseSensitive };
// no group by defined, skip and just return
// empty group object...
if (!groupBy) {
if (filter) {
// filter options
options = options.filter(o => {
return containsFilter({ name: o.name, value: o.value }, filter, filterOptions);
});
}
// need to map indexes
options = options.map((option, index) => {
return { option, index };
});
return [{ options }];
}
const map = new Map();
let i = 0;
for (const option of options) {
// only show items in filter criteria
if (filter && !containsFilter({ name: option.name, value: option.value }, filter, filterOptions)) {
continue;
}
const group = option.value[groupBy];
const opt: any = map.get(group);
// need to map the true indexes
const kv = { option, index: i++ };
if (!opt) {
map.set(group, [kv]);
} else {
opt.push(kv);
}
}
const result = [];
map.forEach((value, key) => {
result.push({ name: key, options: value });
});
return result;
}
}