Convert `Ui/Page/Action` to TypeScript
authorAlexander Ebert <ebert@woltlab.com>
Tue, 27 Oct 2020 15:26:59 +0000 (16:26 +0100)
committerTim Düsterhus <duesterhus@woltlab.com>
Wed, 28 Oct 2020 11:57:21 +0000 (12:57 +0100)
wcfsetup/install/files/js/WoltLabSuite/Core/Core.js
wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Page/Action.js
wcfsetup/install/files/ts/WoltLabSuite/Core/Core.ts
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Action.js [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Action.ts [new file with mode: 0644]

index ed43e1ac146d3d490f63844b900a5d77772084ed..d573f8f08c4ccb8c017b37372d86cac94e9a41c2 100644 (file)
@@ -10,7 +10,7 @@
 define(["require", "exports"], function (require, exports) {
     "use strict";
     Object.defineProperty(exports, "__esModule", { value: true });
-    exports.stringToBool = exports.getStoragePrefix = exports.triggerEvent = exports.serialize = exports.getUuid = exports.getType = exports.isPlainObject = exports.inherit = exports.extend = exports.convertLegacyUrl = exports.clone = void 0;
+    exports.debounce = exports.stringToBool = exports.getStoragePrefix = exports.triggerEvent = exports.serialize = exports.getUuid = exports.getType = exports.isPlainObject = exports.inherit = exports.extend = exports.convertLegacyUrl = exports.clone = void 0;
     const _clone = function (variable) {
         if (typeof variable === 'object' && (Array.isArray(variable) || isPlainObject(variable))) {
             return _cloneObject(variable);
@@ -205,4 +205,32 @@ define(["require", "exports"], function (require, exports) {
         return value === '1' || value === 'true';
     }
     exports.stringToBool = stringToBool;
+    /**
+     * A function that emits a side effect and does not return anything.
+     *
+     * @see https://github.com/chodorowicz/ts-debounce/blob/62f30f2c3379b7b5e778fb1793e1fbfa17354894/src/index.ts
+     */
+    function debounce(func, waitMilliseconds = 50, options = {
+        isImmediate: false,
+    }) {
+        let timeoutId;
+        return function (...args) {
+            const context = this;
+            const doLater = function () {
+                timeoutId = undefined;
+                if (!options.isImmediate) {
+                    func.apply(context, args);
+                }
+            };
+            const shouldCallNow = options.isImmediate && timeoutId === undefined;
+            if (timeoutId !== undefined) {
+                clearTimeout(timeoutId);
+            }
+            timeoutId = setTimeout(doLater, waitMilliseconds);
+            if (shouldCallNow) {
+                func.apply(context, args);
+            }
+        };
+    }
+    exports.debounce = debounce;
 });
index 5bb2cc66ad7c3ace57d8e6cc51573ac3f94a3f12..8cb4bd13fc220cd735e74fe83bf115f2499b3900 100644 (file)
 /**
  * Provides page actions such as "jump to top" and clipboard actions.
  *
- * @author     Alexander Ebert
- * @copyright  2001-2020 WoltLab GmbH
- * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module     WoltLabSuite/Core/Ui/Page/Action
+ * @author  Alexander Ebert
+ * @copyright  2001-2020 WoltLab GmbH
+ * @license  GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module  WoltLabSuite/Core/Ui/Page/Action
  */
-define(['Dictionary', 'Language'], function (Dictionary, Language) {
-    'use strict';
-    var _buttons = new Dictionary();
-    /** @var {Element} */
-    var _container;
-    var _didInit = false;
-    var _lastPosition = -1;
-    /** @var {Element} */
-    var _toTopButton;
-    /** @var {Element} */
-    var _wrapper;
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
+}) : (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+    Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+    o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || function (mod) {
+    if (mod && mod.__esModule) return mod;
+    var result = {};
+    if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
+    __setModuleDefault(result, mod);
+    return result;
+};
+define(["require", "exports", "../../Core", "../../Language"], function (require, exports, Core, Language) {
+    "use strict";
+    Object.defineProperty(exports, "__esModule", { value: true });
+    exports.show = exports.hide = exports.remove = exports.get = exports.has = exports.add = exports.setup = void 0;
+    Core = __importStar(Core);
+    Language = __importStar(Language);
+    const _buttons = new Map();
+    let _container;
+    let _didInit = false;
+    let _lastPosition = -1;
+    let _toTopButton;
+    let _wrapper;
+    function buildToTopButton() {
+        const button = document.createElement('a');
+        button.className = 'button buttonPrimary pageActionButtonToTop initiallyHidden jsTooltip';
+        button.href = '';
+        button.title = Language.get('wcf.global.scrollUp');
+        button.setAttribute('aria-hidden', 'true');
+        button.innerHTML = '<span class="icon icon32 fa-angle-up"></span>';
+        button.addEventListener('click', scrollToTop);
+        return button;
+    }
+    function onScroll() {
+        if (document.documentElement.classList.contains('disableScrolling')) {
+            // Ignore any scroll events that take place while body scrolling is disabled,
+            // because it messes up the scroll offsets.
+            return;
+        }
+        const offset = window.pageYOffset;
+        if (offset === _lastPosition) {
+            // Ignore any scroll event that is fired but without a position change. This can
+            // happen after closing a dialog that prevented the body from being scrolled.
+            return;
+        }
+        if (offset >= 300) {
+            if (_toTopButton.classList.contains('initiallyHidden')) {
+                _toTopButton.classList.remove('initiallyHidden');
+            }
+            _toTopButton.setAttribute('aria-hidden', 'false');
+        }
+        else {
+            _toTopButton.setAttribute('aria-hidden', 'true');
+        }
+        renderContainer();
+        if (_lastPosition !== -1) {
+            _wrapper.classList[offset < _lastPosition ? 'remove' : 'add']('scrolledDown');
+        }
+        _lastPosition = offset;
+    }
+    function scrollToTop(event) {
+        event.preventDefault();
+        const topAnchor = document.getElementById('top');
+        topAnchor.scrollIntoView({ behavior: 'smooth' });
+    }
     /**
-     * @exports     WoltLabSuite/Core/Ui/Page/Action
+     * Toggles the container's visibility.
      */
-    return {
-        /**
-         * Initializes the page action container.
-         */
-        setup: function () {
-            if (_didInit) {
-                return;
-            }
-            _didInit = true;
-            _wrapper = elCreate('div');
-            _wrapper.className = 'pageAction';
-            _container = elCreate('div');
-            _container.className = 'pageActionButtons';
-            _wrapper.appendChild(_container);
-            _toTopButton = this._buildToTopButton();
-            _wrapper.appendChild(_toTopButton);
-            document.body.appendChild(_wrapper);
-            window.addEventListener('scroll', window.debounce(this._onScroll.bind(this), 100, false), { passive: true });
-            this._onScroll();
-        },
-        _buildToTopButton: function () {
-            var button = elCreate('a');
-            button.className = 'button buttonPrimary pageActionButtonToTop initiallyHidden jsTooltip';
-            button.href = '';
-            elAttr(button, 'title', Language.get('wcf.global.scrollUp'));
-            elAttr(button, 'aria-hidden', 'true');
-            button.innerHTML = '<span class="icon icon32 fa-angle-up"></span>';
-            button.addEventListener(WCF_CLICK_EVENT, this._scrollTopTop.bind(this));
-            return button;
-        },
-        /**
-         * @param {Event=} event
-         */
-        _onScroll: function (event) {
-            if (document.documentElement.classList.contains('disableScrolling')) {
-                // Ignore any scroll events that take place while body scrolling is disabled,
-                // because it messes up the scroll offsets.
-                return;
-            }
-            var offset = window.pageYOffset;
-            if (offset === _lastPosition) {
-                // Ignore any scroll event that is fired but without a position change. This can
-                // happen after closing a dialog that prevented the body from being scrolled.
-                return;
-            }
-            if (offset >= 300) {
-                if (_toTopButton.classList.contains('initiallyHidden')) {
-                    _toTopButton.classList.remove('initiallyHidden');
-                }
-                elAttr(_toTopButton, 'aria-hidden', 'false');
-            }
-            else {
-                elAttr(_toTopButton, 'aria-hidden', 'true');
-            }
-            this._renderContainer();
-            if (_lastPosition !== -1) {
-                _wrapper.classList[offset < _lastPosition ? 'remove' : 'add']('scrolledDown');
-            }
-            _lastPosition = offset;
-        },
-        /**
-         * @param {Event} event
-         */
-        _scrollTopTop: function (event) {
-            event.preventDefault();
-            elById('top').scrollIntoView({ behavior: 'smooth' });
-        },
-        /**
-         * Adds a button to the page action list. You can optionally provide a button name to
-         * insert the button right before it. Unmatched button names or empty value will cause
-         * the button to be prepended to the list.
-         *
-         * @param       {string}        buttonName              unique identifier
-         * @param       {Element}       button                  button element, must not be wrapped in a <li>
-         * @param       {string=}       insertBeforeButton      insert button before element identified by provided button name
-         */
-        add: function (buttonName, button, insertBeforeButton) {
-            this.setup();
-            // The wrapper is required for backwards compatibility, because some implementations rely on a
-            // dedicated parent element to insert elements, for example, for drop-down menus.
-            var wrapper = elCreate('div');
-            wrapper.className = 'pageActionButton';
-            wrapper.name = buttonName;
-            elAttr(wrapper, 'aria-hidden', 'true');
-            button.classList.add('button');
-            button.classList.add('buttonPrimary');
-            wrapper.appendChild(button);
-            var insertBefore = null;
-            if (insertBeforeButton) {
-                insertBefore = _buttons.get(insertBeforeButton);
-                if (insertBefore !== undefined) {
-                    insertBefore = insertBefore.parentNode;
-                }
-            }
-            if (insertBefore === null && _container.childElementCount) {
-                insertBefore = _container.children[0];
-            }
-            if (insertBefore === null) {
-                insertBefore = _container.firstChild;
+    function renderContainer() {
+        const visibleChild = Array.from(_container.children).find(element => {
+            return element.getAttribute('aria-hidden') === 'false';
+        });
+        _container.classList[visibleChild ? 'add' : 'remove']('active');
+    }
+    /**
+     * Initializes the page action container.
+     */
+    function setup() {
+        if (_didInit) {
+            return;
+        }
+        _didInit = true;
+        _wrapper = document.createElement('div');
+        _wrapper.className = 'pageAction';
+        _container = document.createElement('div');
+        _container.className = 'pageActionButtons';
+        _wrapper.appendChild(_container);
+        _toTopButton = buildToTopButton();
+        _wrapper.appendChild(_toTopButton);
+        document.body.appendChild(_wrapper);
+        window.addEventListener('scroll', Core.debounce(onScroll, 100), { passive: true });
+        onScroll();
+    }
+    exports.setup = setup;
+    /**
+     * Adds a button to the page action list. You can optionally provide a button name to
+     * insert the button right before it. Unmatched button names or empty value will cause
+     * the button to be prepended to the list.
+     */
+    function add(buttonName, button, insertBeforeButton) {
+        setup();
+        // The wrapper is required for backwards compatibility, because some implementations rely on a
+        // dedicated parent element to insert elements, for example, for drop-down menus.
+        const wrapper = document.createElement('div');
+        wrapper.className = 'pageActionButton';
+        wrapper.dataset.name = buttonName;
+        wrapper.setAttribute('aria-hidden', 'true');
+        button.classList.add('button');
+        button.classList.add('buttonPrimary');
+        wrapper.appendChild(button);
+        let insertBefore = null;
+        if (insertBeforeButton) {
+            insertBefore = _buttons.get(insertBeforeButton) || null;
+            if (insertBefore) {
+                insertBefore = insertBefore.parentElement;
             }
-            _container.insertBefore(wrapper, insertBefore);
-            _wrapper.classList.remove('scrolledDown');
-            _buttons.set(buttonName, button);
-            // Query a layout related property to force a reflow, otherwise the transition is optimized away.
-            // noinspection BadExpressionStatementJS
-            wrapper.offsetParent;
-            // Toggle the visibility to force the transition to be applied.
-            elAttr(wrapper, 'aria-hidden', 'false');
-            this._renderContainer();
-        },
-        /**
-         * Returns true if there is a registered button with the provided name.
-         *
-         * @param       {string}        buttonName      unique identifier
-         * @return      {boolean}       true if there is a registered button with this name
-         */
-        has: function (buttonName) {
-            return _buttons.has(buttonName);
-        },
-        /**
-         * Returns the stored button by name or undefined.
-         *
-         * @param       {string}        buttonName      unique identifier
-         * @return      {Element}       button element or undefined
-         */
-        get: function (buttonName) {
-            return _buttons.get(buttonName);
-        },
-        /**
-         * Removes a button by its button name.
-         *
-         * @param       {string}        buttonName      unique identifier
-         */
-        remove: function (buttonName) {
-            var button = _buttons.get(buttonName);
-            if (button !== undefined) {
-                var listItem = button.parentNode;
-                var callback = function () {
-                    try {
-                        if (elAttrBool(listItem, 'aria-hidden')) {
-                            _container.removeChild(listItem);
-                            _buttons.delete(buttonName);
-                        }
-                        listItem.removeEventListener('transitionend', callback);
-                    }
-                    catch (e) {
-                        // ignore errors if the element has already been removed
+        }
+        if (!insertBefore && _container.childElementCount) {
+            insertBefore = _container.children[0];
+        }
+        if (!insertBefore) {
+            insertBefore = _container.firstChild;
+        }
+        _container.insertBefore(wrapper, insertBefore);
+        _wrapper.classList.remove('scrolledDown');
+        _buttons.set(buttonName, button);
+        // Query a layout related property to force a reflow, otherwise the transition is optimized away.
+        // noinspection BadExpressionStatementJS
+        wrapper.offsetParent;
+        // Toggle the visibility to force the transition to be applied.
+        wrapper.setAttribute('aria-hidden', 'false');
+        renderContainer();
+    }
+    exports.add = add;
+    /**
+     * Returns true if there is a registered button with the provided name.
+     */
+    function has(buttonName) {
+        return _buttons.has(buttonName);
+    }
+    exports.has = has;
+    /**
+     * Returns the stored button by name or undefined.
+     */
+    function get(buttonName) {
+        return _buttons.get(buttonName);
+    }
+    exports.get = get;
+    /**
+     * Removes a button by its button name.
+     */
+    function remove(buttonName) {
+        const button = _buttons.get(buttonName);
+        if (button !== undefined) {
+            const listItem = button.parentElement;
+            const callback = () => {
+                try {
+                    if (Core.stringToBool(listItem.getAttribute('aria-hidden'))) {
+                        _container.removeChild(listItem);
+                        _buttons.delete(buttonName);
                     }
-                };
-                listItem.addEventListener('transitionend', callback);
-                this.hide(buttonName);
-            }
-        },
-        /**
-         * Hides a button by its button name.
-         *
-         * @param       {string}        buttonName      unique identifier
-         */
-        hide: function (buttonName) {
-            var button = _buttons.get(buttonName);
-            if (button) {
-                elAttr(button.parentNode, 'aria-hidden', 'true');
-                this._renderContainer();
-            }
-        },
-        /**
-         * Shows a button by its button name.
-         *
-         * @param       {string}        buttonName      unique identifier
-         */
-        show: function (buttonName) {
-            var button = _buttons.get(buttonName);
-            if (button) {
-                if (button.parentNode.classList.contains('initiallyHidden')) {
-                    button.parentNode.classList.remove('initiallyHidden');
+                    listItem.removeEventListener('transitionend', callback);
                 }
-                elAttr(button.parentNode, 'aria-hidden', 'false');
-                _wrapper.classList.remove('scrolledDown');
-                this._renderContainer();
-            }
-        },
-        /**
-         * Toggles the container's visibility.
-         *
-         * @protected
-         */
-        _renderContainer: function () {
-            var hasVisibleItems = false;
-            if (_container.childElementCount) {
-                for (var i = 0, length = _container.childElementCount; i < length; i++) {
-                    if (elAttr(_container.children[i], 'aria-hidden') === 'false') {
-                        hasVisibleItems = true;
-                        break;
-                    }
+                catch (e) {
+                    // ignore errors if the element has already been removed
                 }
+            };
+            listItem.addEventListener('transitionend', callback);
+            hide(buttonName);
+        }
+    }
+    exports.remove = remove;
+    /**
+     * Hides a button by its button name.
+     */
+    function hide(buttonName) {
+        const button = _buttons.get(buttonName);
+        if (button) {
+            const parent = button.parentElement;
+            parent.setAttribute('aria-hidden', 'true');
+            renderContainer();
+        }
+    }
+    exports.hide = hide;
+    /**
+     * Shows a button by its button name.
+     */
+    function show(buttonName) {
+        const button = _buttons.get(buttonName);
+        if (button) {
+            const parent = button.parentElement;
+            if (parent.classList.contains('initiallyHidden')) {
+                parent.classList.remove('initiallyHidden');
             }
-            _container.classList[(hasVisibleItems ? 'add' : 'remove')]('active');
+            parent.setAttribute('aria-hidden', 'false');
+            _wrapper.classList.remove('scrolledDown');
+            renderContainer();
         }
-    };
+    }
+    exports.show = show;
 });
index 481724c2dea5f8bbb46cb98630944184371bfc66..2bb48e09819088b9a580b6cc818d15bd4c9d757c 100644 (file)
@@ -208,9 +208,54 @@ export function getStoragePrefix() {
 }
 
 /**
- * Interprets a string value as a boolean value similar to the behavior of the 
+ * Interprets a string value as a boolean value similar to the behavior of the
  * legacy functions `elAttrBool()` and `elDataBool()`.
  */
 export function stringToBool(value: string | null): boolean {
   return value === '1' || value === 'true';
 }
+
+
+type DebounceCallback = (...args: any[]) => void;
+
+interface DebounceOptions {
+  isImmediate: boolean;
+}
+
+/**
+ * A function that emits a side effect and does not return anything.
+ *
+ * @see https://github.com/chodorowicz/ts-debounce/blob/62f30f2c3379b7b5e778fb1793e1fbfa17354894/src/index.ts
+ */
+export function debounce<F extends DebounceCallback>(
+  func: F,
+  waitMilliseconds = 50,
+  options: DebounceOptions = {
+    isImmediate: false,
+  },
+): (this: ThisParameterType<F>, ...args: Parameters<F>) => void {
+  let timeoutId: ReturnType<typeof setTimeout> | undefined;
+
+  return function (this: ThisParameterType<F>, ...args: Parameters<F>) {
+    const context = this;
+
+    const doLater = function () {
+      timeoutId = undefined;
+      if (!options.isImmediate) {
+        func.apply(context, args);
+      }
+    };
+
+    const shouldCallNow = options.isImmediate && timeoutId === undefined;
+
+    if (timeoutId !== undefined) {
+      clearTimeout(timeoutId);
+    }
+
+    timeoutId = setTimeout(doLater, waitMilliseconds);
+
+    if (shouldCallNow) {
+      func.apply(context, args);
+    }
+  };
+}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Action.js b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Action.js
deleted file mode 100644 (file)
index bdaf2cf..0000000
+++ /dev/null
@@ -1,273 +0,0 @@
-/**
- * Provides page actions such as "jump to top" and clipboard actions.
- *
- * @author     Alexander Ebert
- * @copyright  2001-2020 WoltLab GmbH
- * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module     WoltLabSuite/Core/Ui/Page/Action
- */
-define(['Dictionary', 'Language'], function (Dictionary, Language) {
-       'use strict';
-       
-       var _buttons = new Dictionary();
-       
-       /** @var {Element} */
-       var _container;
-       
-       var _didInit = false;
-       
-       var _lastPosition = -1;
-       
-       /** @var {Element} */
-       var _toTopButton;
-       
-       /** @var {Element} */
-       var _wrapper;
-       
-       /**
-        * @exports     WoltLabSuite/Core/Ui/Page/Action
-        */
-       return {
-               /**
-                * Initializes the page action container.
-                */
-               setup: function () {
-                       if (_didInit) {
-                               return;
-                       }
-                       
-                       _didInit = true;
-                       
-                       _wrapper = elCreate('div');
-                       _wrapper.className = 'pageAction';
-                       
-                       _container = elCreate('div');
-                       _container.className = 'pageActionButtons';
-                       _wrapper.appendChild(_container);
-                       
-                       _toTopButton = this._buildToTopButton();
-                       _wrapper.appendChild(_toTopButton);
-                       
-                       document.body.appendChild(_wrapper);
-                       
-                       window.addEventListener(
-                               'scroll',
-                               window.debounce(this._onScroll.bind(this), 100, false),
-                               {passive: true}
-                       );
-                       
-                       this._onScroll();
-               },
-               
-               _buildToTopButton: function () {
-                       var button = elCreate('a');
-                       button.className = 'button buttonPrimary pageActionButtonToTop initiallyHidden jsTooltip';
-                       button.href = '';
-                       elAttr(button, 'title', Language.get('wcf.global.scrollUp'));
-                       elAttr(button, 'aria-hidden', 'true');
-                       button.innerHTML = '<span class="icon icon32 fa-angle-up"></span>';
-                       
-                       button.addEventListener(WCF_CLICK_EVENT, this._scrollTopTop.bind(this));
-                       
-                       return button;
-               },
-               
-               /**
-                * @param {Event=} event
-                */
-               _onScroll: function (event) {
-                       if (document.documentElement.classList.contains('disableScrolling')) {
-                               // Ignore any scroll events that take place while body scrolling is disabled,
-                               // because it messes up the scroll offsets.
-                               return;
-                       }
-                       
-                       var offset = window.pageYOffset;
-                       if (offset === _lastPosition) {
-                               // Ignore any scroll event that is fired but without a position change. This can
-                               // happen after closing a dialog that prevented the body from being scrolled.
-                               return;
-                       }
-                       
-                       if (offset >= 300) {
-                               if (_toTopButton.classList.contains('initiallyHidden')) {
-                                       _toTopButton.classList.remove('initiallyHidden');
-                               }
-                               
-                               elAttr(_toTopButton, 'aria-hidden', 'false');
-                       }
-                       else {
-                               elAttr(_toTopButton, 'aria-hidden', 'true');
-                       }
-                       
-                       this._renderContainer();
-                       
-                       if (_lastPosition !== -1) {
-                               _wrapper.classList[offset < _lastPosition ? 'remove' : 'add']('scrolledDown');
-                       }
-                       
-                       _lastPosition = offset;
-               },
-               
-               /**
-                * @param {Event} event
-                */
-               _scrollTopTop: function (event) {
-                       event.preventDefault();
-                       
-                       elById('top').scrollIntoView({behavior: 'smooth'});
-               },
-               
-               /**
-                * Adds a button to the page action list. You can optionally provide a button name to
-                * insert the button right before it. Unmatched button names or empty value will cause
-                * the button to be prepended to the list.
-                *
-                * @param       {string}        buttonName              unique identifier
-                * @param       {Element}       button                  button element, must not be wrapped in a <li>
-                * @param       {string=}       insertBeforeButton      insert button before element identified by provided button name
-                */
-               add: function (buttonName, button, insertBeforeButton) {
-                       this.setup();
-                       
-                       // The wrapper is required for backwards compatibility, because some implementations rely on a
-                       // dedicated parent element to insert elements, for example, for drop-down menus.
-                       var wrapper = elCreate('div');
-                       wrapper.className = 'pageActionButton';
-                       wrapper.name = buttonName;
-                       elAttr(wrapper, 'aria-hidden', 'true');
-                       
-                       button.classList.add('button');
-                       button.classList.add('buttonPrimary');
-                       wrapper.appendChild(button);
-                       
-                       var insertBefore = null;
-                       if (insertBeforeButton) {
-                               insertBefore = _buttons.get(insertBeforeButton);
-                               if (insertBefore !== undefined) {
-                                       insertBefore = insertBefore.parentNode;
-                               }
-                       }
-                       
-                       if (insertBefore === null && _container.childElementCount) {
-                               insertBefore = _container.children[0];
-                       }
-                       if (insertBefore === null) {
-                               insertBefore = _container.firstChild;
-                       }
-                       
-                       _container.insertBefore(wrapper, insertBefore);
-                       _wrapper.classList.remove('scrolledDown');
-                       
-                       _buttons.set(buttonName, button);
-                       
-                       // Query a layout related property to force a reflow, otherwise the transition is optimized away.
-                       // noinspection BadExpressionStatementJS
-                       wrapper.offsetParent;
-                       
-                       // Toggle the visibility to force the transition to be applied.
-                       elAttr(wrapper, 'aria-hidden', 'false');
-                       
-                       this._renderContainer();
-               },
-               
-               /**
-                * Returns true if there is a registered button with the provided name.
-                *
-                * @param       {string}        buttonName      unique identifier
-                * @return      {boolean}       true if there is a registered button with this name
-                */
-               has: function (buttonName) {
-                       return _buttons.has(buttonName);
-               },
-               
-               /**
-                * Returns the stored button by name or undefined.
-                *
-                * @param       {string}        buttonName      unique identifier
-                * @return      {Element}       button element or undefined
-                */
-               get: function (buttonName) {
-                       return _buttons.get(buttonName);
-               },
-               
-               /**
-                * Removes a button by its button name.
-                *
-                * @param       {string}        buttonName      unique identifier
-                */
-               remove: function (buttonName) {
-                       var button = _buttons.get(buttonName);
-                       if (button !== undefined) {
-                               var listItem = button.parentNode;
-                               var callback = function () {
-                                       try {
-                                               if (elAttrBool(listItem, 'aria-hidden')) {
-                                                       _container.removeChild(listItem);
-                                                       _buttons.delete(buttonName);
-                                               }
-                                               
-                                               listItem.removeEventListener('transitionend', callback);
-                                       }
-                                       catch (e) {
-                                               // ignore errors if the element has already been removed
-                                       }
-                               };
-                               
-                               listItem.addEventListener('transitionend', callback);
-                               
-                               this.hide(buttonName);
-                       }
-               },
-               
-               /**
-                * Hides a button by its button name.
-                *
-                * @param       {string}        buttonName      unique identifier
-                */
-               hide: function (buttonName) {
-                       var button = _buttons.get(buttonName);
-                       if (button) {
-                               elAttr(button.parentNode, 'aria-hidden', 'true');
-                               this._renderContainer();
-                       }
-               },
-               
-               /**
-                * Shows a button by its button name.
-                *
-                * @param       {string}        buttonName      unique identifier
-                */
-               show: function (buttonName) {
-                       var button = _buttons.get(buttonName);
-                       if (button) {
-                               if (button.parentNode.classList.contains('initiallyHidden')) {
-                                       button.parentNode.classList.remove('initiallyHidden');
-                               }
-                               
-                               elAttr(button.parentNode, 'aria-hidden', 'false');
-                               _wrapper.classList.remove('scrolledDown');
-                               this._renderContainer();
-                       }
-               },
-               
-               /**
-                * Toggles the container's visibility.
-                *
-                * @protected
-                */
-               _renderContainer: function () {
-                       var hasVisibleItems = false;
-                       if (_container.childElementCount) {
-                               for (var i = 0, length = _container.childElementCount; i < length; i++) {
-                                       if (elAttr(_container.children[i], 'aria-hidden') === 'false') {
-                                               hasVisibleItems = true;
-                                               break;
-                                       }
-                               }
-                       }
-                       
-                       _container.classList[(hasVisibleItems ? 'add' : 'remove')]('active');
-               }
-       };
-});
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Action.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Action.ts
new file mode 100644 (file)
index 0000000..fffec83
--- /dev/null
@@ -0,0 +1,236 @@
+/**
+ * Provides page actions such as "jump to top" and clipboard actions.
+ *
+ * @author  Alexander Ebert
+ * @copyright  2001-2020 WoltLab GmbH
+ * @license  GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module  WoltLabSuite/Core/Ui/Page/Action
+ */
+
+import * as Core from '../../Core';
+import * as Language from '../../Language';
+
+const _buttons = new Map<string, HTMLElement>();
+
+let _container: HTMLElement;
+let _didInit = false;
+let _lastPosition = -1;
+let _toTopButton: HTMLElement;
+let _wrapper: HTMLElement;
+
+function buildToTopButton(): HTMLAnchorElement {
+  const button = document.createElement('a');
+  button.className = 'button buttonPrimary pageActionButtonToTop initiallyHidden jsTooltip';
+  button.href = '';
+  button.title = Language.get('wcf.global.scrollUp');
+  button.setAttribute('aria-hidden', 'true');
+  button.innerHTML = '<span class="icon icon32 fa-angle-up"></span>';
+
+  button.addEventListener('click', scrollToTop);
+
+  return button;
+}
+
+function onScroll(): void {
+  if (document.documentElement.classList.contains('disableScrolling')) {
+    // Ignore any scroll events that take place while body scrolling is disabled,
+    // because it messes up the scroll offsets.
+    return;
+  }
+
+  const offset = window.pageYOffset;
+  if (offset === _lastPosition) {
+    // Ignore any scroll event that is fired but without a position change. This can
+    // happen after closing a dialog that prevented the body from being scrolled.
+    return;
+  }
+
+  if (offset >= 300) {
+    if (_toTopButton.classList.contains('initiallyHidden')) {
+      _toTopButton.classList.remove('initiallyHidden');
+    }
+
+    _toTopButton.setAttribute('aria-hidden', 'false');
+  } else {
+    _toTopButton.setAttribute('aria-hidden', 'true');
+  }
+
+  renderContainer();
+
+  if (_lastPosition !== -1) {
+    _wrapper.classList[offset < _lastPosition ? 'remove' : 'add']('scrolledDown');
+  }
+
+  _lastPosition = offset;
+}
+
+function scrollToTop(event: MouseEvent): void {
+  event.preventDefault();
+
+  const topAnchor = document.getElementById('top')!;
+  topAnchor.scrollIntoView({behavior: 'smooth'});
+}
+
+/**
+ * Toggles the container's visibility.
+ */
+function renderContainer() {
+  const visibleChild = Array.from(_container.children).find(element => {
+    return element.getAttribute('aria-hidden') === 'false';
+  });
+
+  _container.classList[visibleChild ? 'add' : 'remove']('active');
+}
+
+/**
+ * Initializes the page action container.
+ */
+export function setup() {
+  if (_didInit) {
+    return;
+  }
+
+  _didInit = true;
+
+  _wrapper = document.createElement('div');
+  _wrapper.className = 'pageAction';
+
+  _container = document.createElement('div');
+  _container.className = 'pageActionButtons';
+  _wrapper.appendChild(_container);
+
+  _toTopButton = buildToTopButton();
+  _wrapper.appendChild(_toTopButton);
+
+  document.body.appendChild(_wrapper);
+
+  window.addEventListener(
+    'scroll',
+    Core.debounce(onScroll, 100),
+    {passive: true},
+  );
+
+  onScroll();
+}
+
+/**
+ * Adds a button to the page action list. You can optionally provide a button name to
+ * insert the button right before it. Unmatched button names or empty value will cause
+ * the button to be prepended to the list.
+ */
+export function add(buttonName: string, button: HTMLElement, insertBeforeButton?: string) {
+  setup();
+
+  // The wrapper is required for backwards compatibility, because some implementations rely on a
+  // dedicated parent element to insert elements, for example, for drop-down menus.
+  const wrapper = document.createElement('div');
+  wrapper.className = 'pageActionButton';
+  wrapper.dataset.name = buttonName;
+  wrapper.setAttribute('aria-hidden', 'true');
+
+  button.classList.add('button');
+  button.classList.add('buttonPrimary');
+  wrapper.appendChild(button);
+
+  let insertBefore: HTMLElement | null = null;
+  if (insertBeforeButton) {
+    insertBefore = _buttons.get(insertBeforeButton) || null;
+    if (insertBefore) {
+      insertBefore = insertBefore.parentElement;
+    }
+  }
+
+  if (!insertBefore && _container.childElementCount) {
+    insertBefore = _container.children[0] as HTMLElement;
+  }
+  if (!insertBefore) {
+    insertBefore = _container.firstChild as HTMLElement;
+  }
+
+  _container.insertBefore(wrapper, insertBefore);
+  _wrapper.classList.remove('scrolledDown');
+
+  _buttons.set(buttonName, button);
+
+  // Query a layout related property to force a reflow, otherwise the transition is optimized away.
+  // noinspection BadExpressionStatementJS
+  wrapper.offsetParent;
+
+  // Toggle the visibility to force the transition to be applied.
+  wrapper.setAttribute('aria-hidden', 'false');
+
+  renderContainer();
+}
+
+/**
+ * Returns true if there is a registered button with the provided name.
+ */
+export function has(buttonName: string): boolean {
+  return _buttons.has(buttonName);
+}
+
+/**
+ * Returns the stored button by name or undefined.
+ */
+export function get(buttonName: string): HTMLElement | undefined {
+  return _buttons.get(buttonName);
+}
+
+/**
+ * Removes a button by its button name.
+ */
+export function remove(buttonName: string): void {
+  const button = _buttons.get(buttonName);
+  if (button !== undefined) {
+    const listItem = button.parentElement!;
+    const callback = () => {
+      try {
+        if (Core.stringToBool(listItem.getAttribute('aria-hidden'))) {
+          _container.removeChild(listItem);
+          _buttons.delete(buttonName);
+        }
+
+        listItem.removeEventListener('transitionend', callback);
+      } catch (e) {
+        // ignore errors if the element has already been removed
+      }
+    };
+
+    listItem.addEventListener('transitionend', callback);
+
+    hide(buttonName);
+  }
+}
+
+/**
+ * Hides a button by its button name.
+ */
+export function hide(buttonName: string): void {
+  const button = _buttons.get(buttonName);
+  if (button) {
+    const parent = button.parentElement!;
+    parent.setAttribute('aria-hidden', 'true');
+
+    renderContainer();
+  }
+}
+
+/**
+ * Shows a button by its button name.
+ */
+export function show(buttonName: string): void {
+  const button = _buttons.get(buttonName);
+  if (button) {
+    const parent = button.parentElement!;
+    if (parent.classList.contains('initiallyHidden')) {
+      parent.classList.remove('initiallyHidden');
+    }
+
+    parent.setAttribute('aria-hidden', 'false');
+    _wrapper.classList.remove('scrolledDown');
+
+    renderContainer();
+  }
+}
+               
+