Skip to content

Instantly share code, notes, and snippets.

@leye0
Created December 4, 2018 15:31
Show Gist options
  • Save leye0/c63ca8655de8cabdeab84654e2ec3f80 to your computer and use it in GitHub Desktop.
Save leye0/c63ca8655de8cabdeab84654e2ec3f80 to your computer and use it in GitHub Desktop.
Some select / dropdown / autocomplete / nameit directive (almost) clear of useless options
// Disclaimer: Use bootstrap 4 classes
import { Directive, Input, OnInit, ElementRef, Output, EventEmitter, SimpleChanges, OnChanges, HostListener } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { debounceTime, filter } from 'rxjs/operators';
const key_arrow_up = 38;
const key_arrow_down = 40;
const key_enter = 13;
@Directive({
// tslint:disable-next-line:directive-selector
selector: '[sugg]'
})
export class SuggDirective implements OnInit, OnChanges {
@Input() sugg: HTMLElement = <HTMLElement>{}; // The HTMLElement corresponding to the list
@Input() suggItemCssSelector = ''; // When clicking on it, select the model with the index corresponding to this element
@Input() suggCloseCssSelector = ''; // When clicking on these items, close the popup
@Input() suggNextFieldCssSelector = ''; // When set, will focus to this field after selection
@Input() suggLabelProperty = ''; // When set, the label of the item corresponds to this property on a model
@Input() suggModels: any[] = []; // The list of models
@Output() itemSelected: EventEmitter<any> = new EventEmitter();
@Output() textChanged: EventEmitter<any> = new EventEmitter();
divWrapper: HTMLElement = <HTMLElement>{};
listElement: HTMLElement = <HTMLElement>{};
listHeight = 0;
inputElement: HTMLInputElement = <HTMLInputElement>{};
collection: HTMLElement[] = [];
isFocused = false;
private _currentItemIndex = 0;
private _query$: BehaviorSubject<string> = new BehaviorSubject<string>('');
constructor(private element: ElementRef) { }
@HostListener('document:click', ['$event.target']) documentClicked(target: any) {
if (!this.divWrapper.contains(target as any)) {
this._showSuggestionsBox(false);
}
}
ngOnChanges(changes: SimpleChanges) {
if (changes.suggModels && this.listElement && this.listElement.querySelectorAll) {
setTimeout(() => {
this.collection = this.listElement.querySelectorAll(this.suggItemCssSelector) as unknown as HTMLElement[];
for (let i = 0; i < this.collection.length; i++) {
const element = this.collection[i];
element.onclick = (event) => {
if (this.itemSelected) {
this._selectItemAt(i);
return;
}
};
}
(this.listElement.querySelectorAll(this.suggCloseCssSelector) as unknown as HTMLElement[])
.forEach(element => {
if (element) {
element.onclick = event => {
this._showSuggestionsBox(false);
};
}
});
if (this.suggModels.length > 0) {
if (this._currentItemIndex > this.suggModels.length - 1) {
this._currentItemIndex = this.suggModels.length - 1;
}
this._updateSelectedItem();
this._showSuggestionsBox(true);
}
this.findItem();
}, 100);
}
}
ngOnInit(): void {
this.inputElement = this.element.nativeElement as HTMLInputElement;
this.listElement = this.sugg as HTMLElement;
// Wrap element in a position-relative div
this.divWrapper = document.createElement('div');
this.divWrapper.style.overflow = 'visible';
this.divWrapper.style.position = 'relative';
this.inputElement.before(this.divWrapper);
this.divWrapper.insertBefore(this.inputElement, null); // Place the input element in the wrapper
this.divWrapper.insertBefore(this.listElement, null); // Then the list element
this.listElement.classList.add('d-none'); // For now, hide the list
this.listElement.style.userSelect = 'none'; // Prevent text selection in the list
this.listElement.style.zIndex = '10';
this.inputElement.onkeydown = event => this.onKeyDown(event);
this.inputElement.onkeyup = event => this.onKeyUp(event);
const inputSize = this.inputElement.getBoundingClientRect();
// The list takes the current input width: (Could be placed in an entry point that is called often.)
this.listElement.style.width = inputSize.width + 'px';
this.listElement.style.height = 'auto';
this.listElement.style.maxHeight = '300px';
this.listElement.style.overflowX = 'hidden';
this.listElement.style.overflowY = 'auto';
// The wrapper should have the size of the input element. (It will show overflow)
this.divWrapper.style.height = inputSize.height + 2 + 'px';
this.listElement.style.position = 'relative';
this.inputElement.onfocus = () => this.isFocused = true;
this.inputElement.onblur = () => this.isFocused = false;
// Subscribe to query change. Debounce at 300ms.
this._query$
.pipe(debounceTime(300), filter(query => !!query && query !== ''))
.subscribe(() => {
if (this.inputElement.value) {
this.textChanged.emit(this.inputElement.value);
}
});
}
onKeyDown($event: any): void {
// If there is nothing in the list, don't navigate with arrow and enter keys
if (this.suggModels.length === 0) { return; }
// Else, change the index if needed or select the item
if ($event.keyCode === key_arrow_up) {
$event.preventDefault();
this._currentItemIndex -= 1;
} else if ($event.keyCode === key_arrow_down) {
$event.preventDefault();
this._currentItemIndex += 1;
} else if ($event.keyCode === key_enter && !isNaN(this._currentItemIndex)) {
this._selectItemAt(this._currentItemIndex);
return;
}
const collectionLength = this.collection.length;
this._currentItemIndex = this._currentItemIndex % collectionLength;
this._currentItemIndex = this._currentItemIndex < 0 ? this._currentItemIndex + collectionLength : this._currentItemIndex;
this._updateSelectedItem();
}
onKeyUp($event: any): void {
// if input is empty, on key press, just hide the suggestions
if (this.inputElement.value.trim() === '') {
this._showSuggestionsBox(false);
}
// If any non-navigation keys were pressed, search in the suggestions
if ([key_arrow_up, key_arrow_down, key_enter].indexOf($event.keyCode) === -1) {
this.findItem();
this._query$.next(this.inputElement.value);
}
}
getTextContentAt(index: number): string {
const item = this.collection[index];
if (item && item.textContent) {
return item.textContent.toLowerCase();
}
return '';
}
findItem(): void {
if (!this.inputElement.value) { return; }
const value = this.inputElement.value.toLowerCase();
for (let i = 0; i < this.collection.length; i++) {
if (this.getTextContentAt(i).indexOf(value) > -1) {
this._highlightItemAt(i);
return;
}
}
}
private _highlightItemAt(index: number) {
this._showSuggestionsBox(true);
this._currentItemIndex = index;
this._updateSelectedItem();
}
private _selectItemAt(index: number): void {
this._highlightItemAt(index);
const model = this.suggModels[index];
this.itemSelected.emit(model);
// If a pointer to a label on the objet was specified:
if (this.suggLabelProperty) {
this.inputElement.value = model[this.suggLabelProperty];
} else if (!(model instanceof Object)) { // Else if a model is potentially a primitive value
this.inputElement.value = model;
} else { // Else we fall back to the text contained in the clicked htmlelement containing the model
this.inputElement.value = this.getTextContentAt(index).trim();
}
setTimeout(() => { // (Delay. For elegance.)
// If a "next" field to focus was specified, focus into it
if (this.suggNextFieldCssSelector) {
(document.querySelector(this.suggNextFieldCssSelector) as any).focus();
}
// In all cases, hide the suggestions
this._showSuggestionsBox(false);
}, 100);
}
private _updateSelectedItem() {
const collectionLength = this.collection.length;
for (let i = 0; i < collectionLength; i++) {
const elementClasses = this.collection[i].classList;
if (i === this._currentItemIndex) {
elementClasses.add('suggestion-item-selected');
this.collection[i].scrollIntoView({behavior: 'auto', block: 'center', inline: 'center' });
} else {
elementClasses.remove('suggestion-item-selected');
}
}
}
private _showSuggestionsBox(shown: boolean) {
const elementClasses = this.listElement.classList;
if (shown && this.isFocused) {
elementClasses.remove('d-none');
elementClasses.add('d-block');
} else {
elementClasses.remove('d-block');
elementClasses.add('d-none');
}
}
}
@leye0
Copy link
Author

leye0 commented Dec 4, 2018

Usage:

<input
  class="form-control mt-2"
  ([ngModel])="selectedItem"
  (itemSelected)="selectItem($event)"
  (textChanged)="textChanged($event)"
  [sugg]="suggestionsBox"
  [suggModels]="items"
  [suggLabelProperty]="'value'"
  [suggItemCssSelector]="'.suggestion-item'"
  [suggCloseCssSelector]="'.suggestion-close'" />
<div class="card" #suggestionsBox>
  <div *ngFor="let item of items" class="suggestion-item p-2">
    <span>{{ item.value }}</span>
  </div>
</div>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment