Convert `Ui/Search/Input` to TypeScript
authorAlexander Ebert <ebert@woltlab.com>
Sun, 25 Oct 2020 23:13:23 +0000 (00:13 +0100)
committerTim Düsterhus <duesterhus@woltlab.com>
Wed, 28 Oct 2020 11:57:20 +0000 (12:57 +0100)
wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Search/Input.js
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Search/Input.js [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Search/Input.ts [new file with mode: 0644]

index 58bc04c5264926d45e5943a5935822a2ff4a0d38..2e81735d150fdecfe16ca5970727c0ca0c2216d2 100644 (file)
 /**
  * Provides suggestions using an input field, designed to work with `wcf\data\ISearchAction`.
  *
- * @author     Alexander Ebert
- * @copyright  2001-2019 WoltLab GmbH
- * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module     WoltLabSuite/Core/Ui/Search/Input
+ * @author  Alexander Ebert
+ * @copyright  2001-2019 WoltLab GmbH
+ * @license  GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module  WoltLabSuite/Core/Ui/Search/Input
  */
-define(['Ajax', 'Core', 'EventKey', 'Dom/Util', 'Ui/SimpleDropdown'], function (Ajax, Core, EventKey, DomUtil, UiSimpleDropdown) {
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
+}) : (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+    Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+    o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || function (mod) {
+    if (mod && mod.__esModule) return mod;
+    var result = {};
+    if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
+    __setModuleDefault(result, mod);
+    return result;
+};
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+define(["require", "exports", "../../Ajax", "../../Core", "../../Dom/Util", "../Dropdown/Simple"], function (require, exports, Ajax, Core, Util_1, Simple_1) {
     "use strict";
-    /**
-     * @param       {Element}       element         target input[type="text"]
-     * @param       {Object}        options         search options and settings
-     * @constructor
-     */
-    function UiSearchInput(element, options) { this.init(element, options); }
-    UiSearchInput.prototype = {
+    Ajax = __importStar(Ajax);
+    Core = __importStar(Core);
+    Util_1 = __importDefault(Util_1);
+    Simple_1 = __importDefault(Simple_1);
+    class UiSearchInput {
         /**
          * Initializes the search input field.
          *
          * @param       {Element}       element         target input[type="text"]
          * @param       {Object}        options         search options and settings
          */
-        init: function (element, options) {
-            this._element = element;
-            if (!(this._element instanceof Element)) {
+        constructor(element, options) {
+            this.activeItem = undefined;
+            this.callbackDropdownInit = undefined;
+            this.callbackSelect = undefined;
+            this.dropdownContainerId = '';
+            this.excludedSearchValues = new Set();
+            this.list = undefined;
+            this.lastValue = '';
+            this.request = undefined;
+            this.timerDelay = undefined;
+            this.element = element;
+            if (!(this.element instanceof HTMLInputElement)) {
                 throw new TypeError("Expected a valid DOM element.");
             }
-            else if (this._element.nodeName !== 'INPUT' || (this._element.type !== 'search' && this._element.type !== 'text')) {
+            else if (this.element.nodeName !== 'INPUT' || (this.element.type !== 'search' && this.element.type !== 'text')) {
                 throw new Error('Expected an input[type="text"].');
             }
-            this._activeItem = null;
-            this._dropdownContainerId = '';
-            this._lastValue = '';
-            this._list = null;
-            this._request = null;
-            this._timerDelay = null;
-            this._options = Core.extend({
+            options = Core.extend({
                 ajax: {
                     actionName: 'getSearchResultList',
                     className: '',
-                    interfaceName: 'wcf\\data\\ISearchAction'
+                    interfaceName: 'wcf\\data\\ISearchAction',
                 },
                 autoFocus: true,
-                callbackDropdownInit: null,
-                callbackSelect: null,
+                callbackDropdownInit: undefined,
+                callbackSelect: undefined,
                 delay: 500,
                 excludedSearchValues: [],
                 minLength: 3,
                 noResultPlaceholder: '',
-                preventSubmit: false
+                preventSubmit: false,
             }, options);
-            // disable auto-complete as it collides with the suggestion dropdown
-            elAttr(this._element, 'autocomplete', 'off');
-            this._element.addEventListener('keydown', this._keydown.bind(this));
-            this._element.addEventListener('keyup', this._keyup.bind(this));
-        },
+            this.ajaxPayload = options.ajax;
+            this.autoFocus = options.autoFocus;
+            this.callbackDropdownInit = options.callbackDropdownInit;
+            this.callbackSelect = options.callbackSelect;
+            this.delay = options.delay;
+            options.excludedSearchValues.forEach(value => {
+                this.addExcludedSearchValues(value);
+            });
+            this.minLength = options.minLength;
+            this.noResultPlaceholder = options.noResultPlaceholder;
+            this.preventSubmit = options.preventSubmit;
+            // Disable auto-complete because it collides with the suggestion dropdown.
+            this.element.autocomplete = 'off';
+            this.element.addEventListener('keydown', this.keydown.bind(this));
+            this.element.addEventListener('keyup', this.keyup.bind(this));
+        }
         /**
          * Adds an excluded search value.
-         *
-         * @param       {string}        value   excluded value
          */
-        addExcludedSearchValues: function (value) {
-            if (this._options.excludedSearchValues.indexOf(value) === -1) {
-                this._options.excludedSearchValues.push(value);
-            }
-        },
+        addExcludedSearchValues(value) {
+            this.excludedSearchValues.add(value);
+        }
         /**
          * Removes a value from the excluded search values.
-         *
-         * @param       {string}        value   excluded value
          */
-        removeExcludedSearchValues: function (value) {
-            var index = this._options.excludedSearchValues.indexOf(value);
-            if (index !== -1) {
-                this._options.excludedSearchValues.splice(index, 1);
-            }
-        },
+        removeExcludedSearchValues(value) {
+            this.excludedSearchValues.delete(value);
+        }
         /**
          * Handles the 'keydown' event.
-         *
-         * @param       {Event}         event           event object
-         * @protected
          */
-        _keydown: function (event) {
-            if ((this._activeItem !== null && UiSimpleDropdown.isOpen(this._dropdownContainerId)) || this._options.preventSubmit) {
-                if (EventKey.Enter(event)) {
+        keydown(event) {
+            if ((this.activeItem !== null && Simple_1.default.isOpen(this.dropdownContainerId)) || this.preventSubmit) {
+                if (event.key === 'Enter') {
                     event.preventDefault();
                 }
             }
-            if (EventKey.ArrowUp(event) || EventKey.ArrowDown(event) || EventKey.Escape(event)) {
+            if (['ArrowUp', 'ArrowDown', 'Escape'].includes(event.key)) {
                 event.preventDefault();
             }
-        },
+        }
         /**
          * Handles the 'keyup' event, provides keyboard navigation and executes search queries.
-         *
-         * @param       {Event}         event           event object
-         * @protected
          */
-        _keyup: function (event) {
+        keyup(event) {
             // handle dropdown keyboard navigation
-            if (this._activeItem !== null || !this._options.autoFocus) {
-                if (UiSimpleDropdown.isOpen(this._dropdownContainerId)) {
-                    if (EventKey.ArrowUp(event)) {
+            if (this.activeItem !== null || !this.autoFocus) {
+                if (Simple_1.default.isOpen(this.dropdownContainerId)) {
+                    if (event.key === 'ArrowUp') {
                         event.preventDefault();
-                        return this._keyboardPreviousItem();
+                        return this.keyboardPreviousItem();
                     }
-                    else if (EventKey.ArrowDown(event)) {
+                    else if (event.key === 'ArrowDown') {
                         event.preventDefault();
-                        return this._keyboardNextItem();
+                        return this.keyboardNextItem();
                     }
-                    else if (EventKey.Enter(event)) {
+                    else if (event.key === 'Enter') {
                         event.preventDefault();
-                        return this._keyboardSelectItem();
+                        return this.keyboardSelectItem();
                     }
                 }
                 else {
-                    this._activeItem = null;
+                    this.activeItem = undefined;
                 }
             }
             // close list on escape
-            if (EventKey.Escape(event)) {
-                UiSimpleDropdown.close(this._dropdownContainerId);
+            if (event.key === 'Escape') {
+                Simple_1.default.close(this.dropdownContainerId);
                 return;
             }
-            var value = this._element.value.trim();
-            if (this._lastValue === value) {
+            const value = this.element.value.trim();
+            if (this.lastValue === value) {
                 // value did not change, e.g. previously it was "Test" and now it is "Test ",
                 // but the trailing whitespace has been ignored
                 return;
             }
-            this._lastValue = value;
-            if (value.length < this._options.minLength) {
-                if (this._dropdownContainerId) {
-                    UiSimpleDropdown.close(this._dropdownContainerId);
-                    this._activeItem = null;
+            this.lastValue = value;
+            if (value.length < this.minLength) {
+                if (this.dropdownContainerId) {
+                    Simple_1.default.close(this.dropdownContainerId);
+                    this.activeItem = undefined;
                 }
                 // value below threshold
                 return;
             }
-            if (this._options.delay) {
-                if (this._timerDelay !== null) {
-                    window.clearTimeout(this._timerDelay);
+            if (this.delay) {
+                if (this.timerDelay) {
+                    window.clearTimeout(this.timerDelay);
                 }
-                this._timerDelay = window.setTimeout((function () {
-                    this._search(value);
-                }).bind(this), this._options.delay);
+                this.timerDelay = window.setTimeout(() => {
+                    this.search(value);
+                }, this.delay);
             }
             else {
-                this._search(value);
+                this.search(value);
             }
-        },
+        }
         /**
          * Queries the server with the provided search string.
-         *
-         * @param       {string}        value   search string
-         * @protected
          */
-        _search: function (value) {
-            if (this._request) {
-                this._request.abortPrevious();
+        search(value) {
+            if (this.request) {
+                this.request.abortPrevious();
             }
-            this._request = Ajax.api(this, this._getParameters(value));
-        },
+            this.request = Ajax.api(this, this.getParameters(value));
+        }
         /**
          * Returns additional AJAX parameters.
-         *
-         * @param       {string}        value   search string
-         * @return      {Object}        additional AJAX parameters
-         * @protected
          */
-        _getParameters: function (value) {
+        getParameters(value) {
             return {
                 parameters: {
                     data: {
-                        excludedSearchValues: this._options.excludedSearchValues,
-                        searchString: value
-                    }
-                }
+                        excludedSearchValues: this.excludedSearchValues,
+                        searchString: value,
+                    },
+                },
             };
-        },
+        }
         /**
          * Selects the next dropdown item.
-         *
-         * @protected
          */
-        _keyboardNextItem: function () {
-            var nextItem;
-            if (this._activeItem !== null) {
-                this._activeItem.classList.remove('active');
-                if (this._activeItem.nextElementSibling) {
-                    nextItem = this._activeItem.nextElementSibling;
+        keyboardNextItem() {
+            let nextItem = undefined;
+            if (this.activeItem) {
+                this.activeItem.classList.remove('active');
+                if (this.activeItem.nextElementSibling) {
+                    nextItem = this.activeItem.nextElementSibling;
                 }
             }
-            this._activeItem = nextItem || this._list.children[0];
-            this._activeItem.classList.add('active');
-        },
+            this.activeItem = nextItem || this.list.children[0];
+            this.activeItem.classList.add('active');
+        }
         /**
          * Selects the previous dropdown item.
-         *
-         * @protected
          */
-        _keyboardPreviousItem: function () {
-            var nextItem;
-            if (this._activeItem !== null) {
-                this._activeItem.classList.remove('active');
-                if (this._activeItem.previousElementSibling) {
-                    nextItem = this._activeItem.previousElementSibling;
+        keyboardPreviousItem() {
+            let nextItem = undefined;
+            if (this.activeItem) {
+                this.activeItem.classList.remove('active');
+                if (this.activeItem.previousElementSibling) {
+                    nextItem = this.activeItem.previousElementSibling;
                 }
             }
-            this._activeItem = nextItem || this._list.children[this._list.childElementCount - 1];
-            this._activeItem.classList.add('active');
-        },
+            this.activeItem = nextItem || this.list.children[this.list.childElementCount - 1];
+            this.activeItem.classList.add('active');
+        }
         /**
          * Selects the active item from the dropdown.
-         *
-         * @protected
          */
-        _keyboardSelectItem: function () {
-            this._selectItem(this._activeItem);
-        },
+        keyboardSelectItem() {
+            this.selectItem(this.activeItem);
+        }
         /**
          * Selects an item from the dropdown by clicking it.
-         *
-         * @param       {Event}         event           event object
-         * @protected
          */
-        _clickSelectItem: function (event) {
-            this._selectItem(event.currentTarget);
-        },
+        clickSelectItem(event) {
+            this.selectItem(event.currentTarget);
+        }
         /**
          * Selects an item.
-         *
-         * @param       {Element}       item    selected item
-         * @protected
          */
-        _selectItem: function (item) {
-            if (this._options.callbackSelect && this._options.callbackSelect(item) === false) {
-                this._element.value = '';
+        selectItem(item) {
+            if (this.callbackSelect && !this.callbackSelect(item)) {
+                this.element.value = '';
             }
             else {
-                this._element.value = elData(item, 'label');
+                this.element.value = item.dataset.label || '';
             }
-            this._activeItem = null;
-            UiSimpleDropdown.close(this._dropdownContainerId);
-        },
+            this.activeItem = undefined;
+            Simple_1.default.close(this.dropdownContainerId);
+        }
         /**
          * Handles successful AJAX requests.
-         *
-         * @param       {Object}        data    response data
-         * @protected
          */
-        _ajaxSuccess: function (data) {
-            var createdList = false;
-            if (this._list === null) {
-                this._list = elCreate('ul');
-                this._list.className = 'dropdownMenu';
+        _ajaxSuccess(data) {
+            let createdList = false;
+            if (!this.list) {
+                this.list = document.createElement('ul');
+                this.list.className = 'dropdownMenu';
                 createdList = true;
-                if (typeof this._options.callbackDropdownInit === 'function') {
-                    this._options.callbackDropdownInit(this._list);
+                if (typeof this.callbackDropdownInit === 'function') {
+                    this.callbackDropdownInit(this.list);
                 }
             }
             else {
                 // reset current list
-                this._list.innerHTML = '';
+                this.list.innerHTML = '';
             }
             if (typeof data.returnValues === 'object') {
-                var callbackClick = this._clickSelectItem.bind(this), listItem;
-                for (var key in data.returnValues) {
-                    if (data.returnValues.hasOwnProperty(key)) {
-                        listItem = this._createListItem(data.returnValues[key]);
-                        listItem.addEventListener(WCF_CLICK_EVENT, callbackClick);
-                        this._list.appendChild(listItem);
-                    }
-                }
+                const callbackClick = this.clickSelectItem.bind(this);
+                let listItem;
+                Object.keys(data.returnValues).forEach(key => {
+                    listItem = this.createListItem(data.returnValues[key]);
+                    listItem.addEventListener('click', callbackClick);
+                    this.list.appendChild(listItem);
+                });
             }
             if (createdList) {
-                DomUtil.insertAfter(this._list, this._element);
-                UiSimpleDropdown.initFragment(this._element.parentNode, this._list);
-                this._dropdownContainerId = DomUtil.identify(this._element.parentNode);
+                this.list.parentElement.insertBefore(this.element, this.list.nextSibling);
+                const parent = this.element.parentElement;
+                Simple_1.default.initFragment(parent, this.list);
+                this.dropdownContainerId = Util_1.default.identify(parent);
             }
-            if (this._dropdownContainerId) {
-                this._activeItem = null;
-                if (!this._list.childElementCount && this._handleEmptyResult() === false) {
-                    UiSimpleDropdown.close(this._dropdownContainerId);
+            if (this.dropdownContainerId) {
+                this.activeItem = undefined;
+                if (!this.list.childElementCount && !this.handleEmptyResult()) {
+                    Simple_1.default.close(this.dropdownContainerId);
                 }
                 else {
-                    UiSimpleDropdown.open(this._dropdownContainerId, true);
+                    Simple_1.default.open(this.dropdownContainerId, true);
                     // mark first item as active
-                    if (this._options.autoFocus && this._list.childElementCount && ~~elData(this._list.children[0], 'object-id')) {
-                        this._activeItem = this._list.children[0];
-                        this._activeItem.classList.add('active');
+                    const firstChild = this.list.childElementCount ? this.list.children[0] : undefined;
+                    if (this.autoFocus && firstChild && ~~(firstChild.dataset.objectId || '')) {
+                        this.activeItem = firstChild;
+                        this.activeItem.classList.add('active');
                     }
                 }
             }
-        },
+        }
         /**
          * Handles an empty result set, return a boolean false to hide the dropdown.
-         *
-         * @return      {boolean}      false to close the dropdown
-         * @protected
          */
-        _handleEmptyResult: function () {
-            if (!this._options.noResultPlaceholder) {
+        handleEmptyResult() {
+            if (!this.noResultPlaceholder) {
                 return false;
             }
-            var listItem = elCreate('li');
+            const listItem = document.createElement('li');
             listItem.className = 'dropdownText';
-            var span = elCreate('span');
-            span.textContent = this._options.noResultPlaceholder;
+            const span = document.createElement('span');
+            span.textContent = this.noResultPlaceholder;
             listItem.appendChild(span);
-            this._list.appendChild(listItem);
+            this.list.appendChild(listItem);
             return true;
-        },
+        }
         /**
          * Creates an list item from response data.
-         *
-         * @param       {Object}        item    response data
-         * @return      {Element}       list item
-         * @protected
          */
-        _createListItem: function (item) {
-            var listItem = elCreate('li');
-            elData(listItem, 'object-id', item.objectID);
-            elData(listItem, 'label', item.label);
-            var span = elCreate('span');
+        createListItem(item) {
+            const listItem = document.createElement('li');
+            listItem.dataset.objectId = item.objectID.toString();
+            listItem.dataset.label = item.label;
+            const span = document.createElement('span');
             span.textContent = item.label;
             listItem.appendChild(span);
             return listItem;
-        },
-        _ajaxSetup: function () {
+        }
+        _ajaxSetup() {
             return {
-                data: this._options.ajax
+                data: this.ajaxPayload,
             };
         }
-    };
+    }
     return UiSearchInput;
 });
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Search/Input.js b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Search/Input.js
deleted file mode 100644 (file)
index 245824f..0000000
+++ /dev/null
@@ -1,396 +0,0 @@
-/**
- * Provides suggestions using an input field, designed to work with `wcf\data\ISearchAction`.
- * 
- * @author     Alexander Ebert
- * @copyright  2001-2019 WoltLab GmbH
- * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module     WoltLabSuite/Core/Ui/Search/Input
- */
-define(['Ajax', 'Core', 'EventKey', 'Dom/Util', 'Ui/SimpleDropdown'], function(Ajax, Core, EventKey, DomUtil, UiSimpleDropdown) {
-       "use strict";
-       
-       /**
-        * @param       {Element}       element         target input[type="text"]
-        * @param       {Object}        options         search options and settings
-        * @constructor
-        */
-       function UiSearchInput(element, options) { this.init(element, options); }
-       UiSearchInput.prototype = {
-               /**
-                * Initializes the search input field.
-                * 
-                * @param       {Element}       element         target input[type="text"]
-                * @param       {Object}        options         search options and settings
-                */
-               init: function(element, options) {
-                       this._element = element;
-                       if (!(this._element instanceof Element)) {
-                               throw new TypeError("Expected a valid DOM element.");
-                       }
-                       else if (this._element.nodeName !== 'INPUT' || (this._element.type !== 'search' && this._element.type !== 'text')) {
-                               throw new Error('Expected an input[type="text"].');
-                       }
-                       
-                       this._activeItem = null;
-                       this._dropdownContainerId = '';
-                       this._lastValue = '';
-                       this._list = null;
-                       this._request = null;
-                       this._timerDelay = null;
-                       
-                       this._options = Core.extend({
-                               ajax: {
-                                       actionName: 'getSearchResultList',
-                                       className: '',
-                                       interfaceName: 'wcf\\data\\ISearchAction'
-                               },
-                               autoFocus: true,
-                               callbackDropdownInit: null,
-                               callbackSelect: null,
-                               delay: 500,
-                               excludedSearchValues: [],
-                               minLength: 3,
-                               noResultPlaceholder: '',
-                               preventSubmit: false
-                       }, options);
-                       
-                       // disable auto-complete as it collides with the suggestion dropdown
-                       elAttr(this._element, 'autocomplete', 'off');
-                       
-                       this._element.addEventListener('keydown', this._keydown.bind(this));
-                       this._element.addEventListener('keyup', this._keyup.bind(this));
-               },
-               
-               /**
-                * Adds an excluded search value.
-                * 
-                * @param       {string}        value   excluded value
-                */
-               addExcludedSearchValues: function (value) {
-                       if (this._options.excludedSearchValues.indexOf(value) === -1) {
-                               this._options.excludedSearchValues.push(value);
-                       }
-               },
-               
-               /**
-                * Removes a value from the excluded search values.
-                * 
-                * @param       {string}        value   excluded value
-                */
-               removeExcludedSearchValues: function (value) {
-                       var index = this._options.excludedSearchValues.indexOf(value);
-                       if (index !== -1) {
-                               this._options.excludedSearchValues.splice(index, 1);
-                       }
-               },
-               
-               /**
-                * Handles the 'keydown' event.
-                * 
-                * @param       {Event}         event           event object
-                * @protected
-                */
-               _keydown: function(event) {
-                       if ((this._activeItem !== null && UiSimpleDropdown.isOpen(this._dropdownContainerId)) || this._options.preventSubmit) {
-                               if (EventKey.Enter(event)) {
-                                       event.preventDefault();
-                               }
-                       }
-                       
-                       if (EventKey.ArrowUp(event) || EventKey.ArrowDown(event) || EventKey.Escape(event)) {
-                               event.preventDefault();
-                       }
-               },
-               
-               /**
-                * Handles the 'keyup' event, provides keyboard navigation and executes search queries.
-                * 
-                * @param       {Event}         event           event object
-                * @protected
-                */
-               _keyup: function(event) {
-                       // handle dropdown keyboard navigation
-                       if (this._activeItem !== null || !this._options.autoFocus) {
-                               if (UiSimpleDropdown.isOpen(this._dropdownContainerId)) {
-                                       if (EventKey.ArrowUp(event)) {
-                                               event.preventDefault();
-                                               
-                                               return this._keyboardPreviousItem();
-                                       }
-                                       else if (EventKey.ArrowDown(event)) {
-                                               event.preventDefault();
-                                               
-                                               return this._keyboardNextItem();
-                                       }
-                                       else if (EventKey.Enter(event)) {
-                                               event.preventDefault();
-                                               
-                                               return this._keyboardSelectItem();
-                                       }
-                               }
-                               else {
-                                       this._activeItem = null;
-                               }
-                       }
-                       
-                       // close list on escape
-                       if (EventKey.Escape(event)) {
-                               UiSimpleDropdown.close(this._dropdownContainerId);
-                               
-                               return;
-                       }
-                       
-                       var value = this._element.value.trim();
-                       if (this._lastValue === value) {
-                               // value did not change, e.g. previously it was "Test" and now it is "Test ",
-                               // but the trailing whitespace has been ignored
-                               return;
-                       }
-                       
-                       this._lastValue = value;
-                       
-                       if (value.length < this._options.minLength) {
-                               if (this._dropdownContainerId) {
-                                       UiSimpleDropdown.close(this._dropdownContainerId);
-                                       this._activeItem = null;
-                               }
-                               
-                               // value below threshold
-                               return;
-                       }
-                       
-                       if (this._options.delay) {
-                               if (this._timerDelay !== null) {
-                                       window.clearTimeout(this._timerDelay);
-                               }
-                               
-                               this._timerDelay = window.setTimeout((function() {
-                                       this._search(value);
-                               }).bind(this), this._options.delay);
-                       }
-                       else {
-                               this._search(value);
-                       }
-               },
-               
-               /**
-                * Queries the server with the provided search string.
-                * 
-                * @param       {string}        value   search string
-                * @protected
-                */
-               _search: function(value) {
-                       if (this._request) {
-                               this._request.abortPrevious();
-                       }
-                       
-                       this._request = Ajax.api(this, this._getParameters(value));
-               },
-               
-               /**
-                * Returns additional AJAX parameters.
-                * 
-                * @param       {string}        value   search string
-                * @return      {Object}        additional AJAX parameters
-                * @protected
-                */
-               _getParameters: function(value) {
-                       return {
-                               parameters: {
-                                       data: {
-                                               excludedSearchValues: this._options.excludedSearchValues,
-                                               searchString: value
-                                       }
-                               }
-                       };
-               },
-               
-               /**
-                * Selects the next dropdown item.
-                * 
-                * @protected
-                */
-               _keyboardNextItem: function() {
-                       var nextItem;
-                       
-                       if (this._activeItem !== null) {
-                               this._activeItem.classList.remove('active');
-                               
-                               if (this._activeItem.nextElementSibling) {
-                                       nextItem = this._activeItem.nextElementSibling;
-                               }
-                       }
-                       
-                       this._activeItem = nextItem || this._list.children[0];
-                       this._activeItem.classList.add('active');
-               },
-               
-               /**
-                * Selects the previous dropdown item.
-                * 
-                * @protected
-                */
-               _keyboardPreviousItem: function() {
-                       var nextItem;
-                       
-                       if (this._activeItem !== null) {
-                               this._activeItem.classList.remove('active');
-                               
-                               if (this._activeItem.previousElementSibling) {
-                                       nextItem = this._activeItem.previousElementSibling;
-                               }
-                       }
-                       
-                       this._activeItem = nextItem || this._list.children[this._list.childElementCount - 1];
-                       this._activeItem.classList.add('active');
-               },
-               
-               /**
-                * Selects the active item from the dropdown.
-                * 
-                * @protected
-                */
-               _keyboardSelectItem: function() {
-                       this._selectItem(this._activeItem);
-               },
-               
-               /**
-                * Selects an item from the dropdown by clicking it.
-                * 
-                * @param       {Event}         event           event object
-                * @protected
-                */
-               _clickSelectItem: function(event) {
-                       this._selectItem(event.currentTarget);
-               },
-               
-               /**
-                * Selects an item.
-                * 
-                * @param       {Element}       item    selected item
-                * @protected
-                */
-               _selectItem: function(item) {
-                       if (this._options.callbackSelect && this._options.callbackSelect(item) === false) {
-                               this._element.value = '';
-                       }
-                       else {
-                               this._element.value = elData(item, 'label');
-                       }
-                       
-                       this._activeItem = null;
-                       UiSimpleDropdown.close(this._dropdownContainerId);
-               },
-               
-               /**
-                * Handles successful AJAX requests.
-                * 
-                * @param       {Object}        data    response data
-                * @protected
-                */
-               _ajaxSuccess: function(data) {
-                       var createdList = false;
-                       if (this._list === null) {
-                               this._list = elCreate('ul');
-                               this._list.className = 'dropdownMenu';
-                               
-                               createdList = true;
-                               
-                               if (typeof this._options.callbackDropdownInit === 'function') {
-                                       this._options.callbackDropdownInit(this._list);
-                               }
-                       }
-                       else {
-                               // reset current list
-                               this._list.innerHTML = '';
-                       }
-                       
-                       if (typeof data.returnValues === 'object') {
-                               var callbackClick = this._clickSelectItem.bind(this), listItem;
-                               
-                               for (var key in data.returnValues) {
-                                       if (data.returnValues.hasOwnProperty(key)) {
-                                               listItem = this._createListItem(data.returnValues[key]);
-                                               
-                                               listItem.addEventListener(WCF_CLICK_EVENT, callbackClick);
-                                               this._list.appendChild(listItem);
-                                       }
-                               }
-                       }
-                       
-                       if (createdList) {
-                               DomUtil.insertAfter(this._list, this._element);
-                               UiSimpleDropdown.initFragment(this._element.parentNode, this._list);
-                               
-                               this._dropdownContainerId = DomUtil.identify(this._element.parentNode);
-                       }
-                       
-                       if (this._dropdownContainerId) {
-                               this._activeItem = null;
-                               
-                               if (!this._list.childElementCount && this._handleEmptyResult() === false) {
-                                       UiSimpleDropdown.close(this._dropdownContainerId);
-                               }
-                               else {
-                                       UiSimpleDropdown.open(this._dropdownContainerId, true);
-                                       
-                                       // mark first item as active
-                                       if (this._options.autoFocus && this._list.childElementCount && ~~elData(this._list.children[0], 'object-id')) {
-                                               this._activeItem = this._list.children[0];
-                                               this._activeItem.classList.add('active');
-                                       }
-                               }
-                       }
-               },
-               
-               /**
-                * Handles an empty result set, return a boolean false to hide the dropdown.
-                * 
-                * @return      {boolean}      false to close the dropdown
-                * @protected
-                */
-               _handleEmptyResult: function() {
-                       if (!this._options.noResultPlaceholder) {
-                               return false;
-                       }
-                       
-                       var listItem = elCreate('li');
-                       listItem.className = 'dropdownText';
-                       
-                       var span = elCreate('span');
-                       span.textContent = this._options.noResultPlaceholder;
-                       listItem.appendChild(span);
-                       
-                       this._list.appendChild(listItem);
-                       
-                       return true;
-               },
-               
-               /**
-                * Creates an list item from response data.
-                * 
-                * @param       {Object}        item    response data
-                * @return      {Element}       list item
-                * @protected
-                */
-               _createListItem: function(item) {
-                       var listItem = elCreate('li');
-                       elData(listItem, 'object-id', item.objectID);
-                       elData(listItem, 'label', item.label);
-                       
-                       var span = elCreate('span');
-                       span.textContent = item.label;
-                       listItem.appendChild(span);
-                       
-                       return listItem;
-               },
-               
-               _ajaxSetup: function() {
-                       return {
-                               data: this._options.ajax
-                       };
-               }
-       };
-       
-       return UiSearchInput;
-});
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Search/Input.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Search/Input.ts
new file mode 100644 (file)
index 0000000..fec4381
--- /dev/null
@@ -0,0 +1,386 @@
+/**
+ * Provides suggestions using an input field, designed to work with `wcf\data\ISearchAction`.
+ *
+ * @author  Alexander Ebert
+ * @copyright  2001-2019 WoltLab GmbH
+ * @license  GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module  WoltLabSuite/Core/Ui/Search/Input
+ */
+
+import * as Ajax from '../../Ajax';
+import * as Core from '../../Core';
+import DomUtil from '../../Dom/Util';
+import UiDropdownSimple from '../Dropdown/Simple';
+import { DatabaseObjectActionPayload, DatabaseObjectActionResponse } from '../../Ajax/Data';
+import AjaxRequest from '../../Ajax/Request';
+
+class UiSearchInput {
+  private activeItem?: HTMLLIElement = undefined;
+  private readonly ajaxPayload: DatabaseObjectActionPayload;
+  private readonly autoFocus: boolean;
+  private readonly callbackDropdownInit?: CallbackDropdownInit = undefined;
+  private readonly callbackSelect?: CallbackSelect = undefined;
+  private readonly delay: number;
+  private dropdownContainerId = '';
+  private readonly element: HTMLInputElement;
+  private readonly excludedSearchValues = new Set<string>();
+  private list?: HTMLUListElement = undefined;
+  private lastValue = '';
+  private readonly minLength: number;
+  private readonly noResultPlaceholder: string;
+  private readonly preventSubmit: boolean;
+  private request?: AjaxRequest = undefined;
+  private timerDelay?: number = undefined;
+
+  /**
+   * Initializes the search input field.
+   *
+   * @param       {Element}       element         target input[type="text"]
+   * @param       {Object}        options         search options and settings
+   */
+  constructor(element: HTMLInputElement, options: SearchInputOptions) {
+    this.element = element;
+    if (!(this.element instanceof HTMLInputElement)) {
+      throw new TypeError("Expected a valid DOM element.");
+    } else if (this.element.nodeName !== 'INPUT' || (this.element.type !== 'search' && this.element.type !== 'text')) {
+      throw new Error('Expected an input[type="text"].');
+    }
+
+    options = Core.extend({
+      ajax: {
+        actionName: 'getSearchResultList',
+        className: '',
+        interfaceName: 'wcf\\data\\ISearchAction',
+      },
+      autoFocus: true,
+      callbackDropdownInit: undefined,
+      callbackSelect: undefined,
+      delay: 500,
+      excludedSearchValues: [],
+      minLength: 3,
+      noResultPlaceholder: '',
+      preventSubmit: false,
+    }, options) as SearchInputOptions;
+
+    this.ajaxPayload = options.ajax;
+    this.autoFocus = options.autoFocus!;
+    this.callbackDropdownInit = options.callbackDropdownInit;
+    this.callbackSelect = options.callbackSelect;
+    this.delay = options.delay!;
+    options.excludedSearchValues!.forEach(value => {
+      this.addExcludedSearchValues(value);
+    });
+    this.minLength = options.minLength!;
+    this.noResultPlaceholder = options.noResultPlaceholder!;
+    this.preventSubmit = options.preventSubmit!;
+
+    // Disable auto-complete because it collides with the suggestion dropdown.
+    this.element.autocomplete = 'off';
+
+    this.element.addEventListener('keydown', this.keydown.bind(this));
+    this.element.addEventListener('keyup', this.keyup.bind(this));
+  }
+
+  /**
+   * Adds an excluded search value.
+   */
+  addExcludedSearchValues(value: string): void {
+    this.excludedSearchValues.add(value);
+  }
+
+  /**
+   * Removes a value from the excluded search values.
+   */
+  removeExcludedSearchValues(value: string): void {
+    this.excludedSearchValues.delete(value);
+  }
+
+  /**
+   * Handles the 'keydown' event.
+   */
+  private keydown(event: KeyboardEvent): void {
+    if ((this.activeItem !== null && UiDropdownSimple.isOpen(this.dropdownContainerId)) || this.preventSubmit) {
+      if (event.key === 'Enter') {
+        event.preventDefault();
+      }
+    }
+
+    if (['ArrowUp', 'ArrowDown', 'Escape'].includes(event.key)) {
+      event.preventDefault();
+    }
+  }
+
+  /**
+   * Handles the 'keyup' event, provides keyboard navigation and executes search queries.
+   */
+  private keyup(event: KeyboardEvent): void {
+    // handle dropdown keyboard navigation
+    if (this.activeItem !== null || !this.autoFocus) {
+      if (UiDropdownSimple.isOpen(this.dropdownContainerId)) {
+        if (event.key === 'ArrowUp') {
+          event.preventDefault();
+
+          return this.keyboardPreviousItem();
+        } else if (event.key === 'ArrowDown') {
+          event.preventDefault();
+
+          return this.keyboardNextItem();
+        } else if (event.key === 'Enter') {
+          event.preventDefault();
+
+          return this.keyboardSelectItem();
+        }
+      } else {
+        this.activeItem = undefined;
+      }
+    }
+
+    // close list on escape
+    if (event.key === 'Escape') {
+      UiDropdownSimple.close(this.dropdownContainerId);
+
+      return;
+    }
+
+    const value = this.element.value.trim();
+    if (this.lastValue === value) {
+      // value did not change, e.g. previously it was "Test" and now it is "Test ",
+      // but the trailing whitespace has been ignored
+      return;
+    }
+
+    this.lastValue = value;
+
+    if (value.length < this.minLength) {
+      if (this.dropdownContainerId) {
+        UiDropdownSimple.close(this.dropdownContainerId);
+        this.activeItem = undefined;
+      }
+
+      // value below threshold
+      return;
+    }
+
+    if (this.delay) {
+      if (this.timerDelay) {
+        window.clearTimeout(this.timerDelay);
+      }
+
+      this.timerDelay = window.setTimeout(() => {
+        this.search(value);
+      }, this.delay);
+    } else {
+      this.search(value);
+    }
+  }
+
+  /**
+   * Queries the server with the provided search string.
+   */
+  private search(value: string): void {
+    if (this.request) {
+      this.request.abortPrevious();
+    }
+
+    this.request = Ajax.api(this, this.getParameters(value));
+  }
+
+  /**
+   * Returns additional AJAX parameters.
+   */
+  private getParameters(value: string): Partial<DatabaseObjectActionPayload> {
+    return {
+      parameters: {
+        data: {
+          excludedSearchValues: this.excludedSearchValues,
+          searchString: value,
+        },
+      },
+    };
+  }
+
+  /**
+   * Selects the next dropdown item.
+   */
+  private keyboardNextItem(): void {
+    let nextItem: HTMLLIElement | undefined = undefined;
+
+    if (this.activeItem) {
+      this.activeItem.classList.remove('active');
+
+      if (this.activeItem.nextElementSibling) {
+        nextItem = this.activeItem.nextElementSibling as HTMLLIElement;
+      }
+    }
+
+    this.activeItem = nextItem || this.list!.children[0] as HTMLLIElement;
+    this.activeItem.classList.add('active');
+  }
+
+  /**
+   * Selects the previous dropdown item.
+   */
+  private keyboardPreviousItem(): void {
+    let nextItem: HTMLLIElement | undefined = undefined;
+
+    if (this.activeItem) {
+      this.activeItem.classList.remove('active');
+
+      if (this.activeItem.previousElementSibling) {
+        nextItem = this.activeItem.previousElementSibling as HTMLLIElement;
+      }
+    }
+
+    this.activeItem = nextItem || this.list!.children[this.list!.childElementCount - 1] as HTMLLIElement;
+    this.activeItem.classList.add('active');
+  }
+
+  /**
+   * Selects the active item from the dropdown.
+   */
+  private keyboardSelectItem(): void {
+    this.selectItem(this.activeItem!);
+  }
+
+  /**
+   * Selects an item from the dropdown by clicking it.
+   */
+  private clickSelectItem(event: MouseEvent): void {
+    this.selectItem(event.currentTarget as HTMLLIElement);
+  }
+
+  /**
+   * Selects an item.
+   */
+  private selectItem(item: HTMLLIElement): void {
+    if (this.callbackSelect && !this.callbackSelect(item)) {
+      this.element.value = '';
+    } else {
+      this.element.value = item.dataset.label || '';
+    }
+
+    this.activeItem = undefined;
+    UiDropdownSimple.close(this.dropdownContainerId);
+  }
+
+  /**
+   * Handles successful AJAX requests.
+   */
+  _ajaxSuccess(data: DatabaseObjectActionResponse): void {
+    let createdList = false;
+    if (!this.list) {
+      this.list = document.createElement('ul');
+      this.list.className = 'dropdownMenu';
+
+      createdList = true;
+
+      if (typeof this.callbackDropdownInit === 'function') {
+        this.callbackDropdownInit(this.list);
+      }
+    } else {
+      // reset current list
+      this.list.innerHTML = '';
+    }
+
+    if (typeof data.returnValues === 'object') {
+      const callbackClick = this.clickSelectItem.bind(this);
+      let listItem;
+
+      Object.keys(data.returnValues).forEach(key => {
+        listItem = this.createListItem(data.returnValues[key]);
+
+        listItem.addEventListener('click', callbackClick);
+        this.list!.appendChild(listItem);
+      });
+    }
+
+    if (createdList) {
+      this.list.parentElement!.insertBefore(this.element, this.list.nextSibling);
+      const parent = this.element.parentElement!;
+      UiDropdownSimple.initFragment(parent, this.list);
+
+      this.dropdownContainerId = DomUtil.identify(parent);
+    }
+
+    if (this.dropdownContainerId) {
+      this.activeItem = undefined;
+
+      if (!this.list.childElementCount && !this.handleEmptyResult()) {
+        UiDropdownSimple.close(this.dropdownContainerId);
+      } else {
+        UiDropdownSimple.open(this.dropdownContainerId, true);
+
+        // mark first item as active
+        const firstChild = this.list.childElementCount ? this.list.children[0] as HTMLLIElement : undefined;
+        if (this.autoFocus && firstChild && ~~(firstChild.dataset.objectId || '')) {
+          this.activeItem = firstChild;
+          this.activeItem.classList.add('active');
+        }
+      }
+    }
+  }
+
+  /**
+   * Handles an empty result set, return a boolean false to hide the dropdown.
+   */
+  private handleEmptyResult(): boolean {
+    if (!this.noResultPlaceholder) {
+      return false;
+    }
+
+    const listItem = document.createElement('li');
+    listItem.className = 'dropdownText';
+
+    const span = document.createElement('span');
+    span.textContent = this.noResultPlaceholder;
+    listItem.appendChild(span);
+
+    this.list!.appendChild(listItem);
+
+    return true;
+  }
+
+  /**
+   * Creates an list item from response data.
+   */
+  private createListItem(item: ListItemData): HTMLLIElement {
+    const listItem = document.createElement('li');
+    listItem.dataset.objectId = item.objectID.toString();
+    listItem.dataset.label = item.label;
+
+    const span = document.createElement('span');
+    span.textContent = item.label;
+    listItem.appendChild(span);
+
+    return listItem;
+  }
+
+  _ajaxSetup() {
+    return {
+      data: this.ajaxPayload,
+    };
+  }
+}
+
+export = UiSearchInput
+
+type CallbackDropdownInit = (list: HTMLUListElement) => void
+
+type CallbackSelect = (item: HTMLElement) => boolean
+
+interface SearchInputOptions {
+  ajax: DatabaseObjectActionPayload;
+  autoFocus?: boolean;
+  callbackDropdownInit?: CallbackDropdownInit;
+  callbackSelect?: CallbackSelect;
+  delay?: number;
+  excludedSearchValues?: string[];
+  minLength?: number;
+  noResultPlaceholder?: string;
+  preventSubmit?: boolean;
+}
+
+interface ListItemData {
+  label: string;
+  objectID: number;
+}