2 * Simple dropdown implementation.
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
10 [ 'CallbackList', 'Core', 'Dictionary', 'Ui/Alignment', 'Dom/ChangeListener', 'Dom/Traverse', 'Dom/Util', 'Ui/CloseOverlay'],
11 function(CallbackList
, Core
, Dictionary
, UiAlignment
, DomChangeListener
, DomTraverse
, DomUtil
, UiCloseOverlay
)
15 var _availableDropdowns
= null;
16 var _callbacks
= new CallbackList();
18 var _dropdowns
= new Dictionary();
19 var _menus
= new Dictionary();
20 var _menuContainer
= null;
23 * @exports WoltLab/WCF/Ui/Dropdown/Simple
27 * Performs initial setup such as setting up dropdowns and binding listeners.
33 _menuContainer
= elCreate('div');
34 _menuContainer
.className
= 'dropdownMenuContainer';
35 document
.body
.appendChild(_menuContainer
);
37 _availableDropdowns
= elByClass('dropdownToggle');
41 UiCloseOverlay
.add('WoltLab/WCF/Ui/Dropdown/Simple', this.closeAll
.bind(this));
42 DomChangeListener
.add('WoltLab/WCF/Ui/Dropdown/Simple', this.initAll
.bind(this));
44 document
.addEventListener('scroll', this._onScroll
.bind(this));
46 // expose on window object for backward compatibility
47 window
.bc_wcfSimpleDropdown
= this;
51 * Loops through all possible dropdowns and registers new ones.
54 for (var i
= 0, length
= _availableDropdowns
.length
; i
< length
; i
++) {
55 this.init(_availableDropdowns
[i
], false);
60 * Initializes a dropdown.
62 * @param {Element} button
63 * @param {boolean} isLazyInitialization
65 init: function(button
, isLazyInitialization
) {
68 if (button
.classList
.contains('jsDropdownEnabled') || elData(button
, 'target')) {
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.");
77 var menu
= DomTraverse
.nextByClass(button
, 'dropdownMenu');
79 throw new Error("Invalid dropdown passed, button '" + DomUtil
.identify(button
) + "' does not have a menu as next sibling.");
82 // move menu into global container
83 _menuContainer
.appendChild(menu
);
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));
90 _dropdowns
.set(containerId
, dropdown
);
91 _menus
.set(containerId
, menu
);
93 if (!containerId
.match(/^wcf\d+$/)) {
94 elData(menu
, 'source', containerId
);
98 elData(button
, 'target', containerId
);
100 if (isLazyInitialization
) {
101 setTimeout(function() { Core
.triggerEvent(button
, WCF_CLICK_EVENT
); }, 10);
106 * Initializes a remote-controlled dropdown.
108 * @param {Element} dropdown dropdown wrapper element
109 * @param {Element} menu menu list element
111 initFragment: function(dropdown
, menu
) {
114 var containerId
= DomUtil
.identify(dropdown
);
115 if (_dropdowns
.has(containerId
)) {
119 _dropdowns
.set(containerId
, dropdown
);
120 _menuContainer
.appendChild(menu
);
122 _menus
.set(containerId
, menu
);
126 * Registers a callback for open/close events.
128 * @param {string} containerId dropdown wrapper id
129 * @param {function(string, string)} callback
131 registerCallback: function(containerId
, callback
) {
132 _callbacks
.add(containerId
, callback
);
136 * Returns the requested dropdown wrapper element.
138 * @return {Element} dropdown wrapper element
140 getDropdown: function(containerId
) {
141 return _dropdowns
.get(containerId
);
145 * Returns the requested dropdown menu list element.
147 * @return {Element} menu list element
149 getDropdownMenu: function(containerId
) {
150 return _menus
.get(containerId
);
154 * Toggles the requested dropdown between opened and closed.
156 * @param {string} containerId dropdown wrapper id
157 * @param {Element=} referenceElement alternative reference element, used for reusable dropdown menus
159 toggleDropdown: function(containerId
, referenceElement
) {
160 this._toggle(null, containerId
, referenceElement
);
164 * Calculates and sets the alignment of given dropdown.
166 * @param {Element} dropdown dropdown wrapper element
167 * @param {Element} dropdownMenu menu list element
168 * @param {Element=} alternateElement alternative reference element for alignment
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
;
177 UiAlignment
.set(dropdownMenu
, alternateElement
|| dropdown
, {
178 pointerClassNames
: ['dropdownArrowBottom', 'dropdownArrowRight'],
179 refDimensionsElement
: refDimensionsElement
|| null,
182 horizontal
: (elData(dropdownMenu
, 'dropdown-alignment-horizontal') === 'right') ? 'right' : 'left',
183 vertical
: (elData(dropdownMenu
, 'dropdown-alignment-vertical') === 'top') ? 'top' : 'bottom'
188 * Calculats and sets the alignment of the dropdown identified by given id.
190 * @param {string} containerId dropdown wrapper id
192 setAlignmentById: function(containerId
) {
193 var dropdown
= _dropdowns
.get(containerId
);
194 if (dropdown
=== undefined) {
195 throw new Error("Unknown dropdown identifier '" + containerId
+ "'.");
198 var menu
= _menus
.get(containerId
);
200 this.setAlignment(dropdown
, menu
);
204 * Returns true if target dropdown exists and is open.
206 * @param {string} containerId dropdown wrapper id
207 * @return {boolean} true if dropdown exists and is open
209 isOpen: function(containerId
) {
210 var menu
= _menus
.get(containerId
);
211 return (menu
!== undefined && menu
.classList
.contains('dropdownOpen'));
215 * Opens the dropdown unless it is already open.
217 * @param {string} containerId dropdown wrapper id
219 open: function(containerId
) {
220 var menu
= _menus
.get(containerId
);
221 if (menu
!== undefined && !menu
.classList
.contains('dropdownOpen')) {
222 this.toggleDropdown(containerId
);
227 * Closes the dropdown identified by given id without notifying callbacks.
229 * @param {string} containerId dropdown wrapper id
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');
240 * Closes all dropdowns.
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');
248 this._notifyCallbacks(containerId
, 'close');
254 * Destroys a dropdown identified by given id.
256 * @param {string} containerId dropdown wrapper id
257 * @return {boolean} false for unknown dropdowns
259 destroy: function(containerId
) {
260 if (!_dropdowns
.has(containerId
)) {
264 this.close(containerId
);
266 var menu
= _menus
.get(containerId
);
267 _menus
.parentNode
.removeChild(menu
);
269 _menus
['delete'](containerId
);
270 _dropdowns
['delete'](containerId
);
276 * Handles dropdown positions in overlays when scrolling in the overlay.
278 * @param {Event} event event object
280 _onDialogScroll: function(event
) {
281 var dialogContent
= event
.currentTarget
;
282 //noinspection JSCheckFunctionSignatures
283 var dropdowns
= elBySelAll('.dropdown.dropdownOpen', dialogContent
);
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
);
291 // check if dropdown toggle is still (partially) visible
292 if (offset
.top
+ dropdown
.clientHeight
<= dialogOffset
.top
) {
294 this.toggleDropdown(containerId
);
296 else if (offset
.top
>= dialogOffset
.top
+ dialogContent
.offsetHeight
) {
298 this.toggleDropdown(containerId
);
300 else if (offset
.left
<= dialogOffset
.left
) {
302 this.toggleDropdown(containerId
);
304 else if (offset
.left
>= dialogOffset
.left
+ dialogContent
.offsetWidth
) {
306 this.toggleDropdown(containerId
);
309 this.setAlignment(containerId
, _menus
.get(containerId
));
315 * Recalculates dropdown positions on page scroll.
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
));
324 this.close(containerId
);
331 * Notifies callbacks on status change.
333 * @param {string} containerId dropdown wrapper id
334 * @param {string} action can be either 'open' or 'close'
336 _notifyCallbacks: function(containerId
, action
) {
337 _callbacks
.forEach(containerId
, function(callback
) {
338 callback(containerId
, action
);
343 * Toggles the dropdown's state between open and close.
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
350 _toggle: function(event
, targetId
, alternateElement
) {
351 if (event
!== null) {
352 event
.preventDefault();
353 event
.stopPropagation();
355 //noinspection JSCheckFunctionSignatures
356 targetId
= elData(event
.currentTarget
, 'target');
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
364 var button
= event
.currentTarget
, parent
= button
.parentNode
;
365 if (parent
!== dropdown
) {
366 parent
.classList
.add('dropdown');
367 parent
.id
= dropdown
.id
;
369 // remove dropdown class and id from old parent
370 dropdown
.classList
.remove('dropdown');
374 _dropdowns
.set(targetId
, parent
);
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;
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));
391 if (dialogContent
!== null) {
392 dialogContent
.addEventListener('scroll', this._onDialogScroll
.bind(this));
397 // close all dropdowns
398 _dropdowns
.forEach((function(dropdown
, containerId
) {
399 var menu
= _menus
.get(containerId
);
401 if (dropdown
.classList
.contains('dropdownOpen')) {
402 if (preventToggle
=== false) {
403 dropdown
.classList
.remove('dropdownOpen');
404 menu
.classList
.remove('dropdownOpen');
406 this._notifyCallbacks(containerId
, 'close');
409 else if (containerId
=== targetId
&& menu
.childElementCount
> 0) {
410 dropdown
.classList
.add('dropdownOpen');
411 menu
.classList
.add('dropdownOpen');
413 this._notifyCallbacks(containerId
, 'open');
415 this.setAlignment(dropdown
, menu
, alternateElement
);
419 //noinspection JSDeprecatedSymbols
420 window
.WCF
.Dropdown
.Interactive
.Handler
.closeAll();
422 return (event
=== null);