Created
November 10, 2016 21:04
-
-
Save 2J/fc4624e7820a05f6fe825ee1cb58c8e4 to your computer and use it in GitHub Desktop.
Angular 2 search-field with autocomplete
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
import { Directive, HostListener, Output, OnInit, OnDestroy, ElementRef, EventEmitter } from '@angular/core'; | |
@Directive({ | |
selector: '[offClick]' | |
}) | |
export class OffClickDirective implements OnInit, OnDestroy { | |
@Output('offClick') public offClick: EventEmitter<null> = new EventEmitter<null>(); | |
constructor( | |
private elementRef: ElementRef | |
) { | |
this.documentClick = this.documentClick.bind(this); //bind scope to component | |
} | |
public documentClick($event: any): void { | |
if (!this.elementRef.nativeElement.contains($event.target)) { | |
this.offClick.emit(); | |
} | |
} | |
public ngOnInit(): void { | |
setTimeout(() => { document.addEventListener('mousedown', this.documentClick); }, 0); | |
} | |
public ngOnDestroy(): void { | |
document.removeEventListener('mousedown', this.documentClick); | |
} | |
} |
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
.clear-button { | |
pointer-events: auto; | |
cursor: pointer; | |
} | |
.search-field-select { | |
width: 100%; | |
height: auto; | |
max-height: 200px; | |
overflow-x: hidden; | |
margin-top: 0; | |
} | |
.search-field-option > a, .search-field-option > .autocomplete-item-text { | |
display: block; | |
padding: 3px 20px; | |
clear: both; | |
font-weight: 400; | |
line-height: 1.42857143; | |
color: #333; | |
white-space: nowrap; | |
} | |
.search-field-option.active > a { | |
color: #fff; | |
text-decoration: none; | |
outline: 0; | |
background-color: #428bca; | |
} |
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
<div class="search-field-container dropdown open" (offClick)="clickedOutside()"> | |
<input [(ngModel)]="searchFieldVal" | |
#searchField="ngModel" | |
name="searchField" | |
[type]="type" | |
[placeholder]="placeholder" | |
(focus)="focused($event)" | |
(blur)="blurred($event)" | |
(change)="changed($event)" | |
(keyup)="keyupped($event)" | |
(keydown)="keydowned($event)" | |
autocomplete="off" | |
[required]="required" | |
[disabled]="disabled" | |
[readonly]="readonly" | |
class="form-control search-field" /> | |
<span class="clear-button glyphicon glyphicon-remove-circle form-control-feedback" *ngIf="clearButton && isFocused && !!searchFieldVal" (click)="query = ''"></span> | |
<ul class="search-field-select dropdown-menu" *ngIf="isFocused && !disabled && !readonly" role="menu"> | |
<li *ngIf="autocompleteItems && autocompleteItems.length === 0" class="search-field-option-container" role="menuitem"> | |
<div class="search-field-option dropdown-item"> | |
<span class="autocomplete-item-text"> | |
<i>{{ emptyText }}</i> | |
</span> | |
</div> | |
</li> | |
<li *ngFor="let item of autocompleteItems; let index=index" | |
class="search-field-option-container" | |
role="menuitem"> | |
<div class="search-field-option dropdown-item" | |
[class.active]="isActive(item)" | |
(mouseenter)="selectActive(item)"> | |
<a href="javascript:void(0)" (click)="itemSelected(item)"> | |
<template [ngIf]="displayTemplate == null"> | |
{{ getItemProperty(item) }} | |
</template> | |
<template *ngIf="displayTemplate != null" | |
[ngTemplateOutlet]="displayTemplate" | |
[ngOutletContext]="{ | |
$implicit: item, | |
index: index, | |
query: query | |
}"> | |
</template> | |
</a> | |
</div> | |
</li> | |
</ul> | |
</div> |
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
import { Component, Input, Output, EventEmitter, ContentChild, TemplateRef, ViewChild, ElementRef, DoCheck } from '@angular/core'; | |
import * as _ from 'lodash'; | |
@Component({ | |
moduleId: module.id, | |
selector: 'search-field', | |
templateUrl: 'search-field.component.html', | |
styleUrls: ['search-field.component.css'] | |
}) | |
export class SearchFieldComponent { | |
//2-way bound filteredItems array | |
//Array of items after searching | |
private _filteredItems: Array<any> = new Array<any>(); | |
@Input() | |
get filteredItems(): Array<any> { | |
return this._filteredItems; | |
} | |
@Output() private filteredItemsChange: EventEmitter<Array<any>> = new EventEmitter<Array<any>>(true); | |
set filteredItems(filteredItems: Array<any>) { | |
this._filteredItems = filteredItems; | |
this.setAutocompleteItems(); | |
this.filteredItemsChange.emit(this.filteredItems); | |
} | |
//search query | |
private _query: string = ""; | |
@Input() | |
get query(): string { | |
return this._query; | |
} | |
@Output() private queryChange: EventEmitter<string> = new EventEmitter<string>(true); | |
set query(query: string) { | |
this._query = query; | |
this.setFieldVal(); | |
this.filter(this.query); | |
this.queryChange.emit(this.query); | |
} | |
//search field value | |
private searchFieldVal: string = ""; | |
//Property to navigate to for search | |
@Input() property: string = null; | |
//List of items that the component is going to search through | |
private _items: Array<any>; | |
private itemsOld: Array<any>; //Used to detect changes in items array | |
@Input() | |
get items(): Array<any> { | |
return this._items; | |
} | |
set items(items: Array<any>) { | |
this._items = items; | |
this.itemsOld = _.clone(this.items); | |
this.filter(this.query); //Filter when items array changed | |
} | |
//If set, limits which items to show autocomplete for | |
private _autocompleteOptions: Array<any>; | |
@Input() | |
get autocompleteOptions(): Array<any> { | |
return this._autocompleteOptions; | |
}; | |
set autocompleteOptions(autocompleteOptions: Array<any>) { | |
this._autocompleteOptions = autocompleteOptions; | |
this.setAutocompleteItems(); | |
} | |
//list of items to show autocomplete for | |
private autocompleteItems: Array<any>; | |
//Input field type | |
@Input() type: string = "search"; //Input type | |
//If set to true, matches entire string | |
@Input() exactMatch: boolean = false; | |
//If set to true, returns all objects if query is empty | |
@Input() returnAllOnEmpty: boolean = true; | |
//Placeholder string to show on input field | |
@Input() placeholder: string = ''; | |
//text to show when no results found | |
@Input() emptyText: string = 'No items match the search query'; | |
//Shows clear button when typing | |
@Input() clearButton: boolean = true; | |
//Blur string | |
//Sets field to 'blurString' if not in focus | |
private _blurString: string = null; | |
@Input() | |
get blurString(): string { | |
return this._blurString; | |
} | |
set blurString(blurString: string) { | |
this._blurString = blurString; | |
this.setFieldVal(); //set blue string if string has changed | |
} | |
//If set to true, puts required field on input field | |
@Input() required: boolean = null; | |
//If set to true, puts disabled field on input field | |
@Input() disabled: boolean = null; | |
//If set to true, puts readonly field on input field | |
@Input() readonly: boolean = null; | |
//Template that the list will show. Shows property if template does not exist | |
@ContentChild(TemplateRef) displayTemplate: TemplateRef<any>; | |
//Returns array of filtered objects every time the items are filtered | |
@Output() private search: EventEmitter<Array<any>> = new EventEmitter<Array<any>>(true); | |
//Returns object if selected | |
@Output() private itemSelect: EventEmitter<any> = new EventEmitter<any>(true); | |
@ViewChild('searchField') searchField: ElementRef; | |
//Used to see whether or not input field is currently focused | |
private _isFocused: boolean = false; | |
get isFocused(): boolean { | |
return this._isFocused; | |
} | |
set isFocused(isFocused: boolean) { | |
this._isFocused = isFocused; | |
this.setFieldVal(); | |
} | |
private activeItem: any; | |
@Output() private focus: EventEmitter<any> = new EventEmitter<any>(true); | |
@Output() private blur: EventEmitter<any> = new EventEmitter<any>(true); | |
@Output() private change: EventEmitter<any> = new EventEmitter<any>(true); | |
@Output() private keyup: EventEmitter<any> = new EventEmitter<any>(true); | |
@Output() private keydown: EventEmitter<any> = new EventEmitter<any>(true); | |
ngDoCheck(): void { | |
//When items array has changed | |
if (!_.isEqual(this.itemsOld, this.items)) { | |
this.itemsOld = _.clone(this.items); | |
this.filter(this.query); | |
} | |
} | |
private setAutocompleteItems(): void { | |
if (this.autocompleteOptions == null) { | |
this.autocompleteItems = this.filteredItems; | |
} else { | |
this.autocompleteItems = _.intersection(this.filteredItems, this.autocompleteOptions); | |
} | |
} | |
private focused(event: any): void { | |
this.isFocused = true; | |
this.focus.emit(event); | |
} | |
private blurred(event: any): void { | |
this.blur.emit(event); | |
} | |
private clickedOutside(): void { | |
this.isFocused = false; | |
} | |
private setFieldVal(): void { | |
if (!this.isFocused && (this.blurString !== null)) { | |
this.searchFieldVal = this.blurString; | |
} else { | |
this.searchFieldVal = this.query; | |
} | |
} | |
private changed(event: any): void { | |
this.change.emit(event); | |
} | |
private keyupped(event: any): void { | |
this.query = event.target.value; | |
this.keyup.emit(event); | |
} | |
private keydowned(event: any): void { | |
this.keydown.emit(event); | |
} | |
private filter(query: string): Array<any> { | |
let original = this._filteredItems; | |
if (_.trim(query) === '') { //if query is empty | |
if (this.returnAllOnEmpty) { | |
this.filteredItems = this.items; | |
} else { | |
this.filteredItems = this.items.slice(this.items.length); //return empty array with same type | |
} | |
} else { | |
this.filteredItems = _.filter(this.items, (item) => { | |
return this.isMatch(this.getItemProperty(item), query); | |
}); | |
} | |
//Emit search if filtered items changed | |
if (!_.isEqual(original, this.filteredItems)) { | |
this.search.emit(this.filteredItems); | |
} | |
return this.filteredItems; | |
} | |
private getItemProperty(item: any): string { | |
if (this.property !== null) { | |
if (_.has(item, this.property)) { //If property exists within object | |
return String(_.get(item, this.property)); | |
} else { | |
return ''; | |
} | |
} else { | |
return String(item); | |
} | |
} | |
private isMatch(source: any, query: string): boolean { | |
//prevent null reference exception | |
if (source == null) source = ''; | |
if (query == null) query = ''; | |
if (this.exactMatch) { | |
return source.replace(/\s/g, '').toUpperCase() === query.replace(/\s/g, '').toUpperCase(); | |
} else { | |
return source | |
.replace(/\s/g, '') //remove spaces | |
.toUpperCase() //ignore case | |
.indexOf(query.replace(/\s/g, '').toUpperCase()) !== -1; //contains query | |
} | |
} | |
private isActive(item: any): boolean { | |
return this.activeItem === item; | |
} | |
private selectActive(item: any): void { | |
this.activeItem = item; | |
} | |
private itemSelected(item: any): void { | |
//set query to whatever item's string is | |
this.query = this.getItemProperty(item); | |
this.isFocused = false; | |
this.itemSelect.emit(item); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment