Commit | Line | Data |
---|---|---|
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 |
9 | define(['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 | }); |