From 92edef79e61ad4579d41783fe20986ad547856cf Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Thu, 6 Aug 2020 00:26:01 +0200 Subject: [PATCH] Overhauled the page action buttons' behavior --- .../files/js/WoltLabSuite/Core/Bootstrap.js | 6 +- .../files/js/WoltLabSuite/Core/Dom/Util.js | 2 + .../js/WoltLabSuite/Core/Ui/Page/Action.js | 170 ++++++++++++------ .../js/WoltLabSuite/Core/Ui/Page/JumpToTop.js | 84 --------- wcfsetup/install/files/js/wcf.globalHelper.js | 35 ++++ .../install/files/style/ui/pageAction.scss | 86 +++++---- 6 files changed, 212 insertions(+), 171 deletions(-) delete mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Page/JumpToTop.js diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js index 54f204e61c..cd59626d77 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js @@ -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); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Dom/Util.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Dom/Util.js index 676a0f58bd..c75ba0f23f 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Dom/Util.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Dom/Util.js @@ -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) { diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Page/Action.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Page/Action.js index 5467ca3c46..04b0edc50b 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Page/Action.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Page/Action.js @@ -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 * @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 = ''; + + 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
  • * @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 index 27e6d9d402..0000000000 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Page/JumpToTop.js +++ /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 - * @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 = ''; - - 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; -}); diff --git a/wcfsetup/install/files/js/wcf.globalHelper.js b/wcfsetup/install/files/js/wcf.globalHelper.js index c3aa1615c1..c4c7009a98 100644 --- a/wcfsetup/install/files/js/wcf.globalHelper.js +++ b/wcfsetup/install/files/js/wcf.globalHelper.js @@ -289,6 +289,41 @@ 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. diff --git a/wcfsetup/install/files/style/ui/pageAction.scss b/wcfsetup/install/files/style/ui/pageAction.scss index 2e01908174..5f0410432c 100644 --- a/wcfsetup/install/files/style/ui/pageAction.scss +++ b/wcfsetup/install/files/style/ui/pageAction.scss @@ -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; } } } -- 2.20.1