ngx-bootstrap
Version:
Angular Bootstrap
534 lines • 22.8 kB
JavaScript
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