Convert `Ui/ItemList` to TypeScript
authorAlexander Ebert <ebert@woltlab.com>
Sun, 25 Oct 2020 14:13:44 +0000 (15: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/ItemList.js
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/ItemList.js [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/ItemList.ts [new file with mode: 0644]

index 6cf2104ed4de1f63fb52c95f03aa8671053a3e64..a0e6926e51f8fa6f5101ef5db929db460f2a9c7c 100644 (file)
 /**
  * Flexible UI element featuring both a list of items and an input field with suggestion support.
  *
- * @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/ItemList
+ * @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/ItemList
  */
-define(['Core', 'Dictionary', 'Language', 'Dom/Traverse', 'EventKey', 'WoltLabSuite/Core/Ui/Suggestion', 'Ui/SimpleDropdown'], function (Core, Dictionary, Language, DomTraverse, EventKey, UiSuggestion, 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", "../Core", "../Dom/Traverse", "../Language", "./Suggestion", "./Dropdown/Simple", "../Dom/Util"], function (require, exports, Core, DomTraverse, Language, Suggestion_1, Simple_1, Util_1) {
     "use strict";
-    var _activeId = '';
-    var _data = new Dictionary();
-    var _didInit = false;
-    var _callbackKeyDown = null;
-    var _callbackKeyPress = null;
-    var _callbackKeyUp = null;
-    var _callbackPaste = null;
-    var _callbackRemoveItem = null;
-    var _callbackBlur = null;
+    Object.defineProperty(exports, "__esModule", { value: true });
+    exports.setValues = exports.getValues = exports.init = void 0;
+    Core = __importStar(Core);
+    DomTraverse = __importStar(DomTraverse);
+    Language = __importStar(Language);
+    Suggestion_1 = __importDefault(Suggestion_1);
+    Simple_1 = __importDefault(Simple_1);
+    Util_1 = __importDefault(Util_1);
+    let _activeId = '';
+    const _data = new Map();
     /**
-     * @exports        WoltLabSuite/Core/Ui/ItemList
+     * Creates the DOM structure for target element. If `element` is a `<textarea>`
+     * it will be automatically replaced with an `<input>` element.
      */
-    return {
-        /**
-         * Initializes an item list.
-         *
-         * The `values` argument must be empty or contain a list of strings or object, e.g.
-         * `['foo', 'bar']` or `[{ objectId: 1337, value: 'baz'}, {...}]`
-         *
-         * @param      {string}        elementId       input element id
-         * @param      {Array}         values          list of existing values
-         * @param      {Object}        options         option list
-         */
-        init: function (elementId, values, options) {
-            var element = elById(elementId);
-            if (element === null) {
-                throw new Error("Expected a valid element id, '" + elementId + "' is invalid.");
-            }
-            // remove data from previous instance
-            if (_data.has(elementId)) {
-                var tmp = _data.get(elementId);
-                for (var key in tmp) {
-                    if (tmp.hasOwnProperty(key)) {
-                        var el = tmp[key];
-                        if (el instanceof Element && el.parentNode) {
-                            elRemove(el);
-                        }
-                    }
-                }
-                UiSimpleDropdown.destroy(elementId);
-                _data.delete(elementId);
-            }
-            options = Core.extend({
-                // search parameters for suggestions
-                ajax: {
-                    actionName: 'getSearchResultList',
-                    className: '',
-                    data: {}
-                },
-                // list of excluded string values, e.g. `['ignore', 'these strings', 'when', 'searching']`
-                excludedSearchValues: [],
-                // maximum number of items this list may contain, `-1` for infinite
-                maxItems: -1,
-                // maximum length of an item value, `-1` for infinite
-                maxLength: -1,
-                // disallow custom values, only values offered by the suggestion dropdown are accepted
-                restricted: false,
-                // initial value will be interpreted as comma separated value and submitted as such
-                isCSV: false,
-                // will be invoked whenever the items change, receives the element id first and list of values second
-                callbackChange: null,
-                // callback once the form is about to be submitted
-                callbackSubmit: null,
-                // Callback for the custom shadow synchronization.
-                callbackSyncShadow: null,
-                // Callback to set values during the setup.
-                callbackSetupValues: null,
-                // value may contain the placeholder `{$objectId}`
-                submitFieldName: ''
-            }, options);
-            var form = DomTraverse.parentByTag(element, 'FORM');
-            if (form !== null) {
-                if (options.isCSV === false) {
-                    if (!options.submitFieldName.length && typeof options.callbackSubmit !== 'function') {
-                        throw new Error("Expected a valid function for option 'callbackSubmit', a non-empty value for option 'submitFieldName' or enabling the option 'submitFieldCSV'.");
-                    }
-                    form.addEventListener('submit', (function () {
-                        if (this._acceptsNewItems(elementId)) {
-                            var value = _data.get(elementId).element.value.trim();
-                            if (value.length) {
-                                this._addItem(elementId, { objectId: 0, value: value });
-                            }
-                        }
-                        var values = this.getValues(elementId);
-                        if (options.submitFieldName.length) {
-                            var input;
-                            for (var i = 0, length = values.length; i < length; i++) {
-                                input = elCreate('input');
-                                input.type = 'hidden';
-                                input.name = options.submitFieldName.replace('{$objectId}', values[i].objectId);
-                                input.value = values[i].value;
-                                form.appendChild(input);
-                            }
-                        }
-                        else {
-                            options.callbackSubmit(form, values);
-                        }
-                    }).bind(this));
-                }
-                else {
-                    form.addEventListener('submit', function () {
-                        if (this._acceptsNewItems(elementId)) {
-                            var value = _data.get(elementId).element.value.trim();
-                            if (value.length) {
-                                this._addItem(elementId, { objectId: 0, value: value });
-                            }
-                        }
-                    }.bind(this));
+    function createUI(element, options) {
+        const parentElement = element.parentElement;
+        const list = document.createElement('ol');
+        list.className = 'inputItemList' + (element.disabled ? ' disabled' : '');
+        list.dataset.elementId = element.id;
+        list.addEventListener('click', event => {
+            if (event.target === list) {
+                element.focus();
+            }
+        });
+        const listItem = document.createElement('li');
+        listItem.className = 'input';
+        list.appendChild(listItem);
+        element.addEventListener('keydown', keyDown);
+        element.addEventListener('keypress', keyPress);
+        element.addEventListener('keyup', keyUp);
+        element.addEventListener('paste', paste);
+        const hasFocus = (element === document.activeElement);
+        if (hasFocus) {
+            element.blur();
+        }
+        element.addEventListener('blur', blur);
+        parentElement.insertBefore(list, element);
+        listItem.appendChild(element);
+        if (hasFocus) {
+            window.setTimeout(() => {
+                element.focus();
+            }, 1);
+        }
+        if (options.maxLength !== -1) {
+            element.maxLength = options.maxLength;
+        }
+        const limitReached = document.createElement('span');
+        limitReached.className = 'inputItemListLimitReached';
+        limitReached.textContent = Language.get('wcf.global.form.input.maxItems');
+        Util_1.default.hide(limitReached);
+        listItem.appendChild(limitReached);
+        let shadow = null;
+        const values = [];
+        if (options.isCSV) {
+            shadow = document.createElement('input');
+            shadow.className = 'itemListInputShadow';
+            shadow.type = 'hidden';
+            shadow.name = element.name;
+            element.removeAttribute('name');
+            list.parentNode.insertBefore(shadow, list);
+            element.value.split(',').forEach(value => {
+                value = value.trim();
+                if (value) {
+                    values.push(value);
                 }
-            }
-            this._setup();
-            var data = this._createUI(element, options);
-            //noinspection JSUnresolvedVariable
-            var suggestion = new UiSuggestion(elementId, {
-                ajax: options.ajax,
-                callbackSelect: this._addItem.bind(this),
-                excludedSearchValues: options.excludedSearchValues
             });
-            _data.set(elementId, {
-                dropdownMenu: null,
-                element: data.element,
-                limitReached: data.limitReached,
-                list: data.list,
-                listItem: data.element.parentNode,
-                options: options,
-                shadow: data.shadow,
-                suggestion: suggestion
-            });
-            if (options.callbackSetupValues) {
-                values = options.callbackSetupValues();
-            }
-            else {
-                values = (data.values.length) ? data.values : values;
+            if (element.nodeName === 'TEXTAREA') {
+                const inputElement = document.createElement('input');
+                inputElement.type = 'text';
+                parentElement.insertBefore(inputElement, element);
+                inputElement.id = element.id;
+                element.remove();
+                element = inputElement;
             }
-            if (Array.isArray(values)) {
-                var value;
-                for (var i = 0, length = values.length; i < length; i++) {
-                    value = values[i];
-                    if (typeof value === 'string') {
-                        value = { objectId: 0, value: value };
+        }
+        return {
+            element: element,
+            limitReached: limitReached,
+            list: list,
+            shadow: shadow,
+            values: values,
+        };
+    }
+    /**
+     * Returns true if the input accepts new items.
+     */
+    function acceptsNewItems(elementId) {
+        const data = _data.get(elementId);
+        if (data.options.maxItems === -1) {
+            return true;
+        }
+        return (data.list.childElementCount - 1 < data.options.maxItems);
+    }
+    /**
+     * Enforces the maximum number of items.
+     */
+    function handleLimit(elementId) {
+        const data = _data.get(elementId);
+        if (acceptsNewItems(elementId)) {
+            Util_1.default.show(data.element);
+            Util_1.default.hide(data.limitReached);
+        }
+        else {
+            Util_1.default.hide(data.element);
+            Util_1.default.show(data.limitReached);
+        }
+    }
+    /**
+     * Sets the active item list id and handles keyboard access to remove an existing item.
+     */
+    function keyDown(event) {
+        const input = event.currentTarget;
+        _activeId = input.id;
+        const lastItem = input.parentElement.previousElementSibling;
+        if (event.key === 'Backspace') {
+            if (input.value.length === 0) {
+                if (lastItem !== null) {
+                    if (lastItem.classList.contains('active')) {
+                        removeItem(lastItem);
+                    }
+                    else {
+                        lastItem.classList.add('active');
                     }
-                    this._addItem(elementId, value);
                 }
             }
-        },
-        /**
-         * Returns the list of current values.
-         *
-         * @param      {string}        elementId       input element id
-         * @return     {Array}         list of objects containing object id and value
-         */
-        getValues: function (elementId) {
-            if (!_data.has(elementId)) {
-                throw new Error("Element id '" + elementId + "' is unknown.");
-            }
-            var data = _data.get(elementId);
-            var values = [];
-            elBySelAll('.item > span', data.list, function (span) {
-                values.push({
-                    objectId: ~~elData(span, 'object-id'),
-                    value: span.textContent.trim(),
-                    type: elData(span, 'type')
-                });
-            });
-            return values;
-        },
-        /**
-         * Sets the list of current values.
-         *
-         * @param      {string}        elementId       input element id
-         * @param      {Array}         values          list of objects containing object id and value
-         */
-        setValues: function (elementId, values) {
-            if (!_data.has(elementId)) {
-                throw new Error("Element id '" + elementId + "' is unknown.");
+        }
+        else if (event.key === 'Escape') {
+            if (lastItem !== null && lastItem.classList.contains('active')) {
+                lastItem.classList.remove('active');
             }
-            var data = _data.get(elementId);
-            // remove all existing items first
-            var i, length;
-            var items = DomTraverse.childrenByClass(data.list, 'item');
-            for (i = 0, length = items.length; i < length; i++) {
-                this._removeItem(null, items[i], true);
+        }
+    }
+    /**
+     * Handles the `[ENTER]` and `[,]` key to add an item to the list unless it is restricted.
+     */
+    function keyPress(event) {
+        if (event.key === 'Enter' || event.key === ',') {
+            event.preventDefault();
+            const input = event.currentTarget;
+            if (_data.get(input.id).options.restricted) {
+                // restricted item lists only allow results from the dropdown to be picked
+                return;
             }
-            // add new items
-            for (i = 0, length = values.length; i < length; i++) {
-                this._addItem(elementId, values[i]);
+            const value = input.value.trim();
+            if (value.length) {
+                addItem(input.id, { objectId: 0, value: value });
             }
-        },
-        /**
-         * Binds static event listeners.
-         */
-        _setup: function () {
-            if (_didInit) {
-                return;
+        }
+    }
+    /**
+     * Splits comma-separated values being pasted into the input field.
+     */
+    function paste(event) {
+        event.preventDefault();
+        const text = event.clipboardData.getData('text/plain');
+        const element = event.currentTarget;
+        const elementId = element.id;
+        const maxLength = +element.maxLength;
+        text.split(/,/).forEach(item => {
+            item = item.trim();
+            if (maxLength && item.length > maxLength) {
+                // truncating items provides a better UX than throwing an error or silently discarding it
+                item = item.substr(0, maxLength);
+            }
+            if (item.length > 0 && acceptsNewItems(elementId)) {
+                addItem(elementId, { objectId: 0, value: item });
+            }
+        });
+    }
+    /**
+     * Handles the keyup event to unmark an item for deletion.
+     */
+    function keyUp(event) {
+        const input = event.currentTarget;
+        if (input.value.length > 0) {
+            const lastItem = input.parentElement.previousElementSibling;
+            if (lastItem !== null) {
+                lastItem.classList.remove('active');
             }
-            _didInit = true;
-            _callbackKeyDown = this._keyDown.bind(this);
-            _callbackKeyPress = this._keyPress.bind(this);
-            _callbackKeyUp = this._keyUp.bind(this);
-            _callbackPaste = this._paste.bind(this);
-            _callbackRemoveItem = this._removeItem.bind(this);
-            _callbackBlur = this._blur.bind(this);
-        },
-        /**
-         * Creates the DOM structure for target element. If `element` is a `<textarea>`
-         * it will be automatically replaced with an `<input>` element.
-         *
-         * @param      {Element}       element         input element
-         * @param      {Object}        options         option list
-         */
-        _createUI: function (element, options) {
-            var list = elCreate('ol');
-            list.className = 'inputItemList' + (element.disabled ? ' disabled' : '');
-            elData(list, 'element-id', element.id);
-            list.addEventListener(WCF_CLICK_EVENT, function (event) {
-                if (event.target === list) {
-                    //noinspection JSUnresolvedFunction
-                    element.focus();
-                }
-            });
-            var listItem = elCreate('li');
-            listItem.className = 'input';
-            list.appendChild(listItem);
-            element.addEventListener('keydown', _callbackKeyDown);
-            element.addEventListener('keypress', _callbackKeyPress);
-            element.addEventListener('keyup', _callbackKeyUp);
-            element.addEventListener('paste', _callbackPaste);
-            var hasFocus = element === document.activeElement;
-            if (hasFocus) {
-                //noinspection JSUnresolvedFunction
-                element.blur();
+        }
+    }
+    /**
+     * Adds an item to the list.
+     */
+    function addItem(elementId, value) {
+        const data = _data.get(elementId);
+        const listItem = document.createElement('li');
+        listItem.className = 'item';
+        const content = document.createElement('span');
+        content.className = 'content';
+        content.dataset.objectId = value.objectId.toString();
+        if (value.type) {
+            content.dataset.type = value.type;
+        }
+        content.textContent = value.value;
+        listItem.appendChild(content);
+        if (!data.element.disabled) {
+            const button = document.createElement('a');
+            button.className = 'icon icon16 fa-times';
+            button.addEventListener('click', removeItem);
+            listItem.appendChild(button);
+        }
+        data.list.insertBefore(listItem, data.listItem);
+        data.suggestion.addExcludedValue(value.value);
+        data.element.value = '';
+        if (!data.element.disabled) {
+            handleLimit(elementId);
+        }
+        let values = syncShadow(data);
+        if (typeof data.options.callbackChange === 'function') {
+            if (values === null) {
+                values = getValues(elementId);
             }
-            element.addEventListener('blur', _callbackBlur);
-            element.parentNode.insertBefore(list, element);
-            listItem.appendChild(element);
-            if (hasFocus) {
-                window.setTimeout(function () {
-                    //noinspection JSUnresolvedFunction
-                    element.focus();
-                }, 1);
+            data.options.callbackChange(elementId, values);
+        }
+    }
+    /**
+     * Removes an item from the list.
+     */
+    function removeItem(item, noFocus) {
+        if (item instanceof Event) {
+            const target = item.currentTarget;
+            item = target.parentElement;
+        }
+        const parent = item.parentElement;
+        const elementId = parent.dataset.elementId || '';
+        const data = _data.get(elementId);
+        if (item.children[0].textContent) {
+            data.suggestion.removeExcludedValue(item.children[0].textContent);
+        }
+        item.remove();
+        if (!noFocus) {
+            data.element.focus();
+        }
+        handleLimit(elementId);
+        let values = syncShadow(data);
+        if (typeof data.options.callbackChange === 'function') {
+            if (values === null) {
+                values = getValues(elementId);
             }
-            if (options.maxLength !== -1) {
-                elAttr(element, 'maxLength', options.maxLength);
+            data.options.callbackChange(elementId, values);
+        }
+    }
+    /**
+     * Synchronizes the shadow input field with the current list item values.
+     */
+    function syncShadow(data) {
+        if (!data.options.isCSV) {
+            return null;
+        }
+        if (typeof data.options.callbackSyncShadow === 'function') {
+            return data.options.callbackSyncShadow(data);
+        }
+        const values = getValues(data.element.id);
+        data.shadow.value = getValues(data.element.id)
+            .map(value => value.value)
+            .join(',');
+        return values;
+    }
+    /**
+     * Handles the blur event.
+     */
+    function blur(event) {
+        const input = event.currentTarget;
+        const data = _data.get(input.id);
+        if (data.options.restricted) {
+            // restricted item lists only allow results from the dropdown to be picked
+            return;
+        }
+        const value = input.value.trim();
+        if (value.length) {
+            if (!data.suggestion || !data.suggestion.isActive()) {
+                addItem(input.id, { objectId: 0, value: value });
             }
-            var limitReached = elCreate('span');
-            limitReached.className = 'inputItemListLimitReached';
-            limitReached.textContent = Language.get('wcf.global.form.input.maxItems');
-            elHide(limitReached);
-            listItem.appendChild(limitReached);
-            var shadow = null, values = [];
-            if (options.isCSV) {
-                shadow = elCreate('input');
-                shadow.className = 'itemListInputShadow';
-                shadow.type = 'hidden';
-                //noinspection JSUnresolvedVariable
-                shadow.name = element.name;
-                element.removeAttribute('name');
-                list.parentNode.insertBefore(shadow, list);
-                //noinspection JSUnresolvedVariable
-                var value, tmp = element.value.split(',');
-                for (var i = 0, length = tmp.length; i < length; i++) {
-                    value = tmp[i].trim();
-                    if (value.length) {
-                        values.push(value);
-                    }
+        }
+    }
+    /**
+     * Initializes an item list.
+     *
+     * The `values` argument must be empty or contain a list of strings or object, e.g.
+     * `['foo', 'bar']` or `[{ objectId: 1337, value: 'baz'}, {...}]`
+     */
+    function init(elementId, values, options) {
+        const element = document.getElementById(elementId);
+        if (element === null) {
+            throw new Error("Expected a valid element id, '" + elementId + "' is invalid.");
+        }
+        // remove data from previous instance
+        if (_data.has(elementId)) {
+            const tmp = _data.get(elementId);
+            Object.keys(tmp).forEach(key => {
+                const el = tmp[key];
+                if (el instanceof Element && el.parentNode) {
+                    el.remove();
                 }
-                if (element.nodeName === 'TEXTAREA') {
-                    var inputElement = elCreate('input');
-                    inputElement.type = 'text';
-                    element.parentNode.insertBefore(inputElement, element);
-                    inputElement.id = element.id;
-                    elRemove(element);
-                    element = inputElement;
+            });
+            Simple_1.default.destroy(elementId);
+            _data.delete(elementId);
+        }
+        options = Core.extend({
+            // search parameters for suggestions
+            ajax: {
+                actionName: 'getSearchResultList',
+                className: '',
+                data: {},
+            },
+            // list of excluded string values, e.g. `['ignore', 'these strings', 'when', 'searching']`
+            excludedSearchValues: [],
+            // maximum number of items this list may contain, `-1` for infinite
+            maxItems: -1,
+            // maximum length of an item value, `-1` for infinite
+            maxLength: -1,
+            // disallow custom values, only values offered by the suggestion dropdown are accepted
+            restricted: false,
+            // initial value will be interpreted as comma separated value and submitted as such
+            isCSV: false,
+            // will be invoked whenever the items change, receives the element id first and list of values second
+            callbackChange: null,
+            // callback once the form is about to be submitted
+            callbackSubmit: null,
+            // Callback for the custom shadow synchronization.
+            callbackSyncShadow: null,
+            // Callback to set values during the setup.
+            callbackSetupValues: null,
+            // value may contain the placeholder `{$objectId}`
+            submitFieldName: '',
+        }, options);
+        const form = DomTraverse.parentByTag(element, 'FORM');
+        if (form !== null) {
+            if (!options.isCSV) {
+                if (!options.submitFieldName.length && typeof options.callbackSubmit !== 'function') {
+                    throw new Error("Expected a valid function for option 'callbackSubmit', a non-empty value for option 'submitFieldName' or enabling the option 'submitFieldCSV'.");
                 }
-            }
-            return {
-                element: element,
-                limitReached: limitReached,
-                list: list,
-                shadow: shadow,
-                values: values
-            };
-        },
-        /**
-         * Returns true if the input accepts new items.
-         *
-         * @param       {string}        elementId       input element id
-         * @return      {boolean}       true if at least one more item can be added
-         * @protected
-         */
-        _acceptsNewItems: function (elementId) {
-            var data = _data.get(elementId);
-            if (data.options.maxItems === -1) {
-                return true;
-            }
-            return (data.list.childElementCount - 1 < data.options.maxItems);
-        },
-        /**
-         * Enforces the maximum number of items.
-         *
-         * @param      {string}        elementId       input element id
-         */
-        _handleLimit: function (elementId) {
-            var data = _data.get(elementId);
-            if (this._acceptsNewItems(elementId)) {
-                elShow(data.element);
-                elHide(data.limitReached);
-            }
-            else {
-                elHide(data.element);
-                elShow(data.limitReached);
-            }
-        },
-        /**
-         * Sets the active item list id and handles keyboard access to remove an existing item.
-         *
-         * @param      {object}        event           event object
-         */
-        _keyDown: function (event) {
-            var input = event.currentTarget;
-            var lastItem = input.parentNode.previousElementSibling;
-            _activeId = input.id;
-            if (event.keyCode === 8) {
-                // 8 = [BACKSPACE]
-                if (input.value.length === 0) {
-                    if (lastItem !== null) {
-                        if (lastItem.classList.contains('active')) {
-                            this._removeItem(null, lastItem);
-                        }
-                        else {
-                            lastItem.classList.add('active');
+                form.addEventListener('submit', () => {
+                    if (acceptsNewItems(elementId)) {
+                        const value = _data.get(elementId).element.value.trim();
+                        if (value.length) {
+                            addItem(elementId, { objectId: 0, value: value });
                         }
                     }
-                }
-            }
-            else if (event.keyCode === 27) {
-                // 27 = [ESC]
-                if (lastItem !== null && lastItem.classList.contains('active')) {
-                    lastItem.classList.remove('active');
-                }
-            }
-        },
-        /**
-         * Handles the `[ENTER]` and `[,]` key to add an item to the list unless it is restricted.
-         *
-         * @param      {Event}         event           event object
-         */
-        _keyPress: function (event) {
-            if (EventKey.Enter(event) || EventKey.Comma(event)) {
-                event.preventDefault();
-                if (_data.get(event.currentTarget.id).options.restricted) {
-                    // restricted item lists only allow results from the dropdown to be picked
-                    return;
-                }
-                var value = event.currentTarget.value.trim();
-                if (value.length) {
-                    this._addItem(event.currentTarget.id, { objectId: 0, value: value });
-                }
-            }
-        },
-        /**
-         * Splits comma-separated values being pasted into the input field.
-         *
-         * @param       {Event}         event
-         * @protected
-         */
-        _paste: function (event) {
-            var text = '';
-            if (typeof window.clipboardData === 'object') {
-                // IE11
-                text = window.clipboardData.getData('Text');
+                    const values = getValues(elementId);
+                    if (options.submitFieldName.length) {
+                        values.forEach(value => {
+                            const input = document.createElement('input');
+                            input.type = 'hidden';
+                            input.name = options.submitFieldName.replace('{$objectId}', value.objectId.toString());
+                            input.value = value.value;
+                            form.appendChild(input);
+                        });
+                    }
+                    else {
+                        options.callbackSubmit(form, values);
+                    }
+                });
             }
             else {
-                text = event.clipboardData.getData('text/plain');
-            }
-            var element = event.currentTarget;
-            var elementId = element.id;
-            var maxLength = ~~elAttr(element, 'maxLength');
-            text.split(/,/).forEach((function (item) {
-                item = item.trim();
-                if (maxLength && item.length > maxLength) {
-                    // truncating items provides a better UX than throwing an error or silently discarding it
-                    item = item.substr(0, maxLength);
-                }
-                if (item.length > 0 && this._acceptsNewItems(elementId)) {
-                    this._addItem(elementId, { objectId: 0, value: item });
-                }
-            }).bind(this));
-            event.preventDefault();
-        },
-        /**
-         * Handles the keyup event to unmark an item for deletion.
-         *
-         * @param      {object}        event           event object
-         */
-        _keyUp: function (event) {
-            var input = event.currentTarget;
-            if (input.value.length > 0) {
-                var lastItem = input.parentNode.previousElementSibling;
-                if (lastItem !== null) {
-                    lastItem.classList.remove('active');
-                }
-            }
-        },
-        /**
-         * Adds an item to the list.
-         *
-         * @param      {string}        elementId       input element id
-         * @param      {object}        value           item value
-         */
-        _addItem: function (elementId, value) {
-            var data = _data.get(elementId);
-            var listItem = elCreate('li');
-            listItem.className = 'item';
-            var content = elCreate('span');
-            content.className = 'content';
-            elData(content, 'object-id', value.objectId);
-            if (value.type)
-                elData(content, 'type', value.type);
-            content.textContent = value.value;
-            listItem.appendChild(content);
-            if (!data.element.disabled) {
-                var button = elCreate('a');
-                button.className = 'icon icon16 fa-times';
-                button.addEventListener(WCF_CLICK_EVENT, _callbackRemoveItem);
-                listItem.appendChild(button);
-            }
-            data.list.insertBefore(listItem, data.listItem);
-            data.suggestion.addExcludedValue(value.value);
-            data.element.value = '';
-            if (!data.element.disabled) {
-                this._handleLimit(elementId);
-            }
-            var values = this._syncShadow(data);
-            if (typeof data.options.callbackChange === 'function') {
-                if (values === null)
-                    values = this.getValues(elementId);
-                data.options.callbackChange(elementId, values);
-            }
-        },
-        /**
-         * Removes an item from the list.
-         *
-         * @param      {?object}       event           event object
-         * @param      {Element?}      item            list item
-         * @param      {boolean?}      noFocus         input element will not be focused if true
-         */
-        _removeItem: function (event, item, noFocus) {
-            item = (event === null) ? item : event.currentTarget.parentNode;
-            var parent = item.parentNode;
-            //noinspection JSCheckFunctionSignatures
-            var elementId = elData(parent, 'element-id');
-            var data = _data.get(elementId);
-            data.suggestion.removeExcludedValue(item.children[0].textContent);
-            parent.removeChild(item);
-            if (!noFocus)
-                data.element.focus();
-            this._handleLimit(elementId);
-            var values = this._syncShadow(data);
-            if (typeof data.options.callbackChange === 'function') {
-                if (values === null)
-                    values = this.getValues(elementId);
-                data.options.callbackChange(elementId, values);
-            }
-        },
-        /**
-         * Synchronizes the shadow input field with the current list item values.
-         *
-         * @param      {object}        data            element data
-         */
-        _syncShadow: function (data) {
-            if (!data.options.isCSV)
-                return null;
-            if (typeof data.options.callbackSyncShadow === 'function') {
-                return data.options.callbackSyncShadow(data);
-            }
-            var value = '', values = this.getValues(data.element.id);
-            for (var i = 0, length = values.length; i < length; i++) {
-                value += (value.length ? ',' : '') + values[i].value;
-            }
-            data.shadow.value = value;
-            return values;
-        },
-        /**
-         * Handles the blur event.
-         *
-         * @param      {object}        event           event object
-         */
-        _blur: function (event) {
-            var input = event.currentTarget;
-            var data = _data.get(input.id);
-            if (data.options.restricted) {
-                // restricted item lists only allow results from the dropdown to be picked
-                return;
+                form.addEventListener('submit', () => {
+                    if (acceptsNewItems(elementId)) {
+                        const value = _data.get(elementId).element.value.trim();
+                        if (value.length) {
+                            addItem(elementId, { objectId: 0, value: value });
+                        }
+                    }
+                });
             }
-            var value = input.value.trim();
-            if (value.length) {
-                if (!data.suggestion || !data.suggestion.isActive()) {
-                    this._addItem(input.id, { objectId: 0, value: value });
+        }
+        const data = createUI(element, options);
+        const suggestion = new Suggestion_1.default(elementId, {
+            ajax: options.ajax,
+            callbackSelect: addItem,
+            excludedSearchValues: options.excludedSearchValues,
+        });
+        _data.set(elementId, {
+            dropdownMenu: null,
+            element: data.element,
+            limitReached: data.limitReached,
+            list: data.list,
+            listItem: data.element.parentElement,
+            options: options,
+            shadow: data.shadow,
+            suggestion: suggestion,
+        });
+        if (options.callbackSetupValues) {
+            values = options.callbackSetupValues();
+        }
+        else {
+            values = (data.values.length) ? data.values : values;
+        }
+        if (Array.isArray(values)) {
+            values.forEach(value => {
+                if (typeof value === 'string') {
+                    value = { objectId: 0, value: value };
                 }
-            }
+                addItem(elementId, value);
+            });
+        }
+    }
+    exports.init = init;
+    /**
+     * Returns the list of current values.
+     */
+    function getValues(elementId) {
+        const data = _data.get(elementId);
+        if (!data) {
+            throw new Error("Element id '" + elementId + "' is unknown.");
+        }
+        const values = [];
+        data.list.querySelectorAll('.item > span').forEach((span) => {
+            values.push({
+                objectId: +(span.dataset.objectId || ''),
+                value: span.textContent.trim(),
+                type: span.dataset.type,
+            });
+        });
+        return values;
+    }
+    exports.getValues = getValues;
+    /**
+     * Sets the list of current values.
+     */
+    function setValues(elementId, values) {
+        const data = _data.get(elementId);
+        if (!data) {
+            throw new Error("Element id '" + elementId + "' is unknown.");
         }
-    };
+        // remove all existing items first
+        DomTraverse.childrenByClass(data.list, 'item').forEach((item) => {
+            removeItem(item, true);
+        });
+        // add new items
+        values.forEach(value => {
+            addItem(elementId, value);
+        });
+    }
+    exports.setValues = setValues;
 });
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/ItemList.js b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/ItemList.js
deleted file mode 100644 (file)
index 6cf2104..0000000
+++ /dev/null
@@ -1,514 +0,0 @@
-/**
- * Flexible UI element featuring both a list of items and an input field with suggestion support.
- *
- * @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/ItemList
- */
-define(['Core', 'Dictionary', 'Language', 'Dom/Traverse', 'EventKey', 'WoltLabSuite/Core/Ui/Suggestion', 'Ui/SimpleDropdown'], function (Core, Dictionary, Language, DomTraverse, EventKey, UiSuggestion, UiSimpleDropdown) {
-    "use strict";
-    var _activeId = '';
-    var _data = new Dictionary();
-    var _didInit = false;
-    var _callbackKeyDown = null;
-    var _callbackKeyPress = null;
-    var _callbackKeyUp = null;
-    var _callbackPaste = null;
-    var _callbackRemoveItem = null;
-    var _callbackBlur = null;
-    /**
-     * @exports        WoltLabSuite/Core/Ui/ItemList
-     */
-    return {
-        /**
-         * Initializes an item list.
-         *
-         * The `values` argument must be empty or contain a list of strings or object, e.g.
-         * `['foo', 'bar']` or `[{ objectId: 1337, value: 'baz'}, {...}]`
-         *
-         * @param      {string}        elementId       input element id
-         * @param      {Array}         values          list of existing values
-         * @param      {Object}        options         option list
-         */
-        init: function (elementId, values, options) {
-            var element = elById(elementId);
-            if (element === null) {
-                throw new Error("Expected a valid element id, '" + elementId + "' is invalid.");
-            }
-            // remove data from previous instance
-            if (_data.has(elementId)) {
-                var tmp = _data.get(elementId);
-                for (var key in tmp) {
-                    if (tmp.hasOwnProperty(key)) {
-                        var el = tmp[key];
-                        if (el instanceof Element && el.parentNode) {
-                            elRemove(el);
-                        }
-                    }
-                }
-                UiSimpleDropdown.destroy(elementId);
-                _data.delete(elementId);
-            }
-            options = Core.extend({
-                // search parameters for suggestions
-                ajax: {
-                    actionName: 'getSearchResultList',
-                    className: '',
-                    data: {}
-                },
-                // list of excluded string values, e.g. `['ignore', 'these strings', 'when', 'searching']`
-                excludedSearchValues: [],
-                // maximum number of items this list may contain, `-1` for infinite
-                maxItems: -1,
-                // maximum length of an item value, `-1` for infinite
-                maxLength: -1,
-                // disallow custom values, only values offered by the suggestion dropdown are accepted
-                restricted: false,
-                // initial value will be interpreted as comma separated value and submitted as such
-                isCSV: false,
-                // will be invoked whenever the items change, receives the element id first and list of values second
-                callbackChange: null,
-                // callback once the form is about to be submitted
-                callbackSubmit: null,
-                // Callback for the custom shadow synchronization.
-                callbackSyncShadow: null,
-                // Callback to set values during the setup.
-                callbackSetupValues: null,
-                // value may contain the placeholder `{$objectId}`
-                submitFieldName: ''
-            }, options);
-            var form = DomTraverse.parentByTag(element, 'FORM');
-            if (form !== null) {
-                if (options.isCSV === false) {
-                    if (!options.submitFieldName.length && typeof options.callbackSubmit !== 'function') {
-                        throw new Error("Expected a valid function for option 'callbackSubmit', a non-empty value for option 'submitFieldName' or enabling the option 'submitFieldCSV'.");
-                    }
-                    form.addEventListener('submit', (function () {
-                        if (this._acceptsNewItems(elementId)) {
-                            var value = _data.get(elementId).element.value.trim();
-                            if (value.length) {
-                                this._addItem(elementId, { objectId: 0, value: value });
-                            }
-                        }
-                        var values = this.getValues(elementId);
-                        if (options.submitFieldName.length) {
-                            var input;
-                            for (var i = 0, length = values.length; i < length; i++) {
-                                input = elCreate('input');
-                                input.type = 'hidden';
-                                input.name = options.submitFieldName.replace('{$objectId}', values[i].objectId);
-                                input.value = values[i].value;
-                                form.appendChild(input);
-                            }
-                        }
-                        else {
-                            options.callbackSubmit(form, values);
-                        }
-                    }).bind(this));
-                }
-                else {
-                    form.addEventListener('submit', function () {
-                        if (this._acceptsNewItems(elementId)) {
-                            var value = _data.get(elementId).element.value.trim();
-                            if (value.length) {
-                                this._addItem(elementId, { objectId: 0, value: value });
-                            }
-                        }
-                    }.bind(this));
-                }
-            }
-            this._setup();
-            var data = this._createUI(element, options);
-            //noinspection JSUnresolvedVariable
-            var suggestion = new UiSuggestion(elementId, {
-                ajax: options.ajax,
-                callbackSelect: this._addItem.bind(this),
-                excludedSearchValues: options.excludedSearchValues
-            });
-            _data.set(elementId, {
-                dropdownMenu: null,
-                element: data.element,
-                limitReached: data.limitReached,
-                list: data.list,
-                listItem: data.element.parentNode,
-                options: options,
-                shadow: data.shadow,
-                suggestion: suggestion
-            });
-            if (options.callbackSetupValues) {
-                values = options.callbackSetupValues();
-            }
-            else {
-                values = (data.values.length) ? data.values : values;
-            }
-            if (Array.isArray(values)) {
-                var value;
-                for (var i = 0, length = values.length; i < length; i++) {
-                    value = values[i];
-                    if (typeof value === 'string') {
-                        value = { objectId: 0, value: value };
-                    }
-                    this._addItem(elementId, value);
-                }
-            }
-        },
-        /**
-         * Returns the list of current values.
-         *
-         * @param      {string}        elementId       input element id
-         * @return     {Array}         list of objects containing object id and value
-         */
-        getValues: function (elementId) {
-            if (!_data.has(elementId)) {
-                throw new Error("Element id '" + elementId + "' is unknown.");
-            }
-            var data = _data.get(elementId);
-            var values = [];
-            elBySelAll('.item > span', data.list, function (span) {
-                values.push({
-                    objectId: ~~elData(span, 'object-id'),
-                    value: span.textContent.trim(),
-                    type: elData(span, 'type')
-                });
-            });
-            return values;
-        },
-        /**
-         * Sets the list of current values.
-         *
-         * @param      {string}        elementId       input element id
-         * @param      {Array}         values          list of objects containing object id and value
-         */
-        setValues: function (elementId, values) {
-            if (!_data.has(elementId)) {
-                throw new Error("Element id '" + elementId + "' is unknown.");
-            }
-            var data = _data.get(elementId);
-            // remove all existing items first
-            var i, length;
-            var items = DomTraverse.childrenByClass(data.list, 'item');
-            for (i = 0, length = items.length; i < length; i++) {
-                this._removeItem(null, items[i], true);
-            }
-            // add new items
-            for (i = 0, length = values.length; i < length; i++) {
-                this._addItem(elementId, values[i]);
-            }
-        },
-        /**
-         * Binds static event listeners.
-         */
-        _setup: function () {
-            if (_didInit) {
-                return;
-            }
-            _didInit = true;
-            _callbackKeyDown = this._keyDown.bind(this);
-            _callbackKeyPress = this._keyPress.bind(this);
-            _callbackKeyUp = this._keyUp.bind(this);
-            _callbackPaste = this._paste.bind(this);
-            _callbackRemoveItem = this._removeItem.bind(this);
-            _callbackBlur = this._blur.bind(this);
-        },
-        /**
-         * Creates the DOM structure for target element. If `element` is a `<textarea>`
-         * it will be automatically replaced with an `<input>` element.
-         *
-         * @param      {Element}       element         input element
-         * @param      {Object}        options         option list
-         */
-        _createUI: function (element, options) {
-            var list = elCreate('ol');
-            list.className = 'inputItemList' + (element.disabled ? ' disabled' : '');
-            elData(list, 'element-id', element.id);
-            list.addEventListener(WCF_CLICK_EVENT, function (event) {
-                if (event.target === list) {
-                    //noinspection JSUnresolvedFunction
-                    element.focus();
-                }
-            });
-            var listItem = elCreate('li');
-            listItem.className = 'input';
-            list.appendChild(listItem);
-            element.addEventListener('keydown', _callbackKeyDown);
-            element.addEventListener('keypress', _callbackKeyPress);
-            element.addEventListener('keyup', _callbackKeyUp);
-            element.addEventListener('paste', _callbackPaste);
-            var hasFocus = element === document.activeElement;
-            if (hasFocus) {
-                //noinspection JSUnresolvedFunction
-                element.blur();
-            }
-            element.addEventListener('blur', _callbackBlur);
-            element.parentNode.insertBefore(list, element);
-            listItem.appendChild(element);
-            if (hasFocus) {
-                window.setTimeout(function () {
-                    //noinspection JSUnresolvedFunction
-                    element.focus();
-                }, 1);
-            }
-            if (options.maxLength !== -1) {
-                elAttr(element, 'maxLength', options.maxLength);
-            }
-            var limitReached = elCreate('span');
-            limitReached.className = 'inputItemListLimitReached';
-            limitReached.textContent = Language.get('wcf.global.form.input.maxItems');
-            elHide(limitReached);
-            listItem.appendChild(limitReached);
-            var shadow = null, values = [];
-            if (options.isCSV) {
-                shadow = elCreate('input');
-                shadow.className = 'itemListInputShadow';
-                shadow.type = 'hidden';
-                //noinspection JSUnresolvedVariable
-                shadow.name = element.name;
-                element.removeAttribute('name');
-                list.parentNode.insertBefore(shadow, list);
-                //noinspection JSUnresolvedVariable
-                var value, tmp = element.value.split(',');
-                for (var i = 0, length = tmp.length; i < length; i++) {
-                    value = tmp[i].trim();
-                    if (value.length) {
-                        values.push(value);
-                    }
-                }
-                if (element.nodeName === 'TEXTAREA') {
-                    var inputElement = elCreate('input');
-                    inputElement.type = 'text';
-                    element.parentNode.insertBefore(inputElement, element);
-                    inputElement.id = element.id;
-                    elRemove(element);
-                    element = inputElement;
-                }
-            }
-            return {
-                element: element,
-                limitReached: limitReached,
-                list: list,
-                shadow: shadow,
-                values: values
-            };
-        },
-        /**
-         * Returns true if the input accepts new items.
-         *
-         * @param       {string}        elementId       input element id
-         * @return      {boolean}       true if at least one more item can be added
-         * @protected
-         */
-        _acceptsNewItems: function (elementId) {
-            var data = _data.get(elementId);
-            if (data.options.maxItems === -1) {
-                return true;
-            }
-            return (data.list.childElementCount - 1 < data.options.maxItems);
-        },
-        /**
-         * Enforces the maximum number of items.
-         *
-         * @param      {string}        elementId       input element id
-         */
-        _handleLimit: function (elementId) {
-            var data = _data.get(elementId);
-            if (this._acceptsNewItems(elementId)) {
-                elShow(data.element);
-                elHide(data.limitReached);
-            }
-            else {
-                elHide(data.element);
-                elShow(data.limitReached);
-            }
-        },
-        /**
-         * Sets the active item list id and handles keyboard access to remove an existing item.
-         *
-         * @param      {object}        event           event object
-         */
-        _keyDown: function (event) {
-            var input = event.currentTarget;
-            var lastItem = input.parentNode.previousElementSibling;
-            _activeId = input.id;
-            if (event.keyCode === 8) {
-                // 8 = [BACKSPACE]
-                if (input.value.length === 0) {
-                    if (lastItem !== null) {
-                        if (lastItem.classList.contains('active')) {
-                            this._removeItem(null, lastItem);
-                        }
-                        else {
-                            lastItem.classList.add('active');
-                        }
-                    }
-                }
-            }
-            else if (event.keyCode === 27) {
-                // 27 = [ESC]
-                if (lastItem !== null && lastItem.classList.contains('active')) {
-                    lastItem.classList.remove('active');
-                }
-            }
-        },
-        /**
-         * Handles the `[ENTER]` and `[,]` key to add an item to the list unless it is restricted.
-         *
-         * @param      {Event}         event           event object
-         */
-        _keyPress: function (event) {
-            if (EventKey.Enter(event) || EventKey.Comma(event)) {
-                event.preventDefault();
-                if (_data.get(event.currentTarget.id).options.restricted) {
-                    // restricted item lists only allow results from the dropdown to be picked
-                    return;
-                }
-                var value = event.currentTarget.value.trim();
-                if (value.length) {
-                    this._addItem(event.currentTarget.id, { objectId: 0, value: value });
-                }
-            }
-        },
-        /**
-         * Splits comma-separated values being pasted into the input field.
-         *
-         * @param       {Event}         event
-         * @protected
-         */
-        _paste: function (event) {
-            var text = '';
-            if (typeof window.clipboardData === 'object') {
-                // IE11
-                text = window.clipboardData.getData('Text');
-            }
-            else {
-                text = event.clipboardData.getData('text/plain');
-            }
-            var element = event.currentTarget;
-            var elementId = element.id;
-            var maxLength = ~~elAttr(element, 'maxLength');
-            text.split(/,/).forEach((function (item) {
-                item = item.trim();
-                if (maxLength && item.length > maxLength) {
-                    // truncating items provides a better UX than throwing an error or silently discarding it
-                    item = item.substr(0, maxLength);
-                }
-                if (item.length > 0 && this._acceptsNewItems(elementId)) {
-                    this._addItem(elementId, { objectId: 0, value: item });
-                }
-            }).bind(this));
-            event.preventDefault();
-        },
-        /**
-         * Handles the keyup event to unmark an item for deletion.
-         *
-         * @param      {object}        event           event object
-         */
-        _keyUp: function (event) {
-            var input = event.currentTarget;
-            if (input.value.length > 0) {
-                var lastItem = input.parentNode.previousElementSibling;
-                if (lastItem !== null) {
-                    lastItem.classList.remove('active');
-                }
-            }
-        },
-        /**
-         * Adds an item to the list.
-         *
-         * @param      {string}        elementId       input element id
-         * @param      {object}        value           item value
-         */
-        _addItem: function (elementId, value) {
-            var data = _data.get(elementId);
-            var listItem = elCreate('li');
-            listItem.className = 'item';
-            var content = elCreate('span');
-            content.className = 'content';
-            elData(content, 'object-id', value.objectId);
-            if (value.type)
-                elData(content, 'type', value.type);
-            content.textContent = value.value;
-            listItem.appendChild(content);
-            if (!data.element.disabled) {
-                var button = elCreate('a');
-                button.className = 'icon icon16 fa-times';
-                button.addEventListener(WCF_CLICK_EVENT, _callbackRemoveItem);
-                listItem.appendChild(button);
-            }
-            data.list.insertBefore(listItem, data.listItem);
-            data.suggestion.addExcludedValue(value.value);
-            data.element.value = '';
-            if (!data.element.disabled) {
-                this._handleLimit(elementId);
-            }
-            var values = this._syncShadow(data);
-            if (typeof data.options.callbackChange === 'function') {
-                if (values === null)
-                    values = this.getValues(elementId);
-                data.options.callbackChange(elementId, values);
-            }
-        },
-        /**
-         * Removes an item from the list.
-         *
-         * @param      {?object}       event           event object
-         * @param      {Element?}      item            list item
-         * @param      {boolean?}      noFocus         input element will not be focused if true
-         */
-        _removeItem: function (event, item, noFocus) {
-            item = (event === null) ? item : event.currentTarget.parentNode;
-            var parent = item.parentNode;
-            //noinspection JSCheckFunctionSignatures
-            var elementId = elData(parent, 'element-id');
-            var data = _data.get(elementId);
-            data.suggestion.removeExcludedValue(item.children[0].textContent);
-            parent.removeChild(item);
-            if (!noFocus)
-                data.element.focus();
-            this._handleLimit(elementId);
-            var values = this._syncShadow(data);
-            if (typeof data.options.callbackChange === 'function') {
-                if (values === null)
-                    values = this.getValues(elementId);
-                data.options.callbackChange(elementId, values);
-            }
-        },
-        /**
-         * Synchronizes the shadow input field with the current list item values.
-         *
-         * @param      {object}        data            element data
-         */
-        _syncShadow: function (data) {
-            if (!data.options.isCSV)
-                return null;
-            if (typeof data.options.callbackSyncShadow === 'function') {
-                return data.options.callbackSyncShadow(data);
-            }
-            var value = '', values = this.getValues(data.element.id);
-            for (var i = 0, length = values.length; i < length; i++) {
-                value += (value.length ? ',' : '') + values[i].value;
-            }
-            data.shadow.value = value;
-            return values;
-        },
-        /**
-         * Handles the blur event.
-         *
-         * @param      {object}        event           event object
-         */
-        _blur: function (event) {
-            var input = event.currentTarget;
-            var data = _data.get(input.id);
-            if (data.options.restricted) {
-                // restricted item lists only allow results from the dropdown to be picked
-                return;
-            }
-            var value = input.value.trim();
-            if (value.length) {
-                if (!data.suggestion || !data.suggestion.isActive()) {
-                    this._addItem(input.id, { objectId: 0, value: value });
-                }
-            }
-        }
-    };
-});
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/ItemList.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/ItemList.ts
new file mode 100644 (file)
index 0000000..eed41aa
--- /dev/null
@@ -0,0 +1,566 @@
+/**
+ * Flexible UI element featuring both a list of items and an input field with suggestion support.
+ *
+ * @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/ItemList
+ */
+
+import * as Core from '../Core';
+import * as DomTraverse from '../Dom/Traverse';
+import * as Language from '../Language';
+import UiSuggestion from './Suggestion';
+import UiDropdownSimple from './Dropdown/Simple';
+import { DatabaseObjectActionPayload } from '../Ajax/Data';
+import DomUtil from '../Dom/Util';
+
+let _activeId = '';
+const _data = new Map<string, ElementData>();
+
+/**
+ * Creates the DOM structure for target element. If `element` is a `<textarea>`
+ * it will be automatically replaced with an `<input>` element.
+ */
+function createUI(element: ItemListInputElement, options: ItemListOptions): UiData {
+  const parentElement = element.parentElement!;
+
+  const list = document.createElement('ol');
+  list.className = 'inputItemList' + (element.disabled ? ' disabled' : '');
+  list.dataset.elementId = element.id;
+  list.addEventListener('click', event => {
+    if (event.target === list) {
+      element.focus();
+    }
+  });
+
+  const listItem = document.createElement('li');
+  listItem.className = 'input';
+  list.appendChild(listItem);
+  element.addEventListener('keydown', keyDown);
+  element.addEventListener('keypress', keyPress);
+  element.addEventListener('keyup', keyUp);
+  element.addEventListener('paste', paste);
+
+  const hasFocus = (element === document.activeElement);
+  if (hasFocus) {
+    element.blur();
+  }
+  element.addEventListener('blur', blur);
+  parentElement.insertBefore(list, element);
+  listItem.appendChild(element);
+
+  if (hasFocus) {
+    window.setTimeout(() => {
+      element.focus();
+    }, 1);
+  }
+
+  if (options.maxLength !== -1) {
+    element.maxLength = options.maxLength;
+  }
+
+  const limitReached = document.createElement('span');
+  limitReached.className = 'inputItemListLimitReached';
+  limitReached.textContent = Language.get('wcf.global.form.input.maxItems');
+  DomUtil.hide(limitReached);
+  listItem.appendChild(limitReached);
+
+  let shadow: HTMLInputElement | null = null;
+  const values: string[] = [];
+  if (options.isCSV) {
+    shadow = document.createElement('input');
+    shadow.className = 'itemListInputShadow';
+    shadow.type = 'hidden';
+    shadow.name = element.name;
+    element.removeAttribute('name');
+    list.parentNode!.insertBefore(shadow, list);
+
+    element.value.split(',').forEach(value => {
+      value = value.trim();
+      if (value) {
+        values.push(value);
+      }
+    });
+
+    if (element.nodeName === 'TEXTAREA') {
+      const inputElement = document.createElement('input');
+      inputElement.type = 'text';
+      parentElement.insertBefore(inputElement, element);
+      inputElement.id = element.id;
+
+      element.remove();
+      element = inputElement;
+    }
+  }
+
+  return {
+    element: element,
+    limitReached: limitReached,
+    list: list,
+    shadow: shadow,
+    values: values,
+  };
+}
+
+/**
+ * Returns true if the input accepts new items.
+ */
+function acceptsNewItems(elementId: string): boolean {
+  const data = _data.get(elementId)!;
+  if (data.options.maxItems === -1) {
+    return true;
+  }
+
+  return (data.list.childElementCount - 1 < data.options.maxItems);
+}
+
+/**
+ * Enforces the maximum number of items.
+ */
+function handleLimit(elementId: string): void {
+  const data = _data.get(elementId)!;
+  if (acceptsNewItems(elementId)) {
+    DomUtil.show(data.element);
+    DomUtil.hide(data.limitReached);
+  } else {
+    DomUtil.hide(data.element);
+    DomUtil.show(data.limitReached);
+  }
+}
+
+/**
+ * Sets the active item list id and handles keyboard access to remove an existing item.
+ */
+function keyDown(event: KeyboardEvent): void {
+  const input = event.currentTarget as HTMLInputElement;
+  _activeId = input.id;
+
+  const lastItem = input.parentElement!.previousElementSibling as HTMLElement | null;
+  if (event.key === 'Backspace') {
+    if (input.value.length === 0) {
+      if (lastItem !== null) {
+        if (lastItem.classList.contains('active')) {
+          removeItem(lastItem);
+        } else {
+          lastItem.classList.add('active');
+        }
+      }
+    }
+  } else if (event.key === 'Escape') {
+    if (lastItem !== null && lastItem.classList.contains('active')) {
+      lastItem.classList.remove('active');
+    }
+  }
+}
+
+/**
+ * Handles the `[ENTER]` and `[,]` key to add an item to the list unless it is restricted.
+ */
+function keyPress(event: KeyboardEvent): void {
+  if (event.key === 'Enter' || event.key === ',') {
+    event.preventDefault();
+
+    const input = event.currentTarget as HTMLInputElement;
+    if (_data.get(input.id)!.options.restricted) {
+      // restricted item lists only allow results from the dropdown to be picked
+      return;
+    }
+    const value = input.value.trim();
+    if (value.length) {
+      addItem(input.id, {objectId: 0, value: value});
+    }
+  }
+}
+
+/**
+ * Splits comma-separated values being pasted into the input field.
+ */
+function paste(event: ClipboardEvent): void {
+  event.preventDefault();
+
+  const text = event.clipboardData!.getData('text/plain');
+
+  const element = event.currentTarget as HTMLInputElement;
+  const elementId = element.id;
+  const maxLength = +element.maxLength;
+  text.split(/,/).forEach(item => {
+    item = item.trim();
+    if (maxLength && item.length > maxLength) {
+      // truncating items provides a better UX than throwing an error or silently discarding it
+      item = item.substr(0, maxLength);
+    }
+
+    if (item.length > 0 && acceptsNewItems(elementId)) {
+      addItem(elementId, {objectId: 0, value: item});
+    }
+  });
+}
+
+/**
+ * Handles the keyup event to unmark an item for deletion.
+ */
+function keyUp(event: KeyboardEvent): void {
+  const input = event.currentTarget as HTMLInputElement;
+  if (input.value.length > 0) {
+    const lastItem = input.parentElement!.previousElementSibling;
+    if (lastItem !== null) {
+      lastItem.classList.remove('active');
+    }
+  }
+}
+
+/**
+ * Adds an item to the list.
+ */
+function addItem(elementId: string, value: ItemData): void {
+  const data = _data.get(elementId)!;
+  const listItem = document.createElement('li');
+  listItem.className = 'item';
+
+  const content = document.createElement('span');
+  content.className = 'content';
+  content.dataset.objectId = value.objectId.toString();
+  if (value.type) {
+    content.dataset.type = value.type;
+  }
+  content.textContent = value.value;
+  listItem.appendChild(content);
+
+  if (!data.element.disabled) {
+    const button = document.createElement('a');
+    button.className = 'icon icon16 fa-times';
+    button.addEventListener('click', removeItem);
+    listItem.appendChild(button);
+  }
+
+  data.list.insertBefore(listItem, data.listItem);
+  data.suggestion.addExcludedValue(value.value);
+  data.element.value = '';
+  if (!data.element.disabled) {
+    handleLimit(elementId);
+  }
+
+  let values = syncShadow(data);
+  if (typeof data.options.callbackChange === 'function') {
+    if (values === null) {
+      values = getValues(elementId);
+    }
+
+    data.options.callbackChange(elementId, values);
+  }
+}
+
+/**
+ * Removes an item from the list.
+ */
+function removeItem(item: Event | HTMLElement, noFocus?: boolean): void {
+  if (item instanceof Event) {
+    const target = item.currentTarget as HTMLElement;
+    item = target.parentElement!;
+  }
+
+  const parent = item.parentElement!;
+  const elementId = parent.dataset.elementId || '';
+  const data = _data.get(elementId)!;
+  if (item.children[0].textContent) {
+    data.suggestion.removeExcludedValue(item.children[0].textContent);
+  }
+
+  item.remove();
+
+  if (!noFocus) {
+    data.element.focus();
+  }
+
+  handleLimit(elementId);
+
+  let values = syncShadow(data);
+  if (typeof data.options.callbackChange === 'function') {
+    if (values === null) {
+      values = getValues(elementId);
+    }
+
+    data.options.callbackChange(elementId, values);
+  }
+}
+
+/**
+ * Synchronizes the shadow input field with the current list item values.
+ */
+function syncShadow(data: ElementData): ItemData[] | null {
+  if (!data.options.isCSV) {
+    return null;
+  }
+
+  if (typeof data.options.callbackSyncShadow === 'function') {
+    return data.options.callbackSyncShadow(data);
+  }
+
+  const values = getValues(data.element.id);
+
+  data.shadow!.value = getValues(data.element.id)
+    .map(value => value.value)
+    .join(',');
+
+  return values;
+}
+
+/**
+ * Handles the blur event.
+ */
+function blur(event: FocusEvent): void {
+  const input = event.currentTarget as HTMLInputElement;
+  const data = _data.get(input.id)!;
+
+  if (data.options.restricted) {
+    // restricted item lists only allow results from the dropdown to be picked
+    return;
+  }
+
+  const value = input.value.trim();
+  if (value.length) {
+    if (!data.suggestion || !data.suggestion.isActive()) {
+      addItem(input.id, {objectId: 0, value: value});
+    }
+  }
+}
+
+
+/**
+ * Initializes an item list.
+ *
+ * The `values` argument must be empty or contain a list of strings or object, e.g.
+ * `['foo', 'bar']` or `[{ objectId: 1337, value: 'baz'}, {...}]`
+ */
+export function init(elementId: string, values: ItemDataOrPlainValue[], options: ItemListOptions): void {
+  const element = document.getElementById(elementId) as ItemListInputElement;
+  if (element === null) {
+    throw new Error("Expected a valid element id, '" + elementId + "' is invalid.");
+  }
+
+  // remove data from previous instance
+  if (_data.has(elementId)) {
+    const tmp = _data.get(elementId)!;
+    Object.keys(tmp).forEach(key => {
+      const el = tmp[key];
+      if (el instanceof Element && el.parentNode) {
+        el.remove();
+      }
+    });
+
+    UiDropdownSimple.destroy(elementId);
+    _data.delete(elementId);
+  }
+
+  options = Core.extend({
+    // search parameters for suggestions
+    ajax: {
+      actionName: 'getSearchResultList',
+      className: '',
+      data: {},
+    },
+    // list of excluded string values, e.g. `['ignore', 'these strings', 'when', 'searching']`
+    excludedSearchValues: [],
+    // maximum number of items this list may contain, `-1` for infinite
+    maxItems: -1,
+    // maximum length of an item value, `-1` for infinite
+    maxLength: -1,
+    // disallow custom values, only values offered by the suggestion dropdown are accepted
+    restricted: false,
+    // initial value will be interpreted as comma separated value and submitted as such
+    isCSV: false,
+    // will be invoked whenever the items change, receives the element id first and list of values second
+    callbackChange: null,
+    // callback once the form is about to be submitted
+    callbackSubmit: null,
+    // Callback for the custom shadow synchronization.
+    callbackSyncShadow: null,
+    // Callback to set values during the setup.
+    callbackSetupValues: null,
+    // value may contain the placeholder `{$objectId}`
+    submitFieldName: '',
+  }, options) as ItemListOptions;
+
+  const form = DomTraverse.parentByTag(element, 'FORM') as HTMLFormElement;
+  if (form !== null) {
+    if (!options.isCSV) {
+      if (!options.submitFieldName.length && typeof options.callbackSubmit !== 'function') {
+        throw new Error("Expected a valid function for option 'callbackSubmit', a non-empty value for option 'submitFieldName' or enabling the option 'submitFieldCSV'.");
+      }
+
+      form.addEventListener('submit', () => {
+        if (acceptsNewItems(elementId)) {
+          const value = _data.get(elementId)!.element.value.trim();
+          if (value.length) {
+            addItem(elementId, {objectId: 0, value: value});
+          }
+        }
+
+        const values = getValues(elementId);
+        if (options.submitFieldName.length) {
+          values.forEach(value => {
+            const input = document.createElement('input');
+            input.type = 'hidden';
+            input.name = options.submitFieldName.replace('{$objectId}', value.objectId.toString());
+            input.value = value.value;
+            form.appendChild(input);
+          });
+        } else {
+          options.callbackSubmit!(form, values);
+        }
+      });
+    } else {
+      form.addEventListener('submit', () => {
+        if (acceptsNewItems(elementId)) {
+          const value = _data.get(elementId)!.element.value.trim();
+          if (value.length) {
+            addItem(elementId, {objectId: 0, value: value});
+          }
+        }
+      });
+    }
+  }
+
+  const data = createUI(element, options);
+
+  const suggestion = new UiSuggestion(elementId, {
+    ajax: options.ajax,
+    callbackSelect: addItem,
+    excludedSearchValues: options.excludedSearchValues,
+  });
+
+  _data.set(elementId, {
+    dropdownMenu: null,
+    element: data.element,
+    limitReached: data.limitReached,
+    list: data.list,
+    listItem: data.element.parentElement!,
+    options: options,
+    shadow: data.shadow,
+    suggestion: suggestion,
+  });
+
+  if (options.callbackSetupValues) {
+    values = options.callbackSetupValues();
+  } else {
+    values = (data.values.length) ? data.values : values;
+  }
+
+  if (Array.isArray(values)) {
+    values.forEach(value => {
+      if (typeof value === 'string') {
+        value = {objectId: 0, value: value};
+      }
+
+      addItem(elementId, value);
+    });
+  }
+}
+
+/**
+ * Returns the list of current values.
+ */
+export function getValues(elementId: string): ItemData[] {
+  const data = _data.get(elementId);
+  if (!data) {
+    throw new Error("Element id '" + elementId + "' is unknown.");
+  }
+
+  const values: ItemData[] = [];
+  data.list.querySelectorAll('.item > span').forEach((span: HTMLSpanElement) => {
+    values.push({
+      objectId: +(span.dataset.objectId || ''),
+      value: span.textContent!.trim(),
+      type: span.dataset.type,
+    });
+  });
+
+  return values;
+}
+
+/**
+ * Sets the list of current values.
+ */
+export function setValues(elementId: string, values: ItemData[]): void {
+  const data = _data.get(elementId);
+  if (!data) {
+    throw new Error("Element id '" + elementId + "' is unknown.");
+  }
+
+  // remove all existing items first
+  DomTraverse.childrenByClass(data.list, 'item').forEach((item: HTMLElement) => {
+    removeItem(item, true);
+  });
+
+  // add new items
+  values.forEach(value => {
+    addItem(elementId, value);
+  });
+}
+
+type ItemListInputElement = HTMLInputElement | HTMLTextAreaElement;
+
+interface ItemData {
+  objectId: number;
+  value: string;
+  type?: string;
+}
+
+type PlainValue = string;
+
+type ItemDataOrPlainValue = ItemData | PlainValue;
+
+interface ItemListOptions {
+  // search parameters for suggestions
+  ajax: DatabaseObjectActionPayload;
+
+  // list of excluded string values, e.g. `['ignore', 'these strings', 'when', 'searching']`
+  excludedSearchValues: string[];
+
+  // maximum number of items this list may contain, `-1` for infinite
+  maxItems: number;
+
+  // maximum length of an item value, `-1` for infinite
+  maxLength: number;
+
+  // disallow custom values, only values offered by the suggestion dropdown are accepted
+  restricted: boolean;
+
+  // initial value will be interpreted as comma separated value and submitted as such
+  isCSV: boolean;
+
+  // will be invoked whenever the items change, receives the element id first and list of values second
+  callbackChange?: (elementId: string, values: ItemData[]) => void;
+
+  // callback once the form is about to be submitted
+  callbackSubmit?: (form: HTMLFormElement, values: ItemData[]) => void;
+
+  // Callback for the custom shadow synchronization.
+  callbackSyncShadow?: (data: ElementData) => ItemData[];
+
+  // Callback to set values during the setup.
+  callbackSetupValues?: () => ItemDataOrPlainValue[];
+
+  // value may contain the placeholder `{$objectId}`
+  submitFieldName: string;
+}
+
+interface ElementData {
+  dropdownMenu: HTMLElement | null;
+  element: ItemListInputElement;
+  limitReached: HTMLSpanElement;
+  list: HTMLElement;
+  listItem: HTMLElement;
+  options: ItemListOptions,
+  shadow: HTMLInputElement | null;
+  suggestion: UiSuggestion;
+}
+
+interface UiData {
+  element: ItemListInputElement;
+  limitReached: HTMLSpanElement;
+  list: HTMLOListElement;
+  shadow: HTMLInputElement | null;
+  values: string[];
+}