Convert `Controller/Popover` to TypeScript
authorAlexander Ebert <ebert@woltlab.com>
Fri, 1 Jan 2021 15:08:05 +0000 (16:08 +0100)
committerAlexander Ebert <ebert@woltlab.com>
Fri, 1 Jan 2021 15:08:05 +0000 (16:08 +0100)
wcfsetup/install/files/js/WoltLabSuite/Core/Controller/Popover.js
wcfsetup/install/files/ts/WoltLabSuite/Core/Ajax/Data.ts
wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Popover.js [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Popover.ts [new file with mode: 0644]

index bc15086aac66b1aea1c1c01e3880a29736b90614..2ef5288302bcf5b246b267a0d48649718d8238d7 100644 (file)
@@ -1,70 +1,57 @@
 /**
  * Versatile popover manager.
  *
- * @author     Alexander Ebert
- * @copyright  2001-2019 WoltLab GmbH
- * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module     WoltLabSuite/Core/Controller/Popover
+ * @author  Alexander Ebert
+ * @copyright  2001-2019 WoltLab GmbH
+ * @license  GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module  WoltLabSuite/Core/Controller/Popover
  */
-define(['Ajax', 'Dictionary', 'Environment', 'Dom/ChangeListener', 'Dom/Util', 'Ui/Alignment'], function (Ajax, Dictionary, Environment, DomChangeListener, DomUtil, UiAlignment) {
+define(["require", "exports", "tslib", "../Ajax", "../Dom/Change/Listener", "../Dom/Util", "../Environment", "../Ui/Alignment"], function (require, exports, tslib_1, Ajax, Listener_1, Util_1, Environment, UiAlignment) {
     "use strict";
-    var _activeId = null;
-    var _cache = new Dictionary();
-    var _elements = new Dictionary();
-    var _handlers = new Dictionary();
-    var _hoverId = null;
-    var _suspended = false;
-    var _timeoutEnter = null;
-    var _timeoutLeave = null;
-    var _popover = null;
-    var _popoverContent = null;
-    var _callbackClick = null;
-    var _callbackHide = null;
-    var _callbackMouseEnter = null;
-    var _callbackMouseLeave = null;
-    /** @const */ var STATE_NONE = 0;
-    /** @const */ var STATE_LOADING = 1;
-    /** @const */ var STATE_READY = 2;
-    /** @const */ var DELAY_HIDE = 500;
-    /** @const */ var DELAY_SHOW = 800;
-    /**
-     * @exports        WoltLabSuite/Core/Controller/Popover
-     */
-    return {
+    Object.defineProperty(exports, "__esModule", { value: true });
+    exports.ajaxApi = exports.setContent = exports.init = void 0;
+    Ajax = tslib_1.__importStar(Ajax);
+    Listener_1 = tslib_1.__importDefault(Listener_1);
+    Util_1 = tslib_1.__importDefault(Util_1);
+    Environment = tslib_1.__importStar(Environment);
+    UiAlignment = tslib_1.__importStar(UiAlignment);
+    class ControllerPopover {
         /**
          * Builds popover DOM elements and binds event listeners.
          */
-        _setup: function () {
-            if (_popover !== null) {
-                return;
-            }
-            _popover = elCreate('div');
-            _popover.className = 'popover forceHide';
-            _popoverContent = elCreate('div');
-            _popoverContent.className = 'popoverContent';
-            _popover.appendChild(_popoverContent);
-            var pointer = elCreate('span');
-            pointer.className = 'elementPointer';
-            pointer.appendChild(elCreate('span'));
-            _popover.appendChild(pointer);
-            document.body.appendChild(_popover);
-            // static binding for callbacks (they don't change anyway and binding each time is expensive)
-            _callbackClick = this._hide.bind(this);
-            _callbackMouseEnter = this._mouseEnter.bind(this);
-            _callbackMouseLeave = this._mouseLeave.bind(this);
+        constructor() {
+            this.activeId = "";
+            this.cache = new Map();
+            this.elements = new Map();
+            this.handlers = new Map();
+            this.hoverId = "";
+            this.suspended = false;
+            this.timerEnter = undefined;
+            this.timerLeave = undefined;
+            this.popover = document.createElement("div");
+            this.popover.className = "popover forceHide";
+            this.popoverContent = document.createElement("div");
+            this.popoverContent.className = "popoverContent";
+            this.popover.appendChild(this.popoverContent);
+            const pointer = document.createElement("span");
+            pointer.className = "elementPointer";
+            pointer.appendChild(document.createElement("span"));
+            this.popover.appendChild(pointer);
+            document.body.appendChild(this.popover);
             // event listener
-            _popover.addEventListener('mouseenter', this._popoverMouseEnter.bind(this));
-            _popover.addEventListener('mouseleave', _callbackMouseLeave);
-            _popover.addEventListener('animationend', this._clearContent.bind(this));
-            window.addEventListener('beforeunload', (function () {
-                _suspended = true;
-                if (_timeoutEnter !== null) {
-                    window.clearTimeout(_timeoutEnter);
+            this.popover.addEventListener("mouseenter", this.popoverMouseEnter.bind(this));
+            this.popover.addEventListener("mouseleave", () => this.mouseLeave());
+            this.popover.addEventListener("animationend", this.clearContent.bind(this));
+            window.addEventListener("beforeunload", () => {
+                this.suspended = true;
+                if (this.timerEnter) {
+                    window.clearTimeout(this.timerEnter);
+                    this.timerEnter = undefined;
                 }
-                this._hide(true);
-            }).bind(this));
-            DomChangeListener.add('WoltLabSuite/Core/Controller/Popover', this._init.bind(this));
-        },
+                this.hidePopover();
+            });
+            Listener_1.default.add("WoltLabSuite/Core/Controller/Popover", (identifier) => this.initHandler(identifier));
+        }
         /**
          * Initializes a popover handler.
          *
@@ -74,278 +61,305 @@ define(['Ajax', 'Dictionary', 'Environment', 'Dom/ChangeListener', 'Dom/Util', '
          *     attributeName: 'data-object-id',
          *     className: 'fooLink',
          *     identifier: 'com.example.bar.foo',
-         *     loadCallback: function(objectId, popover) {
+         *     loadCallback: (objectId, popover) => {
          *             // request data for object id (e.g. via WoltLabSuite/Core/Ajax)
          *
          *             // then call this to set the content
          *             popover.setContent('com.example.bar.foo', objectId, htmlTemplateString);
          *     }
          * });
-         *
-         * @param      {Object}        options         handler options
          */
-        init: function (options) {
-            if (Environment.platform() !== 'desktop') {
+        init(options) {
+            if (Environment.platform() !== "desktop") {
                 return;
             }
-            options.attributeName = options.attributeName || 'data-object-id';
-            options.legacy = (options.legacy === true);
-            this._setup();
-            if (_handlers.has(options.identifier)) {
+            options.attributeName = options.attributeName || "data-object-id";
+            options.legacy = options.legacy === true;
+            if (this.handlers.has(options.identifier)) {
                 return;
             }
-            _handlers.set(options.identifier, {
+            // Legacy implementations provided a selector for `className`.
+            const selector = options.legacy ? options.className : `.${options.className}`;
+            this.handlers.set(options.identifier, {
                 attributeName: options.attributeName,
                 dboAction: options.dboAction,
-                elements: options.legacy ? options.className : elByClass(options.className),
                 legacy: options.legacy,
-                loadCallback: options.loadCallback
+                loadCallback: options.loadCallback,
+                selector,
             });
-            this._init(options.identifier);
-        },
+            this.initHandler(options.identifier);
+        }
         /**
          * Initializes a popover handler.
-         *
-         * @param      {string}        identifier      handler identifier
          */
-        _init: function (identifier) {
-            if (typeof identifier === 'string' && identifier.length) {
-                this._initElements(_handlers.get(identifier), identifier);
+        initHandler(identifier) {
+            if (typeof identifier === "string" && identifier.length) {
+                this.initElements(this.handlers.get(identifier), identifier);
             }
             else {
-                _handlers.forEach(this._initElements.bind(this));
+                this.handlers.forEach((value, key) => {
+                    this.initElements(value, key);
+                });
             }
-        },
+        }
         /**
          * Binds event listeners for popover-enabled elements.
-         *
-         * @param      {Object}        options         handler options
-         * @param      {string}        identifier      handler identifier
          */
-        _initElements: function (options, identifier) {
-            var elements = options.legacy ? elBySelAll(options.elements) : options.elements;
-            for (var i = 0, length = elements.length; i < length; i++) {
-                var element = elements[i];
-                var id = DomUtil.identify(element);
-                if (_cache.has(id)) {
+        initElements(options, identifier) {
+            document.querySelectorAll(options.selector).forEach((element) => {
+                const id = Util_1.default.identify(element);
+                if (this.cache.has(id)) {
                     return;
                 }
-                // skip if element is in a popover
-                if (element.closest('.popover') !== null) {
-                    _cache.set(id, {
+                // Skip elements that are located inside a popover.
+                if (element.closest(".popover") !== null) {
+                    this.cache.set(id, {
                         content: null,
-                        state: STATE_NONE
+                        state: 0 /* None */,
                     });
                     return;
                 }
-                var objectId = (options.legacy) ? id : ~~element.getAttribute(options.attributeName);
+                const objectId = options.legacy ? id : ~~element.getAttribute(options.attributeName);
                 if (objectId === 0) {
-                    continue;
+                    return;
                 }
-                element.addEventListener('mouseenter', _callbackMouseEnter);
-                element.addEventListener('mouseleave', _callbackMouseLeave);
-                if (element.nodeName === 'A' && elAttr(element, 'href')) {
-                    element.addEventListener('click', _callbackClick);
+                element.addEventListener("mouseenter", (ev) => this.mouseEnter(ev));
+                element.addEventListener("mouseleave", () => this.mouseLeave());
+                if (element instanceof HTMLAnchorElement && element.href) {
+                    element.addEventListener("click", () => this.hidePopover());
                 }
-                var cacheId = identifier + "-" + objectId;
-                elData(element, 'cache-id', cacheId);
-                _elements.set(id, {
-                    element: element,
-                    identifier: identifier,
-                    objectId: objectId
+                const cacheId = `${identifier}-${objectId}`;
+                element.dataset.cacheId = cacheId;
+                this.elements.set(id, {
+                    element,
+                    identifier,
+                    objectId: objectId.toString(),
                 });
-                if (!_cache.has(cacheId)) {
-                    _cache.set(identifier + "-" + objectId, {
+                if (!this.cache.has(cacheId)) {
+                    this.cache.set(cacheId, {
                         content: null,
-                        state: STATE_NONE
+                        state: 0 /* None */,
                     });
                 }
-            }
-        },
+            });
+        }
         /**
          * Sets the content for given identifier and object id.
-         *
-         * @param      {string}        identifier      handler identifier
-         * @param      {int}           objectId        object id
-         * @param      {string}        content         HTML string
          */
-        setContent: function (identifier, objectId, content) {
-            var cacheId = identifier + "-" + objectId;
-            var data = _cache.get(cacheId);
+        setContent(identifier, objectId, content) {
+            const cacheId = `${identifier}-${objectId}`;
+            const data = this.cache.get(cacheId);
             if (data === undefined) {
-                throw new Error("Unable to find element for object id '" + objectId + "' (identifier: '" + identifier + "').");
+                throw new Error(`Unable to find element for object id '${objectId}' (identifier: '${identifier}').`);
+            }
+            let fragment = Util_1.default.createFragmentFromHtml(content);
+            if (!fragment.childElementCount) {
+                fragment = Util_1.default.createFragmentFromHtml("<p>" + content + "</p>");
             }
-            var fragment = DomUtil.createFragmentFromHtml(content);
-            if (!fragment.childElementCount)
-                fragment = DomUtil.createFragmentFromHtml('<p>' + content + '</p>');
             data.content = fragment;
-            data.state = STATE_READY;
-            if (_activeId) {
-                var activeElement = _elements.get(_activeId).element;
-                if (elData(activeElement, 'cache-id') === cacheId) {
-                    this._show();
+            data.state = 2 /* Ready */;
+            if (this.activeId) {
+                const activeElement = this.elements.get(this.activeId).element;
+                if (activeElement.dataset.cacheId === cacheId) {
+                    this.show();
                 }
             }
-        },
+        }
         /**
          * Handles the mouse start hovering the popover-enabled element.
-         *
-         * @param      {object}        event   event object
          */
-        _mouseEnter: function (event) {
-            if (_suspended) {
+        mouseEnter(event) {
+            if (this.suspended) {
                 return;
             }
-            if (_timeoutEnter !== null) {
-                window.clearTimeout(_timeoutEnter);
-                _timeoutEnter = null;
+            if (this.timerEnter) {
+                window.clearTimeout(this.timerEnter);
+                this.timerEnter = undefined;
             }
-            var id = DomUtil.identify(event.currentTarget);
-            if (_activeId === id && _timeoutLeave !== null) {
-                window.clearTimeout(_timeoutLeave);
-                _timeoutLeave = null;
+            const id = Util_1.default.identify(event.currentTarget);
+            if (this.activeId === id && this.timerLeave) {
+                window.clearTimeout(this.timerLeave);
+                this.timerLeave = undefined;
             }
-            _hoverId = id;
-            _timeoutEnter = window.setTimeout((function () {
-                _timeoutEnter = null;
-                if (_hoverId === id) {
-                    this._show();
+            this.hoverId = id;
+            this.timerEnter = window.setTimeout(() => {
+                this.timerEnter = undefined;
+                if (this.hoverId === id) {
+                    this.show();
                 }
-            }).bind(this), DELAY_SHOW);
-        },
+            }, 800 /* Show */);
+        }
         /**
          * Handles the mouse leaving the popover-enabled element or the popover itself.
          */
-        _mouseLeave: function () {
-            _hoverId = null;
-            if (_timeoutLeave !== null) {
+        mouseLeave() {
+            this.hoverId = "";
+            if (this.timerLeave) {
                 return;
             }
-            if (_callbackHide === null) {
-                _callbackHide = this._hide.bind(this);
-            }
-            if (_timeoutLeave !== null) {
-                window.clearTimeout(_timeoutLeave);
-            }
-            _timeoutLeave = window.setTimeout(_callbackHide, DELAY_HIDE);
-        },
+            this.timerLeave = window.setTimeout(() => this.hidePopover(), 500 /* Hide */);
+        }
         /**
          * Handles the mouse start hovering the popover element.
          */
-        _popoverMouseEnter: function () {
-            if (_timeoutLeave !== null) {
-                window.clearTimeout(_timeoutLeave);
-                _timeoutLeave = null;
+        popoverMouseEnter() {
+            if (this.timerLeave) {
+                window.clearTimeout(this.timerLeave);
+                this.timerLeave = undefined;
             }
-        },
+        }
         /**
          * Shows the popover and loads content on-the-fly.
          */
-        _show: function () {
-            if (_timeoutLeave !== null) {
-                window.clearTimeout(_timeoutLeave);
-                _timeoutLeave = null;
+        show() {
+            if (this.timerLeave) {
+                window.clearTimeout(this.timerLeave);
+                this.timerLeave = undefined;
             }
-            var forceHide = false;
-            if (_popover.classList.contains('active')) {
-                if (_activeId !== _hoverId) {
-                    this._hide();
+            let forceHide = false;
+            if (this.popover.classList.contains("active")) {
+                if (this.activeId !== this.hoverId) {
+                    this.hidePopover();
                     forceHide = true;
                 }
             }
-            else if (_popoverContent.childElementCount) {
+            else if (this.popoverContent.childElementCount) {
                 forceHide = true;
             }
             if (forceHide) {
-                _popover.classList.add('forceHide');
+                this.popover.classList.add("forceHide");
                 // force layout
                 //noinspection BadExpressionStatementJS
-                _popover.offsetTop;
-                this._clearContent();
-                _popover.classList.remove('forceHide');
+                this.popover.offsetTop;
+                this.clearContent();
+                this.popover.classList.remove("forceHide");
             }
-            _activeId = _hoverId;
-            var elementData = _elements.get(_activeId);
+            this.activeId = this.hoverId;
+            const elementData = this.elements.get(this.activeId);
             // check if source element is already gone
             if (elementData === undefined) {
                 return;
             }
-            var data = _cache.get(elData(elementData.element, 'cache-id'));
-            if (data.state === STATE_READY) {
-                _popoverContent.appendChild(data.content);
-                this._rebuild(_activeId);
+            const cacheId = elementData.element.dataset.cacheId;
+            const data = this.cache.get(cacheId);
+            if (data.state === 2 /* Ready */) {
+                this.popoverContent.appendChild(data.content);
+                this.rebuild();
             }
-            else if (data.state === STATE_NONE) {
-                data.state = STATE_LOADING;
-                var handler = _handlers.get(elementData.identifier);
+            else if (data.state === 0 /* None */) {
+                data.state = 1 /* Loading */;
+                const handler = this.handlers.get(elementData.identifier);
                 if (handler.loadCallback) {
                     handler.loadCallback(elementData.objectId, this, elementData.element);
                 }
                 else if (handler.dboAction) {
-                    var callback = function (data) {
+                    const callback = (data) => {
                         this.setContent(elementData.identifier, elementData.objectId, data.returnValues.template);
-                    }.bind(this);
+                        return true;
+                    };
                     this.ajaxApi({
-                        actionName: 'getPopover',
+                        actionName: "getPopover",
                         className: handler.dboAction,
-                        interfaceName: 'wcf\\data\\IPopoverAction',
-                        objectIDs: [elementData.objectId]
+                        interfaceName: "wcf\\data\\IPopoverAction",
+                        objectIDs: [elementData.objectId],
                     }, callback, callback);
                 }
             }
-        },
+        }
         /**
          * Hides the popover element.
          */
-        _hide: function () {
-            if (_timeoutLeave !== null) {
-                window.clearTimeout(_timeoutLeave);
-                _timeoutLeave = null;
+        hidePopover() {
+            if (this.timerLeave) {
+                window.clearTimeout(this.timerLeave);
+                this.timerLeave = undefined;
             }
-            _popover.classList.remove('active');
-        },
+            this.popover.classList.remove("active");
+        }
         /**
          * Clears popover content by moving it back into the cache.
          */
-        _clearContent: function () {
-            if (_activeId && _popoverContent.childElementCount && !_popover.classList.contains('active')) {
-                var activeElData = _cache.get(elData(_elements.get(_activeId).element, 'cache-id'));
-                while (_popoverContent.childNodes.length) {
-                    activeElData.content.appendChild(_popoverContent.childNodes[0]);
+        clearContent() {
+            if (this.activeId && this.popoverContent.childElementCount && !this.popover.classList.contains("active")) {
+                const cacheId = this.elements.get(this.activeId).element.dataset.cacheId;
+                const activeElData = this.cache.get(cacheId);
+                while (this.popoverContent.childNodes.length) {
+                    activeElData.content.appendChild(this.popoverContent.childNodes[0]);
                 }
             }
-        },
+        }
         /**
          * Rebuilds the popover.
          */
-        _rebuild: function () {
-            if (_popover.classList.contains('active')) {
+        rebuild() {
+            if (this.popover.classList.contains("active")) {
                 return;
             }
-            _popover.classList.remove('forceHide');
-            _popover.classList.add('active');
-            UiAlignment.set(_popover, _elements.get(_activeId).element, {
+            this.popover.classList.remove("forceHide");
+            this.popover.classList.add("active");
+            UiAlignment.set(this.popover, this.elements.get(this.activeId).element, {
                 pointer: true,
-                vertical: 'top'
+                vertical: "top",
             });
-        },
-        _ajaxSetup: function () {
+        }
+        _ajaxSuccess() {
+            // This class was designed in a strange way without utilizing this method.
+        }
+        _ajaxSetup() {
             return {
-                silent: true
+                silent: true,
             };
-        },
+        }
         /**
          * Sends an AJAX requests to the server, simple wrapper to reuse the request object.
-         *
-         * @param      {Object}        data            request data
-         * @param      {function}      success         success callback
-         * @param      {function=}     failure         error callback
          */
-        ajaxApi: function (data, success, failure) {
-            if (typeof success !== 'function') {
+        ajaxApi(data, success, failure) {
+            if (typeof success !== "function") {
                 throw new TypeError("Expected a valid callback for parameter 'success'.");
             }
             Ajax.api(this, data, success, failure);
         }
-    };
+    }
+    let controllerPopover;
+    function getControllerPopover() {
+        if (!controllerPopover) {
+            controllerPopover = new ControllerPopover();
+        }
+        return controllerPopover;
+    }
+    /**
+     * Initializes a popover handler.
+     *
+     * Usage:
+     *
+     * ControllerPopover.init({
+     *         attributeName: 'data-object-id',
+     *         className: 'fooLink',
+     *         identifier: 'com.example.bar.foo',
+     *         loadCallback: function(objectId, popover) {
+     *                 // request data for object id (e.g. via WoltLabSuite/Core/Ajax)
+     *
+     *                 // then call this to set the content
+     *                 popover.setContent('com.example.bar.foo', objectId, htmlTemplateString);
+     *         }
+     * });
+     */
+    function init(options) {
+        getControllerPopover().init(options);
+    }
+    exports.init = init;
+    /**
+     * Sets the content for given identifier and object id.
+     */
+    function setContent(identifier, objectId, content) {
+        getControllerPopover().setContent(identifier, objectId, content);
+    }
+    exports.setContent = setContent;
+    /**
+     * Sends an AJAX requests to the server, simple wrapper to reuse the request object.
+     */
+    function ajaxApi(data, success, failure) {
+        getControllerPopover().ajaxApi(data, success, failure);
+    }
+    exports.ajaxApi = ajaxApi;
 });
index e40de105a9da030f543711d5008eb78d663461ff..738ccb5764bbc48d7084a4c0396784900ca83c90 100644 (file)
@@ -28,6 +28,7 @@ export interface DatabaseObjectActionResponse extends ResponseData {
     | any[];
 }
 
+// Return `false` to suppress the error message.
 export type CallbackFailure = (
   data: ResponseData,
   responseText: string,
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Popover.js b/wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Popover.js
deleted file mode 100644 (file)
index b6cd6f8..0000000
+++ /dev/null
@@ -1,425 +0,0 @@
-/**
- * Versatile popover manager.
- * 
- * @author     Alexander Ebert
- * @copyright  2001-2019 WoltLab GmbH
- * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module     WoltLabSuite/Core/Controller/Popover
- */
-define(['Ajax', 'Dictionary', 'Environment', 'Dom/ChangeListener', 'Dom/Util', 'Ui/Alignment'], function(Ajax, Dictionary, Environment, DomChangeListener, DomUtil, UiAlignment) {
-       "use strict";
-       
-       var _activeId = null;
-       var _cache = new Dictionary();
-       var _elements = new Dictionary();
-       var _handlers = new Dictionary();
-       var _hoverId = null;
-       var _suspended = false;
-       var _timeoutEnter = null;
-       var _timeoutLeave = null;
-       
-       var _popover = null;
-       var _popoverContent = null;
-       
-       var _callbackClick = null;
-       var _callbackHide = null;
-       var _callbackMouseEnter = null;
-       var _callbackMouseLeave = null;
-       
-       /** @const */ var STATE_NONE = 0;
-       /** @const */ var STATE_LOADING = 1;
-       /** @const */ var STATE_READY = 2;
-       
-       /** @const */ var DELAY_HIDE = 500;
-       /** @const */ var DELAY_SHOW = 800;
-       
-       /**
-        * @exports     WoltLabSuite/Core/Controller/Popover
-        */
-       return {
-               /**
-                * Builds popover DOM elements and binds event listeners.
-                */
-               _setup: function() {
-                       if (_popover !== null) {
-                               return;
-                       }
-                       
-                       _popover = elCreate('div');
-                       _popover.className = 'popover forceHide';
-                       
-                       _popoverContent = elCreate('div');
-                       _popoverContent.className = 'popoverContent';
-                       _popover.appendChild(_popoverContent);
-                       
-                       var pointer = elCreate('span');
-                       pointer.className = 'elementPointer';
-                       pointer.appendChild(elCreate('span'));
-                       _popover.appendChild(pointer);
-                       
-                       document.body.appendChild(_popover);
-                       
-                       // static binding for callbacks (they don't change anyway and binding each time is expensive)
-                       _callbackClick = this._hide.bind(this);
-                       _callbackMouseEnter = this._mouseEnter.bind(this);
-                       _callbackMouseLeave = this._mouseLeave.bind(this);
-                       
-                       // event listener
-                       _popover.addEventListener('mouseenter', this._popoverMouseEnter.bind(this));
-                       _popover.addEventListener('mouseleave', _callbackMouseLeave);
-                       
-                       _popover.addEventListener('animationend', this._clearContent.bind(this));
-                       
-                       window.addEventListener('beforeunload', (function() {
-                               _suspended = true;
-                               
-                               if (_timeoutEnter !== null) {
-                                       window.clearTimeout(_timeoutEnter);
-                               }
-                               
-                               this._hide(true);
-                       }).bind(this));
-                       
-                       DomChangeListener.add('WoltLabSuite/Core/Controller/Popover', this._init.bind(this));
-               },
-               
-               /**
-                * Initializes a popover handler.
-                * 
-                * Usage:
-                * 
-                * ControllerPopover.init({
-                *      attributeName: 'data-object-id',
-                *      className: 'fooLink',
-                *      identifier: 'com.example.bar.foo',
-                *      loadCallback: function(objectId, popover) {
-                *              // request data for object id (e.g. via WoltLabSuite/Core/Ajax)
-                *              
-                *              // then call this to set the content
-                *              popover.setContent('com.example.bar.foo', objectId, htmlTemplateString);
-                *      }
-                * });
-                * 
-                * @param       {Object}        options         handler options
-                */
-               init: function(options) {
-                       if (Environment.platform() !== 'desktop') {
-                               return;
-                       }
-                       
-                       options.attributeName = options.attributeName || 'data-object-id';
-                       options.legacy = (options.legacy === true);
-                       
-                       this._setup();
-                       
-                       if (_handlers.has(options.identifier)) {
-                               return;
-                       }
-                       
-                       _handlers.set(options.identifier, {
-                               attributeName: options.attributeName,
-                               dboAction: options.dboAction,
-                               elements: options.legacy ? options.className : elByClass(options.className),
-                               legacy: options.legacy,
-                               loadCallback: options.loadCallback
-                       });
-                       
-                       this._init(options.identifier);
-               },
-               
-               /**
-                * Initializes a popover handler.
-                * 
-                * @param       {string}        identifier      handler identifier
-                */
-               _init: function(identifier) {
-                       if (typeof identifier === 'string' && identifier.length) {
-                               this._initElements(_handlers.get(identifier), identifier);
-                       }
-                       else {
-                               _handlers.forEach(this._initElements.bind(this));
-                       }
-               },
-               
-               /**
-                * Binds event listeners for popover-enabled elements.
-                * 
-                * @param       {Object}        options         handler options
-                * @param       {string}        identifier      handler identifier
-                */
-               _initElements: function(options, identifier) {
-                       var elements = options.legacy ? elBySelAll(options.elements) : options.elements;
-                       for (var i = 0, length = elements.length; i < length; i++) {
-                               var element = elements[i];
-                               
-                               var id = DomUtil.identify(element);
-                               if (_cache.has(id)) {
-                                       return;
-                               }
-                               // skip if element is in a popover
-                               if (element.closest('.popover') !== null) {
-                                       _cache.set(id, {
-                                               content: null,
-                                               state: STATE_NONE
-                                       });
-                                       return;
-                               }
-                               
-                               var objectId = (options.legacy) ? id : ~~element.getAttribute(options.attributeName);
-                               if (objectId === 0) {
-                                       continue;
-                               }
-                               
-                               element.addEventListener('mouseenter', _callbackMouseEnter);
-                               element.addEventListener('mouseleave', _callbackMouseLeave);
-                               
-                               if (element.nodeName === 'A' && elAttr(element, 'href')) {
-                                       element.addEventListener('click', _callbackClick);
-                               }
-                               
-                               var cacheId = identifier + "-" + objectId;
-                               elData(element, 'cache-id', cacheId);
-                               
-                               _elements.set(id, {
-                                       element: element,
-                                       identifier: identifier,
-                                       objectId: objectId
-                               });
-                               
-                               if (!_cache.has(cacheId)) {
-                                       _cache.set(identifier + "-" + objectId, {
-                                               content: null,
-                                               state: STATE_NONE
-                                       });
-                               }
-                       }
-               },
-               
-               /**
-                * Sets the content for given identifier and object id.
-                * 
-                * @param       {string}        identifier      handler identifier
-                * @param       {int}           objectId        object id
-                * @param       {string}        content         HTML string
-                */
-               setContent: function(identifier, objectId, content) {
-                       var cacheId = identifier + "-" + objectId;
-                       var data = _cache.get(cacheId);
-                       if (data === undefined) {
-                               throw new Error("Unable to find element for object id '" + objectId + "' (identifier: '" + identifier + "').");
-                       }
-                       
-                       var fragment = DomUtil.createFragmentFromHtml(content);
-                       if (!fragment.childElementCount) fragment = DomUtil.createFragmentFromHtml('<p>' + content + '</p>');
-                       data.content = fragment;
-                       data.state = STATE_READY;
-                       
-                       if (_activeId) {
-                               var activeElement = _elements.get(_activeId).element;
-                               
-                               if (elData(activeElement, 'cache-id') === cacheId) {
-                                       this._show();
-                               }
-                       }
-               },
-               
-               /**
-                * Handles the mouse start hovering the popover-enabled element.
-                * 
-                * @param       {object}        event   event object
-                */
-               _mouseEnter: function(event) {
-                       if (_suspended) {
-                               return;
-                       }
-                       
-                       if (_timeoutEnter !== null) {
-                               window.clearTimeout(_timeoutEnter);
-                               _timeoutEnter = null;
-                       }
-                       
-                       var id = DomUtil.identify(event.currentTarget);
-                       if (_activeId === id && _timeoutLeave !== null) {
-                               window.clearTimeout(_timeoutLeave);
-                               _timeoutLeave = null;
-                       }
-                       
-                       _hoverId = id;
-                       
-                       _timeoutEnter = window.setTimeout((function() {
-                               _timeoutEnter = null;
-                               
-                               if (_hoverId === id) {
-                                       this._show();
-                               }
-                       }).bind(this), DELAY_SHOW);
-               },
-               
-               /**
-                * Handles the mouse leaving the popover-enabled element or the popover itself.
-                */
-               _mouseLeave: function() {
-                       _hoverId = null;
-                       
-                       if (_timeoutLeave !== null) {
-                               return;
-                       }
-                       
-                       if (_callbackHide === null) {
-                               _callbackHide = this._hide.bind(this);
-                       }
-                       
-                       if (_timeoutLeave !== null) {
-                               window.clearTimeout(_timeoutLeave);
-                       }
-                       
-                       _timeoutLeave = window.setTimeout(_callbackHide, DELAY_HIDE);
-               },
-               
-               /**
-                * Handles the mouse start hovering the popover element.
-                */
-               _popoverMouseEnter: function() {
-                       if (_timeoutLeave !== null) {
-                               window.clearTimeout(_timeoutLeave);
-                               _timeoutLeave = null;
-                       }
-               },
-               
-               /**
-                * Shows the popover and loads content on-the-fly.
-                */
-               _show: function() {
-                       if (_timeoutLeave !== null) {
-                               window.clearTimeout(_timeoutLeave);
-                               _timeoutLeave = null;
-                       }
-                       
-                       var forceHide = false;
-                       if (_popover.classList.contains('active')) {
-                               if (_activeId !== _hoverId) {
-                                       this._hide();
-                                       
-                                       forceHide = true;
-                               }
-                       }
-                       else if (_popoverContent.childElementCount) {
-                               forceHide = true;
-                       }
-                       
-                       if (forceHide) {
-                               _popover.classList.add('forceHide');
-                               
-                               // force layout
-                               //noinspection BadExpressionStatementJS
-                               _popover.offsetTop;
-                               
-                               this._clearContent();
-                               
-                               _popover.classList.remove('forceHide');
-                       }
-                       
-                       _activeId = _hoverId;
-                       
-                       var elementData = _elements.get(_activeId);
-                       // check if source element is already gone
-                       if (elementData === undefined) {
-                               return;
-                       }
-                       
-                       var data = _cache.get(elData(elementData.element, 'cache-id'));
-                       
-                       if (data.state === STATE_READY) {
-                               _popoverContent.appendChild(data.content);
-                               
-                               this._rebuild(_activeId);
-                       }
-                       else if (data.state === STATE_NONE) {
-                               data.state = STATE_LOADING;
-                               
-                               var handler = _handlers.get(elementData.identifier);
-                               if (handler.loadCallback) {
-                                       handler.loadCallback(elementData.objectId, this, elementData.element);
-                               }
-                               else if (handler.dboAction) {
-                                       var callback = function(data) {
-                                               this.setContent(
-                                                       elementData.identifier,
-                                                       elementData.objectId,
-                                                       data.returnValues.template
-                                               );
-                                       }.bind(this);
-                                       
-                                       this.ajaxApi({
-                                               actionName: 'getPopover',
-                                               className: handler.dboAction,
-                                               interfaceName: 'wcf\\data\\IPopoverAction',
-                                               objectIDs: [ elementData.objectId ]
-                                       }, callback, callback);
-                               }
-                       }
-               },
-               
-               /**
-                * Hides the popover element.
-                */
-               _hide: function() {
-                       if (_timeoutLeave !== null) {
-                               window.clearTimeout(_timeoutLeave);
-                               _timeoutLeave = null;
-                       }
-                       
-                       _popover.classList.remove('active');
-               },
-               
-               /**
-                * Clears popover content by moving it back into the cache.
-                */
-               _clearContent: function() {
-                       if (_activeId && _popoverContent.childElementCount && !_popover.classList.contains('active')) {
-                               var activeElData = _cache.get(elData(_elements.get(_activeId).element, 'cache-id'));
-                               while (_popoverContent.childNodes.length) {
-                                       activeElData.content.appendChild(_popoverContent.childNodes[0]);
-                               }
-                       }
-               },
-               
-               /**
-                * Rebuilds the popover.
-                */
-               _rebuild: function() {
-                       if (_popover.classList.contains('active')) {
-                               return;
-                       }
-                       
-                       _popover.classList.remove('forceHide');
-                       _popover.classList.add('active');
-                       
-                       UiAlignment.set(_popover, _elements.get(_activeId).element, {
-                               pointer: true,
-                               vertical: 'top'
-                       });
-               },
-               
-               _ajaxSetup: function() {
-                       return {
-                               silent: true
-                       };
-               },
-               
-               /**
-                * Sends an AJAX requests to the server, simple wrapper to reuse the request object.
-                * 
-                * @param       {Object}        data            request data
-                * @param       {function}      success         success callback
-                * @param       {function=}     failure         error callback
-                */
-               ajaxApi: function(data, success, failure) {
-                       if (typeof success !== 'function') {
-                               throw new TypeError("Expected a valid callback for parameter 'success'.");
-                       }
-                       
-                       Ajax.api(this, data, success, failure);
-               }
-       };
-});
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Popover.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Popover.ts
new file mode 100644 (file)
index 0000000..545ccea
--- /dev/null
@@ -0,0 +1,475 @@
+/**
+ * Versatile popover manager.
+ *
+ * @author  Alexander Ebert
+ * @copyright  2001-2019 WoltLab GmbH
+ * @license  GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module  WoltLabSuite/Core/Controller/Popover
+ */
+
+import * as Ajax from "../Ajax";
+import DomChangeListener from "../Dom/Change/Listener";
+import DomUtil from "../Dom/Util";
+import * as Environment from "../Environment";
+import * as UiAlignment from "../Ui/Alignment";
+import { AjaxCallbackObject, AjaxCallbackSetup, CallbackFailure, CallbackSuccess, RequestPayload } from "../Ajax/Data";
+
+const enum State {
+  None,
+  Loading,
+  Ready,
+}
+
+const enum Delay {
+  Hide = 500,
+  Show = 800,
+}
+
+type CallbackLoad = (objectId: number | string, popover: ControllerPopover, element: HTMLElement) => void;
+
+interface PopoverOptions {
+  attributeName: string;
+  className: string;
+  dboAction: string;
+  identifier: string;
+  legacy: boolean;
+  loadCallback: CallbackLoad;
+}
+
+interface HandlerData {
+  attributeName: string;
+  dboAction: string;
+  legacy: boolean;
+  loadCallback: CallbackLoad;
+  selector: string;
+}
+
+interface ElementData {
+  element: HTMLElement;
+  identifier: string;
+  objectId: number | string;
+}
+
+interface CacheData {
+  content: DocumentFragment | null;
+  state: State;
+}
+
+class ControllerPopover implements AjaxCallbackObject {
+  private activeId = "";
+  private readonly cache = new Map<string, CacheData>();
+  private readonly elements = new Map<string, ElementData>();
+  private readonly handlers = new Map<string, HandlerData>();
+  private hoverId = "";
+  private readonly popover: HTMLDivElement;
+  private readonly popoverContent: HTMLDivElement;
+  private suspended = false;
+  private timerEnter?: number = undefined;
+  private timerLeave?: number = undefined;
+
+  /**
+   * Builds popover DOM elements and binds event listeners.
+   */
+  constructor() {
+    this.popover = document.createElement("div");
+    this.popover.className = "popover forceHide";
+
+    this.popoverContent = document.createElement("div");
+    this.popoverContent.className = "popoverContent";
+    this.popover.appendChild(this.popoverContent);
+
+    const pointer = document.createElement("span");
+    pointer.className = "elementPointer";
+    pointer.appendChild(document.createElement("span"));
+    this.popover.appendChild(pointer);
+
+    document.body.appendChild(this.popover);
+
+    // event listener
+    this.popover.addEventListener("mouseenter", this.popoverMouseEnter.bind(this));
+    this.popover.addEventListener("mouseleave", () => this.mouseLeave());
+
+    this.popover.addEventListener("animationend", this.clearContent.bind(this));
+
+    window.addEventListener("beforeunload", () => {
+      this.suspended = true;
+
+      if (this.timerEnter) {
+        window.clearTimeout(this.timerEnter);
+        this.timerEnter = undefined;
+      }
+
+      this.hidePopover();
+    });
+
+    DomChangeListener.add("WoltLabSuite/Core/Controller/Popover", (identifier) => this.initHandler(identifier));
+  }
+
+  /**
+   * Initializes a popover handler.
+   *
+   * Usage:
+   *
+   * ControllerPopover.init({
+   *   attributeName: 'data-object-id',
+   *   className: 'fooLink',
+   *   identifier: 'com.example.bar.foo',
+   *   loadCallback: (objectId, popover) => {
+   *           // request data for object id (e.g. via WoltLabSuite/Core/Ajax)
+   *
+   *           // then call this to set the content
+   *           popover.setContent('com.example.bar.foo', objectId, htmlTemplateString);
+   *   }
+   * });
+   */
+  init(options: PopoverOptions): void {
+    if (Environment.platform() !== "desktop") {
+      return;
+    }
+
+    options.attributeName = options.attributeName || "data-object-id";
+    options.legacy = (options.legacy as unknown) === true;
+
+    if (this.handlers.has(options.identifier)) {
+      return;
+    }
+
+    // Legacy implementations provided a selector for `className`.
+    const selector = options.legacy ? options.className : `.${options.className}`;
+
+    this.handlers.set(options.identifier, {
+      attributeName: options.attributeName,
+      dboAction: options.dboAction,
+      legacy: options.legacy,
+      loadCallback: options.loadCallback,
+      selector,
+    });
+
+    this.initHandler(options.identifier);
+  }
+
+  /**
+   * Initializes a popover handler.
+   */
+  private initHandler(identifier?: string): void {
+    if (typeof identifier === "string" && identifier.length) {
+      this.initElements(this.handlers.get(identifier)!, identifier);
+    } else {
+      this.handlers.forEach((value, key) => {
+        this.initElements(value, key);
+      });
+    }
+  }
+
+  /**
+   * Binds event listeners for popover-enabled elements.
+   */
+  private initElements(options: HandlerData, identifier: string): void {
+    document.querySelectorAll(options.selector).forEach((element: HTMLElement) => {
+      const id = DomUtil.identify(element);
+      if (this.cache.has(id)) {
+        return;
+      }
+
+      // Skip elements that are located inside a popover.
+      if (element.closest(".popover") !== null) {
+        this.cache.set(id, {
+          content: null,
+          state: State.None,
+        });
+
+        return;
+      }
+
+      const objectId = options.legacy ? id : ~~element.getAttribute(options.attributeName)!;
+      if (objectId === 0) {
+        return;
+      }
+
+      element.addEventListener("mouseenter", (ev) => this.mouseEnter(ev));
+      element.addEventListener("mouseleave", () => this.mouseLeave());
+
+      if (element instanceof HTMLAnchorElement && element.href) {
+        element.addEventListener("click", () => this.hidePopover());
+      }
+
+      const cacheId = `${identifier}-${objectId}`;
+      element.dataset.cacheId = cacheId;
+
+      this.elements.set(id, {
+        element,
+        identifier,
+        objectId: objectId.toString(),
+      });
+
+      if (!this.cache.has(cacheId)) {
+        this.cache.set(cacheId, {
+          content: null,
+          state: State.None,
+        });
+      }
+    });
+  }
+
+  /**
+   * Sets the content for given identifier and object id.
+   */
+  setContent(identifier: string, objectId: number | string, content: string): void {
+    const cacheId = `${identifier}-${objectId}`;
+    const data = this.cache.get(cacheId);
+    if (data === undefined) {
+      throw new Error(`Unable to find element for object id '${objectId}' (identifier: '${identifier}').`);
+    }
+
+    let fragment = DomUtil.createFragmentFromHtml(content);
+    if (!fragment.childElementCount) {
+      fragment = DomUtil.createFragmentFromHtml("<p>" + content + "</p>");
+    }
+
+    data.content = fragment;
+    data.state = State.Ready;
+
+    if (this.activeId) {
+      const activeElement = this.elements.get(this.activeId)!.element;
+
+      if (activeElement.dataset.cacheId === cacheId) {
+        this.show();
+      }
+    }
+  }
+
+  /**
+   * Handles the mouse start hovering the popover-enabled element.
+   */
+  private mouseEnter(event: MouseEvent): void {
+    if (this.suspended) {
+      return;
+    }
+
+    if (this.timerEnter) {
+      window.clearTimeout(this.timerEnter);
+      this.timerEnter = undefined;
+    }
+
+    const id = DomUtil.identify(event.currentTarget as HTMLElement);
+    if (this.activeId === id && this.timerLeave) {
+      window.clearTimeout(this.timerLeave);
+      this.timerLeave = undefined;
+    }
+
+    this.hoverId = id;
+
+    this.timerEnter = window.setTimeout(() => {
+      this.timerEnter = undefined;
+
+      if (this.hoverId === id) {
+        this.show();
+      }
+    }, Delay.Show);
+  }
+
+  /**
+   * Handles the mouse leaving the popover-enabled element or the popover itself.
+   */
+  private mouseLeave(): void {
+    this.hoverId = "";
+
+    if (this.timerLeave) {
+      return;
+    }
+
+    this.timerLeave = window.setTimeout(() => this.hidePopover(), Delay.Hide);
+  }
+
+  /**
+   * Handles the mouse start hovering the popover element.
+   */
+  private popoverMouseEnter(): void {
+    if (this.timerLeave) {
+      window.clearTimeout(this.timerLeave);
+      this.timerLeave = undefined;
+    }
+  }
+
+  /**
+   * Shows the popover and loads content on-the-fly.
+   */
+  private show(): void {
+    if (this.timerLeave) {
+      window.clearTimeout(this.timerLeave);
+      this.timerLeave = undefined;
+    }
+
+    let forceHide = false;
+    if (this.popover.classList.contains("active")) {
+      if (this.activeId !== this.hoverId) {
+        this.hidePopover();
+
+        forceHide = true;
+      }
+    } else if (this.popoverContent.childElementCount) {
+      forceHide = true;
+    }
+
+    if (forceHide) {
+      this.popover.classList.add("forceHide");
+
+      // force layout
+      //noinspection BadExpressionStatementJS
+      this.popover.offsetTop;
+
+      this.clearContent();
+
+      this.popover.classList.remove("forceHide");
+    }
+
+    this.activeId = this.hoverId;
+
+    const elementData = this.elements.get(this.activeId);
+    // check if source element is already gone
+    if (elementData === undefined) {
+      return;
+    }
+
+    const cacheId = elementData.element.dataset.cacheId!;
+    const data = this.cache.get(cacheId)!;
+
+    if (data.state === State.Ready) {
+      this.popoverContent.appendChild(data.content!);
+
+      this.rebuild();
+    } else if (data.state === State.None) {
+      data.state = State.Loading;
+
+      const handler = this.handlers.get(elementData.identifier)!;
+      if (handler.loadCallback) {
+        handler.loadCallback(elementData.objectId, this, elementData.element);
+      } else if (handler.dboAction) {
+        const callback = (data) => {
+          this.setContent(elementData.identifier, elementData.objectId, data.returnValues.template);
+
+          return true;
+        };
+
+        this.ajaxApi(
+          {
+            actionName: "getPopover",
+            className: handler.dboAction,
+            interfaceName: "wcf\\data\\IPopoverAction",
+            objectIDs: [elementData.objectId],
+          },
+          callback,
+          callback,
+        );
+      }
+    }
+  }
+
+  /**
+   * Hides the popover element.
+   */
+  private hidePopover(): void {
+    if (this.timerLeave) {
+      window.clearTimeout(this.timerLeave);
+      this.timerLeave = undefined;
+    }
+
+    this.popover.classList.remove("active");
+  }
+
+  /**
+   * Clears popover content by moving it back into the cache.
+   */
+  private clearContent(): void {
+    if (this.activeId && this.popoverContent.childElementCount && !this.popover.classList.contains("active")) {
+      const cacheId = this.elements.get(this.activeId)!.element.dataset.cacheId!;
+      const activeElData = this.cache.get(cacheId)!;
+      while (this.popoverContent.childNodes.length) {
+        activeElData.content!.appendChild(this.popoverContent.childNodes[0]);
+      }
+    }
+  }
+
+  /**
+   * Rebuilds the popover.
+   */
+  private rebuild(): void {
+    if (this.popover.classList.contains("active")) {
+      return;
+    }
+
+    this.popover.classList.remove("forceHide");
+    this.popover.classList.add("active");
+
+    UiAlignment.set(this.popover, this.elements.get(this.activeId)!.element, {
+      pointer: true,
+      vertical: "top",
+    });
+  }
+
+  _ajaxSuccess() {
+    // This class was designed in a strange way without utilizing this method.
+  }
+
+  _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+    return {
+      silent: true,
+    };
+  }
+
+  /**
+   * Sends an AJAX requests to the server, simple wrapper to reuse the request object.
+   */
+  ajaxApi(data: RequestPayload, success: CallbackSuccess, failure: CallbackFailure): void {
+    if (typeof success !== "function") {
+      throw new TypeError("Expected a valid callback for parameter 'success'.");
+    }
+
+    Ajax.api(this, data, success, failure);
+  }
+}
+
+let controllerPopover: ControllerPopover;
+
+function getControllerPopover(): ControllerPopover {
+  if (!controllerPopover) {
+    controllerPopover = new ControllerPopover();
+  }
+
+  return controllerPopover;
+}
+
+/**
+ * Initializes a popover handler.
+ *
+ * Usage:
+ *
+ * ControllerPopover.init({
+ *     attributeName: 'data-object-id',
+ *     className: 'fooLink',
+ *     identifier: 'com.example.bar.foo',
+ *     loadCallback: function(objectId, popover) {
+ *             // request data for object id (e.g. via WoltLabSuite/Core/Ajax)
+ *
+ *             // then call this to set the content
+ *             popover.setContent('com.example.bar.foo', objectId, htmlTemplateString);
+ *     }
+ * });
+ */
+export function init(options: PopoverOptions): void {
+  getControllerPopover().init(options);
+}
+
+/**
+ * Sets the content for given identifier and object id.
+ */
+export function setContent(identifier: string, objectId: number, content: string): void {
+  getControllerPopover().setContent(identifier, objectId, content);
+}
+
+/**
+ * Sends an AJAX requests to the server, simple wrapper to reuse the request object.
+ */
+export function ajaxApi(data: RequestPayload, success: CallbackSuccess, failure: CallbackFailure): void {
+  getControllerPopover().ajaxApi(data, success, failure);
+}