Convert `Language/Input` to TypeScript
authorAlexander Ebert <ebert@woltlab.com>
Sun, 8 Nov 2020 14:04:17 +0000 (15:04 +0100)
committerAlexander Ebert <ebert@woltlab.com>
Sun, 8 Nov 2020 14:04:17 +0000 (15:04 +0100)
global.d.ts
wcfsetup/install/files/js/WoltLabSuite/Core/Language/Input.js
wcfsetup/install/files/ts/WoltLabSuite/Core/Language/Input.js [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Language/Input.ts [new file with mode: 0644]

index 1136bb7837b0621ff81b2e653a9153c75a0270dd..f37463b84c3bdedfa27759b5c128f3761454508f 100644 (file)
@@ -11,6 +11,7 @@ declare global {
     Devtools?: typeof Devtools;
     ENABLE_DEBUG_MODE: boolean;
     ENABLE_DEVELOPER_TOOLS: boolean;
+    LANGUAGE_ID: number;
     REACTION_TYPES: {
       [key: string]: Reaction;
     };
index 144f18f844eaa7c4a7b4ac30a76ac06371592270..d2f191dc90a09d2554565e506f710445ed4a3ee8 100644 (file)
  * I18n interface for input and textarea fields.
  *
  * @author      Alexander Ebert
- * @copyright  2001-2019 WoltLab GmbH
+ * @copyright  2001-2019 WoltLab GmbH
  * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
  * @module      WoltLabSuite/Core/Language/Input
  */
-define(['Core', 'Dictionary', 'Language', 'ObjectMap', 'StringUtil', 'Dom/Traverse', 'Dom/Util', 'Ui/SimpleDropdown'], function (Core, Dictionary, Language, ObjectMap, StringUtil, DomTraverse, DomUtil, UiSimpleDropdown) {
+define(["require", "exports", "tslib", "../Dom/Util", "../Language", "../Ui/Dropdown/Simple", "../StringUtil"], function (require, exports, tslib_1, Util_1, Language, Simple_1, StringUtil) {
     "use strict";
-    var _elements = new Dictionary();
-    var _didInit = false;
-    var _forms = new ObjectMap();
-    var _values = new Dictionary();
-    var _callbackDropdownToggle = null;
-    var _callbackSubmit = null;
+    Object.defineProperty(exports, "__esModule", { value: true });
+    exports.validate = exports.isEnabled = exports.enable = exports.disable = exports.setValues = exports.getValues = exports.unregister = exports.registerCallback = exports.init = void 0;
+    Util_1 = tslib_1.__importDefault(Util_1);
+    Language = tslib_1.__importStar(Language);
+    Simple_1 = tslib_1.__importDefault(Simple_1);
+    StringUtil = tslib_1.__importStar(StringUtil);
+    const _elements = new Map();
+    const _forms = new WeakMap();
+    const _values = new Map();
     /**
-     * @exports     WoltLabSuite/Core/Language/Input
+     * Sets up DOM and event listeners for an input field.
      */
-    return {
-        /**
-         * Initializes an input field.
-         *
-         * @param       {string}        elementId               input element id
-         * @param       {Object}        values                  preset values per language id
-         * @param       {Object}        availableLanguages      language names per language id
-         * @param       {boolean}       forceSelection          require i18n input
-         */
-        init: function (elementId, values, availableLanguages, forceSelection) {
-            if (_values.has(elementId)) {
-                return;
-            }
-            var element = elById(elementId);
-            if (element === null) {
-                throw new Error("Expected a valid element id, cannot find '" + elementId + "'.");
-            }
-            this._setup();
-            // unescape values
-            var unescapedValues = new Dictionary();
-            for (var key in values) {
-                if (values.hasOwnProperty(key)) {
-                    unescapedValues.set(~~key, StringUtil.unescapeHTML(values[key]));
-                }
-            }
-            _values.set(elementId, unescapedValues);
-            this._initElement(elementId, element, unescapedValues, availableLanguages, forceSelection);
-        },
-        /**
-         * Registers a callback for an element.
-         *
-         * @param       {string}        elementId
-         * @param       {string}        eventName
-         * @param       {function}      callback
-         */
-        registerCallback: function (elementId, eventName, callback) {
-            if (!_values.has(elementId)) {
-                throw new Error("Unknown element id '" + elementId + "'.");
+    function initElement(elementId, element, values, availableLanguages, forceSelection) {
+        let container = element.parentElement;
+        if (!container.classList.contains("inputAddon")) {
+            container = document.createElement("div");
+            container.className = "inputAddon";
+            if (element.nodeName === "TEXTAREA") {
+                container.classList.add("inputAddonTextarea");
             }
-            _elements.get(elementId).callbacks.set(eventName, callback);
-        },
-        /**
-         * Unregisters the element with the given id.
-         *
-         * @param      {string}        elementId
-         * @since      5.2
-         */
-        unregister: function (elementId) {
-            if (!_values.has(elementId)) {
-                throw new Error("Unknown element id '" + elementId + "'.");
+            container.dataset.inputId = elementId;
+            const hasFocus = document.activeElement === element;
+            // DOM manipulation causes focused element to lose focus
+            element.insertAdjacentElement("beforebegin", container);
+            container.appendChild(element);
+            if (hasFocus) {
+                element.focus();
             }
-            _values.delete(elementId);
-            _elements.delete(elementId);
-        },
-        /**
-         * Caches common event listener callbacks.
-         */
-        _setup: function () {
-            if (_didInit)
-                return;
-            _didInit = true;
-            _callbackDropdownToggle = this._dropdownToggle.bind(this);
-            _callbackSubmit = this._submit.bind(this);
-        },
-        /**
-         * Sets up DOM and event listeners for an input field.
-         *
-         * @param       {string}        elementId               input element id
-         * @param       {Element}       element                 input or textarea element
-         * @param       {Dictionary}    values                  preset values per language id
-         * @param       {Object}        availableLanguages      language names per language id
-         * @param       {boolean}       forceSelection          require i18n input
-         */
-        _initElement: function (elementId, element, values, availableLanguages, forceSelection) {
-            var container = element.parentNode;
-            if (!container.classList.contains('inputAddon')) {
-                container = elCreate('div');
-                container.className = 'inputAddon' + (element.nodeName === 'TEXTAREA' ? ' inputAddonTextarea' : '');
-                //noinspection JSCheckFunctionSignatures
-                elData(container, 'input-id', elementId);
-                var hasFocus = document.activeElement === element;
-                // DOM manipulation causes focused element to lose focus
-                element.parentNode.insertBefore(container, element);
-                container.appendChild(element);
-                if (hasFocus) {
-                    element.focus();
-                }
+        }
+        container.classList.add("dropdown");
+        const button = document.createElement("span");
+        button.className = "button dropdownToggle inputPrefix";
+        const buttonLabel = document.createElement("span");
+        buttonLabel.textContent = Language.get("wcf.global.button.disabledI18n");
+        button.appendChild(buttonLabel);
+        container.insertBefore(button, element);
+        const dropdownMenu = document.createElement("ul");
+        dropdownMenu.className = "dropdownMenu";
+        button.insertAdjacentElement("afterend", dropdownMenu);
+        const callbackClick = (event) => {
+            let target;
+            if (event instanceof HTMLElement) {
+                target = event;
             }
-            container.classList.add('dropdown');
-            var button = elCreate('span');
-            button.className = 'button dropdownToggle inputPrefix';
-            var span = elCreate('span');
-            span.textContent = Language.get('wcf.global.button.disabledI18n');
-            button.appendChild(span);
-            container.insertBefore(button, element);
-            var dropdownMenu = elCreate('ul');
-            dropdownMenu.className = 'dropdownMenu';
-            DomUtil.insertAfter(dropdownMenu, button);
-            var callbackClick = (function (event, isInit) {
-                var languageId = ~~elData(event.currentTarget, 'language-id');
-                var activeItem = DomTraverse.childByClass(dropdownMenu, 'active');
-                if (activeItem !== null)
-                    activeItem.classList.remove('active');
-                if (languageId)
-                    event.currentTarget.classList.add('active');
-                this._select(elementId, languageId, isInit || false);
-            }).bind(this);
-            // build language dropdown
-            var listItem;
-            for (var languageId in availableLanguages) {
-                if (availableLanguages.hasOwnProperty(languageId)) {
-                    listItem = elCreate('li');
-                    elData(listItem, 'language-id', languageId);
-                    span = elCreate('span');
-                    span.textContent = availableLanguages[languageId];
-                    listItem.appendChild(span);
-                    listItem.addEventListener('click', callbackClick);
-                    dropdownMenu.appendChild(listItem);
-                }
+            else {
+                target = event.currentTarget;
             }
-            if (forceSelection !== true) {
-                listItem = elCreate('li');
-                listItem.className = 'dropdownDivider';
-                dropdownMenu.appendChild(listItem);
-                listItem = elCreate('li');
-                elData(listItem, 'language-id', 0);
-                span = elCreate('span');
-                span.textContent = Language.get('wcf.global.button.disabledI18n');
-                listItem.appendChild(span);
-                listItem.addEventListener('click', callbackClick);
-                dropdownMenu.appendChild(listItem);
+            const languageId = ~~target.dataset.languageId;
+            const activeItem = dropdownMenu.querySelector(".active");
+            if (activeItem !== null) {
+                activeItem.classList.remove("active");
             }
-            var activeItem = null;
-            if (forceSelection === true || values.size) {
-                for (var i = 0, length = dropdownMenu.childElementCount; i < length; i++) {
-                    //noinspection JSUnresolvedVariable
-                    if (~~elData(dropdownMenu.children[i], 'language-id') === LANGUAGE_ID) {
-                        activeItem = dropdownMenu.children[i];
-                        break;
-                    }
-                }
+            if (languageId) {
+                target.classList.add("active");
             }
-            UiSimpleDropdown.init(button);
-            UiSimpleDropdown.registerCallback(container.id, _callbackDropdownToggle);
-            _elements.set(elementId, {
-                buttonLabel: button.children[0],
-                callbacks: new Dictionary(),
-                element: element,
-                languageId: 0,
-                isEnabled: true,
-                forceSelection: forceSelection
+            const isInit = event instanceof HTMLElement;
+            select(elementId, languageId, isInit);
+        };
+        // build language dropdown
+        Object.entries(availableLanguages).forEach(([languageId, languageName]) => {
+            const listItem = document.createElement("li");
+            listItem.dataset.languageId = languageId;
+            const span = document.createElement("span");
+            span.textContent = languageName;
+            listItem.appendChild(span);
+            listItem.addEventListener("click", callbackClick);
+            dropdownMenu.appendChild(listItem);
+        });
+        if (!forceSelection) {
+            const divider = document.createElement("li");
+            divider.className = "dropdownDivider";
+            dropdownMenu.appendChild(divider);
+            const listItem = document.createElement("li");
+            listItem.dataset.languageId = "0";
+            listItem.addEventListener("click", callbackClick);
+            const span = document.createElement("span");
+            span.textContent = Language.get("wcf.global.button.disabledI18n");
+            listItem.appendChild(span);
+            dropdownMenu.appendChild(listItem);
+        }
+        let activeItem = undefined;
+        if (forceSelection || values.size) {
+            activeItem = Array.from(dropdownMenu.children).find((element) => {
+                return +element.dataset.languageId === window.LANGUAGE_ID;
             });
-            // bind to submit event
-            var submit = DomTraverse.parentByTag(element, 'FORM');
-            if (submit !== null) {
-                submit.addEventListener('submit', _callbackSubmit);
-                var elementIds = _forms.get(submit);
-                if (elementIds === undefined) {
-                    elementIds = [];
-                    _forms.set(submit, elementIds);
-                }
-                elementIds.push(elementId);
+        }
+        Simple_1.default.init(button);
+        Simple_1.default.registerCallback(container.id, dropdownToggle);
+        _elements.set(elementId, {
+            buttonLabel,
+            callbacks: new Map(),
+            element,
+            languageId: 0,
+            isEnabled: true,
+            forceSelection,
+        });
+        // bind to submit event
+        const form = element.closest("form");
+        if (form !== null) {
+            form.addEventListener("submit", submit);
+            let elementIds = _forms.get(form);
+            if (elementIds === undefined) {
+                elementIds = [];
+                _forms.set(form, elementIds);
             }
-            if (activeItem !== null) {
-                callbackClick({ currentTarget: activeItem }, true);
+            elementIds.push(elementId);
+        }
+        if (activeItem) {
+            callbackClick(activeItem);
+        }
+    }
+    /**
+     * Selects a language or non-i18n from the dropdown list.
+     */
+    function select(elementId, languageId, isInit) {
+        const data = _elements.get(elementId);
+        const dropdownMenu = Simple_1.default.getDropdownMenu(data.element.closest(".inputAddon").id);
+        const item = dropdownMenu.querySelector(`[data-language-id="${languageId}"]`);
+        const label = item ? item.textContent : "";
+        // save current value
+        if (data.languageId !== languageId) {
+            const values = _values.get(elementId);
+            if (data.languageId) {
+                values.set(data.languageId, data.element.value);
             }
-        },
-        /**
-         * Selects a language or non-i18n from the dropdown list.
-         *
-         * @param       {string}        elementId       input element id
-         * @param       {int}           languageId      language id or `0` to disable i18n
-         * @param       {boolean}       isInit          triggers pre-selection on init
-         */
-        _select: function (elementId, languageId, isInit) {
-            var data = _elements.get(elementId);
-            var dropdownMenu = UiSimpleDropdown.getDropdownMenu(data.element.closest('.inputAddon').id);
-            var item, label = '';
-            for (var i = 0, length = dropdownMenu.childElementCount; i < length; i++) {
-                item = dropdownMenu.children[i];
-                var itemLanguageId = elData(item, 'language-id');
-                if (itemLanguageId.length && languageId === ~~itemLanguageId) {
-                    label = item.children[0].textContent;
-                }
+            if (languageId === 0) {
+                _values.set(elementId, new Map());
             }
-            // save current value
-            if (data.languageId !== languageId) {
-                var values = _values.get(elementId);
+            else if (data.buttonLabel.classList.contains("active") || isInit) {
+                data.element.value = values.get(languageId) || "";
+            }
+            // update label
+            data.buttonLabel.textContent = label;
+            data.buttonLabel.classList[languageId ? "add" : "remove"]("active");
+            data.languageId = languageId;
+        }
+        if (!isInit) {
+            data.element.blur();
+            data.element.focus();
+        }
+        if (data.callbacks.has("select")) {
+            data.callbacks.get("select")(data.element);
+        }
+    }
+    /**
+     * Callback for dropdowns being opened, flags items with a missing value for one or more languages.
+     */
+    function dropdownToggle(containerId, action) {
+        if (action !== "open") {
+            return;
+        }
+        const dropdownMenu = Simple_1.default.getDropdownMenu(containerId);
+        const container = document.getElementById(containerId);
+        const elementId = container.dataset.inputId;
+        const data = _elements.get(elementId);
+        const values = _values.get(elementId);
+        Array.from(dropdownMenu.children).forEach((item) => {
+            const languageId = ~~(item.dataset.languageId || "");
+            if (languageId) {
+                let hasMissingValue = false;
                 if (data.languageId) {
-                    values.set(data.languageId, data.element.value);
+                    if (languageId === data.languageId) {
+                        hasMissingValue = data.element.value.trim() === "";
+                    }
+                    else {
+                        hasMissingValue = !values.get(languageId);
+                    }
                 }
-                if (languageId === 0) {
-                    _values.set(elementId, new Dictionary());
+                if (hasMissingValue) {
+                    item.classList.add("missingValue");
                 }
-                else if (data.buttonLabel.classList.contains('active') || isInit === true) {
-                    data.element.value = (values.has(languageId)) ? values.get(languageId) : '';
+                else {
+                    item.classList.remove("missingValue");
                 }
-                // update label
-                data.buttonLabel.textContent = label;
-                data.buttonLabel.classList[(languageId ? 'add' : 'remove')]('active');
-                data.languageId = languageId;
-            }
-            if (!isInit) {
-                data.element.blur();
-                data.element.focus();
             }
-            if (data.callbacks.has('select')) {
-                data.callbacks.get('select')(data.element);
-            }
-        },
-        /**
-         * Callback for dropdowns being opened, flags items with a missing value for one or more languages.
-         *
-         * @param       {string}        containerId     dropdown container id
-         * @param       {string}        action          toggle action, can be `open` or `close`
-         */
-        _dropdownToggle: function (containerId, action) {
-            if (action !== 'open') {
+        });
+    }
+    /**
+     * Inserts hidden fields for i18n input on submit.
+     */
+    function submit(event) {
+        const form = event.currentTarget;
+        const elementIds = _forms.get(form);
+        elementIds.forEach((elementId) => {
+            const data = _elements.get(elementId);
+            if (!data.isEnabled) {
                 return;
             }
-            var dropdownMenu = UiSimpleDropdown.getDropdownMenu(containerId);
-            var elementId = elData(elById(containerId), 'input-id');
-            var data = _elements.get(elementId);
-            var values = _values.get(elementId);
-            var item, languageId;
-            for (var i = 0, length = dropdownMenu.childElementCount; i < length; i++) {
-                item = dropdownMenu.children[i];
-                languageId = ~~elData(item, 'language-id');
-                if (languageId) {
-                    var hasMissingValue = false;
-                    if (data.languageId) {
-                        if (languageId === data.languageId) {
-                            hasMissingValue = (data.element.value.trim() === '');
-                        }
-                        else {
-                            hasMissingValue = (!values.get(languageId));
-                        }
-                    }
-                    item.classList[(hasMissingValue ? 'add' : 'remove')]('missingValue');
-                }
+            const values = _values.get(elementId);
+            if (data.callbacks.has("submit")) {
+                data.callbacks.get("submit")(data.element);
             }
-        },
-        /**
-         * Inserts hidden fields for i18n input on submit.
-         *
-         * @param       {Object}        event           event object
-         */
-        _submit: function (event) {
-            var elementIds = _forms.get(event.currentTarget);
-            var data, elementId, input, values;
-            for (var i = 0, length = elementIds.length; i < length; i++) {
-                elementId = elementIds[i];
-                data = _elements.get(elementId);
-                if (data.isEnabled) {
-                    values = _values.get(elementId);
-                    if (data.callbacks.has('submit')) {
-                        data.callbacks.get('submit')(data.element);
-                    }
-                    // update with current value
-                    if (data.languageId) {
-                        values.set(data.languageId, data.element.value);
-                    }
-                    if (values.size) {
-                        values.forEach(function (value, languageId) {
-                            input = elCreate('input');
-                            input.type = 'hidden';
-                            input.name = elementId + '_i18n[' + languageId + ']';
-                            input.value = value;
-                            event.currentTarget.appendChild(input);
-                        });
-                        // remove name attribute to enforce i18n values
-                        data.element.removeAttribute('name');
-                    }
-                }
-            }
-        },
-        /**
-         * Returns the values of an input field.
-         *
-         * @param       {string}        elementId       input element id
-         * @return      {Dictionary}    values stored for the different languages
-         */
-        getValues: function (elementId) {
-            var element = _elements.get(elementId);
-            if (element === undefined) {
-                throw new Error("Expected a valid i18n input element, '" + elementId + "' is not i18n input field.");
-            }
-            var values = _values.get(elementId);
             // update with current value
-            values.set(element.languageId, element.element.value);
-            return values;
-        },
-        /**
-         * Sets the values of an input field.
-         *
-         * @param       {string}        elementId       input element id
-         * @param       {Dictionary}    values          values for the different languages
-         */
-        setValues: function (elementId, values) {
-            var element = _elements.get(elementId);
-            if (element === undefined) {
-                throw new Error("Expected a valid i18n input element, '" + elementId + "' is not i18n input field.");
-            }
-            if (Core.isPlainObject(values)) {
-                values = Dictionary.fromObject(values);
+            if (data.languageId) {
+                values.set(data.languageId, data.element.value);
             }
-            element.element.value = '';
-            if (values.has(0)) {
-                element.element.value = values.get(0);
-                values['delete'](0);
-                _values.set(elementId, values);
-                this._select(elementId, 0, true);
-                return;
+            if (values.size) {
+                values.forEach(function (value, languageId) {
+                    const input = document.createElement("input");
+                    input.type = "hidden";
+                    input.name = `${elementId}_i18n[${languageId}]`;
+                    input.value = value;
+                    form.appendChild(input);
+                });
+                // remove name attribute to enforce i18n values
+                data.element.removeAttribute("name");
             }
+        });
+    }
+    /**
+     * Initializes an input field.
+     */
+    function init(elementId, values, availableLanguages, forceSelection) {
+        if (_values.has(elementId)) {
+            return;
+        }
+        const element = document.getElementById(elementId);
+        if (element === null) {
+            throw new Error(`Expected a valid element id, cannot find '${elementId}'.`);
+        }
+        // unescape values
+        const unescapedValues = new Map();
+        Object.entries(values).forEach(([languageId, value]) => {
+            unescapedValues.set(+languageId, StringUtil.unescapeHTML(value));
+        });
+        _values.set(elementId, unescapedValues);
+        initElement(elementId, element, unescapedValues, availableLanguages, forceSelection);
+    }
+    exports.init = init;
+    /**
+     * Registers a callback for an element.
+     */
+    function registerCallback(elementId, eventName, callback) {
+        if (!_values.has(elementId)) {
+            throw new Error(`Unknown element id '${elementId}'.`);
+        }
+        _elements.get(elementId).callbacks.set(eventName, callback);
+    }
+    exports.registerCallback = registerCallback;
+    /**
+     * Unregisters the element with the given id.
+     *
+     * @since  5.2
+     */
+    function unregister(elementId) {
+        if (!_values.has(elementId)) {
+            throw new Error(`Unknown element id '${elementId}'.`);
+        }
+        _values.delete(elementId);
+        _elements.delete(elementId);
+    }
+    exports.unregister = unregister;
+    /**
+     * Returns the values of an input field.
+     */
+    function getValues(elementId) {
+        const element = _elements.get(elementId);
+        if (element === undefined) {
+            throw new Error(`Expected a valid i18n input element, '${elementId}' is not i18n input field.`);
+        }
+        const values = _values.get(elementId);
+        // update with current value
+        values.set(element.languageId, element.element.value);
+        return values;
+    }
+    exports.getValues = getValues;
+    /**
+     * Sets the values of an input field.
+     */
+    function setValues(elementId, newValues) {
+        const element = _elements.get(elementId);
+        if (element === undefined) {
+            throw new Error(`Expected a valid i18n input element, '${elementId}' is not i18n input field.`);
+        }
+        element.element.value = "";
+        const values = new Map(Object.entries(newValues).map(([languageId, value]) => {
+            return [+languageId, value];
+        }));
+        if (values.has(0)) {
+            element.element.value = values.get(0);
+            values.delete(0);
             _values.set(elementId, values);
-            element.languageId = 0;
-            //noinspection JSUnresolvedVariable
-            this._select(elementId, LANGUAGE_ID, true);
-        },
-        /**
-         * Disables the i18n interface for an input field.
-         *
-         * @param       {string}        elementId       input element id
-         */
-        disable: function (elementId) {
-            var element = _elements.get(elementId);
-            if (element === undefined) {
-                throw new Error("Expected a valid element, '" + elementId + "' is not an i18n input field.");
-            }
-            if (!element.isEnabled)
-                return;
-            element.isEnabled = false;
-            // hide language dropdown
-            //noinspection JSCheckFunctionSignatures
-            elHide(element.buttonLabel.parentNode);
-            var dropdownContainer = element.buttonLabel.parentNode.parentNode;
-            dropdownContainer.classList.remove('inputAddon');
-            dropdownContainer.classList.remove('dropdown');
-        },
-        /**
-         * Enables the i18n interface for an input field.
-         *
-         * @param       {string}        elementId       input element id
-         */
-        enable: function (elementId) {
-            var element = _elements.get(elementId);
-            if (element === undefined) {
-                throw new Error("Expected a valid i18n input element, '" + elementId + "' is not i18n input field.");
-            }
-            if (element.isEnabled)
-                return;
-            element.isEnabled = true;
-            // show language dropdown
-            //noinspection JSCheckFunctionSignatures
-            elShow(element.buttonLabel.parentNode);
-            var dropdownContainer = element.buttonLabel.parentNode.parentNode;
-            dropdownContainer.classList.add('inputAddon');
-            dropdownContainer.classList.add('dropdown');
-        },
-        /**
-         * Returns true if i18n input is enabled for an input field.
-         *
-         * @param       {string}        elementId       input element id
-         * @return      {boolean}
-         */
-        isEnabled: function (elementId) {
-            var element = _elements.get(elementId);
-            if (element === undefined) {
-                throw new Error("Expected a valid i18n input element, '" + elementId + "' is not i18n input field.");
-            }
-            return element.isEnabled;
-        },
-        /**
-         * Returns true if the value of an i18n input field is valid.
-         *
-         * If the element is disabled, true is returned.
-         *
-         * @param       {string}        elementId               input element id
-         * @param       {boolean}       permitEmptyValue        if true, input may be empty for all languages
-         * @return      {boolean}       true if input is valid
-         */
-        validate: function (elementId, permitEmptyValue) {
-            var element = _elements.get(elementId);
-            if (element === undefined) {
-                throw new Error("Expected a valid i18n input element, '" + elementId + "' is not i18n input field.");
-            }
-            if (!element.isEnabled)
-                return true;
-            var values = _values.get(elementId);
-            var dropdownMenu = UiSimpleDropdown.getDropdownMenu(element.element.parentNode.id);
-            if (element.languageId) {
-                values.set(element.languageId, element.element.value);
-            }
-            var item, languageId;
-            var hasEmptyValue = false, hasNonEmptyValue = false;
-            for (var i = 0, length = dropdownMenu.childElementCount; i < length; i++) {
-                item = dropdownMenu.children[i];
-                languageId = ~~elData(item, 'language-id');
-                if (languageId) {
-                    if (!values.has(languageId) || values.get(languageId).length === 0) {
-                        // input has non-empty value for previously checked language
-                        if (hasNonEmptyValue) {
-                            return false;
-                        }
-                        hasEmptyValue = true;
+            select(elementId, 0, true);
+            return;
+        }
+        _values.set(elementId, values);
+        element.languageId = 0;
+        select(elementId, window.LANGUAGE_ID, true);
+    }
+    exports.setValues = setValues;
+    /**
+     * Disables the i18n interface for an input field.
+     */
+    function disable(elementId) {
+        const element = _elements.get(elementId);
+        if (element === undefined) {
+            throw new Error(`Expected a valid element, '${elementId}' is not an i18n input field.`);
+        }
+        if (!element.isEnabled) {
+            return;
+        }
+        element.isEnabled = false;
+        // hide language dropdown
+        const buttonContainer = element.buttonLabel.parentElement;
+        Util_1.default.hide(buttonContainer);
+        const dropdownContainer = buttonContainer.parentElement;
+        dropdownContainer.classList.remove("inputAddon", "dropdown");
+    }
+    exports.disable = disable;
+    /**
+     * Enables the i18n interface for an input field.
+     */
+    function enable(elementId) {
+        const element = _elements.get(elementId);
+        if (element === undefined) {
+            throw new Error(`Expected a valid i18n input element, '${elementId}' is not i18n input field.`);
+        }
+        if (element.isEnabled) {
+            return;
+        }
+        element.isEnabled = true;
+        // show language dropdown
+        const buttonContainer = element.buttonLabel.parentElement;
+        Util_1.default.show(buttonContainer);
+        const dropdownContainer = buttonContainer.parentElement;
+        dropdownContainer.classList.add("inputAddon", "dropdown");
+    }
+    exports.enable = enable;
+    /**
+     * Returns true if i18n input is enabled for an input field.
+     */
+    function isEnabled(elementId) {
+        const element = _elements.get(elementId);
+        if (element === undefined) {
+            throw new Error(`Expected a valid i18n input element, '${elementId}' is not i18n input field.`);
+        }
+        return element.isEnabled;
+    }
+    exports.isEnabled = isEnabled;
+    /**
+     * Returns true if the value of an i18n input field is valid.
+     *
+     * If the element is disabled, true is returned.
+     */
+    function validate(elementId, permitEmptyValue) {
+        const element = _elements.get(elementId);
+        if (element === undefined) {
+            throw new Error(`Expected a valid i18n input element, '${elementId}' is not i18n input field.`);
+        }
+        if (!element.isEnabled) {
+            return true;
+        }
+        const values = _values.get(elementId);
+        const dropdownMenu = Simple_1.default.getDropdownMenu(element.element.parentElement.id);
+        if (element.languageId) {
+            values.set(element.languageId, element.element.value);
+        }
+        let hasEmptyValue = false;
+        let hasNonEmptyValue = false;
+        Array.from(dropdownMenu.children).forEach((item) => {
+            const languageId = ~~item.dataset.languageId;
+            if (languageId) {
+                if (!values.has(languageId) || values.get(languageId).length === 0) {
+                    // input has non-empty value for previously checked language
+                    if (hasNonEmptyValue) {
+                        return false;
                     }
-                    else {
-                        // input has empty value for previously checked language
-                        if (hasEmptyValue) {
-                            return false;
-                        }
-                        hasNonEmptyValue = true;
+                    hasEmptyValue = true;
+                }
+                else {
+                    // input has empty value for previously checked language
+                    if (hasEmptyValue) {
+                        return false;
                     }
+                    hasNonEmptyValue = true;
                 }
             }
-            return (!hasEmptyValue || permitEmptyValue);
-        }
-    };
+        });
+        return !hasEmptyValue || permitEmptyValue;
+    }
+    exports.validate = validate;
 });
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Language/Input.js b/wcfsetup/install/files/ts/WoltLabSuite/Core/Language/Input.js
deleted file mode 100644 (file)
index 2cadb77..0000000
+++ /dev/null
@@ -1,522 +0,0 @@
-/**
- * I18n interface for input and textarea fields.
- * 
- * @author      Alexander Ebert
- * @copyright  2001-2019 WoltLab GmbH
- * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module      WoltLabSuite/Core/Language/Input
- */
-define(['Core', 'Dictionary', 'Language', 'ObjectMap', 'StringUtil', 'Dom/Traverse', 'Dom/Util', 'Ui/SimpleDropdown'], function(Core, Dictionary, Language, ObjectMap, StringUtil, DomTraverse, DomUtil, UiSimpleDropdown) {
-       "use strict";
-       
-       var _elements = new Dictionary();
-       var _didInit = false;
-       var _forms = new ObjectMap();
-       var _values = new Dictionary();
-       
-       var _callbackDropdownToggle = null;
-       var _callbackSubmit = null;
-       
-       /**
-        * @exports     WoltLabSuite/Core/Language/Input
-        */
-       return {
-               /**
-                * Initializes an input field.
-                * 
-                * @param       {string}        elementId               input element id
-                * @param       {Object}        values                  preset values per language id
-                * @param       {Object}        availableLanguages      language names per language id
-                * @param       {boolean}       forceSelection          require i18n input
-                */
-               init: function(elementId, values, availableLanguages, forceSelection) {
-                       if (_values.has(elementId)) {
-                               return;
-                       }
-                       
-                       var element = elById(elementId);
-                       if (element === null) {
-                               throw new Error("Expected a valid element id, cannot find '" + elementId + "'.");
-                       }
-                       
-                       this._setup();
-                       
-                       // unescape values
-                       var unescapedValues = new Dictionary();
-                       for (var key in values) {
-                               if (values.hasOwnProperty(key)) {
-                                       unescapedValues.set(~~key, StringUtil.unescapeHTML(values[key]));
-                               }
-                       }
-                       
-                       _values.set(elementId, unescapedValues);
-                       
-                       this._initElement(elementId, element, unescapedValues, availableLanguages, forceSelection);
-               },
-               
-               /**
-                * Registers a callback for an element.
-                * 
-                * @param       {string}        elementId
-                * @param       {string}        eventName
-                * @param       {function}      callback
-                */
-               registerCallback: function (elementId, eventName, callback) {
-                       if (!_values.has(elementId)) {
-                               throw new Error("Unknown element id '" + elementId + "'.");
-                       }
-                       
-                       _elements.get(elementId).callbacks.set(eventName, callback);
-               },
-               
-               /**
-                * Unregisters the element with the given id.
-                * 
-                * @param       {string}        elementId
-                * @since       5.2
-                */
-               unregister: function(elementId) {
-                       if (!_values.has(elementId)) {
-                               throw new Error("Unknown element id '" + elementId + "'.");
-                       }
-                       
-                       _values.delete(elementId);
-                       _elements.delete(elementId);
-               },
-               
-               /**
-                * Caches common event listener callbacks.
-                */
-               _setup: function() {
-                       if (_didInit) return;
-                       _didInit = true;
-                       
-                       _callbackDropdownToggle = this._dropdownToggle.bind(this);
-                       _callbackSubmit = this._submit.bind(this);
-               },
-               
-               /**
-                * Sets up DOM and event listeners for an input field.
-                * 
-                * @param       {string}        elementId               input element id
-                * @param       {Element}       element                 input or textarea element
-                * @param       {Dictionary}    values                  preset values per language id
-                * @param       {Object}        availableLanguages      language names per language id
-                * @param       {boolean}       forceSelection          require i18n input
-                */
-               _initElement: function(elementId, element, values, availableLanguages, forceSelection) {
-                       var container = element.parentNode;
-                       if (!container.classList.contains('inputAddon')) {
-                               container = elCreate('div');
-                               container.className = 'inputAddon' + (element.nodeName === 'TEXTAREA' ? ' inputAddonTextarea' : '');
-                               //noinspection JSCheckFunctionSignatures
-                               elData(container, 'input-id', elementId);
-                               
-                               var hasFocus = document.activeElement === element;
-                               
-                               // DOM manipulation causes focused element to lose focus
-                               element.parentNode.insertBefore(container, element);
-                               container.appendChild(element);
-                               
-                               if (hasFocus) {
-                                       element.focus();
-                               }
-                       }
-                       
-                       container.classList.add('dropdown');
-                       var button = elCreate('span');
-                       button.className = 'button dropdownToggle inputPrefix';
-                       
-                       var span = elCreate('span');
-                       span.textContent = Language.get('wcf.global.button.disabledI18n');
-                       
-                       button.appendChild(span);
-                       container.insertBefore(button, element);
-                       
-                       var dropdownMenu = elCreate('ul');
-                       dropdownMenu.className = 'dropdownMenu';
-                       DomUtil.insertAfter(dropdownMenu, button);
-                       
-                       var callbackClick = (function(event, isInit) {
-                               var languageId = ~~elData(event.currentTarget, 'language-id');
-                               
-                               var activeItem = DomTraverse.childByClass(dropdownMenu, 'active');
-                               if (activeItem !== null) activeItem.classList.remove('active');
-                               
-                               if (languageId) event.currentTarget.classList.add('active');
-                               
-                               this._select(elementId, languageId, isInit || false);
-                       }).bind(this);
-                       
-                       // build language dropdown
-                       var listItem;
-                       for (var languageId in availableLanguages) {
-                               if (availableLanguages.hasOwnProperty(languageId)) {
-                                       listItem = elCreate('li');
-                                       elData(listItem, 'language-id', languageId);
-                                       
-                                       span = elCreate('span');
-                                       span.textContent = availableLanguages[languageId];
-                                       
-                                       listItem.appendChild(span);
-                                       listItem.addEventListener('click', callbackClick);
-                                       dropdownMenu.appendChild(listItem);
-                               }
-                       }
-                       
-                       if (forceSelection !== true) {
-                               listItem = elCreate('li');
-                               listItem.className = 'dropdownDivider';
-                               dropdownMenu.appendChild(listItem);
-                               
-                               listItem = elCreate('li');
-                               elData(listItem, 'language-id', 0);
-                               span = elCreate('span');
-                               span.textContent = Language.get('wcf.global.button.disabledI18n');
-                               listItem.appendChild(span);
-                               listItem.addEventListener('click', callbackClick);
-                               dropdownMenu.appendChild(listItem);
-                       }
-                       
-                       var activeItem = null;
-                       if (forceSelection === true || values.size) {
-                               for (var i = 0, length = dropdownMenu.childElementCount; i < length; i++) {
-                                       //noinspection JSUnresolvedVariable
-                                       if (~~elData(dropdownMenu.children[i], 'language-id') === LANGUAGE_ID) {
-                                               activeItem = dropdownMenu.children[i];
-                                               break;
-                                       }
-                               }
-                       }
-                       
-                       UiSimpleDropdown.init(button);
-                       UiSimpleDropdown.registerCallback(container.id, _callbackDropdownToggle);
-                       
-                       _elements.set(elementId, {
-                               buttonLabel: button.children[0],
-                               callbacks: new Dictionary(),
-                               element: element,
-                               languageId: 0,
-                               isEnabled: true,
-                               forceSelection: forceSelection
-                       });
-                       
-                       // bind to submit event
-                       var submit = DomTraverse.parentByTag(element, 'FORM');
-                       if (submit !== null) {
-                               submit.addEventListener('submit', _callbackSubmit);
-                               
-                               var elementIds = _forms.get(submit);
-                               if (elementIds === undefined) {
-                                       elementIds = [];
-                                       _forms.set(submit, elementIds);
-                               }
-                               
-                               elementIds.push(elementId);
-                       }
-                       
-                       if (activeItem !== null) {
-                               callbackClick({ currentTarget: activeItem }, true);
-                       }
-               },
-               
-               /**
-                * Selects a language or non-i18n from the dropdown list.
-                * 
-                * @param       {string}        elementId       input element id
-                * @param       {int}           languageId      language id or `0` to disable i18n
-                * @param       {boolean}       isInit          triggers pre-selection on init
-                */
-               _select: function(elementId, languageId, isInit) {
-                       var data = _elements.get(elementId);
-                       
-                       var dropdownMenu = UiSimpleDropdown.getDropdownMenu(data.element.closest('.inputAddon').id);
-                       var item, label = '';
-                       for (var i = 0, length = dropdownMenu.childElementCount; i < length; i++) {
-                               item = dropdownMenu.children[i];
-                               
-                               var itemLanguageId = elData(item, 'language-id');
-                               if (itemLanguageId.length && languageId === ~~itemLanguageId) {
-                                       label = item.children[0].textContent;
-                               }
-                       }
-                       
-                       // save current value
-                       if (data.languageId !== languageId) {
-                               var values = _values.get(elementId);
-                               
-                               if (data.languageId) {
-                                       values.set(data.languageId, data.element.value);
-                               }
-                               
-                               if (languageId === 0) {
-                                       _values.set(elementId, new Dictionary());
-                               }
-                               else if (data.buttonLabel.classList.contains('active') || isInit === true) {
-                                       data.element.value = (values.has(languageId)) ? values.get(languageId) : '';
-                               }
-                               
-                               // update label
-                               data.buttonLabel.textContent = label;
-                               data.buttonLabel.classList[(languageId ? 'add' : 'remove')]('active');
-                               
-                               data.languageId = languageId;
-                       }
-                       
-                       if (!isInit) {
-                               data.element.blur();
-                               data.element.focus();
-                       }
-                       
-                       if (data.callbacks.has('select')) {
-                               data.callbacks.get('select')(data.element);
-                       }
-               },
-               
-               /**
-                * Callback for dropdowns being opened, flags items with a missing value for one or more languages.
-                * 
-                * @param       {string}        containerId     dropdown container id
-                * @param       {string}        action          toggle action, can be `open` or `close`
-                */
-               _dropdownToggle: function(containerId, action) {
-                       if (action !== 'open') {
-                               return;
-                       }
-                       
-                       var dropdownMenu = UiSimpleDropdown.getDropdownMenu(containerId);
-                       var elementId = elData(elById(containerId), 'input-id');
-                       var data = _elements.get(elementId);
-                       var values = _values.get(elementId);
-                       
-                       var item, languageId;
-                       for (var i = 0, length = dropdownMenu.childElementCount; i < length; i++) {
-                               item = dropdownMenu.children[i];
-                               languageId = ~~elData(item, 'language-id');
-                               
-                               if (languageId) {
-                                       var hasMissingValue = false;
-                                       if (data.languageId) {
-                                               if (languageId === data.languageId) {
-                                                       hasMissingValue = (data.element.value.trim() === '');
-                                               }
-                                               else {
-                                                       hasMissingValue = (!values.get(languageId));
-                                               }
-                                       }
-                                       
-                                       item.classList[(hasMissingValue ? 'add' : 'remove')]('missingValue');
-                               }
-                       }
-               },
-               
-               /**
-                * Inserts hidden fields for i18n input on submit.
-                * 
-                * @param       {Object}        event           event object
-                */
-               _submit: function(event) {
-                       var elementIds = _forms.get(event.currentTarget);
-                       
-                       var data, elementId, input, values;
-                       for (var i = 0, length = elementIds.length; i < length; i++) {
-                               elementId = elementIds[i];
-                               data = _elements.get(elementId);
-                               if (data.isEnabled) {
-                                       values = _values.get(elementId);
-                                       
-                                       if (data.callbacks.has('submit')) {
-                                               data.callbacks.get('submit')(data.element);
-                                       }
-                                       
-                                       // update with current value
-                                       if (data.languageId) {
-                                               values.set(data.languageId, data.element.value);
-                                       }
-                                       
-                                       if (values.size) {
-                                               values.forEach(function(value, languageId) {
-                                                       input = elCreate('input');
-                                                       input.type = 'hidden';
-                                                       input.name = elementId + '_i18n[' + languageId + ']';
-                                                       input.value = value;
-                                                       
-                                                       event.currentTarget.appendChild(input);
-                                               });
-                                               
-                                               // remove name attribute to enforce i18n values
-                                               data.element.removeAttribute('name');
-                                       }
-                               }
-                       }
-               },
-               
-               /**
-                * Returns the values of an input field.
-                * 
-                * @param       {string}        elementId       input element id
-                * @return      {Dictionary}    values stored for the different languages
-                */
-               getValues: function(elementId) {
-                       var element = _elements.get(elementId);
-                       if (element === undefined) {
-                               throw new Error("Expected a valid i18n input element, '" + elementId + "' is not i18n input field.");
-                       }
-                       
-                       var values = _values.get(elementId);
-                       
-                       // update with current value
-                       values.set(element.languageId, element.element.value);
-                       
-                       return values;
-               },
-               
-               /**
-                * Sets the values of an input field.
-                * 
-                * @param       {string}        elementId       input element id
-                * @param       {Dictionary}    values          values for the different languages
-                */
-               setValues: function(elementId, values) {
-                       var element = _elements.get(elementId);
-                       if (element === undefined) {
-                               throw new Error("Expected a valid i18n input element, '" + elementId + "' is not i18n input field.");
-                       }
-                       
-                       if (Core.isPlainObject(values)) {
-                               values = Dictionary.fromObject(values);
-                       }
-                       
-                       element.element.value = '';
-                       
-                       if (values.has(0)) {
-                               element.element.value = values.get(0);
-                               values['delete'](0);
-                               _values.set(elementId, values);
-                               this._select(elementId, 0, true);
-                               return;
-                       }
-                       
-                       _values.set(elementId, values);
-                       
-                       element.languageId = 0;
-                       //noinspection JSUnresolvedVariable
-                       this._select(elementId, LANGUAGE_ID, true);
-               },
-               
-               /**
-                * Disables the i18n interface for an input field.
-                * 
-                * @param       {string}        elementId       input element id
-                */
-               disable: function(elementId) {
-                       var element = _elements.get(elementId);
-                       if (element === undefined) {
-                               throw new Error("Expected a valid element, '" + elementId + "' is not an i18n input field.");
-                       }
-                       
-                       if (!element.isEnabled) return;
-                       
-                       element.isEnabled = false;
-                       
-                       // hide language dropdown
-                       //noinspection JSCheckFunctionSignatures
-                       elHide(element.buttonLabel.parentNode);
-                       var dropdownContainer = element.buttonLabel.parentNode.parentNode;
-                       dropdownContainer.classList.remove('inputAddon');
-                       dropdownContainer.classList.remove('dropdown');
-               },
-               
-               /**
-                * Enables the i18n interface for an input field.
-                * 
-                * @param       {string}        elementId       input element id
-                */
-               enable: function(elementId) {
-                       var element = _elements.get(elementId);
-                       if (element === undefined) {
-                               throw new Error("Expected a valid i18n input element, '" + elementId + "' is not i18n input field.");
-                       }
-                       
-                       if (element.isEnabled) return;
-                       
-                       element.isEnabled = true;
-                       
-                       // show language dropdown
-                       //noinspection JSCheckFunctionSignatures
-                       elShow(element.buttonLabel.parentNode);
-                       var dropdownContainer = element.buttonLabel.parentNode.parentNode;
-                       dropdownContainer.classList.add('inputAddon');
-                       dropdownContainer.classList.add('dropdown');
-               },
-               
-               /**
-                * Returns true if i18n input is enabled for an input field.
-                * 
-                * @param       {string}        elementId       input element id
-                * @return      {boolean}
-                */
-               isEnabled: function(elementId) {
-                       var element = _elements.get(elementId);
-                       if (element === undefined) {
-                               throw new Error("Expected a valid i18n input element, '" + elementId + "' is not i18n input field.");
-                       }
-                       
-                       return element.isEnabled;
-               },
-               
-               /**
-                * Returns true if the value of an i18n input field is valid.
-                * 
-                * If the element is disabled, true is returned.
-                * 
-                * @param       {string}        elementId               input element id
-                * @param       {boolean}       permitEmptyValue        if true, input may be empty for all languages
-                * @return      {boolean}       true if input is valid
-                */
-               validate: function(elementId, permitEmptyValue) {
-                       var element = _elements.get(elementId);
-                       if (element === undefined) {
-                               throw new Error("Expected a valid i18n input element, '" + elementId + "' is not i18n input field.");
-                       }
-                       
-                       if (!element.isEnabled) return true;
-                       
-                       var values = _values.get(elementId);
-                       
-                       var dropdownMenu = UiSimpleDropdown.getDropdownMenu(element.element.parentNode.id);
-                       
-                       if (element.languageId) {
-                               values.set(element.languageId, element.element.value);
-                       }
-                       
-                       var item, languageId;
-                       var hasEmptyValue = false, hasNonEmptyValue = false;
-                       for (var i = 0, length = dropdownMenu.childElementCount; i < length; i++) {
-                               item = dropdownMenu.children[i];
-                               languageId = ~~elData(item, 'language-id');
-                               
-                               if (languageId) {
-                                       if (!values.has(languageId) || values.get(languageId).length === 0) {
-                                               // input has non-empty value for previously checked language
-                                               if (hasNonEmptyValue) {
-                                                       return false;
-                                               }
-                                               
-                                               hasEmptyValue = true;
-                                       }
-                                       else {
-                                               // input has empty value for previously checked language
-                                               if (hasEmptyValue) {
-                                                       return false;
-                                               }
-                                               
-                                               hasNonEmptyValue = true;
-                                       }
-                               }
-                       }
-                       
-                       return (!hasEmptyValue || permitEmptyValue);
-               }
-       };
-});
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Language/Input.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Language/Input.ts
new file mode 100644 (file)
index 0000000..dd56ae1
--- /dev/null
@@ -0,0 +1,508 @@
+/**
+ * I18n interface for input and textarea fields.
+ *
+ * @author      Alexander Ebert
+ * @copyright  2001-2019 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module      WoltLabSuite/Core/Language/Input
+ */
+
+import DomUtil from "../Dom/Util";
+import * as Language from "../Language";
+import { NotificationAction } from "../Ui/Dropdown/Data";
+import UiDropdownSimple from "../Ui/Dropdown/Simple";
+import * as StringUtil from "../StringUtil";
+
+type LanguageId = number;
+
+interface I18nValues {
+  // languageID => value
+  [key: string]: string;
+}
+
+interface Languages {
+  // languageID => languageName
+  [key: string]: string;
+}
+
+type Values = Map<LanguageId, string>;
+
+type InputOrTextarea = HTMLInputElement | HTMLTextAreaElement;
+
+type CallbackEvent = "select" | "submit";
+type Callback = (element: InputOrTextarea) => void;
+
+interface ElementData {
+  buttonLabel: HTMLElement;
+  callbacks: Map<CallbackEvent, Callback>;
+  element: InputOrTextarea;
+  languageId: number;
+  isEnabled: boolean;
+  forceSelection: boolean;
+}
+
+const _elements = new Map<string, ElementData>();
+const _forms = new WeakMap<HTMLFormElement, string[]>();
+const _values = new Map<string, Values>();
+
+/**
+ * Sets up DOM and event listeners for an input field.
+ */
+function initElement(
+  elementId: string,
+  element: InputOrTextarea,
+  values: Values,
+  availableLanguages: Languages,
+  forceSelection: boolean,
+): void {
+  let container = element.parentElement!;
+  if (!container.classList.contains("inputAddon")) {
+    container = document.createElement("div");
+    container.className = "inputAddon";
+    if (element.nodeName === "TEXTAREA") {
+      container.classList.add("inputAddonTextarea");
+    }
+    container.dataset.inputId = elementId;
+
+    const hasFocus = document.activeElement === element;
+
+    // DOM manipulation causes focused element to lose focus
+    element.insertAdjacentElement("beforebegin", container);
+    container.appendChild(element);
+
+    if (hasFocus) {
+      element.focus();
+    }
+  }
+
+  container.classList.add("dropdown");
+  const button = document.createElement("span");
+  button.className = "button dropdownToggle inputPrefix";
+
+  const buttonLabel = document.createElement("span");
+  buttonLabel.textContent = Language.get("wcf.global.button.disabledI18n");
+
+  button.appendChild(buttonLabel);
+  container.insertBefore(button, element);
+
+  const dropdownMenu = document.createElement("ul");
+  dropdownMenu.className = "dropdownMenu";
+  button.insertAdjacentElement("afterend", dropdownMenu);
+
+  const callbackClick = (event: MouseEvent | HTMLElement): void => {
+    let target: HTMLElement;
+    if (event instanceof HTMLElement) {
+      target = event;
+    } else {
+      target = event.currentTarget as HTMLElement;
+    }
+
+    const languageId = ~~target.dataset.languageId!;
+
+    const activeItem = dropdownMenu.querySelector(".active");
+    if (activeItem !== null) {
+      activeItem.classList.remove("active");
+    }
+
+    if (languageId) {
+      target.classList.add("active");
+    }
+
+    const isInit = event instanceof HTMLElement;
+    select(elementId, languageId, isInit);
+  };
+
+  // build language dropdown
+  Object.entries(availableLanguages).forEach(([languageId, languageName]) => {
+    const listItem = document.createElement("li");
+    listItem.dataset.languageId = languageId;
+
+    const span = document.createElement("span");
+    span.textContent = languageName;
+
+    listItem.appendChild(span);
+    listItem.addEventListener("click", callbackClick);
+    dropdownMenu.appendChild(listItem);
+  });
+
+  if (!forceSelection) {
+    const divider = document.createElement("li");
+    divider.className = "dropdownDivider";
+    dropdownMenu.appendChild(divider);
+
+    const listItem = document.createElement("li");
+    listItem.dataset.languageId = "0";
+    listItem.addEventListener("click", callbackClick);
+
+    const span = document.createElement("span");
+    span.textContent = Language.get("wcf.global.button.disabledI18n");
+    listItem.appendChild(span);
+
+    dropdownMenu.appendChild(listItem);
+  }
+
+  let activeItem: HTMLElement | undefined = undefined;
+  if (forceSelection || values.size) {
+    activeItem = Array.from(dropdownMenu.children).find((element: HTMLElement) => {
+      return +element.dataset.languageId! === window.LANGUAGE_ID;
+    }) as HTMLElement;
+  }
+
+  UiDropdownSimple.init(button);
+  UiDropdownSimple.registerCallback(container.id, dropdownToggle);
+
+  _elements.set(elementId, {
+    buttonLabel,
+    callbacks: new Map<CallbackEvent, Callback>(),
+    element,
+    languageId: 0,
+    isEnabled: true,
+    forceSelection,
+  });
+
+  // bind to submit event
+  const form = element.closest("form");
+  if (form !== null) {
+    form.addEventListener("submit", submit);
+
+    let elementIds = _forms.get(form);
+    if (elementIds === undefined) {
+      elementIds = [];
+      _forms.set(form, elementIds);
+    }
+
+    elementIds.push(elementId);
+  }
+
+  if (activeItem) {
+    callbackClick(activeItem);
+  }
+}
+
+/**
+ * Selects a language or non-i18n from the dropdown list.
+ */
+function select(elementId: string, languageId: number, isInit: boolean): void {
+  const data = _elements.get(elementId)!;
+
+  const dropdownMenu = UiDropdownSimple.getDropdownMenu(data.element.closest(".inputAddon")!.id)!;
+
+  const item = dropdownMenu.querySelector(`[data-language-id="${languageId}"]`);
+  const label = item ? item.textContent! : "";
+
+  // save current value
+  if (data.languageId !== languageId) {
+    const values = _values.get(elementId)!;
+
+    if (data.languageId) {
+      values.set(data.languageId, data.element.value);
+    }
+
+    if (languageId === 0) {
+      _values.set(elementId, new Map<LanguageId, string>());
+    } else if (data.buttonLabel.classList.contains("active") || isInit) {
+      data.element.value = values.get(languageId) || "";
+    }
+
+    // update label
+    data.buttonLabel.textContent = label;
+    data.buttonLabel.classList[languageId ? "add" : "remove"]("active");
+
+    data.languageId = languageId;
+  }
+
+  if (!isInit) {
+    data.element.blur();
+    data.element.focus();
+  }
+
+  if (data.callbacks.has("select")) {
+    data.callbacks.get("select")!(data.element);
+  }
+}
+
+/**
+ * Callback for dropdowns being opened, flags items with a missing value for one or more languages.
+ */
+function dropdownToggle(containerId: string, action: NotificationAction): void {
+  if (action !== "open") {
+    return;
+  }
+
+  const dropdownMenu = UiDropdownSimple.getDropdownMenu(containerId)!;
+  const container = document.getElementById(containerId)!;
+  const elementId = container.dataset.inputId!;
+  const data = _elements.get(elementId)!;
+  const values = _values.get(elementId)!;
+
+  Array.from(dropdownMenu.children).forEach((item: HTMLElement) => {
+    const languageId = ~~(item.dataset.languageId || "");
+
+    if (languageId) {
+      let hasMissingValue = false;
+      if (data.languageId) {
+        if (languageId === data.languageId) {
+          hasMissingValue = data.element.value.trim() === "";
+        } else {
+          hasMissingValue = !values.get(languageId);
+        }
+      }
+
+      if (hasMissingValue) {
+        item.classList.add("missingValue");
+      } else {
+        item.classList.remove("missingValue");
+      }
+    }
+  });
+}
+
+/**
+ * Inserts hidden fields for i18n input on submit.
+ */
+function submit(event: Event): void {
+  const form = event.currentTarget as HTMLFormElement;
+  const elementIds = _forms.get(form)!;
+
+  elementIds.forEach((elementId) => {
+    const data = _elements.get(elementId)!;
+    if (!data.isEnabled) {
+      return;
+    }
+
+    const values = _values.get(elementId)!;
+
+    if (data.callbacks.has("submit")) {
+      data.callbacks.get("submit")!(data.element);
+    }
+
+    // update with current value
+    if (data.languageId) {
+      values.set(data.languageId, data.element.value);
+    }
+
+    if (values.size) {
+      values.forEach(function (value, languageId) {
+        const input = document.createElement("input");
+        input.type = "hidden";
+        input.name = `${elementId}_i18n[${languageId}]`;
+        input.value = value;
+
+        form.appendChild(input);
+      });
+
+      // remove name attribute to enforce i18n values
+      data.element.removeAttribute("name");
+    }
+  });
+}
+
+/**
+ * Initializes an input field.
+ */
+export function init(
+  elementId: string,
+  values: I18nValues,
+  availableLanguages: Languages,
+  forceSelection: boolean,
+): void {
+  if (_values.has(elementId)) {
+    return;
+  }
+
+  const element = document.getElementById(elementId) as InputOrTextarea;
+  if (element === null) {
+    throw new Error(`Expected a valid element id, cannot find '${elementId}'.`);
+  }
+
+  // unescape values
+  const unescapedValues = new Map<LanguageId, string>();
+  Object.entries(values).forEach(([languageId, value]) => {
+    unescapedValues.set(+languageId, StringUtil.unescapeHTML(value));
+  });
+
+  _values.set(elementId, unescapedValues);
+
+  initElement(elementId, element, unescapedValues, availableLanguages, forceSelection);
+}
+
+/**
+ * Registers a callback for an element.
+ */
+export function registerCallback(elementId: string, eventName: CallbackEvent, callback: Callback): void {
+  if (!_values.has(elementId)) {
+    throw new Error(`Unknown element id '${elementId}'.`);
+  }
+
+  _elements.get(elementId)!.callbacks.set(eventName, callback);
+}
+
+/**
+ * Unregisters the element with the given id.
+ *
+ * @since  5.2
+ */
+export function unregister(elementId: string): void {
+  if (!_values.has(elementId)) {
+    throw new Error(`Unknown element id '${elementId}'.`);
+  }
+
+  _values.delete(elementId);
+  _elements.delete(elementId);
+}
+
+/**
+ * Returns the values of an input field.
+ */
+export function getValues(elementId: string): Values {
+  const element = _elements.get(elementId)!;
+  if (element === undefined) {
+    throw new Error(`Expected a valid i18n input element, '${elementId}' is not i18n input field.`);
+  }
+
+  const values = _values.get(elementId)!;
+
+  // update with current value
+  values.set(element.languageId, element.element.value);
+
+  return values;
+}
+
+/**
+ * Sets the values of an input field.
+ */
+export function setValues(elementId: string, newValues: Values | I18nValues): void {
+  const element = _elements.get(elementId);
+  if (element === undefined) {
+    throw new Error(`Expected a valid i18n input element, '${elementId}' is not i18n input field.`);
+  }
+
+  element.element.value = "";
+
+  const values = new Map<LanguageId, string>(
+    Object.entries(newValues).map(([languageId, value]) => {
+      return [+languageId, value];
+    }),
+  );
+
+  if (values.has(0)) {
+    element.element.value = values.get(0)!;
+    values.delete(0);
+
+    _values.set(elementId, values);
+    select(elementId, 0, true);
+
+    return;
+  }
+
+  _values.set(elementId, values);
+
+  element.languageId = 0;
+  select(elementId, window.LANGUAGE_ID, true);
+}
+
+/**
+ * Disables the i18n interface for an input field.
+ */
+export function disable(elementId: string): void {
+  const element = _elements.get(elementId);
+  if (element === undefined) {
+    throw new Error(`Expected a valid element, '${elementId}' is not an i18n input field.`);
+  }
+
+  if (!element.isEnabled) {
+    return;
+  }
+
+  element.isEnabled = false;
+
+  // hide language dropdown
+  const buttonContainer = element.buttonLabel.parentElement!;
+  DomUtil.hide(buttonContainer);
+  const dropdownContainer = buttonContainer.parentElement!;
+  dropdownContainer.classList.remove("inputAddon", "dropdown");
+}
+
+/**
+ * Enables the i18n interface for an input field.
+ */
+export function enable(elementId: string): void {
+  const element = _elements.get(elementId);
+  if (element === undefined) {
+    throw new Error(`Expected a valid i18n input element, '${elementId}' is not i18n input field.`);
+  }
+
+  if (element.isEnabled) {
+    return;
+  }
+
+  element.isEnabled = true;
+
+  // show language dropdown
+  const buttonContainer = element.buttonLabel.parentElement!;
+  DomUtil.show(buttonContainer);
+  const dropdownContainer = buttonContainer.parentElement!;
+  dropdownContainer.classList.add("inputAddon", "dropdown");
+}
+
+/**
+ * Returns true if i18n input is enabled for an input field.
+ */
+export function isEnabled(elementId: string): boolean {
+  const element = _elements.get(elementId);
+  if (element === undefined) {
+    throw new Error(`Expected a valid i18n input element, '${elementId}' is not i18n input field.`);
+  }
+
+  return element.isEnabled;
+}
+
+/**
+ * Returns true if the value of an i18n input field is valid.
+ *
+ * If the element is disabled, true is returned.
+ */
+export function validate(elementId: string, permitEmptyValue: boolean): boolean {
+  const element = _elements.get(elementId)!;
+  if (element === undefined) {
+    throw new Error(`Expected a valid i18n input element, '${elementId}' is not i18n input field.`);
+  }
+
+  if (!element.isEnabled) {
+    return true;
+  }
+
+  const values = _values.get(elementId)!;
+
+  const dropdownMenu = UiDropdownSimple.getDropdownMenu(element.element.parentElement!.id)!;
+
+  if (element.languageId) {
+    values.set(element.languageId, element.element.value);
+  }
+
+  let hasEmptyValue = false;
+  let hasNonEmptyValue = false;
+  Array.from(dropdownMenu.children).forEach((item: HTMLElement) => {
+    const languageId = ~~item.dataset.languageId!;
+
+    if (languageId) {
+      if (!values.has(languageId) || values.get(languageId)!.length === 0) {
+        // input has non-empty value for previously checked language
+        if (hasNonEmptyValue) {
+          return false;
+        }
+
+        hasEmptyValue = true;
+      } else {
+        // input has empty value for previously checked language
+        if (hasEmptyValue) {
+          return false;
+        }
+
+        hasNonEmptyValue = true;
+      }
+    }
+  });
+
+  return !hasEmptyValue || permitEmptyValue;
+}