Overhauled the page action buttons' behavior
authorAlexander Ebert <ebert@woltlab.com>
Wed, 5 Aug 2020 22:26:01 +0000 (00:26 +0200)
committerAlexander Ebert <ebert@woltlab.com>
Wed, 5 Aug 2020 22:26:01 +0000 (00:26 +0200)
wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js
wcfsetup/install/files/js/WoltLabSuite/Core/Dom/Util.js
wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Page/Action.js
wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Page/JumpToTop.js [deleted file]
wcfsetup/install/files/js/wcf.globalHelper.js
wcfsetup/install/files/style/ui/pageAction.scss

index 54f204e61c2e36b0efc549ef39a8b08eaefa44ab..cd59626d77c7c9bf304f8655c8e9b8b0c677e68b 100644 (file)
@@ -13,14 +13,14 @@ define(
                'favico',                  'enquire',                'perfect-scrollbar',      'WoltLabSuite/Core/Date/Time/Relative',
                'Ui/SimpleDropdown',       'WoltLabSuite/Core/Ui/Mobile',  'WoltLabSuite/Core/Ui/TabMenu', 'WoltLabSuite/Core/Ui/FlexibleMenu',
                'Ui/Dialog',               'WoltLabSuite/Core/Ui/Tooltip', 'WoltLabSuite/Core/Language',   'WoltLabSuite/Core/Environment',
-               'WoltLabSuite/Core/Date/Picker', 'EventHandler',           'Core',                   'WoltLabSuite/Core/Ui/Page/JumpToTop',
+               'WoltLabSuite/Core/Date/Picker', 'EventHandler',           'Core',                   'WoltLabSuite/Core/Ui/Page/Action',
                'Devtools', 'Dom/ChangeListener'
        ], 
        function(
                 favico,                   enquire,                  perfectScrollbar,         DateTimeRelative,
                 UiSimpleDropdown,         UiMobile,                 UiTabMenu,                UiFlexibleMenu,
                 UiDialog,                 UiTooltip,                Language,                 Environment,
-                DatePicker,               EventHandler,             Core,                     UiPageJumpToTop,
+                DatePicker,               EventHandler,             Core,                     UiPageAction,
                 Devtools, DomChangeListener
        )
 {
@@ -91,7 +91,7 @@ define(
                                        // putting it at the end of the jQuery queue avoids trashing the
                                        // layout too early and thus delaying the page initialization
                                        window.jQuery(function() {
-                                               new UiPageJumpToTop();
+                                               UiPageAction.setup();
                                        });
                                        
                                        window.jQuery.holdReady(false);
index 676a0f58bd119fdcfecdfded49ca907eae26ec79..c75ba0f23f8152f1032889479bf8f0bcf36398e9 100644 (file)
@@ -165,6 +165,7 @@ define(['Environment', 'StringUtil'], function(Environment, StringUtil) {
                 * 
                 * @param       {Element}       el              element to prepend
                 * @param       {Element}       parentEl        future containing element
+                * @deprecated 5.3 Use `parentEl.insertBefore(el, parentEl.firstChild)` instead.
                 */
                prepend: function(el, parentEl) {
                        if (parentEl.childNodes.length === 0) {
@@ -180,6 +181,7 @@ define(['Environment', 'StringUtil'], function(Environment, StringUtil) {
                 * 
                 * @param       {Element}       newEl           element to insert
                 * @param       {Element}       el              reference element
+                * @deprecated 5.3 Use `el.parentNode.insertBefore(newEl, el.nextSibling)` instead.
                 */
                insertAfter: function(newEl, el) {
                        if (el.nextSibling !== null) {
index 5467ca3c467fb5d1ac09058f768ffe2579f949d1..04b0edc50be404cdccc4859c312344497ab16998 100644 (file)
@@ -1,18 +1,29 @@
 /**
  * Provides page actions such as "jump to top" and clipboard actions.
- * 
+ *
  * @author     Alexander Ebert
- * @copyright  2001-2019 WoltLab GmbH
+ * @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', 'Dom/Util'], function(Dictionary, DomUtil) {
-       "use strict";
+define(['Dictionary', 'Language'], function (Dictionary, Language) {
+       'use strict';
        
        var _buttons = new Dictionary();
-       var _container = null;
+       
+       /** @var {Element} */
+       var _container;
+       
        var _didInit = false;
        
+       var _lastPosition = -1;
+       
+       /** @var {Element} */
+       var _toTopButton;
+       
+       /** @var {Element} */
+       var _wrapper;
+       
        /**
         * @exports     WoltLabSuite/Core/Ui/Page/Action
         */
@@ -20,66 +31,125 @@ define(['Dictionary', 'Dom/Util'], function(Dictionary, DomUtil) {
                /**
                 * Initializes the page action container.
                 */
-               setup: function() {
+               setup: function () {
                        _didInit = true;
                        
-                       _container = elCreate('ul');
-                       _container.className = 'pageAction';
-                       document.body.appendChild(_container);
+                       _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) {
+                       var offset = window.pageYOffset;
+                       console.log(offset, _lastPosition);
+                       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) {
+               add: function (buttonName, button, insertBeforeButton) {
                        if (_didInit === false) this.setup();
                        
-                       var listItem = elCreate('li');
+                       // 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', 'false');
+                       
                        button.classList.add('button');
                        button.classList.add('buttonPrimary');
-                       listItem.appendChild(button);
-                       elAttr(listItem, 'aria-hidden', (buttonName === 'toTop' ? 'true' : 'false'));
-                       elData(listItem, 'name', buttonName);
-                       
-                       // force 'to top' button to be always at the most outer position
-                       if (buttonName === 'toTop') {
-                               listItem.className = 'toTop initiallyHidden';
-                               _container.appendChild(listItem);
-                       }
-                       else {
-                               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) {
-                                       DomUtil.prepend(listItem, _container);
-                               }
-                               else {
-                                       _container.insertBefore(listItem, insertBefore);
+                       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);
+                       
                        _buttons.set(buttonName, button);
                        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
                 */
@@ -89,20 +159,20 @@ define(['Dictionary', 'Dom/Util'], function(Dictionary, DomUtil) {
                
                /**
                 * Returns the stored button by name or undefined.
-                * 
+                *
                 * @param       {string}        buttonName      unique identifier
                 * @return      {Element}       button element or undefined
                 */
-               get: function(buttonName) {
+               get: function (buttonName) {
                        return _buttons.get(buttonName);
                },
                
                /**
                 * Removes a button by its button name.
-                * 
+                *
                 * @param       {string}        buttonName      unique identifier
                 */
-               remove: function(buttonName) {
+               remove: function (buttonName) {
                        var button = _buttons.get(buttonName);
                        if (button !== undefined) {
                                var listItem = button.parentNode;
@@ -128,10 +198,10 @@ define(['Dictionary', 'Dom/Util'], function(Dictionary, DomUtil) {
                
                /**
                 * Hides a button by its button name.
-                * 
+                *
                 * @param       {string}        buttonName      unique identifier
                 */
-               hide: function(buttonName) {
+               hide: function (buttonName) {
                        var button = _buttons.get(buttonName);
                        if (button) {
                                elAttr(button.parentNode, 'aria-hidden', 'true');
@@ -141,10 +211,10 @@ define(['Dictionary', 'Dom/Util'], function(Dictionary, DomUtil) {
                
                /**
                 * Shows a button by its button name.
-                * 
+                *
                 * @param       {string}        buttonName      unique identifier
                 */
-               show: function(buttonName) {
+               show: function (buttonName) {
                        var button = _buttons.get(buttonName);
                        if (button) {
                                if (button.parentNode.classList.contains('initiallyHidden')) {
@@ -158,10 +228,10 @@ define(['Dictionary', 'Dom/Util'], function(Dictionary, DomUtil) {
                
                /**
                 * Toggles the container's visibility.
-                * 
+                *
                 * @protected
                 */
-               _renderContainer: function() {
+               _renderContainer: function () {
                        var hasVisibleItems = false;
                        if (_container.childElementCount) {
                                for (var i = 0, length = _container.childElementCount; i < length; i++) {
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Page/JumpToTop.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Page/JumpToTop.js
deleted file mode 100644 (file)
index 27e6d9d..0000000
+++ /dev/null
@@ -1,84 +0,0 @@
-/**
- * Provides a link to scroll to top once the page is scrolled by at least 50% the height of the window.
- * 
- * @author     Alexander Ebert
- * @copyright  2001-2019 WoltLab GmbH
- * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module     WoltLabSuite/Core/Ui/Page/JumpToTop
- */
-define(['Environment', 'Language', './Action'], function(Environment, Language, PageAction) {
-       "use strict";
-       
-       /**
-        * @constructor
-        */
-       function JumpToTop() { this.init(); }
-       JumpToTop.prototype = {
-               /**
-                * Initializes the top link for desktop browsers only.
-                */
-               init: function() {
-                       // top link is not available on smartphones and tablets (they have a built-in function to accomplish this)
-                       if (Environment.platform() !== 'desktop') {
-                               return;
-                       }
-                       
-                       this._callbackScrollEnd = this._afterScroll.bind(this);
-                       this._timeoutScroll = null;
-                       
-                       var button = elCreate('a');
-                       button.className = '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._jump.bind(this));
-                       
-                       PageAction.add('toTop', button);
-                       
-                       window.addEventListener('scroll', this._scroll.bind(this));
-                       
-                       // invoke callback on page load
-                       this._afterScroll();
-               },
-               
-               /**
-                * Handles clicks on the top link.
-                * 
-                * @param       {Event}         event   event object
-                * @protected
-                */
-               _jump: function(event) {
-                       event.preventDefault();
-                       
-                       elById('top').scrollIntoView({ behavior: 'smooth' });
-               },
-               
-               /**
-                * Callback executed whenever the window is being scrolled.
-                * 
-                * @protected
-                */
-               _scroll: function() {
-                       if (this._timeoutScroll !== null) {
-                               window.clearTimeout(this._timeoutScroll);
-                       }
-                       
-                       this._timeoutScroll = window.setTimeout(this._callbackScrollEnd, 100);
-               },
-               
-               /**
-                * Delayed callback executed once the page has not been scrolled for a certain amount of time.
-                * 
-                * @protected
-                */
-               _afterScroll: function() {
-                       this._timeoutScroll = null;
-                       
-                       PageAction[(window.pageYOffset >= 300) ? 'show' : 'hide']('toTop');
-               }
-       };
-       
-       return JumpToTop;
-});
index c3aa1615c1f88f37eff75ba143f08743f2bc7fd9..c4c7009a98baaaf0922178633a1fb3d5b7f9b42e 100644 (file)
                return obj.hasOwnProperty(property);
        };
        
+       /**
+        * Returns a function, that, as long as it continues to be invoked, will not
+        * be triggered. The function will be called after it stops being called for
+        * N milliseconds. If `immediate` is passed, trigger the function on the
+        * leading edge, instead of the trailing.
+        * 
+        * @param {function} func
+        * @param {number} wait
+        * @param {boolean} immediate
+        * @return function
+        * @see https://davidwalsh.name/javascript-debounce-function
+        */
+       window.debounce = function (func, wait, immediate) {
+               var timeout;
+               
+               return function() {
+                       var context = this;
+                       var args = arguments;
+                       
+                       clearTimeout(timeout);
+                       
+                       timeout = setTimeout(function() {
+                               timeout = null;
+                               
+                               if (!immediate) {
+                                       func.apply(context, args)
+                               }
+                       }, wait);
+                       
+                       if (immediate && !timeout) {
+                               func.apply(context, args)
+                       }
+               };
+       };
+       
        /* assigns a global constant defining the proper 'click' event depending on the browser,
           enforcing 'touchstart' on mobile devices for a better UX. We're using defineProperty()
           here because at the time of writing Safari does not support 'const'. Thanks Safari.
index 2e019081741183a8e2d76a30e4170606d2b10a02..5f0410432c563b7e33f0af984927005546914896 100644 (file)
@@ -1,71 +1,89 @@
 @keyframes wcfPageAction {
-       0%   { visibility: visible; transform: translateY(-10px); opacity: 0; }
-       100% { visibility: visible; transform: translateY(0);     opacity: 1; }
+       0%   { visibility: visible; opacity: 0; }
+       100% { visibility: visible; opacity: 1; }
 }
 
 @keyframes wcfPageActionOut {
-       0%   { visibility: visible; transform: translateY(0);     opacity: 1; }
-       100% { visibility: hidden;  transform: translateY(-10px); opacity: 0; }
+       0%   { visibility: visible; opacity: 1; }
+       100% { visibility: hidden;  opacity: 0; }
 }
 
 @keyframes wcfPageActionRemove {
-       0%   { visibility: visible; transform: translateY(0);     opacity: 1; }
-       60%  { visibility: hidden;  transform: translateY(-10px); opacity: 0; }
-       100% { visibility: hidden;  transform: translateY(-10px); opacity: 0; max-width: 0; }
+       0%   { visibility: visible; opacity: 1; }
+       60%  { visibility: hidden;  opacity: 0; }
+       100% { visibility: hidden;  opacity: 0; max-width: 0; }
 }
 
 .pageAction {
        bottom: 10px;
+       display: flex;
+       justify-content: flex-end;
+       left: 10px;
+       pointer-events: none;
        position: fixed;
        right: 10px;
        z-index: 400;
        
-       @include inlineList;
+       .pageActionButtons {
+               display: flex;
+               flex: 0 auto;
+               overflow: auto;
+       }
+       
+       .pageActionButtons,
+       .pageActionButtonToTop {
+               pointer-events: all;
+       }
        
-       > li {
-               animation: wcfPageActionOut .3s;
-               animation-fill-mode: forwards;
+       .pageActionButton {
                display: flex;
+               flex: 0 0 auto;
                
                // required to animate 'max-width' properly when removing items
                max-width: 400px;
                white-space: nowrap;
                
-               &[aria-hidden="false"] {
-                       animation: wcfPageAction .3s;
-                       animation-fill-mode: forwards;
+               &:not(:first-child) {
+                       margin-left: 5px;
                }
                
                &.remove {
-                       animation: wcfPageActionRemove .5s;
-                       animation-fill-mode: forwards;
+                       animation: wcfPageActionRemove .48s;
+               }
+       }
+       
+       .pageActionButton,
+       .pageActionButtonToTop {
+               animation: wcfPageActionOut .24s forwards;
+               
+               &[aria-hidden="false"] {
+                       animation: wcfPageAction .24s;
                }
                
                &.initiallyHidden {
                        animation: 0;
                        visibility: hidden;
                }
-               
-               &.toTop > a {
-                       padding: 2px;
-               }
        }
        
-       @include screen-xs {
-               flex-wrap: nowrap;
-               left: 10px;
+       .pageActionButtonToTop {
+               align-self: flex-start;
+               flex: 0 0 auto;
+               margin-left: 5px;
+               padding: 2px;
+       }
+       
+       @include screen-sm-down {
+               /* The iOS bottom touch zone is approximately 44px high. Any touches within will show the
+                  menu instead of registering any touch on a button. */
+               bottom: 44px;
+               
+               opacity: 1;
+               transition: opacity .12s linear;
                
-               > li {
-                       flex: 1 1 auto;
-                       max-width: none;
-                       overflow: hidden;
-                       
-                       > a {
-                               overflow: hidden;
-                               text-align: center;
-                               text-overflow: ellipsis;
-                               width: 100%;
-                       }
+               &.scrolledDown {
+                       opacity: 0;
+                       transition-delay: .4s;
                }
        }
 }