Improved the scroll behavior on mobile devices
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / js / WoltLabSuite / Core / Ui / Page / Action.js
CommitLineData
3a8d4181
AE
1/**
2 * Provides page actions such as "jump to top" and clipboard actions.
92edef79 3 *
3a8d4181 4 * @author Alexander Ebert
92edef79 5 * @copyright 2001-2020 WoltLab GmbH
3a8d4181 6 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
58d7e8f8 7 * @module WoltLabSuite/Core/Ui/Page/Action
3a8d4181 8 */
92edef79
AE
9define(['Dictionary', 'Language'], function (Dictionary, Language) {
10 'use strict';
3a8d4181
AE
11
12 var _buttons = new Dictionary();
92edef79
AE
13
14 /** @var {Element} */
15 var _container;
16
3a8d4181
AE
17 var _didInit = false;
18
92edef79
AE
19 var _lastPosition = -1;
20
21 /** @var {Element} */
22 var _toTopButton;
23
24 /** @var {Element} */
25 var _wrapper;
26
0b65868a
AE
27 var _resetLastPosition = window.debounce(function () {
28 _lastPosition = -1;
29 }, 50, false);
30
3a8d4181 31 /**
58d7e8f8 32 * @exports WoltLabSuite/Core/Ui/Page/Action
3a8d4181
AE
33 */
34 return {
35 /**
36 * Initializes the page action container.
37 */
92edef79 38 setup: function () {
a9aa38c4
AE
39 if (_didInit) {
40 return;
41 }
42
3a8d4181
AE
43 _didInit = true;
44
92edef79
AE
45 _wrapper = elCreate('div');
46 _wrapper.className = 'pageAction';
47
48 _container = elCreate('div');
49 _container.className = 'pageActionButtons';
50 _wrapper.appendChild(_container);
51
52 _toTopButton = this._buildToTopButton();
53 _wrapper.appendChild(_toTopButton);
54
55 document.body.appendChild(_wrapper);
56
0b65868a 57 var debounce = window.debounce(this._onScroll.bind(this), 100, false);
92edef79 58 window.addEventListener(
0b65868a
AE
59 "scroll",
60 function () {
61 if (_lastPosition === -1) {
62 _lastPosition = window.pageYOffset;
63
64 // Invoke the scroll handler once to immediately respond to
65 // the user action before debouncing all further calls.
66 window.setTimeout(function () {
67 this._onScroll();
68
69 _lastPosition = window.pageYOffset;
70 }.bind(this), 60);
71 }
72
73 debounce();
74 }.bind(this),
92edef79
AE
75 {passive: true}
76 );
77
0b65868a
AE
78 window.addEventListener("touchstart", function () {
79 // Force a reset of the scroll position to trigger an immediate reaction
80 // when the user touches the display again.
81 if (_lastPosition !== -1) {
82 _lastPosition = -1;
83 }
84 }, {passive: true});
85
92edef79
AE
86 this._onScroll();
87 },
88
89 _buildToTopButton: function () {
90 var button = elCreate('a');
91 button.className = 'button buttonPrimary pageActionButtonToTop initiallyHidden jsTooltip';
92 button.href = '';
93 elAttr(button, 'title', Language.get('wcf.global.scrollUp'));
94 elAttr(button, 'aria-hidden', 'true');
95 button.innerHTML = '<span class="icon icon32 fa-angle-up"></span>';
96
97 button.addEventListener(WCF_CLICK_EVENT, this._scrollTopTop.bind(this));
98
99 return button;
100 },
101
0b65868a 102 _onScroll: function () {
8797b341
AE
103 if (document.documentElement.classList.contains('disableScrolling')) {
104 // Ignore any scroll events that take place while body scrolling is disabled,
105 // because it messes up the scroll offsets.
106 return;
107 }
108
92edef79 109 var offset = window.pageYOffset;
8797b341
AE
110 if (offset === _lastPosition) {
111 // Ignore any scroll event that is fired but without a position change. This can
112 // happen after closing a dialog that prevented the body from being scrolled.
0b65868a 113 _resetLastPosition();
8797b341
AE
114 return;
115 }
be4d0f49 116
92edef79
AE
117 if (offset >= 300) {
118 if (_toTopButton.classList.contains('initiallyHidden')) {
119 _toTopButton.classList.remove('initiallyHidden');
120 }
121
122 elAttr(_toTopButton, 'aria-hidden', 'false');
123 }
124 else {
125 elAttr(_toTopButton, 'aria-hidden', 'true');
126 }
127
128 this._renderContainer();
129
130 if (_lastPosition !== -1) {
131 _wrapper.classList[offset < _lastPosition ? 'remove' : 'add']('scrolledDown');
132 }
133
0b65868a 134 _lastPosition = -1;
92edef79
AE
135 },
136
137 /**
138 * @param {Event} event
139 */
140 _scrollTopTop: function (event) {
141 event.preventDefault();
142
143 elById('top').scrollIntoView({behavior: 'smooth'});
3a8d4181
AE
144 },
145
146 /**
147 * Adds a button to the page action list. You can optionally provide a button name to
148 * insert the button right before it. Unmatched button names or empty value will cause
149 * the button to be prepended to the list.
92edef79 150 *
3a8d4181
AE
151 * @param {string} buttonName unique identifier
152 * @param {Element} button button element, must not be wrapped in a <li>
153 * @param {string=} insertBeforeButton insert button before element identified by provided button name
154 */
92edef79 155 add: function (buttonName, button, insertBeforeButton) {
a9aa38c4 156 this.setup();
3a8d4181 157
92edef79
AE
158 // The wrapper is required for backwards compatibility, because some implementations rely on a
159 // dedicated parent element to insert elements, for example, for drop-down menus.
160 var wrapper = elCreate('div');
161 wrapper.className = 'pageActionButton';
162 wrapper.name = buttonName;
be4d0f49 163 elAttr(wrapper, 'aria-hidden', 'true');
92edef79 164
4d194569
MW
165 button.classList.add('button');
166 button.classList.add('buttonPrimary');
92edef79
AE
167 wrapper.appendChild(button);
168
169 var insertBefore = null;
170 if (insertBeforeButton) {
171 insertBefore = _buttons.get(insertBeforeButton);
172 if (insertBefore !== undefined) {
173 insertBefore = insertBefore.parentNode;
3a8d4181
AE
174 }
175 }
176
92edef79
AE
177 if (insertBefore === null && _container.childElementCount) {
178 insertBefore = _container.children[0];
179 }
180 if (insertBefore === null) {
181 insertBefore = _container.firstChild;
182 }
183
184 _container.insertBefore(wrapper, insertBefore);
aa806ad7 185 _wrapper.classList.remove('scrolledDown');
92edef79 186
3a8d4181 187 _buttons.set(buttonName, button);
be4d0f49
AE
188
189 // Query a layout related property to force a reflow, otherwise the transition is optimized away.
190 // noinspection BadExpressionStatementJS
191 wrapper.offsetParent;
192
193 // Toggle the visibility to force the transition to be applied.
194 elAttr(wrapper, 'aria-hidden', 'false');
195
3a8d4181
AE
196 this._renderContainer();
197 },
198
28fe0c48
AE
199 /**
200 * Returns true if there is a registered button with the provided name.
92edef79 201 *
28fe0c48
AE
202 * @param {string} buttonName unique identifier
203 * @return {boolean} true if there is a registered button with this name
204 */
205 has: function (buttonName) {
206 return _buttons.has(buttonName);
207 },
208
209 /**
210 * Returns the stored button by name or undefined.
92edef79 211 *
28fe0c48
AE
212 * @param {string} buttonName unique identifier
213 * @return {Element} button element or undefined
214 */
92edef79 215 get: function (buttonName) {
28fe0c48
AE
216 return _buttons.get(buttonName);
217 },
218
3a8d4181
AE
219 /**
220 * Removes a button by its button name.
92edef79 221 *
3a8d4181
AE
222 * @param {string} buttonName unique identifier
223 */
92edef79 224 remove: function (buttonName) {
3a8d4181
AE
225 var button = _buttons.get(buttonName);
226 if (button !== undefined) {
227 var listItem = button.parentNode;
e343c91c 228 var callback = function () {
293e3724 229 try {
e343c91c
AE
230 if (elAttrBool(listItem, 'aria-hidden')) {
231 _container.removeChild(listItem);
232 _buttons.delete(buttonName);
233 }
234
be4d0f49 235 listItem.removeEventListener('transitionend', callback);
293e3724
MS
236 }
237 catch (e) {
238 // ignore errors if the element has already been removed
239 }
e343c91c
AE
240 };
241
be4d0f49 242 listItem.addEventListener('transitionend', callback);
293e3724 243
3a8d4181
AE
244 this.hide(buttonName);
245 }
246 },
247
248 /**
249 * Hides a button by its button name.
92edef79 250 *
3a8d4181
AE
251 * @param {string} buttonName unique identifier
252 */
92edef79 253 hide: function (buttonName) {
3a8d4181
AE
254 var button = _buttons.get(buttonName);
255 if (button) {
256 elAttr(button.parentNode, 'aria-hidden', 'true');
257 this._renderContainer();
258 }
259 },
260
261 /**
262 * Shows a button by its button name.
92edef79 263 *
3a8d4181
AE
264 * @param {string} buttonName unique identifier
265 */
92edef79 266 show: function (buttonName) {
3a8d4181
AE
267 var button = _buttons.get(buttonName);
268 if (button) {
269 if (button.parentNode.classList.contains('initiallyHidden')) {
270 button.parentNode.classList.remove('initiallyHidden');
271 }
272
273 elAttr(button.parentNode, 'aria-hidden', 'false');
aa806ad7 274 _wrapper.classList.remove('scrolledDown');
3a8d4181
AE
275 this._renderContainer();
276 }
277 },
278
279 /**
280 * Toggles the container's visibility.
92edef79 281 *
3a8d4181
AE
282 * @protected
283 */
92edef79 284 _renderContainer: function () {
3a8d4181
AE
285 var hasVisibleItems = false;
286 if (_container.childElementCount) {
287 for (var i = 0, length = _container.childElementCount; i < length; i++) {
288 if (elAttr(_container.children[i], 'aria-hidden') === 'false') {
289 hasVisibleItems = true;
290 break;
291 }
292 }
293 }
294
295 _container.classList[(hasVisibleItems ? 'add' : 'remove')]('active');
296 }
297 };
298});