Convert `Ui/Dialog` to TypeScript
authorAlexander Ebert <ebert@woltlab.com>
Tue, 20 Oct 2020 22:24:21 +0000 (00:24 +0200)
committerTim Düsterhus <duesterhus@woltlab.com>
Wed, 28 Oct 2020 11:33:43 +0000 (12:33 +0100)
global.d.ts
wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Dialog.js
wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Dialog/Data.js [new file with mode: 0644]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dialog.ts [new file with mode: 0644]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dialog/Data.ts [new file with mode: 0644]

index 70ad473efc776ec4d983b98f77de7cf104656206..e09f2fc61e5e65718cdff8f3e56b9c0b53670820 100644 (file)
@@ -10,6 +10,7 @@ declare global {
     WCF_PATH: string;
     WSC_API_URL: string;
 
+    WCF: any;
     bc_wcfDomUtil: typeof DomUtil;
     __wcf_bc_colorUtil: typeof ColorUtil;
   }
index c14feff92325b175335a1366540755e1855ec29b..c1743846de5cb7707c4407a8cc3857f7b75882d4 100644 (file)
 /**
  * Modal dialog handler.
- * 
- * @author     Alexander Ebert
- * @copyright  2001-2019 WoltLab GmbH
- * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module     Ui/Dialog (alias)
- * @module     WoltLabSuite/Core/Ui/Dialog
+ *
+ * @author  Alexander Ebert
+ * @copyright  2001-2019 WoltLab GmbH
+ * @license  GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module  Ui/Dialog (alias)
+ * @module  WoltLabSuite/Core/Ui/Dialog
  */
-define(
-       [
-               'Ajax',         'Core',       'Dictionary',
-               'Environment',  'Language',   'ObjectMap', 'Dom/ChangeListener',
-               'Dom/Traverse', 'Dom/Util',   'Ui/Confirmation', 'Ui/Screen', 'Ui/SimpleDropdown',
-               'EventHandler', 'List',       'EventKey'
-       ],
-       function(
-               Ajax,           Core,         Dictionary,
-               Environment,    Language,     ObjectMap,   DomChangeListener,
-               DomTraverse,    DomUtil,      UiConfirmation, UiScreen, UiSimpleDropdown,
-               EventHandler,   List,         EventKey
-       )
-{
-       "use strict";
-       
-       var _activeDialog = null;
-       var _callbackFocus = null;
-       var _container = null;
-       var _dialogs = new Dictionary();
-       var _dialogFullHeight = false;
-       var _dialogObjects = new ObjectMap();
-       var _dialogToObject = new Dictionary();
-       var _focusedBeforeDialog = null;
-       var _keyupListener = null;
-       var _staticDialogs = elByClass('jsStaticDialog');
-       var _validCallbacks = ['onBeforeClose', 'onClose', 'onShow'];
-       
-       // list of supported `input[type]` values for dialog submit
-       var _validInputTypes = ['number', 'password', 'search', 'tel', 'text', 'url'];
-       
-       var _focusableElements = [
-               'a[href]:not([tabindex^="-"]):not([inert])',
-               'area[href]:not([tabindex^="-"]):not([inert])',
-               'input:not([disabled]):not([inert])',
-               'select:not([disabled]):not([inert])',
-               'textarea:not([disabled]):not([inert])',
-               'button:not([disabled]):not([inert])',
-               'iframe:not([tabindex^="-"]):not([inert])',
-               'audio:not([tabindex^="-"]):not([inert])',
-               'video:not([tabindex^="-"]):not([inert])',
-               '[contenteditable]:not([tabindex^="-"]):not([inert])',
-               '[tabindex]:not([tabindex^="-"]):not([inert])'
-       ];
-       
-       /**
-        * @exports     WoltLabSuite/Core/Ui/Dialog
-        */
-       return {
-               /**
-                * Sets up global container and internal variables.
-                */
-               setup: function() {
-                       // Fetch Ajax, as it cannot be provided because of a circular dependency
-                       if (Ajax === undefined) Ajax = require('Ajax');
-                       
-                       _container = elCreate('div');
-                       _container.classList.add('dialogOverlay');
-                       elAttr(_container, 'aria-hidden', 'true');
-                       _container.addEventListener('mousedown', this._closeOnBackdrop.bind(this));
-                       _container.addEventListener('wheel', function (event) {
-                               if (event.target === _container) {
-                                       event.preventDefault();
-                               }
-                       }, { passive: false });
-                       
-                       elById('content').appendChild(_container);
-                       
-                       _keyupListener = (function(event) {
-                               if (event.keyCode === 27) {
-                                       if (event.target.nodeName !== 'INPUT' && event.target.nodeName !== 'TEXTAREA') {
-                                               this.close(_activeDialog);
-                                               
-                                               return false;
-                                       }
-                               }
-                               
-                               return true;
-                       }).bind(this);
-                       
-                       UiScreen.on('screen-xs', {
-                               match: function() { _dialogFullHeight = true; },
-                               unmatch: function() { _dialogFullHeight = false; },
-                               setup: function() { _dialogFullHeight = true; }
-                       });
-                       
-                       this._initStaticDialogs();
-                       DomChangeListener.add('Ui/Dialog', this._initStaticDialogs.bind(this));
-                       
-                       UiScreen.setDialogContainer(_container);
-                       
-                       window.addEventListener('resize', (function () {
-                               _dialogs.forEach((function (dialog) {
-                                       if (!elAttrBool(dialog.dialog, 'aria-hidden')) {
-                                               this.rebuild(elData(dialog.dialog, 'id'));
-                                       }
-                               }).bind(this));
-                       }).bind(this));
-               },
-               
-               _initStaticDialogs: function() {
-                       var button, container, id;
-                       while (_staticDialogs.length) {
-                               button = _staticDialogs[0];
-                               button.classList.remove('jsStaticDialog');
-                               
-                               id = elData(button, 'dialog-id');
-                               if (id && (container = elById(id))) {
-                                       ((function(button, container) {
-                                               container.classList.remove('jsStaticDialogContent');
-                                               elData(container, 'is-static-dialog', true);
-                                               elHide(container);
-                                               button.addEventListener(WCF_CLICK_EVENT, (function(event) {
-                                                       event.preventDefault();
-                                                       
-                                                       this.openStatic(container.id, null, { title: elData(container, 'title') });
-                                               }).bind(this));
-                                       }).bind(this))(button, container);
-                               }
-                       }
-               },
-               
-               /**
-                * Opens the dialog and implicitly creates it on first usage.
-                * 
-                * @param       {object}                        callbackObject  used to invoke `_dialogSetup()` on first call
-                * @param       {(string|DocumentFragment=}     html            html content or document fragment to use for dialog content
-                * @returns     {object<string, *>}             dialog data
-                */
-               open: function(callbackObject, html) {
-                       var dialogData = _dialogObjects.get(callbackObject);
-                       if (Core.isPlainObject(dialogData)) {
-                               // dialog already exists
-                               return this.openStatic(dialogData.id, html);
-                       }
-                       
-                       // initialize a new dialog
-                       if (typeof callbackObject._dialogSetup !== 'function') {
-                               throw new Error("Callback object does not implement the method '_dialogSetup()'.");
-                       }
-                       
-                       var setupData = callbackObject._dialogSetup();
-                       if (!Core.isPlainObject(setupData)) {
-                               throw new Error("Expected an object literal as return value of '_dialogSetup()'.");
-                       }
-                       
-                       dialogData = { id: setupData.id };
-                       
-                       var createOnly = true;
-                       if (setupData.source === undefined) {
-                               var dialogElement = elById(setupData.id);
-                               if (dialogElement === null) {
-                                       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.");
-                               }
-                               
-                               setupData.source = document.createDocumentFragment();
-                               setupData.source.appendChild(dialogElement);
-                               
-                               // remove id and `display: none` from dialog element
-                               dialogElement.removeAttribute('id');
-                               elShow(dialogElement);
-                       }
-                       else if (setupData.source === null) {
-                               // `null` means there is no static markup and `html` should be used instead
-                               setupData.source = html;
-                       }
-                       
-                       else if (typeof setupData.source === 'function') {
-                               setupData.source();
-                       }
-                       else if (Core.isPlainObject(setupData.source)) {
-                               if (typeof html === 'string' && html.trim() !== '') {
-                                       setupData.source = html;
-                               }
-                               else {
-                                       Ajax.api(this, setupData.source.data, (function (data) {
-                                               if (data.returnValues && typeof data.returnValues.template === 'string') {
-                                                       this.open(callbackObject, data.returnValues.template);
-                                                       
-                                                       if (typeof setupData.source.after === 'function') {
-                                                               setupData.source.after(_dialogs.get(setupData.id).content, data);
-                                                       }
-                                               }
-                                       }).bind(this));
-                                       
-                                       // deferred initialization
-                                       return {};
-                               }
-                       }
-                       else {
-                               if (typeof setupData.source === 'string') {
-                                       var dialogElement = elCreate('div');
-                                       elAttr(dialogElement, 'id', setupData.id);
-                                       DomUtil.setInnerHtml(dialogElement, setupData.source);
-                                       
-                                       setupData.source = document.createDocumentFragment();
-                                       setupData.source.appendChild(dialogElement);
-                               }
-                               
-                               if (!setupData.source.nodeType || setupData.source.nodeType !== Node.DOCUMENT_FRAGMENT_NODE) {
-                                       throw new Error("Expected at least a document fragment as 'source' attribute.");
-                               }
-                               
-                               createOnly = false;
-                       }
-                       
-                       _dialogObjects.set(callbackObject, dialogData);
-                       _dialogToObject.set(setupData.id, callbackObject);
-                       
-                       return this.openStatic(setupData.id, setupData.source, setupData.options, createOnly);
-               },
-               
-               /**
-                * Opens an dialog, if the dialog is already open the content container
-                * will be replaced by the HTML string contained in the parameter html.
-                * 
-                * If id is an existing element id, html will be ignored and the referenced
-                * element will be appended to the content element instead.
-                * 
-                * @param       {string}                        id              element id, if exists the html parameter is ignored in favor of the existing element
-                * @param       {?(string|DocumentFragment)}    html            content html
-                * @param       {object<string, *>}             options         list of options, is completely ignored if the dialog already exists
-                * @param       {boolean=}                      createOnly      create the dialog but do not open it
-                * @return      {object<string, *>}             dialog data
-                */
-               openStatic: function(id, html, options, createOnly) {
-                       UiScreen.pageOverlayOpen();
-                       
-                       if (Environment.platform() !== 'desktop') {
-                               if (!this.isOpen(id)) {
-                                       UiScreen.scrollDisable();
-                               }
-                       }
-                       
-                       if (_dialogs.has(id)) {
-                               this._updateDialog(id, html);
-                       }
-                       else {
-                               options = Core.extend({
-                                       backdropCloseOnClick: true,
-                                       closable: true,
-                                       closeButtonLabel: Language.get('wcf.global.button.close'),
-                                       closeConfirmMessage: '',
-                                       disableContentPadding: false,
-                                       title: '',
-                                       
-                                       // callbacks
-                                       onBeforeClose: null,
-                                       onClose: null,
-                                       onShow: null
-                               }, options);
-                               
-                               if (!options.closable) options.backdropCloseOnClick = false;
-                               if (options.closeConfirmMessage) {
-                                       options.onBeforeClose = (function(id) {
-                                               UiConfirmation.show({
-                                                       confirm: this.close.bind(this, id),
-                                                       message: options.closeConfirmMessage
-                                               });
-                                       }).bind(this);
-                               }
-                               
-                               this._createDialog(id, html, options);
-                       }
-                       
-                       var data = _dialogs.get(id);
-                       
-                       // iOS breaks `position: fixed` when input elements or `contenteditable`
-                       // are focused, this will freeze the screen and force Safari to scroll
-                       // to the input field
-                       if (Environment.platform() === 'ios') {
-                               window.setTimeout((function () {
-                                       var input = elBySel('input, textarea', data.content);
-                                       if (input !== null) {
-                                               input.focus();
-                                       }
-                               }).bind(this), 200);
-                       }
-                       
-                       return data;
-               },
-               
-               /**
-                * Sets the dialog title.
-                * 
-                * @param       {(string|object)}       id              element id
-                * @param       {string}                title           dialog title
-                */
-               setTitle: function(id, title) {
-                       id = this._getDialogId(id);
-                       
-                       var data = _dialogs.get(id);
-                       if (data === undefined) {
-                               throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
-                       }
-                       
-                       var dialogTitle = elByClass('dialogTitle', data.dialog);
-                       if (dialogTitle.length) {
-                               dialogTitle[0].textContent = title;
-                       }
-               },
-               
-               /**
-                * Sets a callback function on runtime.
-                * 
-                * @param       {(string|object)}       id              element id
-                * @param       {string}                key             callback identifier
-                * @param       {?function}             value           callback function or `null`
-                */
-               setCallback: function(id, key, value) {
-                       if (typeof id === 'object') {
-                               var dialogData = _dialogObjects.get(id);
-                               if (dialogData !== undefined) {
-                                       id = dialogData.id;
-                               }
-                       }
-                       
-                       var data = _dialogs.get(id);
-                       if (data === undefined) {
-                               throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
-                       }
-                       
-                       if (_validCallbacks.indexOf(key) === -1) {
-                               throw new Error("Invalid callback identifier, '" + key + "' is not recognized.");
-                       }
-                       
-                       if (typeof value !== 'function' && value !== null) {
-                               throw new Error("Only functions or the 'null' value are acceptable callback values ('" + typeof value+ "' given).");
-                       }
-                       
-                       data[key] = value;
-               },
-               
-               /**
-                * Creates the DOM for a new dialog and opens it.
-                * 
-                * @param       {string}                        id              element id, if exists the html parameter is ignored in favor of the existing element
-                * @param       {?(string|DocumentFragment)}    html            content html
-                * @param       {object<string, *>}             options         list of options
-                * @param       {boolean=}                      createOnly      create the dialog but do not open it
-                */
-               _createDialog: function(id, html, options, createOnly) {
-                       var element = null;
-                       if (html === null) {
-                               element = elById(id);
-                               if (element === null) {
-                                       throw new Error("Expected either a HTML string or an existing element id.");
-                               }
-                       }
-                       
-                       var dialog = elCreate('div');
-                       dialog.classList.add('dialogContainer');
-                       elAttr(dialog, 'aria-hidden', 'true');
-                       elAttr(dialog, 'role', 'dialog');
-                       elData(dialog, 'id', id);
-                       
-                       var header = elCreate('header');
-                       dialog.appendChild(header);
-                       
-                       var titleId = DomUtil.getUniqueId();
-                       elAttr(dialog, 'aria-labelledby', titleId);
-                       
-                       var title = elCreate('span');
-                       title.classList.add('dialogTitle');
-                       title.textContent = options.title;
-                       elAttr(title, 'id', titleId);
-                       header.appendChild(title);
-                       
-                       if (options.closable) {
-                               var closeButton = elCreate('a');
-                               closeButton.className = 'dialogCloseButton jsTooltip';
-                               closeButton.href = '#';
-                               elAttr(closeButton, 'role', 'button');
-                               elAttr(closeButton, 'tabindex', '0');
-                               elAttr(closeButton, 'title', options.closeButtonLabel);
-                               elAttr(closeButton, 'aria-label', options.closeButtonLabel);
-                               closeButton.addEventListener(WCF_CLICK_EVENT, this._close.bind(this));
-                               header.appendChild(closeButton);
-                               
-                               var span = elCreate('span');
-                               span.className = 'icon icon24 fa-times';
-                               closeButton.appendChild(span);
-                       }
-                       
-                       var contentContainer = elCreate('div');
-                       contentContainer.classList.add('dialogContent');
-                       if (options.disableContentPadding) contentContainer.classList.add('dialogContentNoPadding');
-                       dialog.appendChild(contentContainer);
-                       
-                       contentContainer.addEventListener('wheel', function (event) {
-                               var allowScroll = false;
-                               var element = event.target, clientHeight, scrollHeight, scrollTop;
-                               while (true) {
-                                       clientHeight = element.clientHeight;
-                                       scrollHeight = element.scrollHeight;
-                                       
-                                       if (clientHeight < scrollHeight) {
-                                               scrollTop = element.scrollTop;
-                                               
-                                               // negative value: scrolling up
-                                               if (event.deltaY < 0 && scrollTop > 0) {
-                                                       allowScroll = true;
-                                                       break;
-                                               }
-                                               else if (event.deltaY > 0 && (scrollTop + clientHeight < scrollHeight)) {
-                                                       allowScroll = true;
-                                                       break;
-                                               }
-                                       }
-                                       
-                                       if (!element || element === contentContainer) {
-                                               break;
-                                       }
-                                       
-                                       element = element.parentNode;
-                               }
-                               
-                               if (allowScroll === false) {
-                                       event.preventDefault();
-                               }
-                       }, { passive: false });
-                       
-                       var content;
-                       if (element === null) {
-                               if (typeof html === 'string') {
-                                       content = elCreate('div');
-                                       content.id = id;
-                                       DomUtil.setInnerHtml(content, html);
-                               }
-                               else if (html instanceof DocumentFragment) {
-                                       var children = [], node;
-                                       for (var i = 0, length = html.childNodes.length; i < length; i++) {
-                                               node = html.childNodes[i];
-                                               
-                                               if (node.nodeType === Node.ELEMENT_NODE) {
-                                                       children.push(node);
-                                               }
-                                       }
-                                       
-                                       if (children[0].nodeName !== 'DIV' || children.length > 1) {
-                                               content = elCreate('div');
-                                               content.id = id;
-                                               content.appendChild(html);
-                                       }
-                                       else {
-                                               content = children[0];
-                                       }
-                               }
-                               else {
-                                       throw new TypeError("'html' must either be a string or a DocumentFragment");
-                               }
-                       }
-                       else {
-                               content = element;
-                       }
-                       
-                       contentContainer.appendChild(content);
-                       
-                       if (content.style.getPropertyValue('display') === 'none') {
-                               elShow(content);
-                       }
-                       
-                       _dialogs.set(id, {
-                               backdropCloseOnClick: options.backdropCloseOnClick,
-                               closable: options.closable,
-                               content: content,
-                               dialog: dialog,
-                               header: header,
-                               onBeforeClose: options.onBeforeClose,
-                               onClose: options.onClose,
-                               onShow: options.onShow,
-                               
-                               submitButton: null,
-                               inputFields: new List()
-                       });
-                       
-                       DomUtil.prepend(dialog, _container);
-                       
-                       if (typeof options.onSetup === 'function') {
-                               options.onSetup(content);
-                       }
-                       
-                       if (createOnly !== true) {
-                               this._updateDialog(id, null);
-                       }
-               },
-               
-               /**
-                * Updates the dialog's content element.
-                * 
-                * @param       {string}                id              element id
-                * @param       {?string}               html            content html, prevent changes by passing null
-                */
-               _updateDialog: function(id, html) {
-                       var data = _dialogs.get(id);
-                       if (data === undefined) {
-                               throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
-                       }
-                       
-                       if (typeof html === 'string') {
-                               DomUtil.setInnerHtml(data.content, html);
-                       }
-                       
-                       if (elAttr(data.dialog, 'aria-hidden') === 'true') {
-                               // close existing dropdowns
-                               UiSimpleDropdown.closeAll();
-                               window.WCF.Dropdown.Interactive.Handler.closeAll();
-                               
-                               if (_callbackFocus === null) {
-                                       _callbackFocus = this._maintainFocus.bind(this);
-                                       document.body.addEventListener('focus', _callbackFocus, { capture: true });
-                               }
-                               
-                               if (data.closable && elAttr(_container, 'aria-hidden') === 'true') {
-                                       window.addEventListener('keyup', _keyupListener);
-                               }
-                               
-                               // Move the dialog to the front to prevent it being hidden behind already open dialogs
-                               // if it was previously visible.
-                               data.dialog.parentNode.insertBefore(data.dialog, data.dialog.parentNode.firstChild);
-                               
-                               elAttr(data.dialog, 'aria-hidden', 'false');
-                               elAttr(_container, 'aria-hidden', 'false');
-                               elData(_container, 'close-on-click', (data.backdropCloseOnClick ? 'true' : 'false'));
-                               _activeDialog = id;
-                               
-                               // Keep a reference to the currently focused element to be able to restore it later.
-                               _focusedBeforeDialog = document.activeElement;
-                               
-                               // Set the focus to the first focusable child of the dialog element.
-                               var closeButton = elBySel('.dialogCloseButton', data.header);
-                               if (closeButton) elAttr(closeButton, 'inert', true);
-                               this._setFocusToFirstItem(data.dialog);
-                               if (closeButton) closeButton.removeAttribute('inert');
-                               
-                               if (typeof data.onShow === 'function') {
-                                       data.onShow(data.content);
-                               }
-                               
-                               if (elDataBool(data.content, 'is-static-dialog')) {
-                                       EventHandler.fire('com.woltlab.wcf.dialog', 'openStatic', {
-                                               content: data.content,
-                                               id: id
-                                       });
-                               }
-                       }
-                       
-                       this.rebuild(id);
-                       
-                       DomChangeListener.trigger();
-               },
-               
-               /**
-                * @param {Event} event
-                */
-               _maintainFocus: function(event) {
-                       if (_activeDialog) {
-                               var data = _dialogs.get(_activeDialog);
-                               if (!data.dialog.contains(event.target) && !event.target.closest('.dropdownMenuContainer') && !event.target.closest('.datePicker')) {
-                                       this._setFocusToFirstItem(data.dialog, true);
-                               }
-                       }
-               },
-               
-               /**
-                * @param {Element} dialog
-                * @param {boolean} maintain
-                */
-               _setFocusToFirstItem: function(dialog, maintain) {
-                       var focusElement = this._getFirstFocusableChild(dialog);
-                       if (focusElement !== null) {
-                               if (maintain) {
-                                       if (focusElement.id === 'username' || focusElement.name === 'username') {
-                                               if (Environment.browser() === 'safari' && Environment.platform() === 'ios') {
-                                                       // iOS Safari's username/password autofill breaks if the input field is focused 
-                                                       focusElement = null;
-                                               }
-                                       }
-                               }
-                               
-                               if (focusElement) {
-                                       // Setting the focus to a select element in iOS is pretty strange, because
-                                       // it focuses it, but also displays the keyboard for a fraction of a second,
-                                       // causing it to pop out from below and immediately vanish.
-                                       // 
-                                       // iOS will only show the keyboard if an input element is focused *and* the
-                                       // focus is an immediate result of a user interaction. This method must be
-                                       // assumed to be called from within a click event, but we want to set the
-                                       // focus without triggering the keyboard.
-                                       // 
-                                       // We can break the condition by wrapping it in a setTimeout() call,
-                                       // effectively tricking iOS into focusing the element without showing the
-                                       // keyboard.
-                                       setTimeout(function() {
-                                               focusElement.focus();
-                                       }, 1);
-                               }
-                       }
-               },
-               
-               /**
-                * @param {Element} node
-                * @returns {?Element}
-                */
-               _getFirstFocusableChild: function(node) {
-                       var nodeList = elBySelAll(_focusableElements.join(','), node);
-                       for (var i = 0, length = nodeList.length; i < length; i++) {
-                               if (nodeList[i].offsetWidth && nodeList[i].offsetHeight && nodeList[i].getClientRects().length) {
-                                       return nodeList[i];
-                               }
-                       }
-                       
-                       return null;    
-               },
-               
-               /**
-                * Rebuilds dialog identified by given id.
-                * 
-                * @param       {string}        id      element id
-                */
-               rebuild: function(id) {
-                       id = this._getDialogId(id);
-                       
-                       var data = _dialogs.get(id);
-                       if (data === undefined) {
-                               throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
-                       }
-                       
-                       // ignore non-active dialogs
-                       if (elAttr(data.dialog, 'aria-hidden') === 'true') {
-                               return;
-                       }
-                       
-                       var contentContainer = data.content.parentNode;
-                       
-                       var formSubmit = elBySel('.formSubmit', data.content);
-                       var unavailableHeight = 0;
-                       if (formSubmit !== null) {
-                               contentContainer.classList.add('dialogForm');
-                               formSubmit.classList.add('dialogFormSubmit');
-                               
-                               unavailableHeight += DomUtil.outerHeight(formSubmit);
-                               
-                               // Calculated height can be a fractional value and depending on the
-                               // browser the results can vary. By subtracting a single pixel we're
-                               // working around fractional values, without visually changing anything.
-                               unavailableHeight -= 1;
-                               
-                               contentContainer.style.setProperty('margin-bottom', unavailableHeight + 'px', '');
-                       }
-                       else {
-                               contentContainer.classList.remove('dialogForm');
-                               contentContainer.style.removeProperty('margin-bottom');
-                       }
-                       
-                       unavailableHeight += DomUtil.outerHeight(data.header);
-                       
-                       var maximumHeight = (window.innerHeight * (_dialogFullHeight ? 1 : 0.8)) - unavailableHeight;
-                       contentContainer.style.setProperty('max-height', ~~maximumHeight + 'px', '');
-                       
-                       // fix for a calculation bug in Chrome causing the scrollbar to overlap the border
-                       if (Environment.browser() === 'chrome') {
-                               if (data.content.scrollHeight > maximumHeight) {
-                                       data.content.style.setProperty('margin-right', '-1px', '');
-                               }
-                               else {
-                                       data.content.style.removeProperty('margin-right');
-                               }
-                       }
-                       
-                       // Chrome and Safari use heavy anti-aliasing when the dialog's width
-                       // cannot be evenly divided, causing the whole text to become blurry
-                       if (Environment.browser() === 'chrome' || Environment.browser() === 'safari') {
-                               // The new Microsoft Edge is detected as "chrome", because effectively we're detecting
-                               // Chromium rather than Chrome specifically. The workaround for fractional pixels does
-                               // not work well in Edge, there seems to be a different logic for fractional positions,
-                               // causing the text to be blurry.
-                               // 
-                               // We can use `backface-visibility: hidden` to prevent the anti aliasing artifacts in
-                               // WebKit/Blink, which will also prevent some weird font rendering issues when resizing.
-                               data.content.parentNode.classList.add('jsWebKitFractionalPixelFix');
-                       }
-                       
-                       var callbackObject = _dialogToObject.get(id);
-                       //noinspection JSUnresolvedVariable
-                       if (callbackObject !== undefined && typeof callbackObject._dialogSubmit === 'function') {
-                               var inputFields = elBySelAll('input[data-dialog-submit-on-enter="true"]', data.content);
-                               
-                               var submitButton = elBySel('.formSubmit > input[type="submit"], .formSubmit > button[data-type="submit"]', data.content);
-                               if (submitButton === null) {
-                                       // check if there is at least one input field with submit handling,
-                                       // otherwise we'll assume the dialog has not been populated yet
-                                       if (inputFields.length === 0) {
-                                               console.warn("Broken dialog, expected a submit button.", data.content);
-                                       }
-                                       
-                                       return;
-                               }
-                               
-                               if (data.submitButton !== submitButton) {
-                                       data.submitButton = submitButton;
-                                       
-                                       submitButton.addEventListener(WCF_CLICK_EVENT, (function (event) {
-                                               event.preventDefault();
-                                               
-                                               this._submit(id);
-                                       }).bind(this));
-                                       
-                                       // bind input fields
-                                       var inputField, _callbackKeydown = null;
-                                       for (var i = 0, length = inputFields.length; i < length; i++) {
-                                               inputField = inputFields[i];
-                                               
-                                               if (data.inputFields.has(inputField)) continue;
-                                               
-                                               if (_validInputTypes.indexOf(inputField.type) === -1) {
-                                                       console.warn("Unsupported input type.", inputField);
-                                                       continue;
-                                               }
-                                               
-                                               data.inputFields.add(inputField);
-                                               
-                                               if (_callbackKeydown === null) {
-                                                       _callbackKeydown = (function (event) {
-                                                               if (EventKey.Enter(event)) {
-                                                                       event.preventDefault();
-                                                                       
-                                                                       this._submit(id);
-                                                               }
-                                                       }).bind(this);
-                                               }
-                                               inputField.addEventListener('keydown', _callbackKeydown);
-                                       }
-                               }
-                       }
-               },
-               
-               /**
-                * Submits the dialog.
-                * 
-                * @param       {string}        id      dialog id
-                * @protected
-                */
-               _submit: function (id) {
-                       var data = _dialogs.get(id);
-                       
-                       var isValid = true;
-                       data.inputFields.forEach(function (inputField) {
-                               if (inputField.required) {
-                                       if (inputField.value.trim() === '') {
-                                               elInnerError(inputField, Language.get('wcf.global.form.error.empty'));
-                                               
-                                               isValid = false;
-                                       }
-                                       else {
-                                               elInnerError(inputField, false);
-                                       }
-                               }
-                       });
-                       
-                       if (isValid) {
-                               //noinspection JSUnresolvedFunction
-                               _dialogToObject.get(id)._dialogSubmit();
-                       }
-               },
-               
-               /**
-                * Handles clicks on the close button or the backdrop if enabled.
-                * 
-                * @param       {object}        event           click event
-                * @return      {boolean}       false if the event should be cancelled
-                */
-               _close: function(event) {
-                       event.preventDefault();
-                       
-                       var data = _dialogs.get(_activeDialog);
-                       if (typeof data.onBeforeClose === 'function') {
-                               data.onBeforeClose(_activeDialog);
-                               
-                               return false;
-                       }
-                       
-                       this.close(_activeDialog);
-               },
-               
-               /**
-                * Closes the current active dialog by clicks on the backdrop.
-                * 
-                * @param       {object}        event   event object
-                */
-               _closeOnBackdrop: function(event) {
-                       if (event.target !== _container) {
-                               return true;
-                       }
-                       
-                       if (elData(_container, 'close-on-click') === 'true') {
-                               this._close(event);
-                       }
-                       else {
-                               event.preventDefault();
-                       }
-               },
-               
-               /**
-                * Closes a dialog identified by given id.
-                * 
-                * @param       {(string|object)}       id      element id or callback object
-                */
-               close: function(id) {
-                       id = this._getDialogId(id);
-                       
-                       var data = _dialogs.get(id);
-                       if (data === undefined) {
-                               throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
-                       }
-                       
-                       elAttr(data.dialog, 'aria-hidden', 'true');
-                       
-                       // avoid keyboard focus on a now hidden element 
-                       if (document.activeElement.closest('.dialogContainer') === data.dialog) {
-                               document.activeElement.blur();
-                       }
-                       
-                       if (typeof data.onClose === 'function') {
-                               data.onClose(id);
-                       }
-                       
-                       // get next active dialog
-                       _activeDialog = null;
-                       for (var i = 0; i < _container.childElementCount; i++) {
-                               var child = _container.children[i];
-                               if (elAttr(child, 'aria-hidden') === 'false') {
-                                       _activeDialog = elData(child, 'id');
-                                       break;
-                               }
-                       }
-                       
-                       UiScreen.pageOverlayClose();
-                       
-                       if (_activeDialog === null) {
-                               elAttr(_container, 'aria-hidden', 'true');
-                               elData(_container, 'close-on-click', 'false');
-                               
-                               if (data.closable) {
-                                       window.removeEventListener('keyup', _keyupListener);
-                               }
-                       }
-                       else {
-                               data = _dialogs.get(_activeDialog);
-                               elData(_container, 'close-on-click', (data.backdropCloseOnClick ? 'true' : 'false'));
-                       }
-                       
-                       if (Environment.platform() !== 'desktop') {
-                               UiScreen.scrollEnable();
-                       }
-               },
-               
-               /**
-                * Returns the dialog data for given element id.
-                * 
-                * @param       {(string|object)}       id      element id or callback object
-                * @return      {(object|undefined)}    dialog data or undefined if element id is unknown
-                */
-               getDialog: function(id) {
-                       return _dialogs.get(this._getDialogId(id));
-               },
-               
-               /**
-                * Returns true for open dialogs.
-                * 
-                * @param       {(string|object)}       id      element id or callback object
-                * @return      {boolean}
-                */
-               isOpen: function(id) {
-                       var data = this.getDialog(id);
-                       return (data !== undefined && elAttr(data.dialog, 'aria-hidden') === 'false');
-               },
-               
-               /**
-                * Destroys a dialog instance.
-                * 
-                * @param       {Object}        callbackObject  the same object that was used to invoke `_dialogSetup()` on first call
-                */
-               destroy: function(callbackObject) {
-                       if (typeof callbackObject !== 'object' || callbackObject instanceof String) {
-                               throw new TypeError("Expected the callback object as parameter.");
-                       }
-                       
-                       if (_dialogObjects.has(callbackObject)) {
-                               var id = _dialogObjects.get(callbackObject).id;
-                               if (this.isOpen(id)) {
-                                       this.close(id);
-                               }
-                               
-                               // If the dialog is destroyed in the close callback, this method is
-                               // called twice resulting in `_dialogs.get(id)` being undefined for
-                               // the initial call.
-                               if (_dialogs.has(id)) {
-                                       elRemove(_dialogs.get(id).dialog);
-                                       _dialogs.delete(id);
-                               }
-                               _dialogObjects.delete(callbackObject);
-                       }
-               },
-               
-               /**
-                * Returns a dialog's id.
-                * 
-                * @param       {(string|object)}       id      element id or callback object
-                * @return      {string}
-                * @protected
-                */
-               _getDialogId: function(id) {
-                       if (typeof id === 'object') {
-                               var dialogData = _dialogObjects.get(id);
-                               if (dialogData !== undefined) {
-                                       return dialogData.id;
-                               }
-                       }
-                       
-                       return id.toString();
-               },
-               
-               _ajaxSetup: function() {
-                       return {};
-               }
-       };
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
+}) : (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+    Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+    o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || function (mod) {
+    if (mod && mod.__esModule) return mod;
+    var result = {};
+    if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
+    __setModuleDefault(result, mod);
+    return result;
+};
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+define(["require", "exports", "../Core", "../Dom/Change/Listener", "./Screen", "../Dom/Util", "../Language", "../Environment", "../Event/Handler"], function (require, exports, Core, Listener_1, UiScreen, Util_1, Language, Environment, EventHandler) {
+    "use strict";
+    Core = __importStar(Core);
+    Listener_1 = __importDefault(Listener_1);
+    UiScreen = __importStar(UiScreen);
+    Util_1 = __importDefault(Util_1);
+    Language = __importStar(Language);
+    Environment = __importStar(Environment);
+    EventHandler = __importStar(EventHandler);
+    let _activeDialog = null;
+    let _callbackFocus;
+    let _container;
+    const _dialogs = new Map();
+    let _dialogFullHeight = false;
+    const _dialogObjects = new WeakMap();
+    const _dialogToObject = new Map();
+    let _focusedBeforeDialog;
+    let _keyupListener;
+    const _validCallbacks = ['onBeforeClose', 'onClose', 'onShow'];
+    // list of supported `input[type]` values for dialog submit
+    const _validInputTypes = ['number', 'password', 'search', 'tel', 'text', 'url'];
+    const _focusableElements = [
+        'a[href]:not([tabindex^="-"]):not([inert])',
+        'area[href]:not([tabindex^="-"]):not([inert])',
+        'input:not([disabled]):not([inert])',
+        'select:not([disabled]):not([inert])',
+        'textarea:not([disabled]):not([inert])',
+        'button:not([disabled]):not([inert])',
+        'iframe:not([tabindex^="-"]):not([inert])',
+        'audio:not([tabindex^="-"]):not([inert])',
+        'video:not([tabindex^="-"]):not([inert])',
+        '[contenteditable]:not([tabindex^="-"]):not([inert])',
+        '[tabindex]:not([tabindex^="-"]):not([inert])',
+    ];
+    return {
+        /**
+         * Sets up global container and internal variables.
+         */
+        setup() {
+            _container = document.createElement('div');
+            _container.classList.add('dialogOverlay');
+            _container.setAttribute('aria-hidden', 'true');
+            _container.addEventListener('mousedown', this._closeOnBackdrop.bind(this));
+            _container.addEventListener('wheel', event => {
+                if (event.target === _container) {
+                    event.preventDefault();
+                }
+            }, { passive: false });
+            document.getElementById('content').appendChild(_container);
+            _keyupListener = (event) => {
+                if (event.key === "Escape") {
+                    const target = event.target;
+                    if (target.nodeName !== 'INPUT' && target.nodeName !== 'TEXTAREA') {
+                        this.close(_activeDialog);
+                        return false;
+                    }
+                }
+                return true;
+            };
+            UiScreen.on('screen-xs', {
+                match() {
+                    _dialogFullHeight = true;
+                },
+                unmatch() {
+                    _dialogFullHeight = false;
+                },
+                setup() {
+                    _dialogFullHeight = true;
+                },
+            });
+            this._initStaticDialogs();
+            Listener_1.default.add('Ui/Dialog', this._initStaticDialogs.bind(this));
+            UiScreen.setDialogContainer(_container);
+            window.addEventListener('resize', () => {
+                _dialogs.forEach(dialog => {
+                    if (!Core.stringToBool(dialog.dialog.getAttribute('aria-hidden'))) {
+                        this.rebuild(dialog.dialog.getAttribute('data-id'));
+                    }
+                });
+            });
+        },
+        _initStaticDialogs() {
+            document.querySelectorAll('.jsStaticDialog').forEach(button => {
+                button.classList.remove('jsStaticDialog');
+                const id = button.getAttribute('data-dialog-id');
+                if (id) {
+                    const container = document.getElementById(id);
+                    if (container !== null) {
+                        container.classList.remove('jsStaticDialogContent');
+                        container.setAttribute('data-is-static-dialog', 'true');
+                        Util_1.default.hide(container);
+                        button.addEventListener('click', event => {
+                            event.preventDefault();
+                            this.openStatic(container.id, null, { title: container.getAttribute('data-title') || '' });
+                        });
+                    }
+                }
+            });
+        },
+        /**
+         * Opens the dialog and implicitly creates it on first usage.
+         */
+        open(callbackObject, html) {
+            let dialogData = _dialogObjects.get(callbackObject);
+            if (dialogData && Core.isPlainObject(dialogData)) {
+                // dialog already exists
+                return this.openStatic(dialogData.id, html);
+            }
+            // initialize a new dialog
+            if (typeof callbackObject._dialogSetup !== 'function') {
+                throw new Error("Callback object does not implement the method '_dialogSetup()'.");
+            }
+            const setupData = callbackObject._dialogSetup();
+            if (!Core.isPlainObject(setupData)) {
+                throw new Error("Expected an object literal as return value of '_dialogSetup()'.");
+            }
+            const id = setupData.id;
+            dialogData = { id };
+            let createOnly = true;
+            let dialogElement;
+            if (setupData.source === undefined) {
+                dialogElement = document.getElementById(id);
+                if (dialogElement === null) {
+                    throw new Error("Element id '" + 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.");
+                }
+                setupData.source = document.createDocumentFragment();
+                setupData.source.appendChild(dialogElement);
+                dialogElement.removeAttribute('id');
+                Util_1.default.show(dialogElement);
+            }
+            else if (setupData.source === null) {
+                // `null` means there is no static markup and `html` should be used instead
+                setupData.source = html;
+            }
+            else if (typeof setupData.source === 'function') {
+                setupData.source();
+            }
+            else if (Core.isPlainObject(setupData.source)) {
+                if (typeof html === 'string' && html.trim() !== '') {
+                    setupData.source = html;
+                }
+                else {
+                    new Promise((resolve_1, reject_1) => { require(['../Ajax'], resolve_1, reject_1); }).then(__importStar).then(Ajax => {
+                        const source = setupData.source;
+                        Ajax.api(this, source.data, data => {
+                            if (data.returnValues && typeof data.returnValues.template === 'string') {
+                                this.open(callbackObject, data.returnValues.template);
+                                if (typeof source.after === 'function') {
+                                    source.after(_dialogs.get(id).content, data);
+                                }
+                            }
+                        });
+                    });
+                    return {};
+                }
+            }
+            else {
+                if (typeof setupData.source === 'string') {
+                    dialogElement = document.createElement('div');
+                    dialogElement.id = id;
+                    Util_1.default.setInnerHtml(dialogElement, setupData.source);
+                    setupData.source = document.createDocumentFragment();
+                    setupData.source.appendChild(dialogElement);
+                }
+                if (!setupData.source.nodeType || setupData.source.nodeType !== Node.DOCUMENT_FRAGMENT_NODE) {
+                    throw new Error("Expected at least a document fragment as 'source' attribute.");
+                }
+                createOnly = false;
+            }
+            _dialogObjects.set(callbackObject, dialogData);
+            _dialogToObject.set(id, callbackObject);
+            return this.openStatic(id, setupData.source, setupData.options, createOnly);
+        },
+        /**
+         * Opens an dialog, if the dialog is already open the content container
+         * will be replaced by the HTML string contained in the parameter html.
+         *
+         * If id is an existing element id, html will be ignored and the referenced
+         * element will be appended to the content element instead.
+         */
+        openStatic(id, html, options, createOnly) {
+            UiScreen.pageOverlayOpen();
+            if (Environment.platform() !== 'desktop') {
+                if (!this.isOpen(id)) {
+                    UiScreen.scrollDisable();
+                }
+            }
+            if (_dialogs.has(id)) {
+                this._updateDialog(id, html);
+            }
+            else {
+                options = Core.extend({
+                    backdropCloseOnClick: true,
+                    closable: true,
+                    closeButtonLabel: Language.get('wcf.global.button.close'),
+                    closeConfirmMessage: '',
+                    disableContentPadding: false,
+                    title: '',
+                    onBeforeClose: null,
+                    onClose: null,
+                    onShow: null,
+                }, options || {});
+                if (!options.closable)
+                    options.backdropCloseOnClick = false;
+                if (options.closeConfirmMessage) {
+                    // TODO
+                    /*
+                    options.onBeforeClose = id => {
+                      UiConfirmation.show({
+                        confirm: this.close.bind(this, id),
+                        message: options.closeConfirmMessage,
+                      });
+                    };
+                    */
+                }
+                this._createDialog(id, html, options, createOnly || false);
+            }
+            const data = _dialogs.get(id);
+            // iOS breaks `position: fixed` when input elements or `contenteditable`
+            // are focused, this will freeze the screen and force Safari to scroll
+            // to the input field
+            if (Environment.platform() === 'ios') {
+                window.setTimeout(() => {
+                    var _a;
+                    (_a = data.content.querySelector('input, textarea')) === null || _a === void 0 ? void 0 : _a.focus();
+                }, 200);
+            }
+            return data;
+        },
+        /**
+         * Sets the dialog title.
+         */
+        setTitle(id, title) {
+            id = this._getDialogId(id);
+            const data = _dialogs.get(id);
+            if (data === undefined) {
+                throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
+            }
+            const dialogTitle = data.dialog.querySelector('.dialogTitle');
+            if (dialogTitle) {
+                dialogTitle.textContent = title;
+            }
+        },
+        /**
+         * Sets a callback function on runtime.
+         */
+        setCallback(id, key, value) {
+            if (typeof id === 'object') {
+                const dialogData = _dialogObjects.get(id);
+                if (dialogData !== undefined) {
+                    id = dialogData.id;
+                }
+            }
+            const data = _dialogs.get(id);
+            if (data === undefined) {
+                throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
+            }
+            if (_validCallbacks.indexOf(key) === -1) {
+                throw new Error("Invalid callback identifier, '" + key + "' is not recognized.");
+            }
+            if (typeof value !== 'function' && value !== null) {
+                throw new Error("Only functions or the 'null' value are acceptable callback values ('" + typeof value + "' given).");
+            }
+            data[key] = value;
+        },
+        /**
+         * Creates the DOM for a new dialog and opens it.
+         *
+         * @param  {string}      id    element id, if exists the html parameter is ignored in favor of the existing element
+         * @param  {?(string|DocumentFragment)}  html    content html
+         * @param  {object<string, *>}    options    list of options
+         * @param  {boolean=}      createOnly  create the dialog but do not open it
+         */
+        _createDialog(id, html, options, createOnly) {
+            let element = null;
+            if (html === null) {
+                element = document.getElementById(id);
+                if (element === null) {
+                    throw new Error("Expected either a HTML string or an existing element id.");
+                }
+            }
+            const dialog = document.createElement('div');
+            dialog.classList.add('dialogContainer');
+            dialog.setAttribute('aria-hidden', 'true');
+            dialog.setAttribute('role', 'dialog');
+            dialog.id = id;
+            const header = document.createElement('header');
+            dialog.appendChild(header);
+            const titleId = Util_1.default.getUniqueId();
+            dialog.setAttribute('aria-labelledby', titleId);
+            const title = document.createElement('span');
+            title.classList.add('dialogTitle');
+            title.textContent = options.title;
+            title.id = titleId;
+            header.appendChild(title);
+            if (options.closable) {
+                const closeButton = document.createElement('a');
+                closeButton.className = 'dialogCloseButton jsTooltip';
+                closeButton.href = '#';
+                closeButton.setAttribute('role', 'button');
+                closeButton.tabIndex = 0;
+                closeButton.title = options.closeButtonLabel;
+                closeButton.setAttribute('aria-label', options.closeButtonLabel);
+                closeButton.addEventListener('click', this._close.bind(this));
+                header.appendChild(closeButton);
+                const span = document.createElement('span');
+                span.className = 'icon icon24 fa-times';
+                closeButton.appendChild(span);
+            }
+            const contentContainer = document.createElement('div');
+            contentContainer.classList.add('dialogContent');
+            if (options.disableContentPadding)
+                contentContainer.classList.add('dialogContentNoPadding');
+            dialog.appendChild(contentContainer);
+            contentContainer.addEventListener('wheel', event => {
+                let allowScroll = false;
+                let element = event.target;
+                let clientHeight, scrollHeight, scrollTop;
+                while (true) {
+                    clientHeight = element.clientHeight;
+                    scrollHeight = element.scrollHeight;
+                    if (clientHeight < scrollHeight) {
+                        scrollTop = element.scrollTop;
+                        // negative value: scrolling up
+                        if (event.deltaY < 0 && scrollTop > 0) {
+                            allowScroll = true;
+                            break;
+                        }
+                        else if (event.deltaY > 0 && (scrollTop + clientHeight < scrollHeight)) {
+                            allowScroll = true;
+                            break;
+                        }
+                    }
+                    if (!element || element === contentContainer) {
+                        break;
+                    }
+                    element = element.parentNode;
+                }
+                if (!allowScroll) {
+                    event.preventDefault();
+                }
+            }, { passive: false });
+            let content;
+            if (element === null) {
+                if (typeof html === 'string') {
+                    content = document.createElement('div');
+                    content.id = id;
+                    Util_1.default.setInnerHtml(content, html);
+                }
+                else if (html instanceof DocumentFragment) {
+                    const children = [];
+                    let node;
+                    for (let i = 0, length = html.childNodes.length; i < length; i++) {
+                        node = html.childNodes[i];
+                        if (node.nodeType === Node.ELEMENT_NODE) {
+                            children.push(node);
+                        }
+                    }
+                    if (children[0].nodeName !== 'DIV' || children.length > 1) {
+                        content = document.createElement('div');
+                        content.id = id;
+                        content.appendChild(html);
+                    }
+                    else {
+                        content = children[0];
+                    }
+                }
+                else {
+                    throw new TypeError("'html' must either be a string or a DocumentFragment");
+                }
+            }
+            else {
+                content = element;
+            }
+            contentContainer.appendChild(content);
+            if (content.style.getPropertyValue('display') === 'none') {
+                Util_1.default.show(content);
+            }
+            _dialogs.set(id, {
+                backdropCloseOnClick: options.backdropCloseOnClick,
+                closable: options.closable,
+                content: content,
+                dialog: dialog,
+                header: header,
+                onBeforeClose: options.onBeforeClose,
+                onClose: options.onClose,
+                onShow: options.onShow,
+                submitButton: null,
+                inputFields: new Set(),
+            });
+            _container.insertBefore(dialog, _container.firstChild);
+            if (typeof options.onSetup === 'function') {
+                options.onSetup(content);
+            }
+            if (!createOnly) {
+                this._updateDialog(id, null);
+            }
+        },
+        /**
+         * Updates the dialog's content element.
+         */
+        _updateDialog(id, html) {
+            const data = _dialogs.get(id);
+            if (data === undefined) {
+                throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
+            }
+            if (typeof html === 'string') {
+                Util_1.default.setInnerHtml(data.content, html);
+            }
+            if (Core.stringToBool(data.dialog.getAttribute('aria-hidden'))) {
+                // close existing dropdowns
+                // TODO
+                //UiSimpleDropdown.closeAll();
+                window.WCF.Dropdown.Interactive.Handler.closeAll();
+                if (_callbackFocus === null) {
+                    _callbackFocus = this._maintainFocus.bind(this);
+                    document.body.addEventListener('focus', _callbackFocus, { capture: true });
+                }
+                if (data.closable && Core.stringToBool(_container.getAttribute('aria-hidden'))) {
+                    window.addEventListener('keyup', _keyupListener);
+                }
+                // Move the dialog to the front to prevent it being hidden behind already open dialogs
+                // if it was previously visible.
+                data.dialog.parentNode.insertBefore(data.dialog, data.dialog.parentNode.firstChild);
+                data.dialog.setAttribute('aria-hidden', 'false');
+                _container.setAttribute('aria-hidden', 'false');
+                _container.setAttribute('close-on-click', (data.backdropCloseOnClick ? 'true' : 'false'));
+                _activeDialog = id;
+                // Keep a reference to the currently focused element to be able to restore it later.
+                _focusedBeforeDialog = document.activeElement;
+                // Set the focus to the first focusable child of the dialog element.
+                const closeButton = data.header.querySelector('.dialogCloseButton');
+                if (closeButton)
+                    closeButton.setAttribute('inert', 'true');
+                this._setFocusToFirstItem(data.dialog, false);
+                if (closeButton)
+                    closeButton.removeAttribute('inert');
+                if (typeof data.onShow === 'function') {
+                    data.onShow(data.content);
+                }
+                if (Core.stringToBool(data.content.getAttribute('data-is-static-dialog'))) {
+                    EventHandler.fire('com.woltlab.wcf.dialog', 'openStatic', {
+                        content: data.content,
+                        id: id,
+                    });
+                }
+            }
+            this.rebuild(id);
+            Listener_1.default.trigger();
+        },
+        _maintainFocus(event) {
+            if (_activeDialog) {
+                const data = _dialogs.get(_activeDialog);
+                const target = event.target;
+                if (!data.dialog.contains(target) && !target.closest('.dropdownMenuContainer') && !target.closest('.datePicker')) {
+                    this._setFocusToFirstItem(data.dialog, true);
+                }
+            }
+        },
+        _setFocusToFirstItem(dialog, maintain) {
+            let focusElement = this._getFirstFocusableChild(dialog);
+            if (focusElement !== null) {
+                if (maintain) {
+                    if (focusElement.id === 'username' || focusElement.name === 'username') {
+                        if (Environment.browser() === 'safari' && Environment.platform() === 'ios') {
+                            // iOS Safari's username/password autofill breaks if the input field is focused 
+                            focusElement = null;
+                        }
+                    }
+                }
+                if (focusElement) {
+                    // Setting the focus to a select element in iOS is pretty strange, because
+                    // it focuses it, but also displays the keyboard for a fraction of a second,
+                    // causing it to pop out from below and immediately vanish.
+                    // 
+                    // iOS will only show the keyboard if an input element is focused *and* the
+                    // focus is an immediate result of a user interaction. This method must be
+                    // assumed to be called from within a click event, but we want to set the
+                    // focus without triggering the keyboard.
+                    // 
+                    // We can break the condition by wrapping it in a setTimeout() call,
+                    // effectively tricking iOS into focusing the element without showing the
+                    // keyboard.
+                    setTimeout(() => {
+                        focusElement.focus();
+                    }, 1);
+                }
+            }
+        },
+        _getFirstFocusableChild(element) {
+            const nodeList = element.querySelectorAll(_focusableElements.join(','));
+            for (let i = 0, length = nodeList.length; i < length; i++) {
+                if (nodeList[i].offsetWidth && nodeList[i].offsetHeight && nodeList[i].getClientRects().length) {
+                    return nodeList[i];
+                }
+            }
+            return null;
+        },
+        /**
+         * Rebuilds dialog identified by given id.
+         */
+        rebuild(id) {
+            id = this._getDialogId(id);
+            const data = _dialogs.get(id);
+            if (data === undefined) {
+                throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
+            }
+            // ignore non-active dialogs
+            if (Core.stringToBool(data.dialog.getAttribute('aria-hidden'))) {
+                return;
+            }
+            const contentContainer = data.content.parentNode;
+            const formSubmit = data.content.querySelector('.formSubmit');
+            let unavailableHeight = 0;
+            if (formSubmit !== null) {
+                contentContainer.classList.add('dialogForm');
+                formSubmit.classList.add('dialogFormSubmit');
+                unavailableHeight += Util_1.default.outerHeight(formSubmit);
+                // Calculated height can be a fractional value and depending on the
+                // browser the results can vary. By subtracting a single pixel we're
+                // working around fractional values, without visually changing anything.
+                unavailableHeight -= 1;
+                contentContainer.style.setProperty('margin-bottom', unavailableHeight + 'px', '');
+            }
+            else {
+                contentContainer.classList.remove('dialogForm');
+                contentContainer.style.removeProperty('margin-bottom');
+            }
+            unavailableHeight += Util_1.default.outerHeight(data.header);
+            const maximumHeight = (window.innerHeight * (_dialogFullHeight ? 1 : 0.8)) - unavailableHeight;
+            contentContainer.style.setProperty('max-height', ~~maximumHeight + 'px', '');
+            // fix for a calculation bug in Chrome causing the scrollbar to overlap the border
+            if (Environment.browser() === 'chrome') {
+                if (data.content.scrollHeight > maximumHeight) {
+                    data.content.style.setProperty('margin-right', '-1px', '');
+                }
+                else {
+                    data.content.style.removeProperty('margin-right');
+                }
+            }
+            // Chrome and Safari use heavy anti-aliasing when the dialog's width
+            // cannot be evenly divided, causing the whole text to become blurry
+            if (Environment.browser() === 'chrome' || Environment.browser() === 'safari') {
+                // The new Microsoft Edge is detected as "chrome", because effectively we're detecting
+                // Chromium rather than Chrome specifically. The workaround for fractional pixels does
+                // not work well in Edge, there seems to be a different logic for fractional positions,
+                // causing the text to be blurry.
+                // 
+                // We can use `backface-visibility: hidden` to prevent the anti aliasing artifacts in
+                // WebKit/Blink, which will also prevent some weird font rendering issues when resizing.
+                contentContainer.classList.add('jsWebKitFractionalPixelFix');
+            }
+            const callbackObject = _dialogToObject.get(id);
+            //noinspection JSUnresolvedVariable
+            if (callbackObject !== undefined && typeof callbackObject._dialogSubmit === 'function') {
+                const inputFields = data.content.querySelectorAll('input[data-dialog-submit-on-enter="true"]');
+                const submitButton = data.content.querySelector('.formSubmit > input[type="submit"], .formSubmit > button[data-type="submit"]');
+                if (submitButton === null) {
+                    // check if there is at least one input field with submit handling,
+                    // otherwise we'll assume the dialog has not been populated yet
+                    if (inputFields.length === 0) {
+                        console.warn("Broken dialog, expected a submit button.", data.content);
+                    }
+                    return;
+                }
+                if (data.submitButton !== submitButton) {
+                    data.submitButton = submitButton;
+                    submitButton.addEventListener('click', event => {
+                        event.preventDefault();
+                        this._submit(id);
+                    });
+                    const _callbackKeydown = (event) => {
+                        if (event.key === 'Enter') {
+                            event.preventDefault();
+                            this._submit(id);
+                        }
+                    };
+                    // bind input fields
+                    let inputField;
+                    for (let i = 0, length = inputFields.length; i < length; i++) {
+                        inputField = inputFields[i];
+                        if (data.inputFields.has(inputField))
+                            continue;
+                        if (_validInputTypes.indexOf(inputField.type) === -1) {
+                            console.warn("Unsupported input type.", inputField);
+                            continue;
+                        }
+                        data.inputFields.add(inputField);
+                        inputField.addEventListener('keydown', _callbackKeydown);
+                    }
+                }
+            }
+        },
+        /**
+         * Submits the dialog.
+         */
+        _submit(id) {
+            const data = _dialogs.get(id);
+            let isValid = true;
+            data.inputFields.forEach(inputField => {
+                if (inputField.required) {
+                    if (inputField.value.trim() === '') {
+                        Util_1.default.innerError(inputField, Language.get('wcf.global.form.error.empty'));
+                        isValid = false;
+                    }
+                    else {
+                        Util_1.default.innerError(inputField, false);
+                    }
+                }
+            });
+            if (isValid) {
+                const callbackObject = _dialogToObject.get(id);
+                if (typeof callbackObject._dialogSubmit === 'function') {
+                    callbackObject._dialogSubmit();
+                }
+            }
+        },
+        /**
+         * Handles clicks on the close button or the backdrop if enabled.
+         */
+        _close(event) {
+            event.preventDefault();
+            const data = _dialogs.get(_activeDialog);
+            if (typeof data.onBeforeClose === 'function') {
+                data.onBeforeClose(_activeDialog);
+                return false;
+            }
+            this.close(_activeDialog);
+            return true;
+        },
+        /**
+         * Closes the current active dialog by clicks on the backdrop.
+         */
+        _closeOnBackdrop(event) {
+            if (event.target !== _container) {
+                return;
+            }
+            if (Core.stringToBool(_container.getAttribute('close-on-click'))) {
+                this._close(event);
+            }
+            else {
+                event.preventDefault();
+            }
+        },
+        /**
+         * Closes a dialog identified by given id.
+         */
+        close(id) {
+            id = this._getDialogId(id);
+            let data = _dialogs.get(id);
+            if (data === undefined) {
+                throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
+            }
+            data.dialog.setAttribute('aria-hidden', 'true');
+            // Move the keyboard focus away from a now hidden element.
+            const activeElement = document.activeElement;
+            if (activeElement.closest('.dialogContainer') === data.dialog) {
+                activeElement.blur();
+            }
+            if (typeof data.onClose === 'function') {
+                data.onClose(id);
+            }
+            // get next active dialog
+            _activeDialog = null;
+            for (let i = 0; i < _container.childElementCount; i++) {
+                const child = _container.children[i];
+                if (!Core.stringToBool(child.getAttribute('aria-hidden'))) {
+                    _activeDialog = child.getAttribute('data-id');
+                    break;
+                }
+            }
+            UiScreen.pageOverlayClose();
+            if (_activeDialog === null) {
+                _container.setAttribute('aria-hidden', 'true');
+                _container.setAttribute('data-close-on-click', 'false');
+                if (data.closable) {
+                    window.removeEventListener('keyup', _keyupListener);
+                }
+            }
+            else {
+                data = _dialogs.get(_activeDialog);
+                _container.setAttribute('data-close-on-click', (data.backdropCloseOnClick ? 'true' : 'false'));
+            }
+            if (Environment.platform() !== 'desktop') {
+                UiScreen.scrollEnable();
+            }
+        },
+        /**
+         * Returns the dialog data for given element id.
+         */
+        getDialog(id) {
+            return _dialogs.get(this._getDialogId(id));
+        },
+        /**
+         * Returns true for open dialogs.
+         */
+        isOpen(id) {
+            const data = this.getDialog(id);
+            return data !== undefined && data.dialog.getAttribute('aria-hidden') === 'false';
+        },
+        /**
+         * Destroys a dialog instance.
+         *
+         * @param  {Object}  callbackObject  the same object that was used to invoke `_dialogSetup()` on first call
+         */
+        destroy(callbackObject) {
+            if (typeof callbackObject !== 'object') {
+                throw new TypeError("Expected the callback object as parameter.");
+            }
+            if (_dialogObjects.has(callbackObject)) {
+                const id = _dialogObjects.get(callbackObject).id;
+                if (this.isOpen(id)) {
+                    this.close(id);
+                }
+                // If the dialog is destroyed in the close callback, this method is
+                // called twice resulting in `_dialogs.get(id)` being undefined for
+                // the initial call.
+                if (_dialogs.has(id)) {
+                    _dialogs.get(id).dialog.remove();
+                    _dialogs.delete(id);
+                }
+                _dialogObjects.delete(callbackObject);
+            }
+        },
+        /**
+         * Returns a dialog's id.
+         *
+         * @param  {(string|object)}  id  element id or callback object
+         * @return      {string}
+         * @protected
+         */
+        _getDialogId(id) {
+            if (typeof id === 'object') {
+                const dialogData = _dialogObjects.get(id);
+                if (dialogData !== undefined) {
+                    return dialogData.id;
+                }
+            }
+            return id.toString();
+        },
+        _ajaxSetup() {
+            return {};
+        },
+    };
 });
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Dialog/Data.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Dialog/Data.js
new file mode 100644 (file)
index 0000000..2ae92b6
--- /dev/null
@@ -0,0 +1,4 @@
+define(["require", "exports"], function (require, exports) {
+    "use strict";
+    Object.defineProperty(exports, "__esModule", { value: true });
+});
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dialog.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dialog.ts
new file mode 100644 (file)
index 0000000..0e197ef
--- /dev/null
@@ -0,0 +1,893 @@
+/**
+ * Modal dialog handler.
+ *
+ * @author  Alexander Ebert
+ * @copyright  2001-2019 WoltLab GmbH
+ * @license  GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module  Ui/Dialog (alias)
+ * @module  WoltLabSuite/Core/Ui/Dialog
+ */
+
+import * as Core from '../Core';
+import DomChangeListener from '../Dom/Change/Listener';
+import * as UiScreen from './Screen';
+import DomUtil from '../Dom/Util';
+import { CallbackObject, DialogData, DialogId, DialogOptions, DialogHtml, AjaxInitialization } from './Dialog/Data';
+import * as Language from '../Language';
+import * as Environment from '../Environment';
+import * as EventHandler from '../Event/Handler';
+
+let _activeDialog: string | null = null;
+let _callbackFocus: (event: FocusEvent) => void;
+let _container: HTMLElement;
+const _dialogs = new Map<ElementId, DialogData>();
+let _dialogFullHeight = false;
+const _dialogObjects = new WeakMap<CallbackObject, DialogInternalData>();
+const _dialogToObject = new Map<ElementId, CallbackObject>();
+let _focusedBeforeDialog: Element | null;
+let _keyupListener: (event: KeyboardEvent) => boolean;
+const _validCallbacks = ['onBeforeClose', 'onClose', 'onShow'];
+
+// list of supported `input[type]` values for dialog submit
+const _validInputTypes = ['number', 'password', 'search', 'tel', 'text', 'url'];
+
+const _focusableElements = [
+  'a[href]:not([tabindex^="-"]):not([inert])',
+  'area[href]:not([tabindex^="-"]):not([inert])',
+  'input:not([disabled]):not([inert])',
+  'select:not([disabled]):not([inert])',
+  'textarea:not([disabled]):not([inert])',
+  'button:not([disabled]):not([inert])',
+  'iframe:not([tabindex^="-"]):not([inert])',
+  'audio:not([tabindex^="-"]):not([inert])',
+  'video:not([tabindex^="-"]):not([inert])',
+  '[contenteditable]:not([tabindex^="-"]):not([inert])',
+  '[tabindex]:not([tabindex^="-"]):not([inert])',
+];
+
+/**
+ * @exports  WoltLabSuite/Core/Ui/Dialog
+ */
+export = {
+  /**
+   * Sets up global container and internal variables.
+   */
+  setup(): void {
+    _container = document.createElement('div');
+    _container.classList.add('dialogOverlay');
+    _container.setAttribute('aria-hidden', 'true');
+    _container.addEventListener('mousedown', this._closeOnBackdrop.bind(this));
+    _container.addEventListener('wheel', event => {
+      if (event.target === _container) {
+        event.preventDefault();
+      }
+    }, {passive: false});
+
+    document.getElementById('content')!.appendChild(_container);
+
+    _keyupListener = (event: KeyboardEvent): boolean => {
+      if (event.key === "Escape") {
+        const target = event.target as HTMLElement;
+        if (target.nodeName !== 'INPUT' && target.nodeName !== 'TEXTAREA') {
+          this.close(_activeDialog!);
+
+          return false;
+        }
+      }
+
+      return true;
+    };
+
+    UiScreen.on('screen-xs', {
+      match() {
+        _dialogFullHeight = true;
+      },
+      unmatch() {
+        _dialogFullHeight = false;
+      },
+      setup() {
+        _dialogFullHeight = true;
+      },
+    });
+
+    this._initStaticDialogs();
+    DomChangeListener.add('Ui/Dialog', this._initStaticDialogs.bind(this));
+
+    UiScreen.setDialogContainer(_container);
+
+    window.addEventListener('resize', () => {
+      _dialogs.forEach(dialog => {
+        if (!Core.stringToBool(dialog.dialog.getAttribute('aria-hidden'))) {
+          this.rebuild(dialog.dialog.getAttribute('data-id')!);
+        }
+      });
+    });
+  },
+
+  _initStaticDialogs(): void {
+    document.querySelectorAll('.jsStaticDialog').forEach(button => {
+      button.classList.remove('jsStaticDialog');
+
+      const id = button.getAttribute('data-dialog-id');
+      if (id) {
+        const container = document.getElementById(id);
+        if (container !== null) {
+          container.classList.remove('jsStaticDialogContent');
+          container.setAttribute('data-is-static-dialog', 'true');
+          DomUtil.hide(container);
+
+          button.addEventListener('click', event => {
+            event.preventDefault();
+
+            this.openStatic(container.id, null, {title: container.getAttribute('data-title') || ''});
+          });
+        }
+      }
+    });
+  },
+
+  /**
+   * Opens the dialog and implicitly creates it on first usage.
+   */
+  open(callbackObject: CallbackObject, html: DialogHtml): DialogData | object {
+    let dialogData = _dialogObjects.get(callbackObject);
+    if (dialogData && Core.isPlainObject(dialogData)) {
+      // dialog already exists
+      return this.openStatic(dialogData.id, html);
+    }
+
+    // initialize a new dialog
+    if (typeof callbackObject._dialogSetup !== 'function') {
+      throw new Error("Callback object does not implement the method '_dialogSetup()'.");
+    }
+
+    const setupData = callbackObject._dialogSetup();
+    if (!Core.isPlainObject(setupData)) {
+      throw new Error("Expected an object literal as return value of '_dialogSetup()'.");
+    }
+
+    const id = setupData.id;
+    dialogData = {id};
+
+    let createOnly = true;
+    let dialogElement: HTMLElement | null;
+    if (setupData.source === undefined) {
+      dialogElement = document.getElementById(id);
+      if (dialogElement === null) {
+        throw new Error("Element id '" + 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.");
+      }
+
+      setupData.source = document.createDocumentFragment();
+      setupData.source.appendChild(dialogElement);
+
+      dialogElement.removeAttribute('id');
+      DomUtil.show(dialogElement);
+    } else if (setupData.source === null) {
+      // `null` means there is no static markup and `html` should be used instead
+      setupData.source = html;
+    } else if (typeof setupData.source === 'function') {
+      setupData.source();
+    } else if (Core.isPlainObject(setupData.source)) {
+      if (typeof html === 'string' && html.trim() !== '') {
+        setupData.source = html;
+      } else {
+        import('../Ajax').then(Ajax => {
+          const source = setupData.source as AjaxInitialization;
+          Ajax.api(this as any, source.data, data => {
+            if (data.returnValues && typeof data.returnValues.template === 'string') {
+              this.open(callbackObject, data.returnValues.template);
+
+              if (typeof source.after === 'function') {
+                source.after(_dialogs.get(id)!.content, data);
+              }
+            }
+          });
+        });
+        
+        return {};
+      }
+    } else {
+      if (typeof setupData.source === 'string') {
+        dialogElement = document.createElement('div');
+        dialogElement.id = id;
+        DomUtil.setInnerHtml(dialogElement, setupData.source);
+
+        setupData.source = document.createDocumentFragment();
+        setupData.source.appendChild(dialogElement);
+      }
+
+      if (!setupData.source.nodeType || setupData.source.nodeType !== Node.DOCUMENT_FRAGMENT_NODE) {
+        throw new Error("Expected at least a document fragment as 'source' attribute.");
+      }
+
+      createOnly = false;
+    }
+
+    _dialogObjects.set(callbackObject, dialogData);
+    _dialogToObject.set(id, callbackObject);
+
+    return this.openStatic(id, setupData.source as DialogHtml, setupData.options, createOnly);
+  },
+
+  /**
+   * Opens an dialog, if the dialog is already open the content container
+   * will be replaced by the HTML string contained in the parameter html.
+   *
+   * If id is an existing element id, html will be ignored and the referenced
+   * element will be appended to the content element instead.
+   */
+  openStatic(id: string, html: DialogHtml, options?: DialogOptions, createOnly?: boolean): DialogData {
+    UiScreen.pageOverlayOpen();
+
+    if (Environment.platform() !== 'desktop') {
+      if (!this.isOpen(id)) {
+        UiScreen.scrollDisable();
+      }
+    }
+
+    if (_dialogs.has(id)) {
+      this._updateDialog(id, html as string);
+    } else {
+      options = Core.extend({
+        backdropCloseOnClick: true,
+        closable: true,
+        closeButtonLabel: Language.get('wcf.global.button.close'),
+        closeConfirmMessage: '',
+        disableContentPadding: false,
+        title: '',
+
+        onBeforeClose: null,
+        onClose: null,
+        onShow: null,
+      }, options || {}) as InternalDialogOptions;
+
+      if (!options.closable) options.backdropCloseOnClick = false;
+      if (options.closeConfirmMessage) {
+        // TODO
+        /*
+        options.onBeforeClose = id => {
+          UiConfirmation.show({
+            confirm: this.close.bind(this, id),
+            message: options.closeConfirmMessage,
+          });
+        };
+        */
+      }
+
+      this._createDialog(id, html, options as InternalDialogOptions, createOnly || false);
+    }
+
+    const data = _dialogs.get(id)!;
+
+    // iOS breaks `position: fixed` when input elements or `contenteditable`
+    // are focused, this will freeze the screen and force Safari to scroll
+    // to the input field
+    if (Environment.platform() === 'ios') {
+      window.setTimeout(() => {
+        data.content.querySelector<HTMLElement>('input, textarea')?.focus();
+      }, 200);
+    }
+
+    return data;
+  },
+
+  /**
+   * Sets the dialog title.
+   */
+  setTitle(id: ElementIdOrCallbackObject, title: string): void {
+    id = this._getDialogId(id);
+
+    const data = _dialogs.get(id);
+    if (data === undefined) {
+      throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
+    }
+
+    const dialogTitle = data.dialog.querySelector('.dialogTitle');
+    if (dialogTitle) {
+      dialogTitle.textContent = title;
+    }
+  },
+
+  /**
+   * Sets a callback function on runtime.
+   */
+  setCallback(id: ElementIdOrCallbackObject, key: string, value: (...args: any[]) => void | null): void {
+    if (typeof id === 'object') {
+      const dialogData = _dialogObjects.get(id);
+      if (dialogData !== undefined) {
+        id = dialogData.id;
+      }
+    }
+
+    const data = _dialogs.get(id as string);
+    if (data === undefined) {
+      throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
+    }
+
+    if (_validCallbacks.indexOf(key) === -1) {
+      throw new Error("Invalid callback identifier, '" + key + "' is not recognized.");
+    }
+
+    if (typeof value !== 'function' && value !== null) {
+      throw new Error("Only functions or the 'null' value are acceptable callback values ('" + typeof value + "' given).");
+    }
+
+    data[key] = value;
+  },
+
+  /**
+   * Creates the DOM for a new dialog and opens it.
+   *
+   * @param  {string}      id    element id, if exists the html parameter is ignored in favor of the existing element
+   * @param  {?(string|DocumentFragment)}  html    content html
+   * @param  {object<string, *>}    options    list of options
+   * @param  {boolean=}      createOnly  create the dialog but do not open it
+   */
+  _createDialog(id: string, html: DialogHtml, options: InternalDialogOptions, createOnly: boolean): void {
+    let element: HTMLElement | null = null;
+    if (html === null) {
+      element = document.getElementById(id);
+      if (element === null) {
+        throw new Error("Expected either a HTML string or an existing element id.");
+      }
+    }
+
+    const dialog = document.createElement('div');
+    dialog.classList.add('dialogContainer');
+    dialog.setAttribute('aria-hidden', 'true');
+    dialog.setAttribute('role', 'dialog');
+    dialog.id = id;
+
+    const header = document.createElement('header');
+    dialog.appendChild(header);
+
+    const titleId = DomUtil.getUniqueId();
+    dialog.setAttribute('aria-labelledby', titleId);
+
+    const title = document.createElement('span');
+    title.classList.add('dialogTitle');
+    title.textContent = options.title;
+    title.id = titleId;
+    header.appendChild(title);
+
+    if (options.closable) {
+      const closeButton = document.createElement('a');
+      closeButton.className = 'dialogCloseButton jsTooltip';
+      closeButton.href = '#';
+      closeButton.setAttribute('role', 'button');
+      closeButton.tabIndex = 0;
+      closeButton.title = options.closeButtonLabel;
+      closeButton.setAttribute('aria-label', options.closeButtonLabel);
+      closeButton.addEventListener('click', this._close.bind(this));
+      header.appendChild(closeButton);
+
+      const span = document.createElement('span');
+      span.className = 'icon icon24 fa-times';
+      closeButton.appendChild(span);
+    }
+
+    const contentContainer = document.createElement('div');
+    contentContainer.classList.add('dialogContent');
+    if (options.disableContentPadding) contentContainer.classList.add('dialogContentNoPadding');
+    dialog.appendChild(contentContainer);
+
+    contentContainer.addEventListener('wheel', event => {
+      let allowScroll = false;
+      let element: HTMLElement | null = event.target as HTMLElement;
+      let clientHeight, scrollHeight, scrollTop;
+      while (true) {
+        clientHeight = element.clientHeight;
+        scrollHeight = element.scrollHeight;
+
+        if (clientHeight < scrollHeight) {
+          scrollTop = element.scrollTop;
+
+          // negative value: scrolling up
+          if (event.deltaY < 0 && scrollTop > 0) {
+            allowScroll = true;
+            break;
+          } else if (event.deltaY > 0 && (scrollTop + clientHeight < scrollHeight)) {
+            allowScroll = true;
+            break;
+          }
+        }
+
+        if (!element || element === contentContainer) {
+          break;
+        }
+
+        element = element.parentNode as HTMLElement;
+      }
+
+      if (!allowScroll) {
+        event.preventDefault();
+      }
+    }, {passive: false});
+
+    let content: HTMLElement;
+    if (element === null) {
+      if (typeof html === 'string') {
+        content = document.createElement('div');
+        content.id = id;
+        DomUtil.setInnerHtml(content, html);
+      } else if (html instanceof DocumentFragment) {
+        const children: HTMLElement[] = [];
+        let node: Node;
+        for (let i = 0, length = html.childNodes.length; i < length; i++) {
+          node = html.childNodes[i];
+
+          if (node.nodeType === Node.ELEMENT_NODE) {
+            children.push(node as HTMLElement);
+          }
+        }
+
+        if (children[0].nodeName !== 'DIV' || children.length > 1) {
+          content = document.createElement('div');
+          content.id = id;
+          content.appendChild(html);
+        } else {
+          content = children[0];
+        }
+      } else {
+        throw new TypeError("'html' must either be a string or a DocumentFragment");
+      }
+    } else {
+      content = element;
+    }
+
+    contentContainer.appendChild(content);
+
+    if (content.style.getPropertyValue('display') === 'none') {
+      DomUtil.show(content);
+    }
+
+    _dialogs.set(id, {
+      backdropCloseOnClick: options.backdropCloseOnClick,
+      closable: options.closable,
+      content: content,
+      dialog: dialog,
+      header: header,
+      onBeforeClose: options.onBeforeClose!,
+      onClose: options.onClose!,
+      onShow: options.onShow!,
+
+      submitButton: null,
+      inputFields: new Set<HTMLInputElement>(),
+    });
+
+    _container.insertBefore(dialog, _container.firstChild);
+
+    if (typeof options.onSetup === 'function') {
+      options.onSetup(content);
+    }
+
+    if (!createOnly) {
+      this._updateDialog(id, null);
+    }
+  },
+
+  /**
+   * Updates the dialog's content element.
+   */
+  _updateDialog(id: ElementId, html: string | null): void {
+    const data = _dialogs.get(id);
+    if (data === undefined) {
+      throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
+    }
+
+    if (typeof html === 'string') {
+      DomUtil.setInnerHtml(data.content, html);
+    }
+
+    if (Core.stringToBool(data.dialog.getAttribute('aria-hidden'))) {
+      // close existing dropdowns
+      // TODO
+      //UiSimpleDropdown.closeAll();
+      window.WCF.Dropdown.Interactive.Handler.closeAll();
+
+      if (_callbackFocus === null) {
+        _callbackFocus = this._maintainFocus.bind(this);
+        document.body.addEventListener('focus', _callbackFocus, {capture: true});
+      }
+
+      if (data.closable && Core.stringToBool(_container.getAttribute('aria-hidden'))) {
+        window.addEventListener('keyup', _keyupListener);
+      }
+
+      // Move the dialog to the front to prevent it being hidden behind already open dialogs
+      // if it was previously visible.
+      data.dialog.parentNode!.insertBefore(data.dialog, data.dialog.parentNode!.firstChild);
+
+      data.dialog.setAttribute('aria-hidden', 'false');
+      _container.setAttribute('aria-hidden', 'false');
+      _container.setAttribute('close-on-click', (data.backdropCloseOnClick ? 'true' : 'false'));
+      _activeDialog = id;
+
+      // Keep a reference to the currently focused element to be able to restore it later.
+      _focusedBeforeDialog = document.activeElement;
+
+      // Set the focus to the first focusable child of the dialog element.
+      const closeButton = data.header.querySelector('.dialogCloseButton');
+      if (closeButton) closeButton.setAttribute('inert', 'true');
+      this._setFocusToFirstItem(data.dialog, false);
+      if (closeButton) closeButton.removeAttribute('inert');
+
+      if (typeof data.onShow === 'function') {
+        data.onShow(data.content);
+      }
+
+      if (Core.stringToBool(data.content.getAttribute('data-is-static-dialog'))) {
+        EventHandler.fire('com.woltlab.wcf.dialog', 'openStatic', {
+          content: data.content,
+          id: id,
+        });
+      }
+    }
+
+    this.rebuild(id);
+
+    DomChangeListener.trigger();
+  },
+
+  _maintainFocus(event: FocusEvent): void {
+    if (_activeDialog) {
+      const data = _dialogs.get(_activeDialog) as DialogData;
+      const target = event.target as HTMLElement;
+      if (!data.dialog.contains(target) && !target.closest('.dropdownMenuContainer') && !target.closest('.datePicker')) {
+        this._setFocusToFirstItem(data.dialog, true);
+      }
+    }
+  },
+
+  _setFocusToFirstItem(dialog: HTMLElement, maintain: boolean): void {
+    let focusElement = this._getFirstFocusableChild(dialog);
+    if (focusElement !== null) {
+      if (maintain) {
+        if (focusElement.id === 'username' || (focusElement as HTMLInputElement).name === 'username') {
+          if (Environment.browser() === 'safari' && Environment.platform() === 'ios') {
+            // iOS Safari's username/password autofill breaks if the input field is focused 
+            focusElement = null;
+          }
+        }
+      }
+
+      if (focusElement) {
+        // Setting the focus to a select element in iOS is pretty strange, because
+        // it focuses it, but also displays the keyboard for a fraction of a second,
+        // causing it to pop out from below and immediately vanish.
+        // 
+        // iOS will only show the keyboard if an input element is focused *and* the
+        // focus is an immediate result of a user interaction. This method must be
+        // assumed to be called from within a click event, but we want to set the
+        // focus without triggering the keyboard.
+        // 
+        // We can break the condition by wrapping it in a setTimeout() call,
+        // effectively tricking iOS into focusing the element without showing the
+        // keyboard.
+        setTimeout(() => {
+          focusElement!.focus();
+        }, 1);
+      }
+    }
+  },
+
+  _getFirstFocusableChild(element: HTMLElement): HTMLElement | null {
+    const nodeList = element.querySelectorAll<HTMLElement>(_focusableElements.join(','));
+    for (let i = 0, length = nodeList.length; i < length; i++) {
+      if (nodeList[i].offsetWidth && nodeList[i].offsetHeight && nodeList[i].getClientRects().length) {
+        return nodeList[i];
+      }
+    }
+
+    return null;
+  },
+
+  /**
+   * Rebuilds dialog identified by given id.
+   */
+  rebuild(id: string): void {
+    id = this._getDialogId(id);
+
+    const data = _dialogs.get(id);
+    if (data === undefined) {
+      throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
+    }
+
+    // ignore non-active dialogs
+    if (Core.stringToBool(data.dialog.getAttribute('aria-hidden'))) {
+      return;
+    }
+
+    const contentContainer = data.content.parentNode as HTMLElement;
+
+    const formSubmit = data.content.querySelector('.formSubmit') as HTMLElement;
+    let unavailableHeight = 0;
+    if (formSubmit !== null) {
+      contentContainer.classList.add('dialogForm');
+      formSubmit.classList.add('dialogFormSubmit');
+
+      unavailableHeight += DomUtil.outerHeight(formSubmit);
+
+      // Calculated height can be a fractional value and depending on the
+      // browser the results can vary. By subtracting a single pixel we're
+      // working around fractional values, without visually changing anything.
+      unavailableHeight -= 1;
+
+      contentContainer.style.setProperty('margin-bottom', unavailableHeight + 'px', '');
+    } else {
+      contentContainer.classList.remove('dialogForm');
+      contentContainer.style.removeProperty('margin-bottom');
+    }
+
+    unavailableHeight += DomUtil.outerHeight(data.header);
+
+    const maximumHeight = (window.innerHeight * (_dialogFullHeight ? 1 : 0.8)) - unavailableHeight;
+    contentContainer.style.setProperty('max-height', ~~maximumHeight + 'px', '');
+
+    // fix for a calculation bug in Chrome causing the scrollbar to overlap the border
+    if (Environment.browser() === 'chrome') {
+      if (data.content.scrollHeight > maximumHeight) {
+        data.content.style.setProperty('margin-right', '-1px', '');
+      } else {
+        data.content.style.removeProperty('margin-right');
+      }
+    }
+
+    // Chrome and Safari use heavy anti-aliasing when the dialog's width
+    // cannot be evenly divided, causing the whole text to become blurry
+    if (Environment.browser() === 'chrome' || Environment.browser() === 'safari') {
+      // The new Microsoft Edge is detected as "chrome", because effectively we're detecting
+      // Chromium rather than Chrome specifically. The workaround for fractional pixels does
+      // not work well in Edge, there seems to be a different logic for fractional positions,
+      // causing the text to be blurry.
+      // 
+      // We can use `backface-visibility: hidden` to prevent the anti aliasing artifacts in
+      // WebKit/Blink, which will also prevent some weird font rendering issues when resizing.
+      contentContainer.classList.add('jsWebKitFractionalPixelFix');
+    }
+
+    const callbackObject = _dialogToObject.get(id);
+    //noinspection JSUnresolvedVariable
+    if (callbackObject !== undefined && typeof callbackObject._dialogSubmit === 'function') {
+      const inputFields = data.content.querySelectorAll<HTMLInputElement>('input[data-dialog-submit-on-enter="true"]');
+
+      const submitButton = data.content.querySelector('.formSubmit > input[type="submit"], .formSubmit > button[data-type="submit"]');
+      if (submitButton === null) {
+        // check if there is at least one input field with submit handling,
+        // otherwise we'll assume the dialog has not been populated yet
+        if (inputFields.length === 0) {
+          console.warn("Broken dialog, expected a submit button.", data.content);
+        }
+
+        return;
+      }
+
+      if (data.submitButton !== submitButton) {
+        data.submitButton = submitButton as HTMLElement;
+
+        submitButton.addEventListener('click', event => {
+          event.preventDefault();
+
+          this._submit(id);
+        });
+
+        const _callbackKeydown = (event: KeyboardEvent): void => {
+          if (event.key === 'Enter') {
+            event.preventDefault();
+
+            this._submit(id);
+          }
+        };
+
+        // bind input fields
+        let inputField: HTMLInputElement;
+        for (let i = 0, length = inputFields.length; i < length; i++) {
+          inputField = inputFields[i];
+
+          if (data.inputFields.has(inputField)) continue;
+
+          if (_validInputTypes.indexOf(inputField.type) === -1) {
+            console.warn("Unsupported input type.", inputField);
+            continue;
+          }
+
+          data.inputFields.add(inputField);
+
+          inputField.addEventListener('keydown', _callbackKeydown);
+        }
+      }
+    }
+  },
+
+  /**
+   * Submits the dialog.
+   */
+  _submit(id: string): void {
+    const data = _dialogs.get(id);
+
+    let isValid = true;
+    data!.inputFields.forEach(inputField => {
+      if (inputField.required) {
+        if (inputField.value.trim() === '') {
+          DomUtil.innerError(inputField, Language.get('wcf.global.form.error.empty'));
+
+          isValid = false;
+        } else {
+          DomUtil.innerError(inputField, false);
+        }
+      }
+    });
+
+    if (isValid) {
+      const callbackObject = _dialogToObject.get(id) as CallbackObject;
+      if (typeof callbackObject._dialogSubmit === 'function') {
+        callbackObject._dialogSubmit();
+      }
+    }
+  },
+
+  /**
+   * Handles clicks on the close button or the backdrop if enabled.
+   */
+  _close(event: MouseEvent): boolean {
+    event.preventDefault();
+
+    const data = _dialogs.get(_activeDialog!) as DialogData;
+    if (typeof data.onBeforeClose === 'function') {
+      data.onBeforeClose(_activeDialog!);
+
+      return false;
+    }
+
+    this.close(_activeDialog!);
+
+    return true;
+  },
+
+  /**
+   * Closes the current active dialog by clicks on the backdrop.
+   */
+  _closeOnBackdrop(event: MouseEvent): void {
+    if (event.target !== _container) {
+      return;
+    }
+
+    if (Core.stringToBool(_container.getAttribute('close-on-click'))) {
+      this._close(event);
+    } else {
+      event.preventDefault();
+    }
+  },
+
+  /**
+   * Closes a dialog identified by given id.
+   */
+  close(id: ElementIdOrCallbackObject): void {
+    id = this._getDialogId(id);
+
+    let data = _dialogs.get(id);
+    if (data === undefined) {
+      throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
+    }
+
+    data.dialog.setAttribute('aria-hidden', 'true');
+
+    // Move the keyboard focus away from a now hidden element.
+    const activeElement = document.activeElement as HTMLElement;
+    if (activeElement.closest('.dialogContainer') === data.dialog) {
+      activeElement.blur();
+    }
+
+    if (typeof data.onClose === 'function') {
+      data.onClose(id);
+    }
+
+    // get next active dialog
+    _activeDialog = null;
+    for (let i = 0; i < _container.childElementCount; i++) {
+      const child = _container.children[i];
+      if (!Core.stringToBool(child.getAttribute('aria-hidden'))) {
+        _activeDialog = child.getAttribute('data-id');
+        break;
+      }
+    }
+
+    UiScreen.pageOverlayClose();
+
+    if (_activeDialog === null) {
+      _container.setAttribute('aria-hidden', 'true');
+      _container.setAttribute('data-close-on-click', 'false');
+
+      if (data.closable) {
+        window.removeEventListener('keyup', _keyupListener);
+      }
+    } else {
+      data = _dialogs.get(_activeDialog) as DialogData;
+      _container.setAttribute('data-close-on-click', (data.backdropCloseOnClick ? 'true' : 'false'));
+    }
+
+    if (Environment.platform() !== 'desktop') {
+      UiScreen.scrollEnable();
+    }
+  },
+
+  /**
+   * Returns the dialog data for given element id.
+   */
+  getDialog(id: ElementIdOrCallbackObject): DialogData | undefined {
+    return _dialogs.get(this._getDialogId(id));
+  },
+
+  /**
+   * Returns true for open dialogs.
+   */
+  isOpen(id: ElementIdOrCallbackObject): boolean {
+    const data = this.getDialog(id);
+    return data !== undefined && data.dialog.getAttribute('aria-hidden') === 'false';
+  },
+
+  /**
+   * Destroys a dialog instance.
+   *
+   * @param  {Object}  callbackObject  the same object that was used to invoke `_dialogSetup()` on first call
+   */
+  destroy(callbackObject: CallbackObject): void {
+    if (typeof callbackObject !== 'object') {
+      throw new TypeError("Expected the callback object as parameter.");
+    }
+
+    if (_dialogObjects.has(callbackObject)) {
+      const id = _dialogObjects.get(callbackObject)!.id;
+      if (this.isOpen(id)) {
+        this.close(id);
+      }
+
+      // If the dialog is destroyed in the close callback, this method is
+      // called twice resulting in `_dialogs.get(id)` being undefined for
+      // the initial call.
+      if (_dialogs.has(id)) {
+        _dialogs.get(id)!.dialog.remove();
+        _dialogs.delete(id);
+      }
+      _dialogObjects.delete(callbackObject);
+    }
+  },
+
+  /**
+   * Returns a dialog's id.
+   *
+   * @param  {(string|object)}  id  element id or callback object
+   * @return      {string}
+   * @protected
+   */
+  _getDialogId(id: ElementIdOrCallbackObject): DialogId {
+    if (typeof id === 'object') {
+      const dialogData = _dialogObjects.get(id);
+      if (dialogData !== undefined) {
+        return dialogData.id;
+      }
+    }
+
+    return id.toString();
+  },
+
+  _ajaxSetup() {
+    return {};
+  },
+};
+
+interface DialogInternalData {
+  id: string;
+}
+
+type ElementId = string;
+
+type ElementIdOrCallbackObject = CallbackObject | ElementId;
+
+interface InternalDialogOptions extends DialogOptions {
+  backdropCloseOnClick: boolean;
+  closable: boolean;
+  closeButtonLabel: string;
+  closeConfirmMessage: string;
+  disableContentPadding: boolean;
+}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dialog/Data.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dialog/Data.ts
new file mode 100644 (file)
index 0000000..021c361
--- /dev/null
@@ -0,0 +1,56 @@
+import { RequestPayload, ResponseData } from '../../Ajax/Data';
+
+export type DialogHtml = DocumentFragment | string | null;
+
+export interface CallbackObject {
+  _dialogSetup: () => DialogSettings;
+  _dialogSubmit?: () => void;
+}
+
+export interface AjaxInitialization extends RequestPayload {
+  after?: (content: HTMLElement, responseData: ResponseData) => void;
+}
+
+export type ExternalInitialization = () => void;
+
+export type DialogId = string;
+
+export interface DialogSettings {
+  id: DialogId;
+  source?: AjaxInitialization | DocumentFragment | ExternalInitialization | string | null;
+  options?: DialogOptions;
+}
+
+type CallbackOnBeforeClose = (id: string) => void;
+type CallbackOnClose = (id: string) => void;
+type CallbackOnSetup = (content: HTMLElement) => void;
+type CallbackOnShow = (content: HTMLElement) => void;
+
+export interface DialogOptions {
+  backdropCloseOnClick?: boolean;
+  closable?: boolean;
+  closeButtonLabel?: string;
+  closeConfirmMessage?: string;
+  disableContentPadding?: boolean;
+  title: string;
+
+  onBeforeClose?: CallbackOnBeforeClose | null;
+  onClose?: CallbackOnClose | null;
+  onSetup?: CallbackOnSetup | null;
+  onShow?: CallbackOnShow | null;
+}
+
+export interface DialogData {
+  backdropCloseOnClick: boolean;
+  closable: boolean;
+  content: HTMLElement;
+  dialog: HTMLElement;
+  header: HTMLElement;
+
+  onBeforeClose: CallbackOnBeforeClose;
+  onClose: CallbackOnClose;
+  onShow: CallbackOnShow;
+
+  submitButton: HTMLElement | null;
+  inputFields: Set<HTMLInputElement>;
+}