Added work-around for page action re-creating dropdown parent
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / js / WoltLab / WCF / Ui / Dropdown / Simple.js
1 /**
2 * Simple dropdown implementation.
3 *
4 * @author Alexander Ebert
5 * @copyright 2001-2016 WoltLab GmbH
6 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
7 * @module WoltLab/WCF/Ui/Dropdown/Simple
8 */
9 define(
10 [ 'CallbackList', 'Core', 'Dictionary', 'Ui/Alignment', 'Dom/ChangeListener', 'Dom/Traverse', 'Dom/Util', 'Ui/CloseOverlay'],
11 function(CallbackList, Core, Dictionary, UiAlignment, DomChangeListener, DomTraverse, DomUtil, UiCloseOverlay)
12 {
13 "use strict";
14
15 var _availableDropdowns = null;
16 var _callbacks = new CallbackList();
17 var _didInit = false;
18 var _dropdowns = new Dictionary();
19 var _menus = new Dictionary();
20 var _menuContainer = null;
21
22 /**
23 * @exports WoltLab/WCF/Ui/Dropdown/Simple
24 */
25 return {
26 /**
27 * Performs initial setup such as setting up dropdowns and binding listeners.
28 */
29 setup: function() {
30 if (_didInit) return;
31 _didInit = true;
32
33 _menuContainer = elCreate('div');
34 _menuContainer.className = 'dropdownMenuContainer';
35 document.body.appendChild(_menuContainer);
36
37 _availableDropdowns = elByClass('dropdownToggle');
38
39 this.initAll();
40
41 UiCloseOverlay.add('WoltLab/WCF/Ui/Dropdown/Simple', this.closeAll.bind(this));
42 DomChangeListener.add('WoltLab/WCF/Ui/Dropdown/Simple', this.initAll.bind(this));
43
44 document.addEventListener('scroll', this._onScroll.bind(this));
45
46 // expose on window object for backward compatibility
47 window.bc_wcfSimpleDropdown = this;
48 },
49
50 /**
51 * Loops through all possible dropdowns and registers new ones.
52 */
53 initAll: function() {
54 for (var i = 0, length = _availableDropdowns.length; i < length; i++) {
55 this.init(_availableDropdowns[i], false);
56 }
57 },
58
59 /**
60 * Initializes a dropdown.
61 *
62 * @param {Element} button
63 * @param {boolean} isLazyInitialization
64 */
65 init: function(button, isLazyInitialization) {
66 this.setup();
67
68 if (button.classList.contains('jsDropdownEnabled') || elData(button, 'target')) {
69 return false;
70 }
71
72 var dropdown = DomTraverse.parentByClass(button, 'dropdown');
73 if (dropdown === null) {
74 throw new Error("Invalid dropdown passed, button '" + DomUtil.identify(button) + "' does not have a parent with .dropdown.");
75 }
76
77 var menu = DomTraverse.nextByClass(button, 'dropdownMenu');
78 if (menu === null) {
79 throw new Error("Invalid dropdown passed, button '" + DomUtil.identify(button) + "' does not have a menu as next sibling.");
80 }
81
82 // move menu into global container
83 _menuContainer.appendChild(menu);
84
85 var containerId = DomUtil.identify(dropdown);
86 if (!_dropdowns.has(containerId)) {
87 button.classList.add('jsDropdownEnabled');
88 button.addEventListener(WCF_CLICK_EVENT, this._toggle.bind(this));
89
90 _dropdowns.set(containerId, dropdown);
91 _menus.set(containerId, menu);
92
93 if (!containerId.match(/^wcf\d+$/)) {
94 elData(menu, 'source', containerId);
95 }
96 }
97
98 elData(button, 'target', containerId);
99
100 if (isLazyInitialization) {
101 setTimeout(function() { Core.triggerEvent(button, WCF_CLICK_EVENT); }, 10);
102 }
103 },
104
105 /**
106 * Initializes a remote-controlled dropdown.
107 *
108 * @param {Element} dropdown dropdown wrapper element
109 * @param {Element} menu menu list element
110 */
111 initFragment: function(dropdown, menu) {
112 this.setup();
113
114 var containerId = DomUtil.identify(dropdown);
115 if (_dropdowns.has(containerId)) {
116 return;
117 }
118
119 _dropdowns.set(containerId, dropdown);
120 _menuContainer.appendChild(menu);
121
122 _menus.set(containerId, menu);
123 },
124
125 /**
126 * Registers a callback for open/close events.
127 *
128 * @param {string} containerId dropdown wrapper id
129 * @param {function(string, string)} callback
130 */
131 registerCallback: function(containerId, callback) {
132 _callbacks.add(containerId, callback);
133 },
134
135 /**
136 * Returns the requested dropdown wrapper element.
137 *
138 * @return {Element} dropdown wrapper element
139 */
140 getDropdown: function(containerId) {
141 return _dropdowns.get(containerId);
142 },
143
144 /**
145 * Returns the requested dropdown menu list element.
146 *
147 * @return {Element} menu list element
148 */
149 getDropdownMenu: function(containerId) {
150 return _menus.get(containerId);
151 },
152
153 /**
154 * Toggles the requested dropdown between opened and closed.
155 *
156 * @param {string} containerId dropdown wrapper id
157 * @param {Element=} referenceElement alternative reference element, used for reusable dropdown menus
158 */
159 toggleDropdown: function(containerId, referenceElement) {
160 this._toggle(null, containerId, referenceElement);
161 },
162
163 /**
164 * Calculates and sets the alignment of given dropdown.
165 *
166 * @param {Element} dropdown dropdown wrapper element
167 * @param {Element} dropdownMenu menu list element
168 * @param {Element=} alternateElement alternative reference element for alignment
169 */
170 setAlignment: function(dropdown, dropdownMenu, alternateElement) {
171 // check if button belongs to an i18n textarea
172 var button = elBySel('.dropdownToggle', dropdown), refDimensionsElement;
173 if (button !== null && button.parentNode.classList.contains('inputAddonTextarea')) {
174 refDimensionsElement = button;
175 }
176
177 UiAlignment.set(dropdownMenu, alternateElement || dropdown, {
178 pointerClassNames: ['dropdownArrowBottom', 'dropdownArrowRight'],
179 refDimensionsElement: refDimensionsElement || null,
180
181 // alignment
182 horizontal: (elData(dropdownMenu, 'dropdown-alignment-horizontal') === 'right') ? 'right' : 'left',
183 vertical: (elData(dropdownMenu, 'dropdown-alignment-vertical') === 'top') ? 'top' : 'bottom'
184 });
185 },
186
187 /**
188 * Calculats and sets the alignment of the dropdown identified by given id.
189 *
190 * @param {string} containerId dropdown wrapper id
191 */
192 setAlignmentById: function(containerId) {
193 var dropdown = _dropdowns.get(containerId);
194 if (dropdown === undefined) {
195 throw new Error("Unknown dropdown identifier '" + containerId + "'.");
196 }
197
198 var menu = _menus.get(containerId);
199
200 this.setAlignment(dropdown, menu);
201 },
202
203 /**
204 * Returns true if target dropdown exists and is open.
205 *
206 * @param {string} containerId dropdown wrapper id
207 * @return {boolean} true if dropdown exists and is open
208 */
209 isOpen: function(containerId) {
210 var menu = _menus.get(containerId);
211 return (menu !== undefined && menu.classList.contains('dropdownOpen'));
212 },
213
214 /**
215 * Opens the dropdown unless it is already open.
216 *
217 * @param {string} containerId dropdown wrapper id
218 */
219 open: function(containerId) {
220 var menu = _menus.get(containerId);
221 if (menu !== undefined && !menu.classList.contains('dropdownOpen')) {
222 this.toggleDropdown(containerId);
223 }
224 },
225
226 /**
227 * Closes the dropdown identified by given id without notifying callbacks.
228 *
229 * @param {string} containerId dropdown wrapper id
230 */
231 close: function(containerId) {
232 var dropdown = _dropdowns.get(containerId);
233 if (dropdown !== undefined) {
234 dropdown.classList.remove('dropdownOpen');
235 _menus.get(containerId).classList.remove('dropdownOpen');
236 }
237 },
238
239 /**
240 * Closes all dropdowns.
241 */
242 closeAll: function() {
243 _dropdowns.forEach((function(dropdown, containerId) {
244 if (dropdown.classList.contains('dropdownOpen')) {
245 dropdown.classList.remove('dropdownOpen');
246 _menus.get(containerId).classList.remove('dropdownOpen');
247
248 this._notifyCallbacks(containerId, 'close');
249 }
250 }).bind(this));
251 },
252
253 /**
254 * Destroys a dropdown identified by given id.
255 *
256 * @param {string} containerId dropdown wrapper id
257 * @return {boolean} false for unknown dropdowns
258 */
259 destroy: function(containerId) {
260 if (!_dropdowns.has(containerId)) {
261 return false;
262 }
263
264 this.close(containerId);
265
266 var menu = _menus.get(containerId);
267 _menus.parentNode.removeChild(menu);
268
269 _menus['delete'](containerId);
270 _dropdowns['delete'](containerId);
271
272 return true;
273 },
274
275 /**
276 * Handles dropdown positions in overlays when scrolling in the overlay.
277 *
278 * @param {Event} event event object
279 */
280 _onDialogScroll: function(event) {
281 var dialogContent = event.currentTarget;
282 //noinspection JSCheckFunctionSignatures
283 var dropdowns = elBySelAll('.dropdown.dropdownOpen', dialogContent);
284
285 for (var i = 0, length = dropdowns.length; i < length; i++) {
286 var dropdown = dropdowns[i];
287 var containerId = DomUtil.identify(dropdown);
288 var offset = DomUtil.offset(dropdown);
289 var dialogOffset = DomUtil.offset(dialogContent);
290
291 // check if dropdown toggle is still (partially) visible
292 if (offset.top + dropdown.clientHeight <= dialogOffset.top) {
293 // top check
294 this.toggleDropdown(containerId);
295 }
296 else if (offset.top >= dialogOffset.top + dialogContent.offsetHeight) {
297 // bottom check
298 this.toggleDropdown(containerId);
299 }
300 else if (offset.left <= dialogOffset.left) {
301 // left check
302 this.toggleDropdown(containerId);
303 }
304 else if (offset.left >= dialogOffset.left + dialogContent.offsetWidth) {
305 // right check
306 this.toggleDropdown(containerId);
307 }
308 else {
309 this.setAlignment(containerId, _menus.get(containerId));
310 }
311 }
312 },
313
314 /**
315 * Recalculates dropdown positions on page scroll.
316 */
317 _onScroll: function() {
318 _dropdowns.forEach((function(dropdown, containerId) {
319 if (dropdown.classList.contains('dropdownOpen')) {
320 if (elDataBool(dropdown, 'is-overlay-dropdown-button')) {
321 this.setAlignment(dropdown, _menus.get(containerId));
322 }
323 else {
324 this.close(containerId);
325 }
326 }
327 }).bind(this));
328 },
329
330 /**
331 * Notifies callbacks on status change.
332 *
333 * @param {string} containerId dropdown wrapper id
334 * @param {string} action can be either 'open' or 'close'
335 */
336 _notifyCallbacks: function(containerId, action) {
337 _callbacks.forEach(containerId, function(callback) {
338 callback(containerId, action);
339 });
340 },
341
342 /**
343 * Toggles the dropdown's state between open and close.
344 *
345 * @param {?Event} event event object, should be 'null' if targetId is given
346 * @param {string?} targetId dropdown wrapper id
347 * @param {Element=} alternateElement alternative reference element for alignment
348 * @return {boolean} 'false' if event is not null
349 */
350 _toggle: function(event, targetId, alternateElement) {
351 if (event !== null) {
352 event.preventDefault();
353 event.stopPropagation();
354
355 //noinspection JSCheckFunctionSignatures
356 targetId = elData(event.currentTarget, 'target');
357 }
358
359 var dropdown = _dropdowns.get(targetId), preventToggle = false;
360 if (dropdown !== undefined) {
361 // check if the dropdown is still the same, as some components (e.g. page actions)
362 // re-create the parent of a button
363 if (event) {
364 var button = event.currentTarget, parent = button.parentNode;
365 if (parent !== dropdown) {
366 parent.classList.add('dropdown');
367 parent.id = dropdown.id;
368
369 // remove dropdown class and id from old parent
370 dropdown.classList.remove('dropdown');
371 dropdown.id = '';
372
373 dropdown = parent;
374 _dropdowns.set(targetId, parent);
375 }
376 }
377
378 // Repeated clicks on the dropdown button will not cause it to close, the only way
379 // to close it is by clicking somewhere else in the document or on another dropdown
380 // toggle. This is used with the search bar to prevent the dropdown from closing by
381 // setting the caret position in the search input field.
382 if (elDataBool(dropdown, 'dropdown-prevent-toggle') && dropdown.classList.contains('dropdownOpen')) {
383 preventToggle = true;
384 }
385
386 // check if 'isOverlayDropdownButton' is set which indicates that the dropdown toggle is within an overlay
387 if (elData(dropdown, 'is-overlay-dropdown-button') === null) {
388 var dialogContent = DomTraverse.parentByClass(dropdown, 'dialogContent');
389 elData(dropdown, 'is-overlay-dropdown-button', (dialogContent !== null));
390
391 if (dialogContent !== null) {
392 dialogContent.addEventListener('scroll', this._onDialogScroll.bind(this));
393 }
394 }
395 }
396
397 // close all dropdowns
398 _dropdowns.forEach((function(dropdown, containerId) {
399 var menu = _menus.get(containerId);
400
401 if (dropdown.classList.contains('dropdownOpen')) {
402 if (preventToggle === false) {
403 dropdown.classList.remove('dropdownOpen');
404 menu.classList.remove('dropdownOpen');
405
406 this._notifyCallbacks(containerId, 'close');
407 }
408 }
409 else if (containerId === targetId && menu.childElementCount > 0) {
410 dropdown.classList.add('dropdownOpen');
411 menu.classList.add('dropdownOpen');
412
413 this._notifyCallbacks(containerId, 'open');
414
415 this.setAlignment(dropdown, menu, alternateElement);
416 }
417 }).bind(this));
418
419 //noinspection JSDeprecatedSymbols
420 window.WCF.Dropdown.Interactive.Handler.closeAll();
421
422 return (event === null);
423 }
424 };
425 });