UNPKG

ngx-bootstrap

Version:
534 lines 22.8 kB
import { ChangeDetectorRef, Directive, ElementRef, EventEmitter, HostListener, Input, Output, Renderer2, TemplateRef, ViewContainerRef } from '@angular/core'; import { NgControl } from '@angular/forms'; import { ComponentLoaderFactory } from 'ngx-bootstrap/component-loader'; import { EMPTY, from, isObservable } from 'rxjs'; import { debounceTime, filter, mergeMap, switchMap, tap, toArray } from 'rxjs/operators'; import { TypeaheadContainerComponent } from './typeahead-container.component'; import { TypeaheadMatch } from './typeahead-match.class'; import { getValueFromObject, latinize, tokenize } from './typeahead-utils'; import { TypeaheadConfig } from './typeahead.config'; export class TypeaheadDirective { constructor(cis, config, changeDetection, element, ngControl, renderer, viewContainerRef) { this.changeDetection = changeDetection; this.element = element; this.ngControl = ngControl; this.renderer = renderer; /** minimal no of characters that needs to be entered before * typeahead kicks-in. When set to 0, typeahead shows on focus with full * list of options (limited as normal by typeaheadOptionsLimit) */ this.typeaheadMinLength = 1; /** sets use adaptive position */ this.adaptivePosition = false; /** turn on/off animation */ this.isAnimated = false; /** minimal wait time after last character typed before typeahead kicks-in */ this.typeaheadWaitMs = 0; /** match latin symbols. * If true the word súper would match super and vice versa. */ this.typeaheadLatinize = true; /** Can be use to search words by inserting a single white space between each characters * for example 'C a l i f o r n i a' will match 'California'. */ this.typeaheadSingleWords = true; /** should be used only in case typeaheadSingleWords attribute is true. * Sets the word delimiter to break words. Defaults to space. */ this.typeaheadWordDelimiters = ' '; /** should be used only in case typeaheadMultipleSearch attribute is true. * Sets the multiple search delimiter to know when to start a new search. Defaults to comma. * If space needs to be used, then explicitly set typeaheadWordDelimiters to something else than space * because space is used by default OR set typeaheadSingleWords attribute to false if you don't need * to use it together with multiple search. */ this.typeaheadMultipleSearchDelimiters = ','; /** should be used only in case typeaheadSingleWords attribute is true. * Sets the word delimiter to match exact phrase. * Defaults to simple and double quotes. */ this.typeaheadPhraseDelimiters = '\'"'; /** specifies if typeahead is scrollable */ this.typeaheadScrollable = false; /** specifies number of options to show in scroll view */ this.typeaheadOptionsInScrollableView = 5; /** fired when an options list was opened and the user clicked Tab * If a value equal true, it will be chosen first or active item in the list * If value equal false, it will be chosen an active item in the list or nothing */ this.typeaheadSelectFirstItem = true; /** makes active first item in a list */ this.typeaheadIsFirstItemActive = true; /** fired when 'busy' state of this component was changed, * fired on async mode only, returns boolean */ this.typeaheadLoading = new EventEmitter(); /** fired on every key event and returns true * in case of matches are not detected */ this.typeaheadNoResults = new EventEmitter(); /** fired when option was selected, return object with data of this option. */ this.typeaheadOnSelect = new EventEmitter(); /** fired when option was previewed, return object with data of this option. */ this.typeaheadOnPreview = new EventEmitter(); /** fired when blur event occurs. returns the active item */ this.typeaheadOnBlur = new EventEmitter(); /** This attribute indicates that the dropdown should be opened upwards */ this.dropup = false; this.isOpen = false; this.list = 'list'; this.isActiveItemChanged = false; this.isFocused = false; this.cancelRequestOnFocusLost = false; this.keyUpEventEmitter = new EventEmitter(); this.placement = 'bottom left'; this._matches = []; this._subscriptions = []; this._outsideClickListener = () => void 0; this._typeahead = cis.createLoader(element, viewContainerRef, renderer) .provide({ provide: TypeaheadConfig, useValue: config }); Object.assign(this, { typeaheadHideResultsOnBlur: config.hideResultsOnBlur, cancelRequestOnFocusLost: config.cancelRequestOnFocusLost, typeaheadSelectFirstItem: config.selectFirstItem, typeaheadIsFirstItemActive: config.isFirstItemActive, typeaheadMinLength: config.minLength, adaptivePosition: config.adaptivePosition, isAnimated: config.isAnimated }); } get matches() { return this._matches; } ngOnInit() { this.typeaheadOptionsLimit = this.typeaheadOptionsLimit || 20; this.typeaheadMinLength = this.typeaheadMinLength === void 0 ? 1 : this.typeaheadMinLength; // async should be false in case of array if (this.typeaheadAsync === undefined && !(isObservable(this.typeahead))) { this.typeaheadAsync = false; } if (isObservable(this.typeahead)) { this.typeaheadAsync = true; } if (this.typeaheadAsync) { this.asyncActions(); } else { this.syncActions(); } this.checkDelimitersConflict(); } // eslint-disable-next-line @typescript-eslint/no-explicit-any onInput(e) { // For `<input>`s, use the `value` property. For others that don't have a // `value` (such as `<span contenteditable="true">`), use either // `textContent` or `innerText` (depending on which one is supported, i.e. // Firefox or IE). const value = e.target.value !== undefined ? e.target.value : e.target.textContent !== undefined ? e.target.textContent : e.target.innerText; if (value != null && value.trim().length >= this.typeaheadMinLength) { this.typeaheadLoading.emit(true); this.keyUpEventEmitter.emit(e.target.value); } else { this.typeaheadLoading.emit(false); this.typeaheadNoResults.emit(false); this.hide(); } } onChange(event) { if (this._container) { // esc if (event.keyCode === 27 || event.key === 'Escape') { this.hide(); return; } // up if (event.keyCode === 38 || event.key === 'ArrowUp') { this.isActiveItemChanged = true; this._container.prevActiveMatch(); return; } // down if (event.keyCode === 40 || event.key === 'ArrowDown') { this.isActiveItemChanged = true; this._container.nextActiveMatch(); return; } // enter if (event.keyCode === 13 || event.key === 'Enter') { this._container.selectActiveMatch(); return; } } } onFocus() { this.isFocused = true; // add setTimeout to fix issue #5251 // to get and emit updated value if it's changed on focus setTimeout(() => { if (this.typeaheadMinLength === 0) { this.typeaheadLoading.emit(true); this.keyUpEventEmitter.emit(this.element.nativeElement.value || ''); } }, 0); } onBlur() { this.isFocused = false; if (this._container && !this._container.isFocused) { this.typeaheadOnBlur.emit(this._container.active); } if (!this.container && this._matches.length === 0) { this.typeaheadOnBlur.emit(new TypeaheadMatch(this.element.nativeElement.value, this.element.nativeElement.value, false)); } } onKeydown(event) { // no container - no problems if (!this._container) { return; } if (event.keyCode === 9 || event.key === 'Tab') { this.onBlur(); } if (event.keyCode === 9 || event.key === 'Tab' || event.keyCode === 13 || event.key === 'Enter') { event.preventDefault(); if (this.typeaheadSelectFirstItem) { this._container.selectActiveMatch(); return; } if (!this.typeaheadSelectFirstItem) { this._container.selectActiveMatch(this.isActiveItemChanged); this.isActiveItemChanged = false; this.hide(); } } } changeModel(match) { var _a; if (!match) { return; } let valueStr; if (this.typeaheadMultipleSearch && this._allEnteredValue) { const tokens = this._allEnteredValue.split(new RegExp(`([${this.typeaheadMultipleSearchDelimiters}]+)`)); this._allEnteredValue = tokens.slice(0, tokens.length - 1).concat(match.value).join(''); valueStr = this._allEnteredValue; } else { valueStr = match.value; } this.ngControl.viewToModelUpdate(valueStr); (_a = this.ngControl.control) === null || _a === void 0 ? void 0 : _a.setValue(valueStr); this.changeDetection.markForCheck(); this.hide(); } show() { this._typeahead .attach(TypeaheadContainerComponent) .to(this.container) .position({ attachment: `${this.dropup ? 'top' : 'bottom'} left` }) .show({ typeaheadRef: this, placement: this.placement, animation: false, dropup: this.dropup }); this._outsideClickListener = this.renderer .listen('document', 'click', (event) => { if (this.typeaheadMinLength === 0 && this.element.nativeElement.contains(event.target)) { return; } if (!this.typeaheadHideResultsOnBlur || this.element.nativeElement.contains(event.target)) { return; } this.onOutsideClick(); }); if (!this._typeahead.instance || !this.ngControl.control) { return; } this._container = this._typeahead.instance; this._container.parent = this; // This improves the speed as it won't have to be done for each list item const normalizedQuery = (this.typeaheadLatinize ? latinize(this.ngControl.control.value) : this.ngControl.control.value) .toString() .toLowerCase(); this._container.query = this.tokenizeQuery(normalizedQuery); this._container.matches = this._matches; this.element.nativeElement.focus(); this._container.activeChangeEvent.subscribe((activeId) => { this.activeDescendant = activeId; this.changeDetection.markForCheck(); }); this.isOpen = true; } hide() { if (this._typeahead.isShown) { this._typeahead.hide(); this._outsideClickListener(); this._container = void 0; this.isOpen = false; this.changeDetection.markForCheck(); } this.typeaheadOnPreview.emit(); } onOutsideClick() { if (this._container && !this._container.isFocused) { this.hide(); } } ngOnDestroy() { // clean up subscriptions for (const sub of this._subscriptions) { sub.unsubscribe(); } this._typeahead.dispose(); } asyncActions() { this._subscriptions.push(this.keyUpEventEmitter .pipe(debounceTime(this.typeaheadWaitMs), tap(value => this._allEnteredValue = value), switchMap(() => { if (!this.typeahead) { return EMPTY; } return this.typeahead; })) .subscribe((matches) => { this.finalizeAsyncCall(matches); })); } syncActions() { this._subscriptions.push(this.keyUpEventEmitter .pipe(debounceTime(this.typeaheadWaitMs), mergeMap((value) => { this._allEnteredValue = value; const normalizedQuery = this.normalizeQuery(value); if (!this.typeahead) { return EMPTY; } const typeahead = isObservable(this.typeahead) ? this.typeahead : from(this.typeahead); return typeahead .pipe(filter((option) => { return !!option && this.testMatch(this.normalizeOption(option), normalizedQuery); }), toArray()); })) .subscribe((matches) => { this.finalizeAsyncCall(matches); })); } normalizeOption(option) { const optionValue = getValueFromObject(option, this.typeaheadOptionField); const normalizedOption = this.typeaheadLatinize ? latinize(optionValue) : optionValue; return normalizedOption.toLowerCase(); } tokenizeQuery(currentQuery) { let query = currentQuery; if (this.typeaheadMultipleSearch && this.typeaheadSingleWords) { if (!this.haveCommonCharacters(`${this.typeaheadPhraseDelimiters}${this.typeaheadWordDelimiters}`, this.typeaheadMultipleSearchDelimiters)) { // single words and multiple search delimiters are different, can be used together query = tokenize(query, this.typeaheadWordDelimiters, this.typeaheadPhraseDelimiters, this.typeaheadMultipleSearchDelimiters); } } else if (this.typeaheadSingleWords) { query = tokenize(query, this.typeaheadWordDelimiters, this.typeaheadPhraseDelimiters); } else { // multiple searches query = tokenize(query, void 0, void 0, this.typeaheadMultipleSearchDelimiters); } return query; } normalizeQuery(value) { // If singleWords, break model here to not be doing extra work on each iteration let normalizedQuery = (this.typeaheadLatinize ? latinize(value) : value) .toString() .toLowerCase(); normalizedQuery = this.tokenizeQuery(normalizedQuery); return normalizedQuery; } testMatch(match, test) { let spaceLength; if (typeof test === 'object') { spaceLength = test.length; for (let i = 0; i < spaceLength; i += 1) { if (test[i].length > 0 && match.indexOf(test[i]) < 0) { return false; } } return true; } return match.indexOf(test) >= 0; } finalizeAsyncCall(matches) { this.prepareMatches(matches || []); this.typeaheadLoading.emit(false); this.typeaheadNoResults.emit(!this.hasMatches()); if (!this.hasMatches()) { this.hide(); return; } if (!this.isFocused && this.cancelRequestOnFocusLost) { return; } if (this._container && this.ngControl.control) { // fix: remove usage of ngControl internals const _controlValue = (this.typeaheadLatinize ? latinize(this.ngControl.control.value) : this.ngControl.control.value) || ''; // This improves the speed as it won't have to be done for each list item const normalizedQuery = _controlValue.toString().toLowerCase(); this._container.query = this.tokenizeQuery(normalizedQuery); this._container.matches = this._matches; } else { this.show(); } } prepareMatches(options) { const limited = options.slice(0, this.typeaheadOptionsLimit); const sorted = !this.typeaheadOrderBy ? limited : this.orderMatches(limited); if (this.typeaheadGroupField) { let matches = []; // extract all group names const groups = sorted .map((option) => getValueFromObject(option, this.typeaheadGroupField)) .filter((v, i, a) => a.indexOf(v) === i); groups.forEach((group) => { // add group header to array of matches matches.push(new TypeaheadMatch(group, group, true)); // add each item of group to array of matches matches = matches.concat(sorted .filter((option) => getValueFromObject(option, this.typeaheadGroupField) === group) .map((option) => new TypeaheadMatch(option, getValueFromObject(option, this.typeaheadOptionField)))); }); this._matches = matches; } else { this._matches = sorted.map( // eslint-disable-next-line @typescript-eslint/no-explicit-any (option) => new TypeaheadMatch(option, getValueFromObject(option, this.typeaheadOptionField))); } } orderMatches(options) { if (!options.length) { return options; } if (this.typeaheadOrderBy !== null && this.typeaheadOrderBy !== undefined && typeof this.typeaheadOrderBy === 'object' && Object.keys(this.typeaheadOrderBy).length === 0) { console.error('Field and direction properties for typeaheadOrderBy have to be set according to documentation!'); return options; } const { field, direction } = (this.typeaheadOrderBy || {}); if (!direction || !(direction === 'asc' || direction === 'desc')) { console.error('typeaheadOrderBy direction has to equal "asc" or "desc". Please follow the documentation.'); return options; } if (typeof options[0] === 'string') { return direction === 'asc' ? options.sort() : options.sort().reverse(); } if (!field || typeof field !== 'string') { console.error('typeaheadOrderBy field has to set according to the documentation.'); return options; } return options.sort((a, b) => { const stringA = getValueFromObject(a, field); const stringB = getValueFromObject(b, field); if (stringA < stringB) { return direction === 'asc' ? -1 : 1; } if (stringA > stringB) { return direction === 'asc' ? 1 : -1; } return 0; }); } hasMatches() { return this._matches.length > 0; } checkDelimitersConflict() { if (this.typeaheadMultipleSearch && this.typeaheadSingleWords && (this.haveCommonCharacters(`${this.typeaheadPhraseDelimiters}${this.typeaheadWordDelimiters}`, this.typeaheadMultipleSearchDelimiters))) { throw new Error(`Delimiters used in typeaheadMultipleSearchDelimiters must be different from delimiters used in typeaheadWordDelimiters (current value: ${this.typeaheadWordDelimiters}) and typeaheadPhraseDelimiters (current value: ${this.typeaheadPhraseDelimiters}). Please refer to the documentation`); } } haveCommonCharacters(str1, str2) { for (let i = 0; i < str1.length; i++) { if (str1.charAt(i).indexOf(str2) > -1) { return true; } } return false; } } TypeaheadDirective.decorators = [ { type: Directive, args: [{ selector: '[typeahead]', exportAs: 'bs-typeahead', // eslint-disable-next-line @angular-eslint/no-host-metadata-property host: { '[attr.aria-activedescendant]': 'activeDescendant', '[attr.aria-owns]': 'isOpen ? this._container.popupId : null', '[attr.aria-expanded]': 'isOpen', '[attr.aria-autocomplete]': 'list' } },] } ]; TypeaheadDirective.ctorParameters = () => [ { type: ComponentLoaderFactory }, { type: TypeaheadConfig }, { type: ChangeDetectorRef }, { type: ElementRef }, { type: NgControl }, { type: Renderer2 }, { type: ViewContainerRef } ]; TypeaheadDirective.propDecorators = { typeahead: [{ type: Input }], typeaheadMinLength: [{ type: Input }], adaptivePosition: [{ type: Input }], isAnimated: [{ type: Input }], typeaheadWaitMs: [{ type: Input }], typeaheadOptionsLimit: [{ type: Input }], typeaheadOptionField: [{ type: Input }], typeaheadGroupField: [{ type: Input }], typeaheadOrderBy: [{ type: Input }], typeaheadAsync: [{ type: Input }], typeaheadLatinize: [{ type: Input }], typeaheadSingleWords: [{ type: Input }], typeaheadWordDelimiters: [{ type: Input }], typeaheadMultipleSearch: [{ type: Input }], typeaheadMultipleSearchDelimiters: [{ type: Input }], typeaheadPhraseDelimiters: [{ type: Input }], typeaheadItemTemplate: [{ type: Input }], optionsListTemplate: [{ type: Input }], typeaheadScrollable: [{ type: Input }], typeaheadOptionsInScrollableView: [{ type: Input }], typeaheadHideResultsOnBlur: [{ type: Input }], typeaheadSelectFirstItem: [{ type: Input }], typeaheadIsFirstItemActive: [{ type: Input }], typeaheadLoading: [{ type: Output }], typeaheadNoResults: [{ type: Output }], typeaheadOnSelect: [{ type: Output }], typeaheadOnPreview: [{ type: Output }], typeaheadOnBlur: [{ type: Output }], container: [{ type: Input }], dropup: [{ type: Input }], onInput: [{ type: HostListener, args: ['input', ['$event'],] }], onChange: [{ type: HostListener, args: ['keyup', ['$event'],] }], onFocus: [{ type: HostListener, args: ['click',] }, { type: HostListener, args: ['focus',] }], onBlur: [{ type: HostListener, args: ['blur',] }], onKeydown: [{ type: HostListener, args: ['keydown', ['$event'],] }] }; //# sourceMappingURL=typeahead.directive.js.map