/* ============================================================= * bootstrap-autocomplete.js v2.3.4 * https://github.com/xcash/bootstrap-autocomplete * ============================================================= * Forked from bootstrap3-typeahead.js v3.1.0 * https://github.com/bassjobsen/Bootstrap-3-Typeahead * ============================================================= * Original written by @mdo and @fat * ============================================================= * Copyright 2018-2020 Paolo Casciello @xcash666 and contributors * * Licensed under the MIT License (the 'License'); * you may not use this file except in compliance with the License. * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an 'AS IS' BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * ============================================================ */ import { AjaxResolver, BaseResolver } from './resolvers'; import { DropdownV3 } from './dropdownV3'; import { DropdownV4 } from './dropdownV4'; export interface AutoCompleteSettings { resolver: string, resolverSettings: any, minLength: number, valueKey: string, formatResult: (item: any) => {}, autoSelect: boolean, noResultsText: string, bootstrapVersion: string, preventEnter: boolean, events: { typed: (newValue: string, el: JQuery) => string, searchPre: (searchText: string, el: JQuery) => string, search: (searchText: string, cbk: (results: any) => void, el: JQuery) => void, searchPost: (results: any, el: JQuery) => any, select: () => void, focus: () => void, } } export class AutoComplete { public static NAME: string = 'autoComplete'; private _el: Element; private _$el: JQuery; private _dd: DropdownV3 | DropdownV4; private _searchText: string; private _selectedItem: any = null; private _defaultValue: any = null; private _defaultText: string = null; private _isSelectElement: boolean = false; private _selectHiddenField: JQuery; private _settings: AutoCompleteSettings = { resolver: 'ajax', resolverSettings: {}, minLength: 3, valueKey: 'value', formatResult: this.defaultFormatResult, autoSelect: true, noResultsText: 'No results', bootstrapVersion: 'auto', preventEnter: false, events: { typed: null, searchPre: null, search: null, searchPost: null, select: null, focus: null, } } private resolver: BaseResolver; constructor(element: HTMLElement, options?: {}) { this._el = element; this._$el = $(this._el) as JQuery; // element type if (this._$el.is('select')) { this._isSelectElement = true; } // inline data attributes this.manageInlineDataAttributes(); // constructor options if (typeof options === 'object') { this._settings = $.extend(true, {}, this.getSettings(), options) as AutoCompleteSettings; } if (this._isSelectElement) { this.convertSelectToText(); } // console.log('initializing', this._settings); this.init(); } private manageInlineDataAttributes() { // updates settings with data-* attributes const s = this.getSettings(); if (this._$el.data('url')) { s.resolverSettings.url = this._$el.data('url'); } if (this._$el.data('default-value')) { this._defaultValue = this._$el.data('default-value'); } if (this._$el.data('default-text')) { this._defaultText = this._$el.data('default-text'); } if (this._$el.data('noresults-text') !== undefined) { s.noResultsText = this._$el.data('noresults-text'); } } private getSettings(): AutoCompleteSettings { return this._settings; } private getBootstrapVersion(): number[] { let versionArray: number[]; if (this._settings.bootstrapVersion === 'auto') { // @ts-ignore const versionString = $.fn.button.Constructor.VERSION; versionArray = versionString.split('.').map(parseInt); } else if (this._settings.bootstrapVersion === '4') { versionArray = [4]; } else if (this._settings.bootstrapVersion === '3') { versionArray = [3]; } else { // tslint:disable-next-line: no-console console.error(`INVALID value for \'bootstrapVersion\' settings property: ${this._settings.bootstrapVersion} defaulting to 4`); versionArray = [4]; } return versionArray; } private convertSelectToText() { // create hidden field const hidField: JQuery = $(''); hidField.attr('type', 'hidden'); hidField.attr('name', this._$el.attr('name')); if (this._defaultValue) { hidField.val(this._defaultValue); } this._selectHiddenField = hidField; hidField.insertAfter(this._$el); // create search input element const searchField: JQuery = $(''); // copy all attributes searchField.attr('type', 'search'); searchField.attr('name', this._$el.attr('name') + '_text'); searchField.attr('id', this._$el.attr('id')); searchField.attr('disabled', this._$el.attr('disabled')); searchField.attr('placeholder', this._$el.attr('placeholder')); searchField.attr('autocomplete', 'off'); searchField.addClass(this._$el.attr('class')); if (this._defaultText) { searchField.val(this._defaultText); } const requiredAttribute: string = this._$el.attr('required'); if (requiredAttribute) { searchField.attr('required', requiredAttribute); } // attach class searchField.data(AutoComplete.NAME, this); // replace original with searchField this._$el.replaceWith(searchField); this._$el = searchField; this._el = searchField.get(0); } private init(): void { // bind default events this.bindDefaultEventListeners(); // RESOLVER if (this._settings.resolver === 'ajax') { // configure default resolver this.resolver = new AjaxResolver(this._settings.resolverSettings); } // Dropdown if (this.getBootstrapVersion()[0] === 4) { // v4 this._dd = new DropdownV4(this._$el, this._settings.formatResult, this._settings.autoSelect, this._settings.noResultsText ); } else { this._dd = new DropdownV3(this._$el, this._settings.formatResult, this._settings.autoSelect, this._settings.noResultsText ); } } private bindDefaultEventListeners(): void { this._$el.on('keydown', (evt: JQueryEventObject) => { // console.log('keydown', evt.which, evt); switch (evt.which) { case 9: // TAB // if (this._settings.autoSelect) { // // if autoSelect enabled selects on blur the currently selected item // this._dd.selectFocusItem(); // } if (this._dd.isItemFocused) { this._dd.selectFocusItem(); } else if (!this._selectedItem) { // if we haven't selected an item (thus firing the corresponding event) we fire the free value // related to issue #71 if (this._$el.val() !== '') { this._$el.trigger('autocomplete.freevalue', this._$el.val()); } } this._dd.hide(); break; case 13: // ENTER if (this._dd.isItemFocused) { this._dd.selectFocusItem(); } else if (!this._selectedItem) { if (this._$el.val() !== '') { this._$el.trigger('autocomplete.freevalue', this._$el.val()); } } this._dd.hide(); if (this._settings.preventEnter) { // console.log('preventDefault'); evt.preventDefault(); } break; case 40: // arrow DOWN (here for usability - issue #80) this._dd.focusNextItem(); break; case 38: // up arrow (here for usability - issue #80) this._dd.focusPreviousItem(); break; } }); this._$el.on('keyup', (evt: JQueryEventObject) => { // console.log('keyup', evt.which, evt); // check key switch (evt.which) { case 16: // shift case 17: // ctrl case 18: // alt case 39: // right case 37: // left case 36: // home case 35: // end break; case 13: // ENTER this._dd.hide(); break; case 27: // ESC this._dd.hide(); break; case 40: // arrow DOWN // this._dd.focusNextItem(); break; case 38: // up arrow // this._dd.focusPreviousItem(); break; default: // reset selectedItem as we modified input value (related to issue #71) this._selectedItem = null; const newValue = this._$el.val() as string; this.handlerTyped(newValue); } }); this._$el.on('blur', (evt: JQueryEventObject) => { // console.log(evt); if (!this._dd.isMouseOver && this._dd.isDdMouseOver && this._dd.isShown()) { // Firefox Workaround setTimeout(() => { this._$el.focus(); }); // Other browsers this._$el.focus(); } else if (!this._dd.isMouseOver) { if (this._isSelectElement) { // if it's a select element if (this._dd.isItemFocused) { this._dd.selectFocusItem(); } else if ((this._selectedItem !== null) && (this._$el.val() !== '')) { // reselect it this._$el.trigger('autocomplete.select', this._selectedItem); } else if ((this._$el.val() !== '') && (this._defaultValue !== null)) { // select Default this._$el.val(this._defaultText); this._selectHiddenField.val(this._defaultValue); this._selectedItem = null; this._$el.trigger('autocomplete.select', this._selectedItem); } else { // empty the values this._$el.val(''); this._selectHiddenField.val(''); this._selectedItem = null; this._$el.trigger('autocomplete.select', this._selectedItem); } } else { // It's a text element, we accept custom value. // Developers may subscribe to `autocomplete.freevalue` to get notified of this if (this._selectedItem === null) { this._$el.trigger('autocomplete.freevalue', this._$el.val()); } } this._dd.hide(); } }); // selected event // @ts-ignore - Ignoring TS type checking this._$el.on('autocomplete.select', (evt: JQueryEventObject, item: any) => { this._selectedItem = item; this.itemSelectedDefaultHandler(item); }); // Paste event // The event occurs before the value is pasted. safe behaviour should be triggering `keyup` this._$el.on('paste', (evt: JQueryEventObject) => { setTimeout(() => { this._$el.trigger('keyup', evt); }, 0) }); } private handlerTyped(newValue: string): void { // field value changed // custom handler may change newValue if (this._settings.events.typed !== null) { newValue = this._settings.events.typed(newValue, this._$el); if (!newValue) { return; } } // if value >= minLength, start autocomplete if (newValue.length >= this._settings.minLength) { this._searchText = newValue; this.handlerPreSearch(); } else { this._dd.hide(); } } private handlerPreSearch(): void { // do nothing, start search // custom handler may change newValue if (this._settings.events.searchPre !== null) { const newValue: string = this._settings.events.searchPre(this._searchText, this._$el); if (!newValue) return; this._searchText = newValue; } this.handlerDoSearch(); } private handlerDoSearch(): void { // custom handler may change newValue if (this._settings.events.search !== null) { this._settings.events.search(this._searchText, (results: any) => { this.postSearchCallback(results); }, this._$el); } else { // Default behaviour // search using current resolver if (this.resolver) { this.resolver.search(this._searchText, (results: any) => { this.postSearchCallback(results); }); } } } private postSearchCallback(results: any): void { // console.log('callback called', results); // custom handler may change newValue if (this._settings.events.searchPost) { results = this._settings.events.searchPost(results, this._$el); if ((typeof results === 'boolean') && !results) return; } this.handlerStartShow(results); } private handlerStartShow(results: any[]): void { // console.log("defaultEventStartShow", results); // for every result, draw it this._dd.updateItems(results, this._searchText); } protected itemSelectedDefaultHandler(item: any): void { // this is a coerce check (!=) to cover null or undefined if (item != null) { // default behaviour is set elment's .val() let itemFormatted: any = this._settings.formatResult(item); if (typeof itemFormatted === 'string') { itemFormatted = { text: itemFormatted } } this._$el.val(itemFormatted.text); // if the element is a select if (this._isSelectElement) { this._selectHiddenField.val(itemFormatted.value); } } else { // item is null -> clear the value this._$el.val(''); if (this._isSelectElement) { this._selectHiddenField.val(''); } } // save selected item this._selectedItem = item; // and hide this._dd.hide(); } private defaultFormatResult(item: any): {} { if (typeof item === 'string') { return { text: item }; } else if (item.text) { return item; } else { // return a toString of the item as last resort // console.error('No default formatter for item', item); return { text: item.toString() } } } public manageAPI(APICmd: any, params: any) { // manages public API if (APICmd === 'set') { this.itemSelectedDefaultHandler(params); } else if (APICmd === 'clear') { // shortcut this.itemSelectedDefaultHandler(null); } else if (APICmd === 'show') { // shortcut this._$el.trigger('keyup'); } else if (APICmd === 'updateResolver') { // update resolver this.resolver = new AjaxResolver(params); } } } (($: JQueryStatic, window: any, document: any) => { // @ts-ignore $.fn[AutoComplete.NAME] = function (optionsOrAPI: any, optionalParams: any) { return this.each(function () { let pluginClass: AutoComplete; pluginClass = $(this).data(AutoComplete.NAME); if (!pluginClass) { pluginClass = new AutoComplete(this, optionsOrAPI); $(this).data(AutoComplete.NAME, pluginClass); } pluginClass.manageAPI(optionsOrAPI, optionalParams); }); }; })(jQuery, window, document);