Created
December 4, 2018 15:31
-
-
Save leye0/c63ca8655de8cabdeab84654e2ec3f80 to your computer and use it in GitHub Desktop.
Some select / dropdown / autocomplete / nameit directive (almost) clear of useless options
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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'); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Usage: