UNPKG

ngx-type-ahead

Version:

Typeahead multi-select dropdown component for angular

562 lines (561 loc) 23.5 kB
import { Component, forwardRef, Input, ElementRef, Output, EventEmitter, Inject, Renderer2, HostListener, HostBinding, TemplateRef } from '@angular/core'; import { Observable, of } from 'rxjs'; import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { mergeAll, publishReplay, refCount, filter, toArray, mergeMap, debounceTime, take } from 'rxjs/operators'; import { Subject } from 'rxjs/internal/Subject'; var KEY_UP = 'keyup'; var KEY_DOWN = 'keydown'; var ARROW_DOWN = 'ArrowDown'; var ARROW_UP = 'ArrowUp'; var ESCAPE = 'Escape'; var ENTER = 'Enter'; var BACKSPACE = 'Backspace'; /** * Sanitize string for string comparison * @param {string} text */ var sanitizeString = function (text) { return text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim(); }; var ɵ0 = sanitizeString; /*** * Usage: * * <typeahead formControlName="myControlName" [suggestions]="['abc', 'def',...]"></typeahead> * <typeahead formControlName="myControlName" [suggestions]="Observable.of(['abc', 'def',...])"></typeahead> */ var TypeaheadComponent = /** @class */ (function () { /** * CTOR * @param elementRef * @param renderer */ function TypeaheadComponent(elementRef, renderer) { this.elementRef = elementRef; this.renderer = renderer; /** suggestions list - array of strings, objects or Observable */ this.suggestions = []; /** field to use from objects as name */ this.nameField = 'name'; /** field to use from objects as id */ this.idField = 'id'; /** allow custom values */ this.custom = true; /** allow multiple values */ this.multi = false; /** use complex suggestions and results */ this.complex = false; /** use complex suggestions and results */ this.placeholder = ''; /** Output value change */ this.valueChange = new EventEmitter(); // ui state this.isDisabled = false; this.isExpanded = false; this.dropDownClass = ''; this.matches = []; // values this.values = []; this.callbackQueue = []; /** * Default values for TypeaheadSettings * @type TypeaheadSettings * @private */ this._settings = { suggestionsLimit: 10, typeDelay: 50, noMatchesText: 'No matches found', tagClass: 'btn badge badge-primary', tagRemoveIconClass: '', dropdownMenuClass: 'dropdown-menu', dropdownMenuExpandedClass: 'dropdown-menu show', dropdownMenuItemClass: 'dropdown-item', dropdownToggleClass: 'dropdown-toggle' }; this._inputChangeEvent = new Subject(); this._removeInProgress = false; this.onChange = function (_) { }; this.onTouched = function () { }; } Object.defineProperty(TypeaheadComponent.prototype, "settings", { get: function () { return this._settings; }, /** Value of form control */ set: function (value) { Object.assign(this._settings, value); }, enumerable: true, configurable: true }); Object.defineProperty(TypeaheadComponent.prototype, "multiBinding", { /** UI Bindings */ get: function () { return this.multi; }, enumerable: true, configurable: true }); Object.defineProperty(TypeaheadComponent.prototype, "disabledBinding", { get: function () { return this.isDisabled || null; }, enumerable: true, configurable: true }); TypeaheadComponent.prototype.focusOutHandler = function (event) { if (this.isDisabled) { return; } if (event.relatedTarget) { // related target is typeahead, input or one of the buttons if (event.relatedTarget === this.elementRef.nativeElement || event.relatedTarget.parentElement === this.elementRef.nativeElement || event.relatedTarget.parentElement.parentElement === this.elementRef.nativeElement) { // grab back input focus after button click since `focus out` cancelled it if (event.target === this._input && event.relatedTarget === this.elementRef.nativeElement) { this._input.focus(); } return; } } // close dropdown this.toggleDropdown(false); // keep just approved tags if (this.multi) { this._input.value = null; this._inputChangeEvent.next(''); return; } // trim values if (!this.custom || this.complex) { this._input.value = this._input.value.trim(); // if not match then cleanup the values if (!this.hasMatch(this._input.value)) { this._input.value = this.value = null; this._inputChangeEvent.next(''); } } }; /** * On component initialization */ TypeaheadComponent.prototype.ngOnInit = function () { this.suggestionsInit(this.suggestions instanceof Observable ? this.suggestions .pipe(publishReplay(1), refCount(), mergeAll()) : of.apply(void 0, this.suggestions)); this.toggleDropdown(false); this._inputChangeEvent.next(''); }; TypeaheadComponent.prototype.ngOnChanges = function (changes) { if (changes.suggestions && !changes.suggestions.firstChange) { this.allMatchesSubscription.unsubscribe(); this.matchesSubscription.unsubscribe(); this.ngOnInit(); } }; TypeaheadComponent.prototype.suggestionsInit = function (suggestion$) { var _this = this; this.matchesSubscription = this._inputChangeEvent .pipe(debounceTime(this.settings.typeDelay), mergeMap(function (value) { var normalizedValue = sanitizeString(value); var filteredSuggestions$ = suggestion$ .pipe(filter(_this.filterSuggestion(normalizedValue))); return _this.settings.suggestionsLimit ? filteredSuggestions$.pipe(take(_this.settings.suggestionsLimit), toArray()) : filteredSuggestions$ .pipe(toArray()); })) .subscribe(function (matches) { _this.matches = matches; }); this.allMatchesSubscription = suggestion$.pipe(toArray()).subscribe(function (suggestions) { _this.allMatches = suggestions; while (_this.callbackQueue.length) { // take first one and process it _this.callbackQueue.shift().apply(_this); _this._inputChangeEvent.next(''); } }); }; /** * Init method */ TypeaheadComponent.prototype.ngAfterViewInit = function () { var _this = this; // set value to input this._input = this.elementRef.nativeElement.querySelector('input'); if (!this.multi && this._value) { var callback = function () { _this._input.value = _this.complex ? _this.extractNameById(_this._value) : _this._value; }; if (this.allMatches || !this.complex) { callback.apply(this); } else { this.callbackQueue.push(callback); } } }; /** * Cleanup timeout */ TypeaheadComponent.prototype.ngOnDestroy = function () { this.allMatchesSubscription.unsubscribe(); this.matchesSubscription.unsubscribe(); }; Object.defineProperty(TypeaheadComponent.prototype, "value", { /** * Value getter * @returns {string|string[]} */ get: function () { return this._value; }, /** * Value setter * @param value */ set: function (value) { if (value === this._value) { return; } this.writeValue(value); }, enumerable: true, configurable: true }); /** * Handle input change event * @param {Event} event */ TypeaheadComponent.prototype.handleInputChange = function (event) { // Stop the propagation of event up the DOM tree in case // `change` event handler is attached to host component. event.stopPropagation(); }; /** * Update value on input change * @param event */ TypeaheadComponent.prototype.handleInput = function (event) { var target = event.target; // if esc key, close dropdown if ([KEY_DOWN, KEY_UP].includes(event.type) && event.key === ESCAPE) { this.toggleDropdown(false); return; } // if arrow down, select first item in the menu if (event.type === KEY_DOWN && event.key === ARROW_DOWN && this.matches.length > 0) { var button = this.elementRef.nativeElement.querySelector('button[role="menuitem"]:first-child'); button.focus(); return; } this.toggleDropdown(true); if (this.multi || this.complex) { if (event.type === KEY_UP && event.key === ENTER && target.value !== '') { this.setValue(target.value); this.toggleDropdown(false); } if ([KEY_DOWN, KEY_UP].includes(event.type) && event.key === BACKSPACE) { if (target.value === '') { if (event.type === KEY_DOWN) { this._removeInProgress = true; } else if (this._removeInProgress) { if (this.multi && this.values.length) { this._removeInProgress = false; this.removeValue(this.values[this.values.length - 1]); } } } else if (this.complex && !this.multi && event.type === KEY_DOWN) { this.value = null; } } } else if (event.type === KEY_UP) { this.setValue(target.value); if (event.key === ENTER && target.value !== '') { this.toggleDropdown(false); } } this._inputChangeEvent.next(target.value); }; /** * Move through collection on dropdown * @param event * @param value */ TypeaheadComponent.prototype.handleButton = function (event, value) { var target = event.target; if (event instanceof MouseEvent) { this.setValue(value, true); this._inputChangeEvent.next(this._input.value); return; } if (event.type === KEY_UP) { if (event.key === ENTER) { this.setValue(value); this._inputChangeEvent.next(this._input.value); this.toggleDropdown(false); } if (event.key === ESCAPE) { this._input.focus(); this.toggleDropdown(false); } } else { if (event.key === ARROW_DOWN && target.nextElementSibling) { target.nextElementSibling.focus(); } if (event.key === ARROW_UP && target.previousElementSibling) { target.previousElementSibling.focus(); } target.parentNode.scrollTop = target.offsetTop; } }; /** * Set value to list of values or as a single value * @param value * @param {boolean} collapseMenu */ TypeaheadComponent.prototype.setValue = function (value, collapseMenu) { if ((!this.custom || this.complex) && !this.hasMatch(value)) { return; } if (this.multi) { if (!this.values.includes(value)) { this.value = this.values.concat(value).map(this.extractIdentifier.bind(this)); this._input.value = ''; } } else { this.value = this.extractIdentifier(value); this._input.value = this.extractName(value); } if (collapseMenu) { this.toggleDropdown(false); } // refocus the input this._input.focus(); }; /** * Remove value from list of values or clear out the value * @param value */ TypeaheadComponent.prototype.removeValue = function (value) { var index = this.values.indexOf(value); if (index !== -1) { if (index === this.values.length - 1) { this.value = this.values.slice(0, -1).map(this.extractIdentifier.bind(this)); } else { this.value = this.values.slice(0, index).concat(this.values.slice(index + 1)).map(this.extractIdentifier.bind(this)); } this._inputChangeEvent.next(this._input.value); this._input.focus(); } }; TypeaheadComponent.prototype.toggleDropdown = function (value) { if (value === undefined) { this._input.focus(); this.isExpanded = !this.isExpanded; } else { this.isExpanded = value; } this.dropDownClass = this.isExpanded ? this.settings.dropdownMenuExpandedClass : this.settings.dropdownMenuClass; }; /** * Write new value * @param value */ TypeaheadComponent.prototype.writeValue = function (value) { var _this = this; // set value this._value = value; this.elementRef.nativeElement.value = value; // modify values list if (this.multi) { if (this.complex) { var callback = function () { _this.values = value ? value.map(_this.parseObjectById.bind(_this)) : []; // make sure not found value doesn't break the UI _this.values = _this.values.filter(function (val) { return !!val; }); }; if (this.allMatches || !value) { callback.apply(this); } else { this.callbackQueue.push(callback); } } else { this.values = value || []; } } // trigger change if ('createEvent' in document) { var event_1 = document.createEvent('HTMLEvents'); event_1.initEvent('change', false, true); this.elementRef.nativeElement.dispatchEvent(event_1); } else { // we need to cast since fireEvent is not standard functionality and works only in IE this.elementRef.nativeElement.fireEvent('onchange'); } this.onChange(value); }; /** * Set disabled state of the component * @param {boolean} value */ TypeaheadComponent.prototype.setDisabledState = function (value) { this.isDisabled = value; this.renderer.setProperty(this.elementRef.nativeElement, 'disabled', value); }; TypeaheadComponent.prototype.registerOnChange = function (fn) { this.onChange = fn; }; TypeaheadComponent.prototype.registerOnTouched = function (fn) { this.onTouched = fn; }; /** * @param {string} searchString * @returns {(value: any) => boolean} */ TypeaheadComponent.prototype.filterSuggestion = function (searchString) { var _this = this; return function (value) { if (_this.values.includes(value)) { return false; } if (typeof value === 'string') { return sanitizeString(value).includes(searchString); } else { return sanitizeString(value[_this.nameField]).includes(searchString) && !_this.values.some(function (element) { return element[_this.idField] === value[_this.idField]; }); } }; }; /** * Check if value has match * @param {string | Object} value * @returns {boolean} */ TypeaheadComponent.prototype.hasMatch = function (value) { var sanitizedValue = typeof value === 'string' ? sanitizeString(value) : null; for (var key in this.matches) { if (typeof this.matches[key] === 'string') { var sanitizedMatch = sanitizeString(this.matches[key]); if (sanitizedMatch === sanitizedValue) { return true; } } else { if (typeof value === 'string') { var sanitizedMatch = sanitizeString(this.matches[key][this.nameField]); if (sanitizedMatch === sanitizedValue) { return true; } } else { if (this.matches[key][this.idField] === value[this.idField]) { return true; } } } } return false; }; /** * Get name by parsing id into object * @param id * @returns {string} */ TypeaheadComponent.prototype.extractNameById = function (id) { var match = this.parseObjectById(id); if (match) { return match[this.nameField]; } else { return ''; } }; /** * Get complex object from id * @param id * @returns {any} */ TypeaheadComponent.prototype.parseObjectById = function (id) { for (var key in this.allMatches) { if (this.allMatches[key][this.idField] === id) { return this.allMatches[key]; } } return null; }; /** * Extract id field from the complex object by name or return value if it's string * @param {string | Object} value * @returns {any} */ TypeaheadComponent.prototype.extractIdentifier = function (value) { var _this = this; if (this.complex) { if (typeof value === 'string') { var sanitizedValue_1 = sanitizeString(value); var match = this.allMatches.find(function (item) { return sanitizeString(item[_this.nameField]) === sanitizedValue_1; }); if (match) { return match[this.idField]; } throw Error('Critical error: Match ID could not be extracted.'); } return value[this.idField]; } return value; }; /** * Extract name from complex object or return value if it's string * @param {string | Object} value * @returns {any} */ TypeaheadComponent.prototype.extractName = function (value) { if (this.complex && typeof value !== 'string') { return value[this.nameField]; } return value; }; TypeaheadComponent.decorators = [ { type: Component, args: [{ selector: 'type-ahead', styles: ["\n :host {\n height: auto;\n min-height: 1em;\n position: relative;\n display: inline-flex;\n flex-wrap: wrap;\n -webkit-appearance: textfield;\n -moz-appearance: textfield-multiline;\n -webkit-rtl-ordering: logical;\n user-select: text;\n cursor: auto;\n }\n :host[disabled] {\n cursor: not-allowed;\n }\n :host[disabled] input {\n background-color: inherit;\n }\n :host .type-ahead-badge {\n white-space: nowrap;\n cursor: pointer;\n }\n :host input {\n border: none;\n outline: 0;\n line-height: 1;\n flex: 1;\n }\n :host [role=\"menuitem\"] {\n cursor: pointer;\n }\n :host [role=\"menuitem\"][disabled] {\n cursor: not-allowed;\n }\n "], template: "\n <!-- default options item template -->\n <ng-template #taItemTemplate let-value=\"item\">\n {{ complex ? value[nameField] : value }}\n </ng-template>\n\n <span [ngClass]=\"settings.tagClass\" class=\"type-ahead-badge\" *ngFor=\"let value of values; let i = index\">\n <ng-template [ngTemplateOutlet]=\"itemTemplate || taItemTemplate\"\n [ngTemplateOutletContext]=\"{ item: value, index: i, complex: complex, nameField: nameField }\"></ng-template>\n <span *ngIf=\"!isDisabled\" aria-hidden=\"true\" (click)=\"removeValue(value)\"\n [ngClass]=\"settings.tagRemoveIconClass\">\u00D7</span>\n </span>\n <input *ngIf=\"!isDisabled || !multi || !values.length\" \n [disabled]=\"isDisabled || null\"\n placeholder=\"{{(isDisabled || values.length) ? '' : placeholder}}\"\n type=\"text\" autocomplete=\"off\"\n (keyup)=\"handleInput($event)\"\n (keydown)=\"handleInput($event)\"\n (paste)=\"handleInput($event)\"\n (click)=\"toggleDropdown(true)\"\n (change)=\"handleInputChange($event)\">\n <i *ngIf=\"!isDisabled\" (click)=\"toggleDropdown()\" tabindex=\"-1\"\n [ngClass]=\"settings.dropdownToggleClass\"></i>\n <div role=\"menu\" [attr.class]=\"dropDownClass\" *ngIf=\"matches.length || !custom\">\n <button *ngFor=\"let match of matches; let i = index\" type=\"button\" role=\"menuitem\" tabindex=\"-1\"\n [ngClass]=\"settings.dropdownMenuItemClass\"\n (mouseup)=\"handleButton($event, match)\"\n (keydown)=\"handleButton($event, match)\"\n (keyup)=\"handleButton($event, match)\">\n <ng-template [ngTemplateOutlet]=\"itemTemplate || taItemTemplate\"\n [ngTemplateOutletContext]=\"{ item: match, index: i, complex: complex, nameField: nameField }\"></ng-template>\n </button>\n <button role=\"menuitem\" *ngIf=\"!matches.length && !custom\" tabindex=\"-1\" aria-disabled=\"true\" disabled=\"disabled\"\n [ngClass]=\"settings.dropdownMenuItemClass\">\n {{ settings.noMatchesText }}\n </button>\n </div>\n ", providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(function () { return TypeaheadComponent; }), multi: true }] },] }, ]; /** @nocollapse */ TypeaheadComponent.ctorParameters = function () { return [ { type: ElementRef, decorators: [{ type: Inject, args: [ElementRef,] }] }, { type: Renderer2, decorators: [{ type: Inject, args: [Renderer2,] }] } ]; }; TypeaheadComponent.propDecorators = { suggestions: [{ type: Input }], itemTemplate: [{ type: Input }], nameField: [{ type: Input }], idField: [{ type: Input }], custom: [{ type: Input }], multi: [{ type: Input }], complex: [{ type: Input }], placeholder: [{ type: Input }], settings: [{ type: Input }], multiBinding: [{ type: HostBinding, args: ['class.multi',] }], disabledBinding: [{ type: HostBinding, args: ['attr.disabled',] }], valueChange: [{ type: Output }], focusOutHandler: [{ type: HostListener, args: ['focusout', ['$event'],] }] }; return TypeaheadComponent; }()); export { TypeaheadComponent }; export { ɵ0 };