From 335697ac1050818454f58ef18b4f53dff196af2d Mon Sep 17 00:00:00 2001
From: Andreas Fernandez <a.fernandez@scripting-base.de>
Date: Thu, 20 Feb 2020 20:19:57 +0100
Subject: [PATCH] [FEATURE] Add JavaScript event handling API

This patch adds API for event handling in JavaScript. The goal is to have
an easy-to-use event handling and delegation by shipping several event
strategies.

Debounce:
Debounces an event listener that is executed after the event happened,
either at the start or at the end. A debounced event listener is not
executed again until a certain amount of time has passed without it being
called.

RequestAnimationFrame:
Traps an event listener into the browser's native rAF API.

Throttle:
Throttles the event listener to be called only after a defined time
during the event's execution over time.

Resolves: #90471
Releases: master
Change-Id: I407f9b98a13f998bbf0879614002223b304389b0
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/63336
Reviewed-by: Markus Klein <markus.klein@typo3.org>
Reviewed-by: Susanne Moog <look@susi.dev>
Tested-by: Markus Klein <markus.klein@typo3.org>
Tested-by: TYPO3com <noreply@typo3.com>
Tested-by: Susanne Moog <look@susi.dev>
---
 .../Container/InlineControlContainer.ts       | 404 ++++++++----------
 .../Public/TypeScript/Event/DebounceEvent.ts  |  53 +++
 .../Public/TypeScript/Event/EventInterface.ts |  20 +
 .../Public/TypeScript/Event/RegularEvent.ts   |  48 +++
 .../Event/RequestAnimationFrameEvent.ts       |  45 ++
 .../Public/TypeScript/Event/ThrottleEvent.ts  |  44 ++
 Build/eslintrc.js                             |   6 +-
 .../Container/InlineControlContainer.js       |   2 +-
 .../Feature-90471-JavaScriptEventAPI.rst      | 186 ++++++++
 .../Public/JavaScript/Event/DebounceEvent.js  |  13 +
 .../Public/JavaScript/Event/EventInterface.js |  13 +
 .../Public/JavaScript/Event/RegularEvent.js   |  13 +
 .../Event/RequestAnimationFrameEvent.js       |  13 +
 .../Public/JavaScript/Event/ThrottleEvent.js  |  13 +
 14 files changed, 630 insertions(+), 243 deletions(-)
 create mode 100644 Build/Sources/TypeScript/core/Resources/Public/TypeScript/Event/DebounceEvent.ts
 create mode 100644 Build/Sources/TypeScript/core/Resources/Public/TypeScript/Event/EventInterface.ts
 create mode 100644 Build/Sources/TypeScript/core/Resources/Public/TypeScript/Event/RegularEvent.ts
 create mode 100644 Build/Sources/TypeScript/core/Resources/Public/TypeScript/Event/RequestAnimationFrameEvent.ts
 create mode 100644 Build/Sources/TypeScript/core/Resources/Public/TypeScript/Event/ThrottleEvent.ts
 create mode 100644 typo3/sysext/core/Documentation/Changelog/master/Feature-90471-JavaScriptEventAPI.rst
 create mode 100644 typo3/sysext/core/Resources/Public/JavaScript/Event/DebounceEvent.js
 create mode 100644 typo3/sysext/core/Resources/Public/JavaScript/Event/EventInterface.js
 create mode 100644 typo3/sysext/core/Resources/Public/JavaScript/Event/RegularEvent.js
 create mode 100644 typo3/sysext/core/Resources/Public/JavaScript/Event/RequestAnimationFrameEvent.js
 create mode 100644 typo3/sysext/core/Resources/Public/JavaScript/Event/ThrottleEvent.js

diff --git a/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/FormEngine/Container/InlineControlContainer.ts b/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/FormEngine/Container/InlineControlContainer.ts
index 7140715f1ff8..1c312b7f970a 100644
--- a/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/FormEngine/Container/InlineControlContainer.ts
+++ b/Build/Sources/TypeScript/backend/Resources/Public/TypeScript/FormEngine/Container/InlineControlContainer.ts
@@ -24,6 +24,7 @@ import Icons = require('../../Icons');
 import InfoWindow = require('../../InfoWindow');
 import Modal = require('../../Modal');
 import Notification = require('../../Notification');
+import RegularEvent = require('TYPO3/CMS/Core/Event/RegularEvent');
 import Severity = require('../../Severity');
 import Utility = require('../../Utility');
 
@@ -97,25 +98,6 @@ class InlineControlContainer {
   private progessQueue: ProgressQueue = {};
   private noTitleString: string = (TYPO3.lang ? TYPO3.lang['FormEngine.noRecordTitle'] : '[No title]');
 
-  /**
-   * Checks whether an event target matches the given selector and returns the matching element.
-   * May be used in conjunction with event delegation.
-   *
-   * @param {EventTarget} eventTarget
-   * @param {string} selector
-   */
-  private static getDelegatedEventTarget(eventTarget: EventTarget, selector: string): HTMLElement | null {
-    let targetElement: HTMLElement;
-
-    if ((targetElement = <HTMLElement>(<Element>eventTarget).closest(selector)) === null) {
-      if ((<Element>eventTarget).matches(selector)) {
-        targetElement = <HTMLElement>eventTarget;
-      }
-    }
-
-    return targetElement;
-  }
-
   /**
    * @param {string} objectId
    * @return HTMLDivElement
@@ -124,21 +106,6 @@ class InlineControlContainer {
     return <HTMLDivElement>document.querySelector('[data-object-id="' + objectId + '"]');
   }
 
-  /**
-   * @param {Event} e
-   */
-  private static registerInfoButton(e: Event): void {
-    let target: HTMLElement;
-    if ((target = InlineControlContainer.getDelegatedEventTarget(e.target, Selectors.infoWindowButton)) === null) {
-      return;
-    }
-
-    e.preventDefault();
-    e.stopImmediatePropagation();
-
-    InfoWindow.showItem(target.dataset.infoTable, target.dataset.infoUid);
-  }
-
   /**
    * @param {string} objectId
    */
@@ -255,23 +222,19 @@ class InlineControlContainer {
   }
 
   private registerEvents(): void {
-    this.container.addEventListener('click', (e: Event): void => {
-      this.registerToggle(e);
-      this.registerSort(e);
-      this.registerCreateRecordButton(e);
-      this.registerEnableDisableButton(e);
-      InlineControlContainer.registerInfoButton(e);
-      this.registerDeleteButton(e);
-      this.registerSynchronizeLocalize(e);
-      this.registerRevertUniquenessAction(e);
-    });
+    this.registerInfoButton();
+    this.registerSort();
+    this.registerCreateRecordButton();
+    this.registerEnableDisableButton();
+    this.registerDeleteButton();
+    this.registerSynchronizeLocalize();
+    this.registerRevertUniquenessAction();
+    this.registerToggle();
 
-    this.container.addEventListener('change', (e: Event): void => {
-      this.registerCreateRecordBySelector(e);
-      this.registerUniqueSelectFieldChanged(e);
-    });
+    this.registerCreateRecordBySelector();
+    this.registerUniqueSelectFieldChanged();
 
-    window.addEventListener('message', this.handlePostMessage);
+    new RegularEvent('message', this.handlePostMessage).bindTo(window);
 
     if (this.getAppearance().useSortable) {
       const recordListContainer = <HTMLDivElement>document.querySelector('#' + this.container.getAttribute('id') + '_records');
@@ -286,82 +249,57 @@ class InlineControlContainer {
     }
   }
 
-  /**
-   * @param {Event} e
-   */
-  private registerToggle(e: Event): void {
-    if (InlineControlContainer.getDelegatedEventTarget(e.target, Selectors.controlSectionSelector)) {
-      // Abort click event in control section
-      return;
-    }
-
-    let target: HTMLElement;
-    if ((target = InlineControlContainer.getDelegatedEventTarget(e.target, Selectors.toggleSelector)) === null) {
-      return;
-    }
+  private registerToggle(): void {
+    const me = this;
+    new RegularEvent('click', function(this: HTMLElement, e: Event) {
+      e.preventDefault();
+      e.stopImmediatePropagation();
 
-    e.preventDefault();
-    e.stopImmediatePropagation();
-
-    this.loadRecordDetails(target.parentElement.dataset.objectId);
+      me.loadRecordDetails(this.parentElement.dataset.objectId);
+    }).delegateTo(this.container, Selectors.toggleSelector);
   }
 
-  /**
-   * @param {Event} e
-   */
-  private registerSort(e: Event): void {
-    let target: HTMLElement;
-    if ((target = InlineControlContainer.getDelegatedEventTarget(e.target, Selectors.controlSectionSelector + ' [data-action="sort"]')) === null) {
-      return;
-    }
-
-    e.preventDefault();
-    e.stopImmediatePropagation();
+  private registerSort(): void {
+    const me = this;
+    new RegularEvent('click', function(this: HTMLElement, e: Event) {
+      e.preventDefault();
+      e.stopImmediatePropagation();
 
-    this.changeSortingByButton(
-      (<HTMLDivElement>target.closest('[data-object-id]')).dataset.objectId,
-      <SortDirections>target.dataset.direction,
-    );
+      me.changeSortingByButton(
+        (<HTMLDivElement>this.closest('[data-object-id]')).dataset.objectId,
+        <SortDirections>this.dataset.direction,
+      );
+    }).delegateTo(this.container, Selectors.controlSectionSelector + ' [data-action="sort"]');
   }
 
-  /**
-   * @param {Event} e
-   */
-  private registerCreateRecordButton(e: Event): void {
-    let target: HTMLElement;
-    if ((target = InlineControlContainer.getDelegatedEventTarget(e.target, Selectors.createNewRecordButtonSelector)) === null) {
-      return;
-    }
+  private registerCreateRecordButton(): void {
+    const me = this;
+    new RegularEvent('click', function(this: HTMLElement, e: Event) {
+      e.preventDefault();
+      e.stopImmediatePropagation();
 
-    e.preventDefault();
-    e.stopImmediatePropagation();
+      if (me.isBelowMax()) {
+        let objectId = me.container.dataset.objectGroup;
+        if (typeof this.dataset.recordUid !== 'undefined') {
+          objectId += Separators.structureSeparator + this.dataset.recordUid;
+        }
 
-    if (this.isBelowMax()) {
-      let objectId = this.container.dataset.objectGroup;
-      if (typeof target.dataset.recordUid !== 'undefined') {
-        objectId += Separators.structureSeparator + target.dataset.recordUid;
+        me.importRecord([objectId], this.dataset.recordUid);
       }
-
-      this.importRecord([objectId], target.dataset.recordUid);
-    }
+    }).delegateTo(this.container, Selectors.createNewRecordButtonSelector);
   }
 
-  /**
-   * @param {Event} e
-   */
-  private registerCreateRecordBySelector(e: Event): void {
-    let target: HTMLElement;
-    if ((target = InlineControlContainer.getDelegatedEventTarget(e.target, Selectors.createNewRecordBySelectorSelector)) === null) {
-      return;
-    }
-
-    e.preventDefault();
-    e.stopImmediatePropagation();
+  private registerCreateRecordBySelector(): void {
+    const me = this;
+    new RegularEvent('change', function(this: HTMLElement, e: Event) {
+      e.preventDefault();
+      e.stopImmediatePropagation();
 
-    const selectTarget = <HTMLSelectElement>target;
-    const recordUid = selectTarget.options[selectTarget.selectedIndex].getAttribute('value');
+      const selectTarget = <HTMLSelectElement>this;
+      const recordUid = selectTarget.options[selectTarget.selectedIndex].getAttribute('value');
 
-    this.importRecord([this.container.dataset.objectGroup, recordUid]);
+      me.importRecord([me.container.dataset.objectGroup, recordUid]);
+    }).delegateTo(this.container, Selectors.createNewRecordBySelectorSelector);
   }
 
   /**
@@ -436,151 +374,143 @@ class InlineControlContainer {
     });
   }
 
-  /**
-   * @param {Event} e
-   */
-  private registerEnableDisableButton(e: Event): void {
-    let target: HTMLElement;
-    if ((target = InlineControlContainer.getDelegatedEventTarget(
-      e.target,
-      Selectors.enableDisableRecordButtonSelector)
-    ) === null) {
-      return;
-    }
+  private registerEnableDisableButton(): void {
+    new RegularEvent('click', function(this: HTMLElement, e: Event) {
+      e.preventDefault();
+      e.stopImmediatePropagation();
+
+      const objectId = (<HTMLDivElement>this.closest('[data-object-id]')).dataset.objectId;
+      const recordContainer = InlineControlContainer.getInlineRecordContainer(objectId);
+      const hiddenFieldName = 'data' + recordContainer.dataset.fieldName + '[' + this.dataset.hiddenField + ']';
+      const hiddenValueCheckBox = <HTMLInputElement>document.querySelector('[data-formengine-input-name="' + hiddenFieldName + '"');
+      const hiddenValueInput = <HTMLInputElement>document.querySelector('[name="' + hiddenFieldName + '"');
+
+      if (hiddenValueCheckBox !== null && hiddenValueInput !== null) {
+        hiddenValueCheckBox.checked = !hiddenValueCheckBox.checked;
+        hiddenValueInput.value = hiddenValueCheckBox.checked ? '1' : '0';
+        TBE_EDITOR.fieldChanged_fName(hiddenFieldName, hiddenFieldName);
+      }
 
-    e.preventDefault();
-    e.stopImmediatePropagation();
+      const hiddenClass = 't3-form-field-container-inline-hidden';
+      const isHidden = recordContainer.classList.contains(hiddenClass);
+      let toggleIcon: string = '';
 
-    const objectId = (<HTMLDivElement>target.closest('[data-object-id]')).dataset.objectId;
-    const recordContainer = InlineControlContainer.getInlineRecordContainer(objectId);
-    const hiddenFieldName = 'data' + recordContainer.dataset.fieldName + '[' + target.dataset.hiddenField + ']';
-    const hiddenValueCheckBox = <HTMLInputElement>document.querySelector('[data-formengine-input-name="' + hiddenFieldName + '"');
-    const hiddenValueInput = <HTMLInputElement>document.querySelector('[name="' + hiddenFieldName + '"');
-
-    if (hiddenValueCheckBox !== null && hiddenValueInput !== null) {
-      hiddenValueCheckBox.checked = !hiddenValueCheckBox.checked;
-      hiddenValueInput.value = hiddenValueCheckBox.checked ? '1' : '0';
-      TBE_EDITOR.fieldChanged_fName(hiddenFieldName, hiddenFieldName);
-    }
+      if (isHidden) {
+        toggleIcon = 'actions-edit-hide';
+        recordContainer.classList.remove(hiddenClass);
+      } else {
+        toggleIcon = 'actions-edit-unhide';
+        recordContainer.classList.add(hiddenClass);
+      }
 
-    const hiddenClass = 't3-form-field-container-inline-hidden';
-    const isHidden = recordContainer.classList.contains(hiddenClass);
-    let toggleIcon: string = '';
+      Icons.getIcon(toggleIcon, Icons.sizes.small).then((markup: string): void => {
+        this.replaceChild(document.createRange().createContextualFragment(markup), this.querySelector('.t3js-icon'));
+      });
+    }).delegateTo(this.container, Selectors.enableDisableRecordButtonSelector);
+  }
 
-    if (isHidden) {
-      toggleIcon = 'actions-edit-hide';
-      recordContainer.classList.remove(hiddenClass);
-    } else {
-      toggleIcon = 'actions-edit-unhide';
-      recordContainer.classList.add(hiddenClass);
-    }
+  private registerInfoButton(): void {
+    new RegularEvent('click', function(this: HTMLElement, e: Event) {
+      e.preventDefault();
+      e.stopImmediatePropagation();
 
-    Icons.getIcon(toggleIcon, Icons.sizes.small).then((markup: string): void => {
-      target.replaceChild(document.createRange().createContextualFragment(markup), target.querySelector('.t3js-icon'));
-    });
+      InfoWindow.showItem(this.dataset.infoTable, this.dataset.infoUid);
+    }).delegateTo(this.container, Selectors.infoWindowButton);
   }
 
-  /**
-   * @param {Event} e
-   */
-  private registerDeleteButton(e: Event): void {
-    let target: HTMLElement;
-    if ((target = InlineControlContainer.getDelegatedEventTarget(e.target, Selectors.deleteRecordButtonSelector)) === null) {
-      return;
-    }
-
-    e.preventDefault();
-    e.stopImmediatePropagation();
-
-    const title = TYPO3.lang['label.confirm.delete_record.title'] || 'Delete this record?';
-    const content = TYPO3.lang['label.confirm.delete_record.content'] || 'Are you sure you want to delete this record?';
-    const $modal = Modal.confirm(title, content, Severity.warning, [
-      {
-        text: TYPO3.lang['buttons.confirm.delete_record.no'] || 'Cancel',
-        active: true,
-        btnClass: 'btn-default',
-        name: 'no',
-      },
-      {
-        text: TYPO3.lang['buttons.confirm.delete_record.yes'] || 'Yes, delete this record',
-        btnClass: 'btn-warning',
-        name: 'yes',
-      },
-    ]);
-    $modal.on('button.clicked', (modalEvent: Event): void => {
-      if ((<HTMLAnchorElement>modalEvent.target).name === 'yes') {
-        const objectId = (<HTMLDivElement>target.closest('[data-object-id]')).dataset.objectId;
-        this.deleteRecord(objectId);
-      }
+  private registerDeleteButton(): void {
+    const me = this;
+    new RegularEvent('click', function(this: HTMLElement, e: Event) {
+      e.preventDefault();
+      e.stopImmediatePropagation();
+
+      const title = TYPO3.lang['label.confirm.delete_record.title'] || 'Delete this record?';
+      const content = TYPO3.lang['label.confirm.delete_record.content'] || 'Are you sure you want to delete this record?';
+      const $modal = Modal.confirm(title, content, Severity.warning, [
+        {
+          text: TYPO3.lang['buttons.confirm.delete_record.no'] || 'Cancel',
+          active: true,
+          btnClass: 'btn-default',
+          name: 'no',
+        },
+        {
+          text: TYPO3.lang['buttons.confirm.delete_record.yes'] || 'Yes, delete this record',
+          btnClass: 'btn-warning',
+          name: 'yes',
+        },
+      ]);
+      $modal.on('button.clicked', (modalEvent: Event): void => {
+        if ((<HTMLAnchorElement>modalEvent.target).name === 'yes') {
+          const objectId = (<HTMLDivElement>this.closest('[data-object-id]')).dataset.objectId;
+          me.deleteRecord(objectId);
+        }
 
-      Modal.dismiss();
-    });
+        Modal.dismiss();
+      });
+    }).delegateTo(this.container, Selectors.deleteRecordButtonSelector);
   }
 
   /**
    * @param {Event} e
    */
-  private registerSynchronizeLocalize(e: Event): void {
-    let target;
-    if ((target = InlineControlContainer.getDelegatedEventTarget(e.target, Selectors.synchronizeLocalizeRecordButtonSelector)) === null) {
-      return;
-    }
-
-    this.ajaxDispatcher.send(
-      this.ajaxDispatcher.newRequest(this.ajaxDispatcher.getEndpoint('record_inline_synchronizelocalize')),
-      [this.container.dataset.objectGroup, target.dataset.type],
-    ).then(async (response: InlineResponseInterface): Promise<any> => {
-      document.querySelector('#' + this.container.getAttribute('id') + '_records').insertAdjacentHTML('beforeend', response.data);
+  private registerSynchronizeLocalize(): void {
+    const me = this;
+    new RegularEvent('click', function(this: HTMLElement, e: Event) {
+      e.preventDefault();
+      e.stopImmediatePropagation();
+
+      me.ajaxDispatcher.send(
+        me.ajaxDispatcher.newRequest(me.ajaxDispatcher.getEndpoint('record_inline_synchronizelocalize')),
+        [me.container.dataset.objectGroup, this.dataset.type],
+      ).then(async (response: InlineResponseInterface): Promise<any> => {
+        document.querySelector('#' + me.container.getAttribute('id') + '_records').insertAdjacentHTML('beforeend', response.data);
+
+        const objectIdPrefix = me.container.dataset.objectGroup + Separators.structureSeparator;
+        for (let itemUid of response.compilerInput.delete) {
+          me.deleteRecord(objectIdPrefix + itemUid, true);
+        }
 
-      const objectIdPrefix = this.container.dataset.objectGroup + Separators.structureSeparator;
-      for (let itemUid of response.compilerInput.delete) {
-        this.deleteRecord(objectIdPrefix + itemUid, true);
-      }
+        for (let item of response.compilerInput.localize) {
+          if (typeof item.remove !== 'undefined') {
+            const removableRecordContainer = InlineControlContainer.getInlineRecordContainer(objectIdPrefix + item.remove);
+            removableRecordContainer.parentElement.removeChild(removableRecordContainer);
+          }
 
-      for (let item of response.compilerInput.localize) {
-        if (typeof item.remove !== 'undefined') {
-          const removableRecordContainer = InlineControlContainer.getInlineRecordContainer(objectIdPrefix + item.remove);
-          removableRecordContainer.parentElement.removeChild(removableRecordContainer);
+          me.memorizeAddRecord(item.uid, null, item.selectedValue);
         }
-
-        this.memorizeAddRecord(item.uid, null, item.selectedValue);
-      }
-    });
+      });
+    }).delegateTo(this.container, Selectors.synchronizeLocalizeRecordButtonSelector);
   }
 
-  /**
-   * @param {Event} e
-   */
-  private registerUniqueSelectFieldChanged(e: Event): void {
-    let target;
-    if ((target = InlineControlContainer.getDelegatedEventTarget(e.target, Selectors.uniqueValueSelectors)) === null) {
-      return;
-    }
-
-    const recordContainer = (<HTMLDivElement>target.closest('[data-object-id]'));
-    if (recordContainer !== null) {
-      const objectId = recordContainer.dataset.objectId;
-      const objectUid = recordContainer.dataset.objectUid;
-      this.handleChangedField(<HTMLSelectElement>target, objectId);
-
-      const formField = this.getFormFieldForElements();
-      if (formField === null) {
-        return;
+  private registerUniqueSelectFieldChanged(): void {
+    const me = this;
+    new RegularEvent('change', function(this: HTMLElement, e: Event) {
+      e.preventDefault();
+      e.stopImmediatePropagation();
+
+      const recordContainer = (<HTMLDivElement>this.closest('[data-object-id]'));
+      if (recordContainer !== null) {
+        const objectId = recordContainer.dataset.objectId;
+        const objectUid = recordContainer.dataset.objectUid;
+        me.handleChangedField(<HTMLSelectElement>this, objectId);
+
+        const formField = me.getFormFieldForElements();
+        if (formField === null) {
+          return;
+        }
+        me.updateUnique(<HTMLSelectElement>this, formField, objectUid);
       }
-      this.updateUnique(<HTMLSelectElement>target, formField, objectUid);
-    }
+    }).delegateTo(this.container, Selectors.uniqueValueSelectors);
   }
 
-  /**
-   * @param {Event} e
-   */
-  private registerRevertUniquenessAction(e: Event): void {
-    let target;
-    if ((target = InlineControlContainer.getDelegatedEventTarget(e.target, Selectors.revertUniqueness)) === null) {
-      return;
-    }
+  private registerRevertUniquenessAction(): void {
+    const me = this;
+    new RegularEvent('click', function(this: HTMLElement, e: Event) {
+      e.preventDefault();
+      e.stopImmediatePropagation();
 
-    this.revertUnique(target.dataset.uid);
+      me.revertUnique(this.dataset.uid);
+    }).delegateTo(this.container, Selectors.revertUniqueness);
   }
 
   /**
@@ -801,10 +731,10 @@ class InlineControlContainer {
       recordContainer.parentElement.insertAdjacentElement('afterbegin', deleteCommandInput);
     }
 
-    recordContainer.addEventListener('transitionend', (): void => {
+    new RegularEvent('transitionend', (): void => {
       recordContainer.parentElement.removeChild(recordContainer);
       FormEngineValidation.validate();
-    });
+    }).bindTo(recordContainer);
 
     this.revertUnique(objectUid);
     this.memorizeRemoveRecord(objectUid);
diff --git a/Build/Sources/TypeScript/core/Resources/Public/TypeScript/Event/DebounceEvent.ts b/Build/Sources/TypeScript/core/Resources/Public/TypeScript/Event/DebounceEvent.ts
new file mode 100644
index 000000000000..de01e7913608
--- /dev/null
+++ b/Build/Sources/TypeScript/core/Resources/Public/TypeScript/Event/DebounceEvent.ts
@@ -0,0 +1,53 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+*/
+
+import {Listener} from './EventInterface';
+import RegularEvent = require('./RegularEvent');
+
+/**
+ * Debounces an event listener that is executed after the event happened, either at the start or at the end.
+ * A debounced event listener is not executed again until a certain amount of time has passed without it being called.
+ */
+class DebounceEvent extends RegularEvent {
+  constructor(eventName: string, callback: Listener, wait: number = 250, immediate: boolean = false) {
+    super(eventName, callback);
+    this.callback = this.debounce(this.callback, wait, immediate);
+  }
+
+  private debounce(callback: Listener, wait: number, immediate: boolean): Listener {
+    let timeout: number = null;
+
+    return () => {
+      const context: any = this;
+      const args = arguments;
+      const later = function() {
+        timeout = null;
+        if (!immediate) {
+          callback.apply(context, args);
+        }
+      };
+
+      const callNow = immediate && !timeout;
+
+      // Reset timeout handler to make sure the callback is executed once
+      clearTimeout(timeout);
+      if (callNow) {
+        callback.apply(context, args);
+      } else {
+        timeout = setTimeout(later, wait);
+      }
+    };
+  }
+}
+
+export = DebounceEvent;
diff --git a/Build/Sources/TypeScript/core/Resources/Public/TypeScript/Event/EventInterface.ts b/Build/Sources/TypeScript/core/Resources/Public/TypeScript/Event/EventInterface.ts
new file mode 100644
index 000000000000..34204cad8525
--- /dev/null
+++ b/Build/Sources/TypeScript/core/Resources/Public/TypeScript/Event/EventInterface.ts
@@ -0,0 +1,20 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+export type Listener = Function & EventListenerOrEventListenerObject;
+
+export interface EventInterface {
+  bindTo(element: EventTarget): void;
+  delegateTo(element: EventTarget, selector: string): void;
+  release(): void;
+}
diff --git a/Build/Sources/TypeScript/core/Resources/Public/TypeScript/Event/RegularEvent.ts b/Build/Sources/TypeScript/core/Resources/Public/TypeScript/Event/RegularEvent.ts
new file mode 100644
index 000000000000..4eba02c7eed9
--- /dev/null
+++ b/Build/Sources/TypeScript/core/Resources/Public/TypeScript/Event/RegularEvent.ts
@@ -0,0 +1,48 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+*/
+
+import {EventInterface, Listener} from './EventInterface';
+
+class RegularEvent implements EventInterface {
+  protected eventName: string;
+  protected callback: Listener;
+  private boundElement: EventTarget;
+
+  constructor(eventName: string, callback: Listener) {
+    this.eventName = eventName;
+    this.callback = callback;
+  }
+
+  public bindTo(element: EventTarget) {
+    this.boundElement = element;
+    element.addEventListener(this.eventName, this.callback);
+  }
+
+  public delegateTo(element: EventTarget, selector: string): void {
+    this.boundElement = element;
+    element.addEventListener(this.eventName, (e: Event): void => {
+      for (let targetElement: Node = <Element>e.target; targetElement && targetElement !== this.boundElement; targetElement = targetElement.parentNode) {
+        if ((<HTMLElement>targetElement).matches(selector)) {
+          this.callback.call(targetElement, e);
+          break;
+        }
+      }
+    }, false);
+  }
+
+  public release(): void {
+    this.boundElement.removeEventListener(this.eventName, this.callback);
+  }
+}
+
+export = RegularEvent;
diff --git a/Build/Sources/TypeScript/core/Resources/Public/TypeScript/Event/RequestAnimationFrameEvent.ts b/Build/Sources/TypeScript/core/Resources/Public/TypeScript/Event/RequestAnimationFrameEvent.ts
new file mode 100644
index 000000000000..e3a17eaeef31
--- /dev/null
+++ b/Build/Sources/TypeScript/core/Resources/Public/TypeScript/Event/RequestAnimationFrameEvent.ts
@@ -0,0 +1,45 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+*/
+
+import {Listener} from './EventInterface';
+import RegularEvent = require('./RegularEvent');
+
+/**
+ * Creates a event aimed for high performance visual operations
+ */
+class RequestAnimationFrameEvent extends RegularEvent {
+  constructor(eventName: string, callback: Listener) {
+    super(eventName, callback);
+    this.callback = this.req(this.callback);
+  }
+
+  private req(callback: Listener): Listener {
+    let timeout: number = null;
+
+    return () => {
+      const context: any = this;
+      const args = arguments;
+
+      if (timeout) {
+        window.cancelAnimationFrame(timeout);
+      }
+
+      timeout = window.requestAnimationFrame(function () {
+        // Run our scroll functions
+        callback.apply(context, args);
+      });
+    };
+  }
+}
+
+export = RequestAnimationFrameEvent;
diff --git a/Build/Sources/TypeScript/core/Resources/Public/TypeScript/Event/ThrottleEvent.ts b/Build/Sources/TypeScript/core/Resources/Public/TypeScript/Event/ThrottleEvent.ts
new file mode 100644
index 000000000000..cbfc6a3f3134
--- /dev/null
+++ b/Build/Sources/TypeScript/core/Resources/Public/TypeScript/Event/ThrottleEvent.ts
@@ -0,0 +1,44 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+*/
+
+import {Listener} from './EventInterface';
+import RegularEvent = require('./RegularEvent');
+
+/**
+ * Throttles the event listener to be called only after a defined time during the event's execution over time.
+ */
+class ThrottleEvent extends RegularEvent {
+  constructor(eventName: string, callback: Listener, limit: number) {
+    super(eventName, callback);
+    this.callback = this.throttle(callback, limit);
+  }
+
+  private throttle(callback: Listener, limit: number): Listener {
+    let wait: boolean = false;
+
+    return () => {
+      if (wait) {
+        return;
+      }
+
+      callback.apply(null, arguments);
+      wait = true;
+
+      setTimeout(function () {
+        wait = false;
+      }, limit);
+    };
+  }
+}
+
+export = ThrottleEvent;
diff --git a/Build/eslintrc.js b/Build/eslintrc.js
index ec8d402b05d2..698a30566272 100644
--- a/Build/eslintrc.js
+++ b/Build/eslintrc.js
@@ -31,11 +31,7 @@ module.exports = {
     }],
     "@typescript-eslint/no-explicit-any": "off",
     "@typescript-eslint/no-require-imports": "off",
-    "@typescript-eslint/no-unused-vars": ["error", {
-      vars: "all",
-      args: "none",
-      ignoreRestSiblings: false
-    }],
+    "@typescript-eslint/no-unused-vars": "off",
     "@typescript-eslint/no-var-requires": "off",
     "@typescript-eslint/quotes": ["error", "single"],
     "@typescript-eslint/type-annotation-spacing": "error",
diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/FormEngine/Container/InlineControlContainer.js b/typo3/sysext/backend/Resources/Public/JavaScript/FormEngine/Container/InlineControlContainer.js
index 76af1f110684..d7cfb05b489a 100644
--- a/typo3/sysext/backend/Resources/Public/JavaScript/FormEngine/Container/InlineControlContainer.js
+++ b/typo3/sysext/backend/Resources/Public/JavaScript/FormEngine/Container/InlineControlContainer.js
@@ -10,4 +10,4 @@
  *
  * The TYPO3 project - inspiring people to share!
  */
-define(["require","exports","jquery","../../Utility/MessageUtility","./../InlineRelation/AjaxDispatcher","nprogress","Sortable","TYPO3/CMS/Backend/FormEngine","TYPO3/CMS/Backend/FormEngineValidation","../../Icons","../../InfoWindow","../../Modal","../../Notification","../../Severity","../../Utility"],(function(e,t,n,i,r,o,a,s,l,c,d,u,g,h,p){"use strict";var m,f,b,v;!function(e){e.toggleSelector='[data-toggle="formengine-inline"]',e.controlSectionSelector=".t3js-formengine-irre-control",e.createNewRecordButtonSelector=".t3js-create-new-button",e.createNewRecordBySelectorSelector=".t3js-create-new-selector",e.deleteRecordButtonSelector=".t3js-editform-delete-inline-record",e.enableDisableRecordButtonSelector=".t3js-toggle-visibility-button",e.infoWindowButton='[data-action="infowindow"]',e.synchronizeLocalizeRecordButtonSelector=".t3js-synchronizelocalize-button",e.uniqueValueSelectors="select.t3js-inline-unique",e.revertUniqueness=".t3js-revert-unique",e.controlContainerButtons=".t3js-inline-controls"}(m||(m={})),function(e){e.new="inlineIsNewRecord",e.visible="panel-visible",e.collapsed="panel-collapsed"}(f||(f={})),function(e){e.structureSeparator="-"}(b||(b={})),function(e){e.DOWN="down",e.UP="up"}(v||(v={}));class S{constructor(e){this.container=null,this.ajaxDispatcher=null,this.appearance=null,this.requestQueue={},this.progessQueue={},this.noTitleString=TYPO3.lang?TYPO3.lang["FormEngine.noRecordTitle"]:"[No title]",this.handlePostMessage=e=>{if(!i.MessageUtility.verifyOrigin(e.origin))throw"Denied message sent by "+e.origin;if("typo3:elementBrowser:elementInserted"===e.data.actionName){if(void 0===e.data.objectGroup)throw"No object group defined for message";if(e.data.objectGroup!==this.container.dataset.objectGroup)return;if(this.isUniqueElementUsed(parseInt(e.data.uid,10),e.data.table))return void g.error("There is already a relation to the selected element");this.importRecord([e.data.objectGroup,e.data.uid])}},n(()=>{this.container=document.querySelector("#"+e),this.ajaxDispatcher=new r.AjaxDispatcher(this.container.dataset.objectGroup),this.registerEvents()})}static getDelegatedEventTarget(e,t){let n;return null===(n=e.closest(t))&&e.matches(t)&&(n=e),n}static getInlineRecordContainer(e){return document.querySelector('[data-object-id="'+e+'"]')}static registerInfoButton(e){let t;null!==(t=S.getDelegatedEventTarget(e.target,m.infoWindowButton))&&(e.preventDefault(),e.stopImmediatePropagation(),d.showItem(t.dataset.infoTable,t.dataset.infoUid))}static toggleElement(e){const t=S.getInlineRecordContainer(e);t.classList.contains(f.collapsed)?(t.classList.remove(f.collapsed),t.classList.add(f.visible)):(t.classList.remove(f.visible),t.classList.add(f.collapsed))}static isNewRecord(e){return S.getInlineRecordContainer(e).classList.contains(f.new)}static updateExpandedCollapsedStateLocally(e,t){const n=S.getInlineRecordContainer(e),i="uc[inlineView]["+n.dataset.topmostParentTable+"]["+n.dataset.topmostParentUid+"]"+n.dataset.fieldName,r=document.getElementsByName(i);r.length&&(r[0].value=t?"1":"0")}static getValuesFromHashMap(e){return Object.keys(e).map(t=>e[t])}static selectOptionValueExists(e,t){return null!==e.querySelector('option[value="'+t+'"]')}static removeSelectOptionByValue(e,t){const n=e.querySelector('option[value="'+t+'"]');null!==n&&n.remove()}static reAddSelectOption(e,t,n){if(S.selectOptionValueExists(e,t))return;const i=e.querySelectorAll("option");let r=-1;for(let e of Object.keys(n.possible)){if(e===t)break;for(let t=0;t<i.length;++t){if(i[t].value===e){r=t;break}}}-1===r?r=0:r<i.length&&r++;const o=document.createElement("option");o.text=n.possible[t],o.value=t,e.insertBefore(o,e.options[r])}registerEvents(){if(this.container.addEventListener("click",e=>{this.registerToggle(e),this.registerSort(e),this.registerCreateRecordButton(e),this.registerEnableDisableButton(e),S.registerInfoButton(e),this.registerDeleteButton(e),this.registerSynchronizeLocalize(e),this.registerRevertUniquenessAction(e)}),this.container.addEventListener("change",e=>{this.registerCreateRecordBySelector(e),this.registerUniqueSelectFieldChanged(e)}),window.addEventListener("message",this.handlePostMessage),this.getAppearance().useSortable){const e=document.querySelector("#"+this.container.getAttribute("id")+"_records");new a(e,{group:e.getAttribute("id"),handle:".sortableHandle",onSort:()=>{this.updateSorting()}})}}registerToggle(e){if(S.getDelegatedEventTarget(e.target,m.controlSectionSelector))return;let t;null!==(t=S.getDelegatedEventTarget(e.target,m.toggleSelector))&&(e.preventDefault(),e.stopImmediatePropagation(),this.loadRecordDetails(t.parentElement.dataset.objectId))}registerSort(e){let t;null!==(t=S.getDelegatedEventTarget(e.target,m.controlSectionSelector+' [data-action="sort"]'))&&(e.preventDefault(),e.stopImmediatePropagation(),this.changeSortingByButton(t.closest("[data-object-id]").dataset.objectId,t.dataset.direction))}registerCreateRecordButton(e){let t;if(null!==(t=S.getDelegatedEventTarget(e.target,m.createNewRecordButtonSelector))&&(e.preventDefault(),e.stopImmediatePropagation(),this.isBelowMax())){let e=this.container.dataset.objectGroup;void 0!==t.dataset.recordUid&&(e+=b.structureSeparator+t.dataset.recordUid),this.importRecord([e],t.dataset.recordUid)}}registerCreateRecordBySelector(e){let t;if(null===(t=S.getDelegatedEventTarget(e.target,m.createNewRecordBySelectorSelector)))return;e.preventDefault(),e.stopImmediatePropagation();const n=t,i=n.options[n.selectedIndex].getAttribute("value");this.importRecord([this.container.dataset.objectGroup,i])}createRecord(e,t,n=null,i=null){let r=this.container.dataset.objectGroup;null!==n&&(r+=b.structureSeparator+n),null!==n?(S.getInlineRecordContainer(r).insertAdjacentHTML("afterend",t),this.memorizeAddRecord(e,n,i)):(document.querySelector("#"+this.container.getAttribute("id")+"_records").insertAdjacentHTML("beforeend",t),this.memorizeAddRecord(e,null,i))}async importRecord(e,t){this.ajaxDispatcher.send(this.ajaxDispatcher.newRequest(this.ajaxDispatcher.getEndpoint("record_inline_create")),e).then(async e=>{this.isBelowMax()&&(this.createRecord(e.compilerInput.uid,e.data,void 0!==t?t:null,void 0!==e.compilerInput.childChildUid?e.compilerInput.childChildUid:null),s.reinitialize(),s.Validation.initializeInputFields(),s.Validation.validate())})}registerEnableDisableButton(e){let t;if(null===(t=S.getDelegatedEventTarget(e.target,m.enableDisableRecordButtonSelector)))return;e.preventDefault(),e.stopImmediatePropagation();const n=t.closest("[data-object-id]").dataset.objectId,i=S.getInlineRecordContainer(n),r="data"+i.dataset.fieldName+"["+t.dataset.hiddenField+"]",o=document.querySelector('[data-formengine-input-name="'+r+'"'),a=document.querySelector('[name="'+r+'"');null!==o&&null!==a&&(o.checked=!o.checked,a.value=o.checked?"1":"0",TBE_EDITOR.fieldChanged_fName(r,r));const s="t3-form-field-container-inline-hidden";let l="";i.classList.contains(s)?(l="actions-edit-hide",i.classList.remove(s)):(l="actions-edit-unhide",i.classList.add(s)),c.getIcon(l,c.sizes.small).then(e=>{t.replaceChild(document.createRange().createContextualFragment(e),t.querySelector(".t3js-icon"))})}registerDeleteButton(e){let t;if(null===(t=S.getDelegatedEventTarget(e.target,m.deleteRecordButtonSelector)))return;e.preventDefault(),e.stopImmediatePropagation();const n=TYPO3.lang["label.confirm.delete_record.title"]||"Delete this record?",i=TYPO3.lang["label.confirm.delete_record.content"]||"Are you sure you want to delete this record?";u.confirm(n,i,h.warning,[{text:TYPO3.lang["buttons.confirm.delete_record.no"]||"Cancel",active:!0,btnClass:"btn-default",name:"no"},{text:TYPO3.lang["buttons.confirm.delete_record.yes"]||"Yes, delete this record",btnClass:"btn-warning",name:"yes"}]).on("button.clicked",e=>{if("yes"===e.target.name){const e=t.closest("[data-object-id]").dataset.objectId;this.deleteRecord(e)}u.dismiss()})}registerSynchronizeLocalize(e){let t;null!==(t=S.getDelegatedEventTarget(e.target,m.synchronizeLocalizeRecordButtonSelector))&&this.ajaxDispatcher.send(this.ajaxDispatcher.newRequest(this.ajaxDispatcher.getEndpoint("record_inline_synchronizelocalize")),[this.container.dataset.objectGroup,t.dataset.type]).then(async e=>{document.querySelector("#"+this.container.getAttribute("id")+"_records").insertAdjacentHTML("beforeend",e.data);const t=this.container.dataset.objectGroup+b.structureSeparator;for(let n of e.compilerInput.delete)this.deleteRecord(t+n,!0);for(let n of e.compilerInput.localize){if(void 0!==n.remove){const e=S.getInlineRecordContainer(t+n.remove);e.parentElement.removeChild(e)}this.memorizeAddRecord(n.uid,null,n.selectedValue)}})}registerUniqueSelectFieldChanged(e){let t;if(null===(t=S.getDelegatedEventTarget(e.target,m.uniqueValueSelectors)))return;const n=t.closest("[data-object-id]");if(null!==n){const e=n.dataset.objectId,i=n.dataset.objectUid;this.handleChangedField(t,e);const r=this.getFormFieldForElements();if(null===r)return;this.updateUnique(t,r,i)}}registerRevertUniquenessAction(e){let t;null!==(t=S.getDelegatedEventTarget(e.target,m.revertUniqueness))&&this.revertUnique(t.dataset.uid)}loadRecordDetails(e){const t=document.querySelector("#"+e+"_fields"),n=void 0!==this.requestQueue[e];if(null!==t&&"\x3c!--notloaded--\x3e"!==t.innerHTML.substr(0,16))this.collapseExpandRecord(e);else{const i=this.getProgress(e);if(n)this.requestQueue[e].abort(),delete this.requestQueue[e],delete this.progessQueue[e],i.done();else{const n=this.ajaxDispatcher.newRequest(this.ajaxDispatcher.getEndpoint("record_inline_details"));this.ajaxDispatcher.send(n,[e]).then(async n=>{if(delete this.requestQueue[e],delete this.progessQueue[e],t.innerHTML=n.data,this.collapseExpandRecord(e),i.done(),s.reinitialize(),s.Validation.initializeInputFields(),s.Validation.validate(),this.hasObjectGroupDefinedUniqueConstraints()){const t=S.getInlineRecordContainer(e);this.removeUsed(t)}}),this.requestQueue[e]=n,i.start()}}}collapseExpandRecord(e){const t=S.getInlineRecordContainer(e),n=!0===this.getAppearance().expandSingle,i=t.classList.contains(f.collapsed);let r=[];const o=[];n&&i&&(r=this.collapseAllRecords(t.dataset.objectUid)),S.toggleElement(e),S.isNewRecord(e)?S.updateExpandedCollapsedStateLocally(e,i):i?o.push(t.dataset.objectUid):i||r.push(t.dataset.objectUid),this.ajaxDispatcher.send(this.ajaxDispatcher.newRequest(this.ajaxDispatcher.getEndpoint("record_inline_expandcollapse")),[e,o.join(","),r.join(",")])}memorizeAddRecord(e,t=null,i=null){const r=this.getFormFieldForElements();if(null===r)return;let o=p.trimExplode(",",r.value);if(t){const n=[];for(let i=0;i<o.length;i++)o[i].length&&n.push(o[i]),t===o[i]&&n.push(e);o=n}else o.push(e);r.value=o.join(","),r.classList.add("has-change"),n(document).trigger("change"),this.redrawSortingButtons(this.container.dataset.objectGroup,o),this.setUnique(e,i),this.isBelowMax()||this.toggleContainerControls(!1),TBE_EDITOR.fieldChanged_fName(r.name,r)}memorizeRemoveRecord(e){const t=this.getFormFieldForElements();if(null===t)return[];let i=p.trimExplode(",",t.value);const r=i.indexOf(e);return r>-1&&(delete i[r],t.value=i.join(","),t.classList.add("has-change"),n(document).trigger("change"),this.redrawSortingButtons(this.container.dataset.objectGroup,i)),i}changeSortingByButton(e,t){const n=S.getInlineRecordContainer(e),i=n.dataset.objectUid,r=document.querySelector("#"+this.container.getAttribute("id")+"_records"),o=Array.from(r.children).map(e=>e.dataset.objectUid);let a=o.indexOf(i),s=!1;if(t===v.UP&&a>0?(o[a]=o[a-1],o[a-1]=i,s=!0):t===v.DOWN&&a<o.length-1&&(o[a]=o[a+1],o[a+1]=i,s=!0),s){const e=this.container.dataset.objectGroup+b.structureSeparator,i=t===v.UP?1:0;n.parentElement.insertBefore(S.getInlineRecordContainer(e+o[a-i]),S.getInlineRecordContainer(e+o[a+1-i])),this.updateSorting()}}updateSorting(){const e=this.getFormFieldForElements();if(null===e)return;const t=document.querySelector("#"+this.container.getAttribute("id")+"_records"),i=Array.from(t.children).map(e=>e.dataset.objectUid);e.value=i.join(","),e.classList.add("has-change"),n(document).trigger("inline:sorting-changed"),n(document).trigger("change"),this.redrawSortingButtons(this.container.dataset.objectGroup,i)}deleteRecord(e,t=!1){const n=S.getInlineRecordContainer(e),i=n.dataset.objectUid;if(n.classList.add("t3js-inline-record-deleted"),!S.isNewRecord(e)&&!t){const e=this.container.querySelector('[name="cmd'+n.dataset.fieldName+'[delete]"]');e.removeAttribute("disabled"),n.parentElement.insertAdjacentElement("afterbegin",e)}n.addEventListener("transitionend",()=>{n.parentElement.removeChild(n),l.validate()}),this.revertUnique(i),this.memorizeRemoveRecord(i),n.classList.add("form-irre-object--deleted"),this.isBelowMax()&&this.toggleContainerControls(!0)}toggleContainerControls(e){this.container.querySelectorAll(m.controlContainerButtons+" a").forEach(t=>{t.style.display=e?null:"none"})}getProgress(e){const t="#"+e+"_header";let n;return void 0!==this.progessQueue[e]?n=this.progessQueue[e]:((n=o).configure({parent:t,showSpinner:!1}),this.progessQueue[e]=n),n}collapseAllRecords(e){const t=this.getFormFieldForElements(),n=[];if(null!==t){const i=p.trimExplode(",",t.value);for(let t of i){if(t===e)continue;const i=this.container.dataset.objectGroup+b.structureSeparator+t,r=S.getInlineRecordContainer(i);r.classList.contains(f.visible)&&(r.classList.remove(f.visible),r.classList.add(f.collapsed),S.isNewRecord(i)?S.updateExpandedCollapsedStateLocally(i,!1):n.push(t))}}return n}getFormFieldForElements(){const e=document.getElementsByName(this.container.dataset.formField);return e.length>0?e[0]:null}redrawSortingButtons(e,t=[]){if(0===t.length){const e=this.getFormFieldForElements();null!==e&&(t=p.trimExplode(",",e.value))}0!==t.length&&t.forEach((n,i)=>{const r="#"+e+b.structureSeparator+n+"_header",o=document.querySelector(r),a=o.querySelector('[data-action="sort"][data-direction="'+v.UP+'"]');if(null!==a){let e="actions-move-up";0===i?(a.classList.add("disabled"),e="empty-empty"):a.classList.remove("disabled"),c.getIcon(e,c.sizes.small).then(e=>{a.replaceChild(document.createRange().createContextualFragment(e),a.querySelector(".t3js-icon"))})}const s=o.querySelector('[data-action="sort"][data-direction="'+v.DOWN+'"]');if(null!==s){let e="actions-move-down";i===t.length-1?(s.classList.add("disabled"),e="empty-empty"):s.classList.remove("disabled"),c.getIcon(e,c.sizes.small).then(e=>{s.replaceChild(document.createRange().createContextualFragment(e),s.querySelector(".t3js-icon"))})}})}isBelowMax(){const e=this.getFormFieldForElements();if(null===e)return!0;if(void 0!==TYPO3.settings.FormEngineInline.config[this.container.dataset.objectGroup]){if(p.trimExplode(",",e.value).length>=TYPO3.settings.FormEngineInline.config[this.container.dataset.objectGroup].max)return!1;if(this.hasObjectGroupDefinedUniqueConstraints()){const e=TYPO3.settings.FormEngineInline.unique[this.container.dataset.objectGroup];if(e.used.length>=e.max&&e.max>=0)return!1}}return!0}isUniqueElementUsed(e,t){if(!this.hasObjectGroupDefinedUniqueConstraints())return!1;const n=TYPO3.settings.FormEngineInline.unique[this.container.dataset.objectGroup],i=S.getValuesFromHashMap(n.used);if("select"===n.type&&-1!==i.indexOf(e))return!0;if("groupdb"===n.type)for(let n=i.length-1;n>=0;n--)if(i[n].table===t&&i[n].uid===e)return!0;return!1}removeUsed(e){if(!this.hasObjectGroupDefinedUniqueConstraints())return;const t=TYPO3.settings.FormEngineInline.unique[this.container.dataset.objectGroup];if("select"!==t.type)return;let n=e.querySelector('[name="data['+t.table+"]["+e.dataset.objectUid+"]["+t.field+']"]');const i=S.getValuesFromHashMap(t.used);if(null!==n){const e=n.options[n.selectedIndex].value;for(let t of i)t!==e&&S.removeSelectOptionByValue(n,t)}}setUnique(e,t){if(!this.hasObjectGroupDefinedUniqueConstraints())return;const n=document.querySelector("#"+this.container.dataset.objectGroup+"_selector"),i=TYPO3.settings.FormEngineInline.unique[this.container.dataset.objectGroup];if("select"===i.type){if(!i.selector||-1!==i.max){const r=this.getFormFieldForElements(),o=this.container.dataset.objectGroup+b.structureSeparator+e;let a=S.getInlineRecordContainer(o).querySelector('[name="data['+i.table+"]["+e+"]["+i.field+']"]');const s=S.getValuesFromHashMap(i.used);if(null!==n){if(null!==a){for(let e of s)S.removeSelectOptionByValue(a,e);i.selector||(t=a.options[0].value,a.options[0].selected=!0,this.updateUnique(a,r,e),this.handleChangedField(a,this.container.dataset.objectGroup+"["+e+"]"))}for(let e of s)S.removeSelectOptionByValue(a,e);void 0!==i.used.length&&(i.used={}),i.used[e]={table:i.elTable,uid:t}}if(null!==r&&S.selectOptionValueExists(n,t)){const n=p.trimExplode(",",r.value);for(let r of n)null!==(a=document.querySelector('[name="data['+i.table+"]["+r+"]["+i.field+']"]'))&&r!==e&&S.removeSelectOptionByValue(a,t)}}}else"groupdb"===i.type&&(i.used[e]={table:i.elTable,uid:t});"select"===i.selector&&S.selectOptionValueExists(n,t)&&(S.removeSelectOptionByValue(n,t),i.used[e]={table:i.elTable,uid:t})}updateUnique(e,t,n){if(!this.hasObjectGroupDefinedUniqueConstraints())return;const i=TYPO3.settings.FormEngineInline.unique[this.container.dataset.objectGroup],r=i.used[n];if("select"===i.selector){const t=document.querySelector("#"+this.container.dataset.objectGroup+"_selector");S.removeSelectOptionByValue(t,e.value),void 0!==r&&S.reAddSelectOption(t,r,i)}if(i.selector&&-1===i.max)return;if(!i||null===t)return;const o=p.trimExplode(",",t.value);let a;for(let t of o)null!==(a=document.querySelector('[name="data['+i.table+"]["+t+"]["+i.field+']"]'))&&a!==e&&(S.removeSelectOptionByValue(a,e.value),void 0!==r&&S.reAddSelectOption(a,r,i));i.used[n]=e.value}revertUnique(e){if(!this.hasObjectGroupDefinedUniqueConstraints())return;const t=TYPO3.settings.FormEngineInline.unique[this.container.dataset.objectGroup],n=this.container.dataset.objectGroup+b.structureSeparator+e,i=S.getInlineRecordContainer(n);let r=i.querySelector('[name="data['+t.table+"]["+i.dataset.objectUid+"]["+t.field+']"]');if("select"===t.type){let n;if(null!==r)n=r.value;else{if(""===i.dataset.tableUniqueOriginalValue)return;n=i.dataset.tableUniqueOriginalValue}if("select"===t.selector&&!isNaN(parseInt(n,10))){const e=document.querySelector("#"+this.container.dataset.objectGroup+"_selector");S.reAddSelectOption(e,n,t)}if(t.selector&&-1===t.max)return;const o=this.getFormFieldForElements();if(null===o)return;const a=p.trimExplode(",",o.value);let s;for(let e=0;e<a.length;e++)null!==(s=document.querySelector('[name="data['+t.table+"]["+a[e]+"]["+t.field+']"]'))&&S.reAddSelectOption(s,n,t);delete t.used[e]}else"groupdb"===t.type&&delete t.used[e]}hasObjectGroupDefinedUniqueConstraints(){return void 0!==TYPO3.settings.FormEngineInline.unique&&void 0!==TYPO3.settings.FormEngineInline.unique[this.container.dataset.objectGroup]}handleChangedField(e,t){let n;n=e instanceof HTMLSelectElement?e.options[e.selectedIndex].text:e.value,document.querySelector("#"+t+"_label").textContent=n.length?n:this.noTitleString}getAppearance(){if(null===this.appearance&&(this.appearance={},"string"==typeof this.container.dataset.appearance))try{this.appearance=JSON.parse(this.container.dataset.appearance)}catch(e){console.error(e)}return this.appearance}}return S}));
\ No newline at end of file
+define(["require","exports","jquery","../../Utility/MessageUtility","./../InlineRelation/AjaxDispatcher","nprogress","Sortable","TYPO3/CMS/Backend/FormEngine","TYPO3/CMS/Backend/FormEngineValidation","../../Icons","../../InfoWindow","../../Modal","../../Notification","TYPO3/CMS/Core/Event/RegularEvent","../../Severity","../../Utility"],(function(e,t,n,i,o,r,a,s,l,c,d,u,h,p,g,m){"use strict";var f,b,S,j;!function(e){e.toggleSelector='[data-toggle="formengine-inline"]',e.controlSectionSelector=".t3js-formengine-irre-control",e.createNewRecordButtonSelector=".t3js-create-new-button",e.createNewRecordBySelectorSelector=".t3js-create-new-selector",e.deleteRecordButtonSelector=".t3js-editform-delete-inline-record",e.enableDisableRecordButtonSelector=".t3js-toggle-visibility-button",e.infoWindowButton='[data-action="infowindow"]',e.synchronizeLocalizeRecordButtonSelector=".t3js-synchronizelocalize-button",e.uniqueValueSelectors="select.t3js-inline-unique",e.revertUniqueness=".t3js-revert-unique",e.controlContainerButtons=".t3js-inline-controls"}(f||(f={})),function(e){e.new="inlineIsNewRecord",e.visible="panel-visible",e.collapsed="panel-collapsed"}(b||(b={})),function(e){e.structureSeparator="-"}(S||(S={})),function(e){e.DOWN="down",e.UP="up"}(j||(j={}));class v{constructor(e){this.container=null,this.ajaxDispatcher=null,this.appearance=null,this.requestQueue={},this.progessQueue={},this.noTitleString=TYPO3.lang?TYPO3.lang["FormEngine.noRecordTitle"]:"[No title]",this.handlePostMessage=e=>{if(!i.MessageUtility.verifyOrigin(e.origin))throw"Denied message sent by "+e.origin;if("typo3:elementBrowser:elementInserted"===e.data.actionName){if(void 0===e.data.objectGroup)throw"No object group defined for message";if(e.data.objectGroup!==this.container.dataset.objectGroup)return;if(this.isUniqueElementUsed(parseInt(e.data.uid,10),e.data.table))return void h.error("There is already a relation to the selected element");this.importRecord([e.data.objectGroup,e.data.uid])}},n(()=>{this.container=document.querySelector("#"+e),this.ajaxDispatcher=new o.AjaxDispatcher(this.container.dataset.objectGroup),this.registerEvents()})}static getInlineRecordContainer(e){return document.querySelector('[data-object-id="'+e+'"]')}static toggleElement(e){const t=v.getInlineRecordContainer(e);t.classList.contains(b.collapsed)?(t.classList.remove(b.collapsed),t.classList.add(b.visible)):(t.classList.remove(b.visible),t.classList.add(b.collapsed))}static isNewRecord(e){return v.getInlineRecordContainer(e).classList.contains(b.new)}static updateExpandedCollapsedStateLocally(e,t){const n=v.getInlineRecordContainer(e),i="uc[inlineView]["+n.dataset.topmostParentTable+"]["+n.dataset.topmostParentUid+"]"+n.dataset.fieldName,o=document.getElementsByName(i);o.length&&(o[0].value=t?"1":"0")}static getValuesFromHashMap(e){return Object.keys(e).map(t=>e[t])}static selectOptionValueExists(e,t){return null!==e.querySelector('option[value="'+t+'"]')}static removeSelectOptionByValue(e,t){const n=e.querySelector('option[value="'+t+'"]');null!==n&&n.remove()}static reAddSelectOption(e,t,n){if(v.selectOptionValueExists(e,t))return;const i=e.querySelectorAll("option");let o=-1;for(let e of Object.keys(n.possible)){if(e===t)break;for(let t=0;t<i.length;++t){if(i[t].value===e){o=t;break}}}-1===o?o=0:o<i.length&&o++;const r=document.createElement("option");r.text=n.possible[t],r.value=t,e.insertBefore(r,e.options[o])}registerEvents(){if(this.registerInfoButton(),this.registerSort(),this.registerCreateRecordButton(),this.registerEnableDisableButton(),this.registerDeleteButton(),this.registerSynchronizeLocalize(),this.registerRevertUniquenessAction(),this.registerToggle(),this.registerCreateRecordBySelector(),this.registerUniqueSelectFieldChanged(),new p("message",this.handlePostMessage).bindTo(window),this.getAppearance().useSortable){const e=document.querySelector("#"+this.container.getAttribute("id")+"_records");new a(e,{group:e.getAttribute("id"),handle:".sortableHandle",onSort:()=>{this.updateSorting()}})}}registerToggle(){const e=this;new p("click",(function(t){t.preventDefault(),t.stopImmediatePropagation(),e.loadRecordDetails(this.parentElement.dataset.objectId)})).delegateTo(this.container,f.toggleSelector)}registerSort(){const e=this;new p("click",(function(t){t.preventDefault(),t.stopImmediatePropagation(),e.changeSortingByButton(this.closest("[data-object-id]").dataset.objectId,this.dataset.direction)})).delegateTo(this.container,f.controlSectionSelector+' [data-action="sort"]')}registerCreateRecordButton(){const e=this;new p("click",(function(t){if(t.preventDefault(),t.stopImmediatePropagation(),e.isBelowMax()){let t=e.container.dataset.objectGroup;void 0!==this.dataset.recordUid&&(t+=S.structureSeparator+this.dataset.recordUid),e.importRecord([t],this.dataset.recordUid)}})).delegateTo(this.container,f.createNewRecordButtonSelector)}registerCreateRecordBySelector(){const e=this;new p("change",(function(t){t.preventDefault(),t.stopImmediatePropagation();const n=this.options[this.selectedIndex].getAttribute("value");e.importRecord([e.container.dataset.objectGroup,n])})).delegateTo(this.container,f.createNewRecordBySelectorSelector)}createRecord(e,t,n=null,i=null){let o=this.container.dataset.objectGroup;null!==n&&(o+=S.structureSeparator+n),null!==n?(v.getInlineRecordContainer(o).insertAdjacentHTML("afterend",t),this.memorizeAddRecord(e,n,i)):(document.querySelector("#"+this.container.getAttribute("id")+"_records").insertAdjacentHTML("beforeend",t),this.memorizeAddRecord(e,null,i))}async importRecord(e,t){this.ajaxDispatcher.send(this.ajaxDispatcher.newRequest(this.ajaxDispatcher.getEndpoint("record_inline_create")),e).then(async e=>{this.isBelowMax()&&(this.createRecord(e.compilerInput.uid,e.data,void 0!==t?t:null,void 0!==e.compilerInput.childChildUid?e.compilerInput.childChildUid:null),s.reinitialize(),s.Validation.initializeInputFields(),s.Validation.validate())})}registerEnableDisableButton(){new p("click",(function(e){e.preventDefault(),e.stopImmediatePropagation();const t=this.closest("[data-object-id]").dataset.objectId,n=v.getInlineRecordContainer(t),i="data"+n.dataset.fieldName+"["+this.dataset.hiddenField+"]",o=document.querySelector('[data-formengine-input-name="'+i+'"'),r=document.querySelector('[name="'+i+'"');null!==o&&null!==r&&(o.checked=!o.checked,r.value=o.checked?"1":"0",TBE_EDITOR.fieldChanged_fName(i,i));const a="t3-form-field-container-inline-hidden";let s="";n.classList.contains(a)?(s="actions-edit-hide",n.classList.remove(a)):(s="actions-edit-unhide",n.classList.add(a)),c.getIcon(s,c.sizes.small).then(e=>{this.replaceChild(document.createRange().createContextualFragment(e),this.querySelector(".t3js-icon"))})})).delegateTo(this.container,f.enableDisableRecordButtonSelector)}registerInfoButton(){new p("click",(function(e){e.preventDefault(),e.stopImmediatePropagation(),d.showItem(this.dataset.infoTable,this.dataset.infoUid)})).delegateTo(this.container,f.infoWindowButton)}registerDeleteButton(){const e=this;new p("click",(function(t){t.preventDefault(),t.stopImmediatePropagation();const n=TYPO3.lang["label.confirm.delete_record.title"]||"Delete this record?",i=TYPO3.lang["label.confirm.delete_record.content"]||"Are you sure you want to delete this record?";u.confirm(n,i,g.warning,[{text:TYPO3.lang["buttons.confirm.delete_record.no"]||"Cancel",active:!0,btnClass:"btn-default",name:"no"},{text:TYPO3.lang["buttons.confirm.delete_record.yes"]||"Yes, delete this record",btnClass:"btn-warning",name:"yes"}]).on("button.clicked",t=>{if("yes"===t.target.name){const t=this.closest("[data-object-id]").dataset.objectId;e.deleteRecord(t)}u.dismiss()})})).delegateTo(this.container,f.deleteRecordButtonSelector)}registerSynchronizeLocalize(){const e=this;new p("click",(function(t){t.preventDefault(),t.stopImmediatePropagation(),e.ajaxDispatcher.send(e.ajaxDispatcher.newRequest(e.ajaxDispatcher.getEndpoint("record_inline_synchronizelocalize")),[e.container.dataset.objectGroup,this.dataset.type]).then(async t=>{document.querySelector("#"+e.container.getAttribute("id")+"_records").insertAdjacentHTML("beforeend",t.data);const n=e.container.dataset.objectGroup+S.structureSeparator;for(let i of t.compilerInput.delete)e.deleteRecord(n+i,!0);for(let i of t.compilerInput.localize){if(void 0!==i.remove){const e=v.getInlineRecordContainer(n+i.remove);e.parentElement.removeChild(e)}e.memorizeAddRecord(i.uid,null,i.selectedValue)}})})).delegateTo(this.container,f.synchronizeLocalizeRecordButtonSelector)}registerUniqueSelectFieldChanged(){const e=this;new p("change",(function(t){t.preventDefault(),t.stopImmediatePropagation();const n=this.closest("[data-object-id]");if(null!==n){const t=n.dataset.objectId,i=n.dataset.objectUid;e.handleChangedField(this,t);const o=e.getFormFieldForElements();if(null===o)return;e.updateUnique(this,o,i)}})).delegateTo(this.container,f.uniqueValueSelectors)}registerRevertUniquenessAction(){const e=this;new p("click",(function(t){t.preventDefault(),t.stopImmediatePropagation(),e.revertUnique(this.dataset.uid)})).delegateTo(this.container,f.revertUniqueness)}loadRecordDetails(e){const t=document.querySelector("#"+e+"_fields"),n=void 0!==this.requestQueue[e];if(null!==t&&"\x3c!--notloaded--\x3e"!==t.innerHTML.substr(0,16))this.collapseExpandRecord(e);else{const i=this.getProgress(e);if(n)this.requestQueue[e].abort(),delete this.requestQueue[e],delete this.progessQueue[e],i.done();else{const n=this.ajaxDispatcher.newRequest(this.ajaxDispatcher.getEndpoint("record_inline_details"));this.ajaxDispatcher.send(n,[e]).then(async n=>{if(delete this.requestQueue[e],delete this.progessQueue[e],t.innerHTML=n.data,this.collapseExpandRecord(e),i.done(),s.reinitialize(),s.Validation.initializeInputFields(),s.Validation.validate(),this.hasObjectGroupDefinedUniqueConstraints()){const t=v.getInlineRecordContainer(e);this.removeUsed(t)}}),this.requestQueue[e]=n,i.start()}}}collapseExpandRecord(e){const t=v.getInlineRecordContainer(e),n=!0===this.getAppearance().expandSingle,i=t.classList.contains(b.collapsed);let o=[];const r=[];n&&i&&(o=this.collapseAllRecords(t.dataset.objectUid)),v.toggleElement(e),v.isNewRecord(e)?v.updateExpandedCollapsedStateLocally(e,i):i?r.push(t.dataset.objectUid):i||o.push(t.dataset.objectUid),this.ajaxDispatcher.send(this.ajaxDispatcher.newRequest(this.ajaxDispatcher.getEndpoint("record_inline_expandcollapse")),[e,r.join(","),o.join(",")])}memorizeAddRecord(e,t=null,i=null){const o=this.getFormFieldForElements();if(null===o)return;let r=m.trimExplode(",",o.value);if(t){const n=[];for(let i=0;i<r.length;i++)r[i].length&&n.push(r[i]),t===r[i]&&n.push(e);r=n}else r.push(e);o.value=r.join(","),o.classList.add("has-change"),n(document).trigger("change"),this.redrawSortingButtons(this.container.dataset.objectGroup,r),this.setUnique(e,i),this.isBelowMax()||this.toggleContainerControls(!1),TBE_EDITOR.fieldChanged_fName(o.name,o)}memorizeRemoveRecord(e){const t=this.getFormFieldForElements();if(null===t)return[];let i=m.trimExplode(",",t.value);const o=i.indexOf(e);return o>-1&&(delete i[o],t.value=i.join(","),t.classList.add("has-change"),n(document).trigger("change"),this.redrawSortingButtons(this.container.dataset.objectGroup,i)),i}changeSortingByButton(e,t){const n=v.getInlineRecordContainer(e),i=n.dataset.objectUid,o=document.querySelector("#"+this.container.getAttribute("id")+"_records"),r=Array.from(o.children).map(e=>e.dataset.objectUid);let a=r.indexOf(i),s=!1;if(t===j.UP&&a>0?(r[a]=r[a-1],r[a-1]=i,s=!0):t===j.DOWN&&a<r.length-1&&(r[a]=r[a+1],r[a+1]=i,s=!0),s){const e=this.container.dataset.objectGroup+S.structureSeparator,i=t===j.UP?1:0;n.parentElement.insertBefore(v.getInlineRecordContainer(e+r[a-i]),v.getInlineRecordContainer(e+r[a+1-i])),this.updateSorting()}}updateSorting(){const e=this.getFormFieldForElements();if(null===e)return;const t=document.querySelector("#"+this.container.getAttribute("id")+"_records"),i=Array.from(t.children).map(e=>e.dataset.objectUid);e.value=i.join(","),e.classList.add("has-change"),n(document).trigger("inline:sorting-changed"),n(document).trigger("change"),this.redrawSortingButtons(this.container.dataset.objectGroup,i)}deleteRecord(e,t=!1){const n=v.getInlineRecordContainer(e),i=n.dataset.objectUid;if(n.classList.add("t3js-inline-record-deleted"),!v.isNewRecord(e)&&!t){const e=this.container.querySelector('[name="cmd'+n.dataset.fieldName+'[delete]"]');e.removeAttribute("disabled"),n.parentElement.insertAdjacentElement("afterbegin",e)}new p("transitionend",()=>{n.parentElement.removeChild(n),l.validate()}).bindTo(n),this.revertUnique(i),this.memorizeRemoveRecord(i),n.classList.add("form-irre-object--deleted"),this.isBelowMax()&&this.toggleContainerControls(!0)}toggleContainerControls(e){this.container.querySelectorAll(f.controlContainerButtons+" a").forEach(t=>{t.style.display=e?null:"none"})}getProgress(e){const t="#"+e+"_header";let n;return void 0!==this.progessQueue[e]?n=this.progessQueue[e]:((n=r).configure({parent:t,showSpinner:!1}),this.progessQueue[e]=n),n}collapseAllRecords(e){const t=this.getFormFieldForElements(),n=[];if(null!==t){const i=m.trimExplode(",",t.value);for(let t of i){if(t===e)continue;const i=this.container.dataset.objectGroup+S.structureSeparator+t,o=v.getInlineRecordContainer(i);o.classList.contains(b.visible)&&(o.classList.remove(b.visible),o.classList.add(b.collapsed),v.isNewRecord(i)?v.updateExpandedCollapsedStateLocally(i,!1):n.push(t))}}return n}getFormFieldForElements(){const e=document.getElementsByName(this.container.dataset.formField);return e.length>0?e[0]:null}redrawSortingButtons(e,t=[]){if(0===t.length){const e=this.getFormFieldForElements();null!==e&&(t=m.trimExplode(",",e.value))}0!==t.length&&t.forEach((n,i)=>{const o="#"+e+S.structureSeparator+n+"_header",r=document.querySelector(o),a=r.querySelector('[data-action="sort"][data-direction="'+j.UP+'"]');if(null!==a){let e="actions-move-up";0===i?(a.classList.add("disabled"),e="empty-empty"):a.classList.remove("disabled"),c.getIcon(e,c.sizes.small).then(e=>{a.replaceChild(document.createRange().createContextualFragment(e),a.querySelector(".t3js-icon"))})}const s=r.querySelector('[data-action="sort"][data-direction="'+j.DOWN+'"]');if(null!==s){let e="actions-move-down";i===t.length-1?(s.classList.add("disabled"),e="empty-empty"):s.classList.remove("disabled"),c.getIcon(e,c.sizes.small).then(e=>{s.replaceChild(document.createRange().createContextualFragment(e),s.querySelector(".t3js-icon"))})}})}isBelowMax(){const e=this.getFormFieldForElements();if(null===e)return!0;if(void 0!==TYPO3.settings.FormEngineInline.config[this.container.dataset.objectGroup]){if(m.trimExplode(",",e.value).length>=TYPO3.settings.FormEngineInline.config[this.container.dataset.objectGroup].max)return!1;if(this.hasObjectGroupDefinedUniqueConstraints()){const e=TYPO3.settings.FormEngineInline.unique[this.container.dataset.objectGroup];if(e.used.length>=e.max&&e.max>=0)return!1}}return!0}isUniqueElementUsed(e,t){if(!this.hasObjectGroupDefinedUniqueConstraints())return!1;const n=TYPO3.settings.FormEngineInline.unique[this.container.dataset.objectGroup],i=v.getValuesFromHashMap(n.used);if("select"===n.type&&-1!==i.indexOf(e))return!0;if("groupdb"===n.type)for(let n=i.length-1;n>=0;n--)if(i[n].table===t&&i[n].uid===e)return!0;return!1}removeUsed(e){if(!this.hasObjectGroupDefinedUniqueConstraints())return;const t=TYPO3.settings.FormEngineInline.unique[this.container.dataset.objectGroup];if("select"!==t.type)return;let n=e.querySelector('[name="data['+t.table+"]["+e.dataset.objectUid+"]["+t.field+']"]');const i=v.getValuesFromHashMap(t.used);if(null!==n){const e=n.options[n.selectedIndex].value;for(let t of i)t!==e&&v.removeSelectOptionByValue(n,t)}}setUnique(e,t){if(!this.hasObjectGroupDefinedUniqueConstraints())return;const n=document.querySelector("#"+this.container.dataset.objectGroup+"_selector"),i=TYPO3.settings.FormEngineInline.unique[this.container.dataset.objectGroup];if("select"===i.type){if(!i.selector||-1!==i.max){const o=this.getFormFieldForElements(),r=this.container.dataset.objectGroup+S.structureSeparator+e;let a=v.getInlineRecordContainer(r).querySelector('[name="data['+i.table+"]["+e+"]["+i.field+']"]');const s=v.getValuesFromHashMap(i.used);if(null!==n){if(null!==a){for(let e of s)v.removeSelectOptionByValue(a,e);i.selector||(t=a.options[0].value,a.options[0].selected=!0,this.updateUnique(a,o,e),this.handleChangedField(a,this.container.dataset.objectGroup+"["+e+"]"))}for(let e of s)v.removeSelectOptionByValue(a,e);void 0!==i.used.length&&(i.used={}),i.used[e]={table:i.elTable,uid:t}}if(null!==o&&v.selectOptionValueExists(n,t)){const n=m.trimExplode(",",o.value);for(let o of n)null!==(a=document.querySelector('[name="data['+i.table+"]["+o+"]["+i.field+']"]'))&&o!==e&&v.removeSelectOptionByValue(a,t)}}}else"groupdb"===i.type&&(i.used[e]={table:i.elTable,uid:t});"select"===i.selector&&v.selectOptionValueExists(n,t)&&(v.removeSelectOptionByValue(n,t),i.used[e]={table:i.elTable,uid:t})}updateUnique(e,t,n){if(!this.hasObjectGroupDefinedUniqueConstraints())return;const i=TYPO3.settings.FormEngineInline.unique[this.container.dataset.objectGroup],o=i.used[n];if("select"===i.selector){const t=document.querySelector("#"+this.container.dataset.objectGroup+"_selector");v.removeSelectOptionByValue(t,e.value),void 0!==o&&v.reAddSelectOption(t,o,i)}if(i.selector&&-1===i.max)return;if(!i||null===t)return;const r=m.trimExplode(",",t.value);let a;for(let t of r)null!==(a=document.querySelector('[name="data['+i.table+"]["+t+"]["+i.field+']"]'))&&a!==e&&(v.removeSelectOptionByValue(a,e.value),void 0!==o&&v.reAddSelectOption(a,o,i));i.used[n]=e.value}revertUnique(e){if(!this.hasObjectGroupDefinedUniqueConstraints())return;const t=TYPO3.settings.FormEngineInline.unique[this.container.dataset.objectGroup],n=this.container.dataset.objectGroup+S.structureSeparator+e,i=v.getInlineRecordContainer(n);let o=i.querySelector('[name="data['+t.table+"]["+i.dataset.objectUid+"]["+t.field+']"]');if("select"===t.type){let n;if(null!==o)n=o.value;else{if(""===i.dataset.tableUniqueOriginalValue)return;n=i.dataset.tableUniqueOriginalValue}if("select"===t.selector&&!isNaN(parseInt(n,10))){const e=document.querySelector("#"+this.container.dataset.objectGroup+"_selector");v.reAddSelectOption(e,n,t)}if(t.selector&&-1===t.max)return;const r=this.getFormFieldForElements();if(null===r)return;const a=m.trimExplode(",",r.value);let s;for(let e=0;e<a.length;e++)null!==(s=document.querySelector('[name="data['+t.table+"]["+a[e]+"]["+t.field+']"]'))&&v.reAddSelectOption(s,n,t);delete t.used[e]}else"groupdb"===t.type&&delete t.used[e]}hasObjectGroupDefinedUniqueConstraints(){return void 0!==TYPO3.settings.FormEngineInline.unique&&void 0!==TYPO3.settings.FormEngineInline.unique[this.container.dataset.objectGroup]}handleChangedField(e,t){let n;n=e instanceof HTMLSelectElement?e.options[e.selectedIndex].text:e.value,document.querySelector("#"+t+"_label").textContent=n.length?n:this.noTitleString}getAppearance(){if(null===this.appearance&&(this.appearance={},"string"==typeof this.container.dataset.appearance))try{this.appearance=JSON.parse(this.container.dataset.appearance)}catch(e){console.error(e)}return this.appearance}}return v}));
\ No newline at end of file
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-90471-JavaScriptEventAPI.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-90471-JavaScriptEventAPI.rst
new file mode 100644
index 000000000000..c602abc092f3
--- /dev/null
+++ b/typo3/sysext/core/Documentation/Changelog/master/Feature-90471-JavaScriptEventAPI.rst
@@ -0,0 +1,186 @@
+.. include:: ../../Includes.txt
+
+======================================
+Feature: #90471 - JavaScript Event API
+======================================
+
+See :issue:`90471`
+
+Description
+===========
+
+A new Event API enables JavaScript developers to have a stable event listening
+interface. The API takes care of common pitfalls like event delegation and clean
+event unbinding.
+
+
+Impact
+======
+
+Event Binding
+-------------
+
+Each event strategy (see below) has two ways to bind a listener to an event:
+
+Direct Binding
+^^^^^^^^^^^^^^
+
+The event listener is bound to the element that triggers the event. This is done
+by using the method :js:`bindTo()`, which accepts any element, :js:`document` and
+:js:`window`.
+
+Example:
+
+.. code-block:: js
+
+   require(['TYPO3/CMS/Core/Event/RegularEvent'], function (RegularEvent) {
+     new RegularEvent('click', function (e) {
+       // Do something
+     }).bindTo(document.querySelector('#my-element'));
+   });
+
+
+Event Delegation
+^^^^^^^^^^^^^^^^
+
+The event listener is called if the event was triggered to any matching element
+inside its bound element.
+
+Example:
+
+.. code-block:: js
+
+   require(['TYPO3/CMS/Core/Event/RegularEvent'], function (RegularEvent) {
+     new RegularEvent('click', function (e) {
+       // Do something
+     }).delegateTo(document, 'a[data-action="toggle"]');
+   });
+
+The event listener is now called every time the element matching the selector
+`a[data-action="toggle"]` within :js:`document` is clicked.
+
+
+Release an event
+^^^^^^^^^^^^^^^^
+
+Since each event is an object instance, it's sufficient to call `release()` to
+detach the event listener.
+
+Example:
+
+.. code-block:: js
+
+   require(['TYPO3/CMS/Core/Event/RegularEvent'], function (RegularEvent) {
+     const clickEvent = new RegularEvent('click', function (e) {
+       // Do something
+     }).delegateTo(document, 'a[data-action="toggle"]');
+
+     // Do more stuff
+
+     clickEvent.release();
+   });
+
+
+Event Strategies
+----------------
+
+The Event API brings several strategies to handle event listeners:
+
+RegularEvent
+^^^^^^^^^^^^
+
+The :js:`RegularEvent` attaches a simple event listener to an event and element
+and has no further tweaks. This is the common use-case for event handling.
+
+Arguments:
+
+* :js:`eventName` (string) - the event to listen on
+* :js:`callback` (function) - the event listener
+
+Example:
+
+.. code-block:: js
+
+   require(['TYPO3/CMS/Core/Event/RegularEvent'], function (RegularEvent) {
+     new RegularEvent('click', function (e) {
+       e.preventDefault();
+       window.location.reload();
+     }).bindTo(document.querySelector('#my-element'));
+   });
+
+
+DebounceEvent
+^^^^^^^^^^^^^
+
+The :js:`DebounceEvent` is most suitable if an event is triggered rather often
+but executing the event listener may called only once after a certain wait time.
+
+Arguments:
+
+* :js:`eventName` (string) - the event to listen on
+* :js:`callback` (function) - the event listener
+* :js:`wait` (number) - the amount of milliseconds to wait before the event listener is called
+* :js:`immediate` (boolean) - if true, the event listener is called right when the event started
+
+Example:
+
+.. code-block:: js
+
+   require(['TYPO3/CMS/Core/Event/DebounceEvent'], function (DebounceEvent) {
+     new DebounceEvent('mousewheel', function (e) {
+       console.log('Triggered once after 250ms!');
+     }, 250).bindTo(document);
+   });
+
+
+ThrottleEvent
+^^^^^^^^^^^^^
+
+Arguments:
+
+* :js:`eventName` (string) - the event to listen on
+* :js:`callback` (function) - the event listener
+* :js:`limit` (number) - the amount of milliseconds to wait before the event listener is called
+
+The :js:`ThrottleEvent` is similar to the :js:`DebounceEvent`. The important
+difference is that the event listener is called after the configured wait time
+during the overall event time.
+
+If an event time is about 2000ms and the wait time is configured to be 100ms,
+the event listener gets called up to 20 times in total (2000 / 100).
+
+Example:
+
+.. code-block:: js
+
+   require(['TYPO3/CMS/Core/Event/ThrottleEvent'], function (ThrottleEvent) {
+     new ThrottleEvent('mousewheel', function (e) {
+       console.log('Triggered every 100ms!');
+     }, 100).bindTo(document);
+   });
+
+
+RequestAnimationFrameEvent
+^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+The :js:`RequestAnimationFrameEvent` binds its execution to the browser's
+:js:`RequestAnimationFrame` API. It is suitable for event listeners that
+manipulate the DOM.
+
+Arguments:
+
+* :js:`eventName` (string) - the event to listen on
+* :js:`callback` (function) - the event listener
+
+Example:
+
+.. code-block:: js
+
+   require(['TYPO3/CMS/Core/Event/RequestAnimationFrameEvent'], function (RequestAnimationFrameEvent) {
+     new RequestAnimationFrameEvent('mousewheel', function (e) {
+       console.log('Triggered every 16ms (= 60 FPS)!');
+     });
+   });
+
+
+.. index:: JavaScript, ext:core
diff --git a/typo3/sysext/core/Resources/Public/JavaScript/Event/DebounceEvent.js b/typo3/sysext/core/Resources/Public/JavaScript/Event/DebounceEvent.js
new file mode 100644
index 000000000000..31f30023fe42
--- /dev/null
+++ b/typo3/sysext/core/Resources/Public/JavaScript/Event/DebounceEvent.js
@@ -0,0 +1,13 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+define(["require","exports","./RegularEvent"],(function(e,t,n){"use strict";return class extends n{constructor(e,t,n=250,u=!1){super(e,t),this.callback=this.debounce(this.callback,n,u)}debounce(e,t,n){let u=null;return()=>{const c=this,l=arguments,s=function(){u=null,n||e.apply(c,l)},r=n&&!u;clearTimeout(u),r?e.apply(c,l):u=setTimeout(s,t)}}}}));
\ No newline at end of file
diff --git a/typo3/sysext/core/Resources/Public/JavaScript/Event/EventInterface.js b/typo3/sysext/core/Resources/Public/JavaScript/Event/EventInterface.js
new file mode 100644
index 000000000000..d3632d54e8aa
--- /dev/null
+++ b/typo3/sysext/core/Resources/Public/JavaScript/Event/EventInterface.js
@@ -0,0 +1,13 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+define(["require","exports"],(function(e,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0})}));
\ No newline at end of file
diff --git a/typo3/sysext/core/Resources/Public/JavaScript/Event/RegularEvent.js b/typo3/sysext/core/Resources/Public/JavaScript/Event/RegularEvent.js
new file mode 100644
index 000000000000..1af7ed3457ed
--- /dev/null
+++ b/typo3/sysext/core/Resources/Public/JavaScript/Event/RegularEvent.js
@@ -0,0 +1,13 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+define(["require","exports"],(function(e,t){"use strict";return class{constructor(e,t){this.eventName=e,this.callback=t}bindTo(e){this.boundElement=e,e.addEventListener(this.eventName,this.callback)}delegateTo(e,t){this.boundElement=e,e.addEventListener(this.eventName,e=>{for(let n=e.target;n&&n!==this.boundElement;n=n.parentNode)if(n.matches(t)){this.callback.call(n,e);break}},!1)}release(){this.boundElement.removeEventListener(this.eventName,this.callback)}}}));
\ No newline at end of file
diff --git a/typo3/sysext/core/Resources/Public/JavaScript/Event/RequestAnimationFrameEvent.js b/typo3/sysext/core/Resources/Public/JavaScript/Event/RequestAnimationFrameEvent.js
new file mode 100644
index 000000000000..35ff40896adc
--- /dev/null
+++ b/typo3/sysext/core/Resources/Public/JavaScript/Event/RequestAnimationFrameEvent.js
@@ -0,0 +1,13 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+define(["require","exports","./RegularEvent"],(function(e,t,n){"use strict";return class extends n{constructor(e,t){super(e,t),this.callback=this.req(this.callback)}req(e){let t=null;return()=>{const n=this,r=arguments;t&&window.cancelAnimationFrame(t),t=window.requestAnimationFrame((function(){e.apply(n,r)}))}}}}));
\ No newline at end of file
diff --git a/typo3/sysext/core/Resources/Public/JavaScript/Event/ThrottleEvent.js b/typo3/sysext/core/Resources/Public/JavaScript/Event/ThrottleEvent.js
new file mode 100644
index 000000000000..82c64f14800c
--- /dev/null
+++ b/typo3/sysext/core/Resources/Public/JavaScript/Event/ThrottleEvent.js
@@ -0,0 +1,13 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+define(["require","exports","./RegularEvent"],(function(t,e,r){"use strict";return class extends r{constructor(t,e,r){super(t,e),this.callback=this.throttle(e,r)}throttle(t,e){let r=!1;return()=>{r||(t.apply(null,arguments),r=!0,setTimeout((function(){r=!1}),e))}}}}));
\ No newline at end of file
-- 
GitLab