Merge branch '5.2' into 5.3
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / js / WoltLabSuite / Core / Ui / Dialog.js
1 /**
2 * Modal dialog handler.
3 *
4 * @author Alexander Ebert
5 * @copyright 2001-2019 WoltLab GmbH
6 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
7 * @module Ui/Dialog (alias)
8 * @module WoltLabSuite/Core/Ui/Dialog
9 */
10 define(
11 [
12 'Ajax', 'Core', 'Dictionary',
13 'Environment', 'Language', 'ObjectMap', 'Dom/ChangeListener',
14 'Dom/Traverse', 'Dom/Util', 'Ui/Confirmation', 'Ui/Screen', 'Ui/SimpleDropdown',
15 'EventHandler', 'List', 'EventKey'
16 ],
17 function(
18 Ajax, Core, Dictionary,
19 Environment, Language, ObjectMap, DomChangeListener,
20 DomTraverse, DomUtil, UiConfirmation, UiScreen, UiSimpleDropdown,
21 EventHandler, List, EventKey
22 )
23 {
24 "use strict";
25
26 var _activeDialog = null;
27 var _callbackFocus = null;
28 var _container = null;
29 var _dialogs = new Dictionary();
30 var _dialogFullHeight = false;
31 var _dialogObjects = new ObjectMap();
32 var _dialogToObject = new Dictionary();
33 var _focusedBeforeDialog = null;
34 var _keyupListener = null;
35 var _staticDialogs = elByClass('jsStaticDialog');
36 var _validCallbacks = ['onBeforeClose', 'onClose', 'onShow'];
37
38 // list of supported `input[type]` values for dialog submit
39 var _validInputTypes = ['number', 'password', 'search', 'tel', 'text', 'url'];
40
41 var _focusableElements = [
42 'a[href]:not([tabindex^="-"]):not([inert])',
43 'area[href]:not([tabindex^="-"]):not([inert])',
44 'input:not([disabled]):not([inert])',
45 'select:not([disabled]):not([inert])',
46 'textarea:not([disabled]):not([inert])',
47 'button:not([disabled]):not([inert])',
48 'iframe:not([tabindex^="-"]):not([inert])',
49 'audio:not([tabindex^="-"]):not([inert])',
50 'video:not([tabindex^="-"]):not([inert])',
51 '[contenteditable]:not([tabindex^="-"]):not([inert])',
52 '[tabindex]:not([tabindex^="-"]):not([inert])'
53 ];
54
55 /**
56 * @exports WoltLabSuite/Core/Ui/Dialog
57 */
58 return {
59 /**
60 * Sets up global container and internal variables.
61 */
62 setup: function() {
63 // Fetch Ajax, as it cannot be provided because of a circular dependency
64 if (Ajax === undefined) Ajax = require('Ajax');
65
66 _container = elCreate('div');
67 _container.classList.add('dialogOverlay');
68 elAttr(_container, 'aria-hidden', 'true');
69 _container.addEventListener('mousedown', this._closeOnBackdrop.bind(this));
70 _container.addEventListener('wheel', function (event) {
71 if (event.target === _container) {
72 event.preventDefault();
73 }
74 }, { passive: false });
75
76 elById('content').appendChild(_container);
77
78 _keyupListener = (function(event) {
79 if (event.keyCode === 27) {
80 if (event.target.nodeName !== 'INPUT' && event.target.nodeName !== 'TEXTAREA') {
81 this.close(_activeDialog);
82
83 return false;
84 }
85 }
86
87 return true;
88 }).bind(this);
89
90 UiScreen.on('screen-xs', {
91 match: function() { _dialogFullHeight = true; },
92 unmatch: function() { _dialogFullHeight = false; },
93 setup: function() { _dialogFullHeight = true; }
94 });
95
96 this._initStaticDialogs();
97 DomChangeListener.add('Ui/Dialog', this._initStaticDialogs.bind(this));
98
99 UiScreen.setDialogContainer(_container);
100
101 window.addEventListener('resize', (function () {
102 _dialogs.forEach((function (dialog) {
103 if (!elAttrBool(dialog.dialog, 'aria-hidden')) {
104 this.rebuild(elData(dialog.dialog, 'id'));
105 }
106 }).bind(this));
107 }).bind(this));
108 },
109
110 _initStaticDialogs: function() {
111 var button, container, id;
112 while (_staticDialogs.length) {
113 button = _staticDialogs[0];
114 button.classList.remove('jsStaticDialog');
115
116 id = elData(button, 'dialog-id');
117 if (id && (container = elById(id))) {
118 ((function(button, container) {
119 container.classList.remove('jsStaticDialogContent');
120 elData(container, 'is-static-dialog', true);
121 elHide(container);
122 button.addEventListener(WCF_CLICK_EVENT, (function(event) {
123 event.preventDefault();
124
125 this.openStatic(container.id, null, { title: elData(container, 'title') });
126 }).bind(this));
127 }).bind(this))(button, container);
128 }
129 }
130 },
131
132 /**
133 * Opens the dialog and implicitly creates it on first usage.
134 *
135 * @param {object} callbackObject used to invoke `_dialogSetup()` on first call
136 * @param {(string|DocumentFragment=} html html content or document fragment to use for dialog content
137 * @returns {object<string, *>} dialog data
138 */
139 open: function(callbackObject, html) {
140 var dialogData = _dialogObjects.get(callbackObject);
141 if (Core.isPlainObject(dialogData)) {
142 // dialog already exists
143 return this.openStatic(dialogData.id, html);
144 }
145
146 // initialize a new dialog
147 if (typeof callbackObject._dialogSetup !== 'function') {
148 throw new Error("Callback object does not implement the method '_dialogSetup()'.");
149 }
150
151 var setupData = callbackObject._dialogSetup();
152 if (!Core.isPlainObject(setupData)) {
153 throw new Error("Expected an object literal as return value of '_dialogSetup()'.");
154 }
155
156 dialogData = { id: setupData.id };
157
158 var createOnly = true;
159 if (setupData.source === undefined) {
160 var dialogElement = elById(setupData.id);
161 if (dialogElement === null) {
162 throw new Error("Element id '" + setupData.id + "' is invalid and no source attribute was given. If you want to use the `html` argument instead, please add `source: null` to your dialog configuration.");
163 }
164
165 setupData.source = document.createDocumentFragment();
166 setupData.source.appendChild(dialogElement);
167
168 // remove id and `display: none` from dialog element
169 dialogElement.removeAttribute('id');
170 elShow(dialogElement);
171 }
172 else if (setupData.source === null) {
173 // `null` means there is no static markup and `html` should be used instead
174 setupData.source = html;
175 }
176
177 else if (typeof setupData.source === 'function') {
178 setupData.source();
179 }
180 else if (Core.isPlainObject(setupData.source)) {
181 if (typeof html === 'string' && html.trim() !== '') {
182 setupData.source = html;
183 }
184 else {
185 Ajax.api(this, setupData.source.data, (function (data) {
186 if (data.returnValues && typeof data.returnValues.template === 'string') {
187 this.open(callbackObject, data.returnValues.template);
188
189 if (typeof setupData.source.after === 'function') {
190 setupData.source.after(_dialogs.get(setupData.id).content, data);
191 }
192 }
193 }).bind(this));
194
195 // deferred initialization
196 return {};
197 }
198 }
199 else {
200 if (typeof setupData.source === 'string') {
201 var dialogElement = elCreate('div');
202 elAttr(dialogElement, 'id', setupData.id);
203 DomUtil.setInnerHtml(dialogElement, setupData.source);
204
205 setupData.source = document.createDocumentFragment();
206 setupData.source.appendChild(dialogElement);
207 }
208
209 if (!setupData.source.nodeType || setupData.source.nodeType !== Node.DOCUMENT_FRAGMENT_NODE) {
210 throw new Error("Expected at least a document fragment as 'source' attribute.");
211 }
212
213 createOnly = false;
214 }
215
216 _dialogObjects.set(callbackObject, dialogData);
217 _dialogToObject.set(setupData.id, callbackObject);
218
219 return this.openStatic(setupData.id, setupData.source, setupData.options, createOnly);
220 },
221
222 /**
223 * Opens an dialog, if the dialog is already open the content container
224 * will be replaced by the HTML string contained in the parameter html.
225 *
226 * If id is an existing element id, html will be ignored and the referenced
227 * element will be appended to the content element instead.
228 *
229 * @param {string} id element id, if exists the html parameter is ignored in favor of the existing element
230 * @param {?(string|DocumentFragment)} html content html
231 * @param {object<string, *>} options list of options, is completely ignored if the dialog already exists
232 * @param {boolean=} createOnly create the dialog but do not open it
233 * @return {object<string, *>} dialog data
234 */
235 openStatic: function(id, html, options, createOnly) {
236 UiScreen.pageOverlayOpen();
237
238 if (Environment.platform() !== 'desktop') {
239 if (!this.isOpen(id)) {
240 UiScreen.scrollDisable();
241 }
242 }
243
244 if (_dialogs.has(id)) {
245 this._updateDialog(id, html);
246 }
247 else {
248 options = Core.extend({
249 backdropCloseOnClick: true,
250 closable: true,
251 closeButtonLabel: Language.get('wcf.global.button.close'),
252 closeConfirmMessage: '',
253 disableContentPadding: false,
254 title: '',
255
256 // callbacks
257 onBeforeClose: null,
258 onClose: null,
259 onShow: null
260 }, options);
261
262 if (!options.closable) options.backdropCloseOnClick = false;
263 if (options.closeConfirmMessage) {
264 options.onBeforeClose = (function(id) {
265 UiConfirmation.show({
266 confirm: this.close.bind(this, id),
267 message: options.closeConfirmMessage
268 });
269 }).bind(this);
270 }
271
272 this._createDialog(id, html, options);
273 }
274
275 var data = _dialogs.get(id);
276
277 // iOS breaks `position: fixed` when input elements or `contenteditable`
278 // are focused, this will freeze the screen and force Safari to scroll
279 // to the input field
280 if (Environment.platform() === 'ios') {
281 window.setTimeout((function () {
282 var input = elBySel('input, textarea', data.content);
283 if (input !== null) {
284 input.focus();
285 }
286 }).bind(this), 200);
287 }
288
289 return data;
290 },
291
292 /**
293 * Sets the dialog title.
294 *
295 * @param {(string|object)} id element id
296 * @param {string} title dialog title
297 */
298 setTitle: function(id, title) {
299 id = this._getDialogId(id);
300
301 var data = _dialogs.get(id);
302 if (data === undefined) {
303 throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
304 }
305
306 var dialogTitle = elByClass('dialogTitle', data.dialog);
307 if (dialogTitle.length) {
308 dialogTitle[0].textContent = title;
309 }
310 },
311
312 /**
313 * Sets a callback function on runtime.
314 *
315 * @param {(string|object)} id element id
316 * @param {string} key callback identifier
317 * @param {?function} value callback function or `null`
318 */
319 setCallback: function(id, key, value) {
320 if (typeof id === 'object') {
321 var dialogData = _dialogObjects.get(id);
322 if (dialogData !== undefined) {
323 id = dialogData.id;
324 }
325 }
326
327 var data = _dialogs.get(id);
328 if (data === undefined) {
329 throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
330 }
331
332 if (_validCallbacks.indexOf(key) === -1) {
333 throw new Error("Invalid callback identifier, '" + key + "' is not recognized.");
334 }
335
336 if (typeof value !== 'function' && value !== null) {
337 throw new Error("Only functions or the 'null' value are acceptable callback values ('" + typeof value+ "' given).");
338 }
339
340 data[key] = value;
341 },
342
343 /**
344 * Creates the DOM for a new dialog and opens it.
345 *
346 * @param {string} id element id, if exists the html parameter is ignored in favor of the existing element
347 * @param {?(string|DocumentFragment)} html content html
348 * @param {object<string, *>} options list of options
349 * @param {boolean=} createOnly create the dialog but do not open it
350 */
351 _createDialog: function(id, html, options, createOnly) {
352 var element = null;
353 if (html === null) {
354 element = elById(id);
355 if (element === null) {
356 throw new Error("Expected either a HTML string or an existing element id.");
357 }
358 }
359
360 var dialog = elCreate('div');
361 dialog.classList.add('dialogContainer');
362 elAttr(dialog, 'aria-hidden', 'true');
363 elAttr(dialog, 'role', 'dialog');
364 elData(dialog, 'id', id);
365
366 var header = elCreate('header');
367 dialog.appendChild(header);
368
369 var titleId = DomUtil.getUniqueId();
370 elAttr(dialog, 'aria-labelledby', titleId);
371
372 var title = elCreate('span');
373 title.classList.add('dialogTitle');
374 title.textContent = options.title;
375 elAttr(title, 'id', titleId);
376 header.appendChild(title);
377
378 if (options.closable) {
379 var closeButton = elCreate('a');
380 closeButton.className = 'dialogCloseButton jsTooltip';
381 closeButton.href = '#';
382 elAttr(closeButton, 'role', 'button');
383 elAttr(closeButton, 'tabindex', '0');
384 elAttr(closeButton, 'title', options.closeButtonLabel);
385 elAttr(closeButton, 'aria-label', options.closeButtonLabel);
386 closeButton.addEventListener(WCF_CLICK_EVENT, this._close.bind(this));
387 header.appendChild(closeButton);
388
389 var span = elCreate('span');
390 span.className = 'icon icon24 fa-times';
391 closeButton.appendChild(span);
392 }
393
394 var contentContainer = elCreate('div');
395 contentContainer.classList.add('dialogContent');
396 if (options.disableContentPadding) contentContainer.classList.add('dialogContentNoPadding');
397 dialog.appendChild(contentContainer);
398
399 contentContainer.addEventListener('wheel', function (event) {
400 var allowScroll = false;
401 var element = event.target, clientHeight, scrollHeight, scrollTop;
402 while (true) {
403 clientHeight = element.clientHeight;
404 scrollHeight = element.scrollHeight;
405
406 if (clientHeight < scrollHeight) {
407 scrollTop = element.scrollTop;
408
409 // negative value: scrolling up
410 if (event.deltaY < 0 && scrollTop > 0) {
411 allowScroll = true;
412 break;
413 }
414 else if (event.deltaY > 0 && (scrollTop + clientHeight < scrollHeight)) {
415 allowScroll = true;
416 break;
417 }
418 }
419
420 if (!element || element === contentContainer) {
421 break;
422 }
423
424 element = element.parentNode;
425 }
426
427 if (allowScroll === false) {
428 event.preventDefault();
429 }
430 }, { passive: false });
431
432 var content;
433 if (element === null) {
434 if (typeof html === 'string') {
435 content = elCreate('div');
436 content.id = id;
437 DomUtil.setInnerHtml(content, html);
438 }
439 else if (html instanceof DocumentFragment) {
440 var children = [], node;
441 for (var i = 0, length = html.childNodes.length; i < length; i++) {
442 node = html.childNodes[i];
443
444 if (node.nodeType === Node.ELEMENT_NODE) {
445 children.push(node);
446 }
447 }
448
449 if (children[0].nodeName !== 'DIV' || children.length > 1) {
450 content = elCreate('div');
451 content.id = id;
452 content.appendChild(html);
453 }
454 else {
455 content = children[0];
456 }
457 }
458 else {
459 throw new TypeError("'html' must either be a string or a DocumentFragment");
460 }
461 }
462 else {
463 content = element;
464 }
465
466 contentContainer.appendChild(content);
467
468 if (content.style.getPropertyValue('display') === 'none') {
469 elShow(content);
470 }
471
472 _dialogs.set(id, {
473 backdropCloseOnClick: options.backdropCloseOnClick,
474 closable: options.closable,
475 content: content,
476 dialog: dialog,
477 header: header,
478 onBeforeClose: options.onBeforeClose,
479 onClose: options.onClose,
480 onShow: options.onShow,
481
482 submitButton: null,
483 inputFields: new List()
484 });
485
486 DomUtil.prepend(dialog, _container);
487
488 if (typeof options.onSetup === 'function') {
489 options.onSetup(content);
490 }
491
492 if (createOnly !== true) {
493 this._updateDialog(id, null);
494 }
495 },
496
497 /**
498 * Updates the dialog's content element.
499 *
500 * @param {string} id element id
501 * @param {?string} html content html, prevent changes by passing null
502 */
503 _updateDialog: function(id, html) {
504 var data = _dialogs.get(id);
505 if (data === undefined) {
506 throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
507 }
508
509 if (typeof html === 'string') {
510 DomUtil.setInnerHtml(data.content, html);
511 }
512
513 if (elAttr(data.dialog, 'aria-hidden') === 'true') {
514 // close existing dropdowns
515 UiSimpleDropdown.closeAll();
516 window.WCF.Dropdown.Interactive.Handler.closeAll();
517
518 if (_callbackFocus === null) {
519 _callbackFocus = this._maintainFocus.bind(this);
520 document.body.addEventListener('focus', _callbackFocus, { capture: true });
521 }
522
523 if (data.closable && elAttr(_container, 'aria-hidden') === 'true') {
524 window.addEventListener('keyup', _keyupListener);
525 }
526
527 // Move the dialog to the front to prevent it being hidden behind already open dialogs
528 // if it was previously visible.
529 data.dialog.parentNode.insertBefore(data.dialog, data.dialog.parentNode.firstChild);
530
531 elAttr(data.dialog, 'aria-hidden', 'false');
532 elAttr(_container, 'aria-hidden', 'false');
533 elData(_container, 'close-on-click', (data.backdropCloseOnClick ? 'true' : 'false'));
534 _activeDialog = id;
535
536 // Keep a reference to the currently focused element to be able to restore it later.
537 _focusedBeforeDialog = document.activeElement;
538
539 // Set the focus to the first focusable child of the dialog element.
540 var closeButton = elBySel('.dialogCloseButton', data.header);
541 if (closeButton) elAttr(closeButton, 'inert', true);
542 this._setFocusToFirstItem(data.dialog);
543 if (closeButton) closeButton.removeAttribute('inert');
544
545 if (typeof data.onShow === 'function') {
546 data.onShow(data.content);
547 }
548
549 if (elDataBool(data.content, 'is-static-dialog')) {
550 EventHandler.fire('com.woltlab.wcf.dialog', 'openStatic', {
551 content: data.content,
552 id: id
553 });
554 }
555 }
556
557 this.rebuild(id);
558
559 DomChangeListener.trigger();
560 },
561
562 /**
563 * @param {Event} event
564 */
565 _maintainFocus: function(event) {
566 if (_activeDialog) {
567 var data = _dialogs.get(_activeDialog);
568 if (!data.dialog.contains(event.target) && !event.target.closest('.dropdownMenuContainer') && !event.target.closest('.datePicker')) {
569 this._setFocusToFirstItem(data.dialog, true);
570 }
571 }
572 },
573
574 /**
575 * @param {Element} dialog
576 * @param {boolean} maintain
577 */
578 _setFocusToFirstItem: function(dialog, maintain) {
579 var focusElement = this._getFirstFocusableChild(dialog);
580 if (focusElement !== null) {
581 if (maintain) {
582 if (focusElement.id === 'username' || focusElement.name === 'username') {
583 if (Environment.browser() === 'safari' && Environment.platform() === 'ios') {
584 // iOS Safari's username/password autofill breaks if the input field is focused
585 focusElement = null;
586 }
587 }
588 }
589
590 if (focusElement) {
591 // Setting the focus to a select element in iOS is pretty strange, because
592 // it focuses it, but also displays the keyboard for a fraction of a second,
593 // causing it to pop out from below and immediately vanish.
594 //
595 // iOS will only show the keyboard if an input element is focused *and* the
596 // focus is an immediate result of a user interaction. This method must be
597 // assumed to be called from within a click event, but we want to set the
598 // focus without triggering the keyboard.
599 //
600 // We can break the condition by wrapping it in a setTimeout() call,
601 // effectively tricking iOS into focusing the element without showing the
602 // keyboard.
603 setTimeout(function() {
604 focusElement.focus();
605 }, 1);
606 }
607 }
608 },
609
610 /**
611 * @param {Element} node
612 * @returns {?Element}
613 */
614 _getFirstFocusableChild: function(node) {
615 var nodeList = elBySelAll(_focusableElements.join(','), node);
616 for (var i = 0, length = nodeList.length; i < length; i++) {
617 if (nodeList[i].offsetWidth && nodeList[i].offsetHeight && nodeList[i].getClientRects().length) {
618 return nodeList[i];
619 }
620 }
621
622 return null;
623 },
624
625 /**
626 * Rebuilds dialog identified by given id.
627 *
628 * @param {string} id element id
629 */
630 rebuild: function(id) {
631 id = this._getDialogId(id);
632
633 var data = _dialogs.get(id);
634 if (data === undefined) {
635 throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
636 }
637
638 // ignore non-active dialogs
639 if (elAttr(data.dialog, 'aria-hidden') === 'true') {
640 return;
641 }
642
643 var contentContainer = data.content.parentNode;
644
645 var formSubmit = elBySel('.formSubmit', data.content);
646 var unavailableHeight = 0;
647 if (formSubmit !== null) {
648 contentContainer.classList.add('dialogForm');
649 formSubmit.classList.add('dialogFormSubmit');
650
651 unavailableHeight += DomUtil.outerHeight(formSubmit);
652
653 // Calculated height can be a fractional value and depending on the
654 // browser the results can vary. By subtracting a single pixel we're
655 // working around fractional values, without visually changing anything.
656 unavailableHeight -= 1;
657
658 contentContainer.style.setProperty('margin-bottom', unavailableHeight + 'px', '');
659 }
660 else {
661 contentContainer.classList.remove('dialogForm');
662 contentContainer.style.removeProperty('margin-bottom');
663 }
664
665 unavailableHeight += DomUtil.outerHeight(data.header);
666
667 var maximumHeight = (window.innerHeight * (_dialogFullHeight ? 1 : 0.8)) - unavailableHeight;
668 contentContainer.style.setProperty('max-height', ~~maximumHeight + 'px', '');
669
670 // Chrome and Safari use heavy anti-aliasing when the dialog's width
671 // cannot be evenly divided, causing the whole text to become blurry
672 if (Environment.browser() === 'chrome' || Environment.browser() === 'safari') {
673 // The new Microsoft Edge is detected as "chrome", because effectively we're detecting
674 // Chromium rather than Chrome specifically. The workaround for fractional pixels does
675 // not work well in Edge, there seems to be a different logic for fractional positions,
676 // causing the text to be blurry.
677 //
678 // We can use `backface-visibility: hidden` to prevent the anti aliasing artifacts in
679 // WebKit/Blink, which will also prevent some weird font rendering issues when resizing.
680 data.content.parentNode.classList.add('jsWebKitFractionalPixelFix');
681 }
682
683 var callbackObject = _dialogToObject.get(id);
684 //noinspection JSUnresolvedVariable
685 if (callbackObject !== undefined && typeof callbackObject._dialogSubmit === 'function') {
686 var inputFields = elBySelAll('input[data-dialog-submit-on-enter="true"]', data.content);
687
688 var submitButton = elBySel('.formSubmit > input[type="submit"], .formSubmit > button[data-type="submit"]', data.content);
689 if (submitButton === null) {
690 // check if there is at least one input field with submit handling,
691 // otherwise we'll assume the dialog has not been populated yet
692 if (inputFields.length === 0) {
693 console.warn("Broken dialog, expected a submit button.", data.content);
694 }
695
696 return;
697 }
698
699 if (data.submitButton !== submitButton) {
700 data.submitButton = submitButton;
701
702 submitButton.addEventListener(WCF_CLICK_EVENT, (function (event) {
703 event.preventDefault();
704
705 this._submit(id);
706 }).bind(this));
707
708 // bind input fields
709 var inputField, _callbackKeydown = null;
710 for (var i = 0, length = inputFields.length; i < length; i++) {
711 inputField = inputFields[i];
712
713 if (data.inputFields.has(inputField)) continue;
714
715 if (_validInputTypes.indexOf(inputField.type) === -1) {
716 console.warn("Unsupported input type.", inputField);
717 continue;
718 }
719
720 data.inputFields.add(inputField);
721
722 if (_callbackKeydown === null) {
723 _callbackKeydown = (function (event) {
724 if (EventKey.Enter(event)) {
725 event.preventDefault();
726
727 this._submit(id);
728 }
729 }).bind(this);
730 }
731 inputField.addEventListener('keydown', _callbackKeydown);
732 }
733 }
734 }
735 },
736
737 /**
738 * Submits the dialog.
739 *
740 * @param {string} id dialog id
741 * @protected
742 */
743 _submit: function (id) {
744 var data = _dialogs.get(id);
745
746 var isValid = true;
747 data.inputFields.forEach(function (inputField) {
748 if (inputField.required) {
749 if (inputField.value.trim() === '') {
750 elInnerError(inputField, Language.get('wcf.global.form.error.empty'));
751
752 isValid = false;
753 }
754 else {
755 elInnerError(inputField, false);
756 }
757 }
758 });
759
760 if (isValid) {
761 //noinspection JSUnresolvedFunction
762 _dialogToObject.get(id)._dialogSubmit();
763 }
764 },
765
766 /**
767 * Handles clicks on the close button or the backdrop if enabled.
768 *
769 * @param {object} event click event
770 * @return {boolean} false if the event should be cancelled
771 */
772 _close: function(event) {
773 event.preventDefault();
774
775 var data = _dialogs.get(_activeDialog);
776 if (typeof data.onBeforeClose === 'function') {
777 data.onBeforeClose(_activeDialog);
778
779 return false;
780 }
781
782 this.close(_activeDialog);
783 },
784
785 /**
786 * Closes the current active dialog by clicks on the backdrop.
787 *
788 * @param {object} event event object
789 */
790 _closeOnBackdrop: function(event) {
791 if (event.target !== _container) {
792 return true;
793 }
794
795 if (elData(_container, 'close-on-click') === 'true') {
796 this._close(event);
797 }
798 else {
799 event.preventDefault();
800 }
801 },
802
803 /**
804 * Closes a dialog identified by given id.
805 *
806 * @param {(string|object)} id element id or callback object
807 */
808 close: function(id) {
809 id = this._getDialogId(id);
810
811 var data = _dialogs.get(id);
812 if (data === undefined) {
813 throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
814 }
815
816 elAttr(data.dialog, 'aria-hidden', 'true');
817
818 // avoid keyboard focus on a now hidden element
819 if (document.activeElement.closest('.dialogContainer') === data.dialog) {
820 document.activeElement.blur();
821 }
822
823 if (typeof data.onClose === 'function') {
824 data.onClose(id);
825 }
826
827 // get next active dialog
828 _activeDialog = null;
829 for (var i = 0; i < _container.childElementCount; i++) {
830 var child = _container.children[i];
831 if (elAttr(child, 'aria-hidden') === 'false') {
832 _activeDialog = elData(child, 'id');
833 break;
834 }
835 }
836
837 UiScreen.pageOverlayClose();
838
839 if (_activeDialog === null) {
840 elAttr(_container, 'aria-hidden', 'true');
841 elData(_container, 'close-on-click', 'false');
842
843 if (data.closable) {
844 window.removeEventListener('keyup', _keyupListener);
845 }
846 }
847 else {
848 data = _dialogs.get(_activeDialog);
849 elData(_container, 'close-on-click', (data.backdropCloseOnClick ? 'true' : 'false'));
850 }
851
852 if (Environment.platform() !== 'desktop') {
853 UiScreen.scrollEnable();
854 }
855 },
856
857 /**
858 * Returns the dialog data for given element id.
859 *
860 * @param {(string|object)} id element id or callback object
861 * @return {(object|undefined)} dialog data or undefined if element id is unknown
862 */
863 getDialog: function(id) {
864 return _dialogs.get(this._getDialogId(id));
865 },
866
867 /**
868 * Returns true for open dialogs.
869 *
870 * @param {(string|object)} id element id or callback object
871 * @return {boolean}
872 */
873 isOpen: function(id) {
874 var data = this.getDialog(id);
875 return (data !== undefined && elAttr(data.dialog, 'aria-hidden') === 'false');
876 },
877
878 /**
879 * Destroys a dialog instance.
880 *
881 * @param {Object} callbackObject the same object that was used to invoke `_dialogSetup()` on first call
882 */
883 destroy: function(callbackObject) {
884 if (typeof callbackObject !== 'object' || callbackObject instanceof String) {
885 throw new TypeError("Expected the callback object as parameter.");
886 }
887
888 if (_dialogObjects.has(callbackObject)) {
889 var id = _dialogObjects.get(callbackObject).id;
890 if (this.isOpen(id)) {
891 this.close(id);
892 }
893
894 // If the dialog is destroyed in the close callback, this method is
895 // called twice resulting in `_dialogs.get(id)` being undefined for
896 // the initial call.
897 if (_dialogs.has(id)) {
898 elRemove(_dialogs.get(id).dialog);
899 _dialogs.delete(id);
900 }
901 _dialogObjects.delete(callbackObject);
902 }
903 },
904
905 /**
906 * Returns a dialog's id.
907 *
908 * @param {(string|object)} id element id or callback object
909 * @return {string}
910 * @protected
911 */
912 _getDialogId: function(id) {
913 if (typeof id === 'object') {
914 var dialogData = _dialogObjects.get(id);
915 if (dialogData !== undefined) {
916 return dialogData.id;
917 }
918 }
919
920 return id.toString();
921 },
922
923 _ajaxSetup: function() {
924 return {};
925 }
926 };
927 });