import '@lion/core/src/differentKeyEventNamesShimIE.js'; import { overlays } from './overlays.js'; import { containFocus } from './utils/contain-focus.js'; import './utils/typedef.js'; async function preloadPopper() { return import('popper.js/dist/esm/popper.min.js'); } const GLOBAL_OVERLAYS_CONTAINER_CLASS = 'global-overlays__overlay-container'; const GLOBAL_OVERLAYS_CLASS = 'global-overlays__overlay'; const supportsCSSTypedObject = window.CSS && CSS.number; /** * @desc OverlayController is the fundament for every single type of overlay. With the right * configuration, it can be used to build (modal) dialogs, tooltips, dropdowns, popovers, * bottom/top/left/right sheets etc. * * ### About contentNode, contentWrapperNode and renderTarget. * * #### contentNode * Node containing actual overlay contents. * It will not be touched by the OverlayController, it will only set attributes needed * for accessibility. * * #### contentWrapperNode * The 'positioning' element. * For local overlays, this node will be provided to Popper and all * inline positioning styles will be added here. It will also act as the container of an arrow * element (the arrow needs to be a sibling of contentNode for Popper to work correctly). * When projecting a contentNode from a shadowRoot, it is essential to have the wrapper in * shadow dom, so that contentNode can be styled via `::slotted` from the shadow root. * The Popper arrow can then be styled from that same shadow root as well. * For global overlays, the contentWrapperNode will be appended to the globalRootNode structure. * * #### renderTarget * Usually the parent node of contentWrapperNode that either exists locally or globally. * When a responsive scenario is created (in which we switch from global to local or vice versa) * we need to know where we should reappend contentWrapperNode (or contentNode in case it's projected) * * So a regular flow can be summarized as follows: * 1. Application Developer spawns an OverlayController with a contentNode reference * 2. OverlayController will create a contentWrapperNode around contentNode (or consumes when provided) * 3. contentWrapperNode will be appended to the right renderTarget * * There are subtle differences depending on the following factors: * - whether in global/local placement mode * - whether contentNode projected * - whether an arrow is provided * * This leads to the following possible combinations: * - [l1]. local + no content projection + no arrow * - [l2]. local + content projection + no arrow * - [l3]. local + no content projection + arrow * - [l4]. local + content projection + arrow * - [g1]. global * * #### html structure for a content projected node *
* *
*
* * Structure above depicts [l4] * So in case of [l1] and [l3], the element would be a regular element * In case of [l1] and [l2], there would be no arrow. * Note that a contentWrapperNode should be provided for [l2], [l3] and [l4] * In case of a global overlay ([g1]), it's enough to provide just the contentNode. * In case of a local overlay or a responsive overlay switching from placementMode, one should * always configure as if it was a local overlay. * */ export class OverlayController { /** * @constructor * @param {OverlayConfig} config initial config. Will be remembered as shared config * when `.updateConfig()` is called. */ constructor(config = {}, manager = overlays) { this.__fakeExtendsEventTarget(); this.manager = manager; this.__sharedConfig = config; /** @type {OverlayConfig} */ this._defaultConfig = { placementMode: null, contentNode: config.contentNode, contentWrapperNode: config.contentWrapperNode, invokerNode: config.invokerNode, backdropNode: config.backdropNode, referenceNode: null, elementToFocusAfterHide: config.invokerNode, inheritsReferenceWidth: 'none', hasBackdrop: false, isBlocking: false, preventsScroll: false, trapsKeyboardFocus: false, hidesOnEsc: false, hidesOnOutsideEsc: false, hidesOnOutsideClick: false, isTooltip: false, invokerRelation: 'description', handlesUserInteraction: false, handlesAccessibility: false, popperConfig: { placement: 'top', positionFixed: false, modifiers: { keepTogether: { enabled: false, }, preventOverflow: { enabled: true, boundariesElement: 'viewport', padding: 8, // viewport-margin for shifting/sliding }, flip: { boundariesElement: 'viewport', padding: 16, // viewport-margin for flipping }, offset: { enabled: true, offset: `0, 8px`, // horizontal and vertical margin (distance between popper and referenceElement) }, arrow: { enabled: false, }, }, }, viewportConfig: { placement: 'center', }, }; this.manager.add(this); this._contentId = `overlay-content--${Math.random().toString(36).substr(2, 10)}`; this.__originalAttrs = new Map(); if (this._defaultConfig.contentNode) { if (!this._defaultConfig.contentNode.isConnected) { throw new Error( '[OverlayController] Could not find a render target, since the provided contentNode is not connected to the DOM. Make sure that it is connected, e.g. by doing "document.body.appendChild(contentNode)", before passing it on.', ); } this.__isContentNodeProjected = Boolean(this._defaultConfig.contentNode.assignedSlot); } this.updateConfig(config); this.__hasActiveTrapsKeyboardFocus = false; this.__hasActiveBackdrop = true; } get invoker() { return this.invokerNode; } get content() { return this._contentWrapperNode; } /** * @desc Usually the parent node of contentWrapperNode that either exists locally or globally. * When a responsive scenario is created (in which we switch from global to local or vice versa) * we need to know where we should reappend contentWrapperNode (or contentNode in case it's * projected). * @type {HTMLElement} */ get _renderTarget() { /** config [g1] */ if (this.placementMode === 'global') { return this.manager.globalRootNode; } /** config [l2] or [l4] */ if (this.__isContentNodeProjected) { return this.__originalContentParent.getRootNode().host; } /** config [l1] or [l3] */ return this.__originalContentParent; } /** * @desc The element our local overlay will be positioned relative to. * @type {HTMLElement} */ get _referenceNode() { return this.referenceNode || this.invokerNode; } set elevation(value) { if (this._contentWrapperNode) { this._contentWrapperNode.style.zIndex = value; } if (this.backdropNode) { this.backdropNode.style.zIndex = value; } } get elevation() { return this._contentWrapperNode.zIndex; } /** * @desc Allows to dynamically change the overlay configuration. Needed in case the * presentation of the overlay changes depending on screen size. * Note that this method is the only allowed way to update a configuration of an * OverlayController instance. * @param { OverlayConfig } cfgToAdd */ updateConfig(cfgToAdd) { // Teardown all previous configs this._handleFeatures({ phase: 'teardown' }); this.__prevConfig = this.config || {}; this.config = { ...this._defaultConfig, // our basic ingredients ...this.__sharedConfig, // the initial configured overlayController ...cfgToAdd, // the added config popperConfig: { ...(this._defaultConfig.popperConfig || {}), ...(this.__sharedConfig.popperConfig || {}), ...(cfgToAdd.popperConfig || {}), modifiers: { ...((this._defaultConfig.popperConfig && this._defaultConfig.popperConfig.modifiers) || {}), ...((this.__sharedConfig.popperConfig && this.__sharedConfig.popperConfig.modifiers) || {}), ...((cfgToAdd.popperConfig && cfgToAdd.popperConfig.modifiers) || {}), }, }, }; this.__validateConfiguration(this.config); Object.assign(this, this.config); this._init({ cfgToAdd }); } // eslint-disable-next-line class-methods-use-this __validateConfiguration(newConfig) { if (!newConfig.placementMode) { throw new Error( '[OverlayController] You need to provide a .placementMode ("global"|"local")', ); } if (!['global', 'local'].includes(newConfig.placementMode)) { throw new Error( `[OverlayController] "${newConfig.placementMode}" is not a valid .placementMode, use ("global"|"local")`, ); } if (!newConfig.contentNode) { throw new Error('[OverlayController] You need to provide a .contentNode'); } if (this.__isContentNodeProjected && !newConfig.contentWrapperNode) { throw new Error( '[OverlayController] You need to provide a .contentWrapperNode when .contentNode is projected', ); } if (newConfig.isTooltip && newConfig.placementMode !== 'local') { throw new Error( '[OverlayController] .isTooltip should be configured with .placementMode "local"', ); } if (newConfig.isTooltip && !newConfig.handlesAccessibility) { throw new Error( '[OverlayController] .isTooltip only takes effect when .handlesAccessibility is enabled', ); } // if (newConfig.popperConfig.modifiers.arrow && !newConfig.contentWrapperNode) { // throw new Error('You need to provide a .contentWrapperNode when Popper arrow is enabled'); // } } async _init({ cfgToAdd }) { this.__initcontentWrapperNode({ cfgToAdd }); this.__initConnectionTarget(); if (this.placementMode === 'local') { // Lazily load Popper if not done yet if (!this.constructor.popperModule) { this.constructor.popperModule = preloadPopper(); } } this._handleFeatures({ phase: 'init' }); } __initConnectionTarget() { // Now, add our node to the right place in dom (renderTarget) if (this._contentWrapperNode !== this.__prevConfig._contentWrapperNode) { if (this.config.placementMode === 'global' || !this.__isContentNodeProjected) { this._contentWrapperNode.appendChild(this.contentNode); } } if (this.__isContentNodeProjected && this.placementMode === 'local') { // We add the contentNode in its slot, so that it will be projected by contentWrapperNode this._renderTarget.appendChild(this.contentNode); } else { const isInsideRenderTarget = this._renderTarget === this._contentWrapperNode.parentNode; if (!isInsideRenderTarget) { // contentWrapperNode becomes the direct (non projected) parent of contentNode this._renderTarget.appendChild(this._contentWrapperNode); } } } /** * @desc Cleanup ._contentWrapperNode. We do this, because creating a fresh wrapper * can lead to problems with event listeners... */ __initcontentWrapperNode({ cfgToAdd }) { if (this.config.contentWrapperNode && this.placementMode === 'local') { /** config [l2],[l3],[l4] */ this._contentWrapperNode = this.config.contentWrapperNode; } else { /** config [l1],[g1] */ this._contentWrapperNode = document.createElement('div'); } this._contentWrapperNode.style.cssText = null; this._contentWrapperNode.style.display = 'none'; if (getComputedStyle(this.contentNode).position === 'absolute') { // Having a _contWrapperNode and a contentNode with 'position:absolute' results in // computed height of 0... this.contentNode.style.position = 'static'; } if (this.__isContentNodeProjected && this._contentWrapperNode.isConnected) { // We need to keep track of the original local context. /** config [l2], [l4] */ this.__originalContentParent = this._contentWrapperNode.parentNode; } else if (cfgToAdd.contentNode && cfgToAdd.contentNode.isConnected) { // We need to keep track of the original local context. /** config [l1], [l3], [g1] */ this.__originalContentParent = this.contentNode.parentNode; } } /** * @desc Display local overlays on top of elements with no z-index that appear later in the DOM */ _handleZIndex({ phase }) { if (this.placementMode !== 'local') { return; } if (phase === 'setup') { const zIndexNumber = Number(getComputedStyle(this.contentNode).zIndex); if (zIndexNumber < 1 || Number.isNaN(zIndexNumber)) { this._contentWrapperNode.style.zIndex = 1; } } } __setupTeardownAccessibility({ phase }) { if (phase === 'init') { this.__storeOriginalAttrs(this.contentNode, ['role', 'id']); this.__storeOriginalAttrs(this.invokerNode, [ 'aria-expanded', 'aria-labelledby', 'aria-describedby', ]); if (!this.contentNode.id) { this.contentNode.setAttribute('id', this._contentId); } if (this.isTooltip) { if (this.invokerNode) { this.invokerNode.setAttribute( this.invokerRelation === 'label' ? 'aria-labelledby' : 'aria-describedby', this._contentId, ); } this.contentNode.setAttribute('role', 'tooltip'); } else { if (this.invokerNode) { this.invokerNode.setAttribute('aria-expanded', this.isShown); } if (!this.contentNode.role) { this.contentNode.setAttribute('role', 'dialog'); } } } else if (phase === 'teardown') { this.__restorOriginalAttrs(); } } __storeOriginalAttrs(node, attrs) { const attrMap = {}; attrs.forEach(attrName => { attrMap[attrName] = node.getAttribute(attrName); }); this.__originalAttrs.set(node, attrMap); } __restorOriginalAttrs() { for (const [node, attrMap] of this.__originalAttrs) { Object.entries(attrMap).forEach(([attrName, value]) => { if (value !== null) { node.setAttribute(attrName, value); } else { node.removeAttribute(attrName); } }); } this.__originalAttrs.clear(); } get isShown() { return Boolean(this._contentWrapperNode.style.display !== 'none'); } /** * @event before-show right before the overlay shows. Used for animations and switching overlays * @event show right after the overlay is shown * @param {HTMLElement} elementToFocusAfterHide */ async show(elementToFocusAfterHide = this.elementToFocusAfterHide) { if (this.manager) { this.manager.show(this); } if (this.isShown) { return; } const event = new CustomEvent('before-show', { cancelable: true }); this.dispatchEvent(event); if (!event.defaultPrevented) { this._contentWrapperNode.style.display = ''; this._keepBodySize({ phase: 'before-show' }); await this._handleFeatures({ phase: 'show' }); this._keepBodySize({ phase: 'show' }); await this._handlePosition({ phase: 'show' }); this.elementToFocusAfterHide = elementToFocusAfterHide; this.dispatchEvent(new Event('show')); } } async _handlePosition({ phase }) { if (this.placementMode === 'global') { const addOrRemove = phase === 'show' ? 'add' : 'remove'; const placementClass = `${GLOBAL_OVERLAYS_CONTAINER_CLASS}--${this.viewportConfig.placement}`; this._contentWrapperNode.classList[addOrRemove](GLOBAL_OVERLAYS_CONTAINER_CLASS); this._contentWrapperNode.classList[addOrRemove](placementClass); this.contentNode.classList[addOrRemove](GLOBAL_OVERLAYS_CLASS); } else if (this.placementMode === 'local' && phase === 'show') { /** * Popper is weird about properly positioning the popper element when it is recreated so * we just recreate the popper instance to make it behave like it should. * Probably related to this issue: https://github.com/FezVrasta/popper.js/issues/796 * calling just the .update() function on the popper instance sadly does not resolve this. * This is however necessary for initial placement. */ await this.__createPopperInstance(); this._popper.update(); } } _keepBodySize({ phase }) { switch (phase) { case 'before-show': this.__bodyClientWidth = document.body.clientWidth; this.__bodyClientHeight = document.body.clientHeight; this.__bodyMarginRight = 0; this.__bodyMarginBottom = 0; break; case 'show': { if (supportsCSSTypedObject) { this.__bodyMarginRight = document.body.computedStyleMap().get('margin-right').value; this.__bodyMarginBottom = document.body.computedStyleMap().get('margin-bottom').value; } else if (window.getComputedStyle) { const bodyStyle = window.getComputedStyle(document.body); if (bodyStyle) { this.__bodyMarginRight = parseInt(bodyStyle.getPropertyValue('margin-right'), 10); this.__bodyMarginBottom = parseInt(bodyStyle.getPropertyValue('margin-bottom'), 10); } } const scrollbarWidth = document.body.clientWidth - this.__bodyClientWidth; const scrollbarHeight = document.body.clientHeight - this.__bodyClientHeight; const newMarginRight = this.__bodyMarginRight + scrollbarWidth; const newMarginBottom = this.__bodyMarginBottom + scrollbarHeight; if (supportsCSSTypedObject) { document.body.attributeStyleMap.set('margin-right', CSS.px(newMarginRight)); document.body.attributeStyleMap.set('margin-bottom', CSS.px(newMarginBottom)); } else { document.body.style.marginRight = `${newMarginRight}px`; document.body.style.marginBottom = `${newMarginBottom}px`; } break; } case 'hide': if (supportsCSSTypedObject) { document.body.attributeStyleMap.set('margin-right', CSS.px(this.__bodyMarginRight)); document.body.attributeStyleMap.set('margin-bottom', CSS.px(this.__bodyMarginBottom)); } else { document.body.style.marginRight = `${this.__bodyMarginRight}px`; document.body.style.marginBottom = `${this.__bodyMarginBottom}px`; } break; /* no default */ } } /** * @event before-hide right before the overlay hides. Used for animations and switching overlays * @event hide right after the overlay is hidden */ async hide() { if (this.manager) { this.manager.hide(this); } if (!this.isShown) { return; } const event = new CustomEvent('before-hide', { cancelable: true }); this.dispatchEvent(event); if (!event.defaultPrevented) { // await this.transitionHide({ backdropNode: this.backdropNode, contentNode: this.contentNode }); this._contentWrapperNode.style.display = 'none'; this._handleFeatures({ phase: 'hide' }); this._keepBodySize({ phase: 'hide' }); this.dispatchEvent(new Event('hide')); this._restoreFocus(); } } // eslint-disable-next-line class-methods-use-this, no-empty-function, no-unused-vars async transitionHide({ backdropNode, contentNode }) {} _restoreFocus() { // We only are allowed to move focus if we (still) 'own' it. // Otherwise we assume the 'outside world' has, purposefully, taken over // if (this._contentWrapperNode.activeElement) { if (this.elementToFocusAfterHide) { this.elementToFocusAfterHide.focus(); } // } } async toggle() { return this.isShown ? this.hide() : this.show(); } /** * @desc All features are handled here. Every feature is set up on show * and torn * @param {object} config * @param {'init'|'show'|'hide'|'teardown'} config.phase */ async _handleFeatures({ phase }) { this._handleZIndex({ phase }); if (this.preventsScroll) { this._handlePreventsScroll({ phase }); } if (this.isBlocking) { this._handleBlocking({ phase }); } if (this.hasBackdrop) { this._handleBackdrop({ phase }); } if (this.trapsKeyboardFocus) { this._handleTrapsKeyboardFocus({ phase }); } if (this.hidesOnEsc) { this._handleHidesOnEsc({ phase }); } if (this.hidesOnOutsideEsc) { this._handleHidesOnOutsideEsc({ phase }); } if (this.hidesOnOutsideClick) { this._handleHidesOnOutsideClick({ phase }); } if (this.handlesAccessibility) { this._handleAccessibility({ phase }); } if (this.inheritsReferenceWidth) { this._handleInheritsReferenceWidth(); } } _handlePreventsScroll({ phase }) { switch (phase) { case 'show': this.manager.requestToPreventScroll(); break; case 'hide': this.manager.requestToEnableScroll(); break; /* no default */ } } _handleBlocking({ phase }) { switch (phase) { case 'show': this.manager.requestToShowOnly(this); break; case 'hide': this.manager.retractRequestToShowOnly(this); break; /* no default */ } } get hasActiveBackdrop() { return this.__hasActiveBackdrop; } /** * @desc Sets up backdrop on the given overlay. If there was a backdrop on another element * it is removed. Otherwise this is the first time displaying a backdrop, so a fade-in * animation is played. */ _handleBackdrop({ animation = true, phase }) { if (this.placementMode === 'local') { switch (phase) { case 'init': if (!this.backdropNode) { this.backdropNode = document.createElement('div'); this.backdropNode.classList.add('local-overlays__backdrop'); } this.backdropNode.slot = '_overlay-shadow-outlet'; this.contentNode.parentNode.insertBefore(this.backdropNode, this.contentNode); break; case 'show': this.__hasActiveBackdrop = true; break; case 'hide': if (!this.backdropNode) { return; } this.__hasActiveBackdrop = false; break; case 'teardown': if (!this.backdropNode) { return; } this.backdropNode.parentNode.removeChild(this.backdropNode); break; /* no default */ } return; } const { backdropNode } = this; switch (phase) { case 'init': this.backdropNode = document.createElement('div'); this.backdropNode.classList.add('global-overlays__backdrop'); this._contentWrapperNode.parentElement.insertBefore( this.backdropNode, this._contentWrapperNode, ); break; case 'show': backdropNode.classList.add('global-overlays__backdrop--visible'); if (animation === true) { backdropNode.classList.add('global-overlays__backdrop--fade-in'); } this.__hasActiveBackdrop = true; break; case 'hide': if (!backdropNode) { return; } backdropNode.classList.remove('global-overlays__backdrop--fade-in'); if (animation) { let afterFadeOut; backdropNode.classList.add('global-overlays__backdrop--fade-out'); this.__backDropAnimation = new Promise(resolve => { afterFadeOut = () => { backdropNode.classList.remove('global-overlays__backdrop--fade-out'); backdropNode.classList.remove('global-overlays__backdrop--visible'); backdropNode.removeEventListener('animationend', afterFadeOut); resolve(); }; }); backdropNode.addEventListener('animationend', afterFadeOut); } else { backdropNode.classList.remove('global-overlays__backdrop--visible'); } this.__hasActiveBackdrop = false; break; case 'teardown': if (!backdropNode) { return; } if (animation && this.__backDropAnimation) { this.__backDropAnimation.then(() => { backdropNode.parentNode.removeChild(backdropNode); }); } else { backdropNode.parentNode.removeChild(backdropNode); } break; /* no default */ } } get hasActiveTrapsKeyboardFocus() { return this.__hasActiveTrapsKeyboardFocus; } _handleTrapsKeyboardFocus({ phase }) { if (phase === 'show') { this.enableTrapsKeyboardFocus(); } else if (phase === 'hide' || phase === 'teardown') { this.disableTrapsKeyboardFocus(); } } enableTrapsKeyboardFocus() { if (this.__hasActiveTrapsKeyboardFocus) { return; } if (this.manager) { this.manager.disableTrapsKeyboardFocusForAll(); } this._containFocusHandler = containFocus(this.contentNode); this.__hasActiveTrapsKeyboardFocus = true; if (this.manager) { this.manager.informTrapsKeyboardFocusGotEnabled(); } } disableTrapsKeyboardFocus({ findNewTrap = true } = {}) { if (!this.__hasActiveTrapsKeyboardFocus) { return; } if (this._containFocusHandler) { this._containFocusHandler.disconnect(); this._containFocusHandler = undefined; } this.__hasActiveTrapsKeyboardFocus = false; if (this.manager) { this.manager.informTrapsKeyboardFocusGotDisabled({ disabledCtrl: this, findNewTrap }); } } _handleHidesOnEsc({ phase }) { if (phase === 'show') { this.__escKeyHandler = ev => ev.key === 'Escape' && this.hide(); this.contentNode.addEventListener('keyup', this.__escKeyHandler); if (this.invokerNode) { this.invokerNode.addEventListener('keyup', this.__escKeyHandler); } } else if (phase === 'hide') { this.contentNode.removeEventListener('keyup', this.__escKeyHandler); if (this.invokerNode) { this.invokerNode.removeEventListener('keyup', this.__escKeyHandler); } } } _handleHidesOnOutsideEsc({ phase }) { if (phase === 'show') { this.__escKeyHandler = ev => ev.key === 'Escape' && this.hide(); document.addEventListener('keyup', this.__escKeyHandler); } else if (phase === 'hide') { document.removeEventListener('keyup', this.__escKeyHandler); } } _handleInheritsReferenceWidth() { if (!this._referenceNode || this.placementMode === 'global') { return; } const referenceWidth = `${this._referenceNode.clientWidth}px`; switch (this.inheritsReferenceWidth) { case 'max': this._contentWrapperNode.style.maxWidth = referenceWidth; break; case 'full': this._contentWrapperNode.style.width = referenceWidth; break; case 'min': this._contentWrapperNode.style.minWidth = referenceWidth; this._contentWrapperNode.style.width = 'auto'; break; /* no default */ } } _handleHidesOnOutsideClick({ phase }) { const addOrRemoveListener = phase === 'show' ? 'addEventListener' : 'removeEventListener'; if (phase === 'show') { let wasClickInside = false; // handle on capture phase and remember till the next task that there was an inside click this.__preventCloseOutsideClick = () => { wasClickInside = true; setTimeout(() => { wasClickInside = false; }); }; // handle on capture phase and schedule the hide if needed this.__onCaptureHtmlClick = () => { setTimeout(() => { if (wasClickInside === false) { this.hide(); } }); }; } this._contentWrapperNode[addOrRemoveListener]('click', this.__preventCloseOutsideClick, true); if (this.invokerNode) { this.invokerNode[addOrRemoveListener]('click', this.__preventCloseOutsideClick, true); } document.documentElement[addOrRemoveListener]('click', this.__onCaptureHtmlClick, true); } _handleAccessibility({ phase }) { if (phase === 'init' || phase === 'teardown') { this.__setupTeardownAccessibility({ phase }); } if (this.invokerNode && !this.isTooltip) { this.invokerNode.setAttribute('aria-expanded', phase === 'show'); } } teardown() { this._handleFeatures({ phase: 'teardown' }); // Remove the content node wrapper from the global rootnode this._teardowncontentWrapperNode(); } _teardowncontentWrapperNode() { if ( this.placementMode === 'global' && this._contentWrapperNode && this._contentWrapperNode.parentNode ) { this._contentWrapperNode.parentNode.removeChild(this._contentWrapperNode); } } async __createPopperInstance() { if (this._popper) { this._popper.destroy(); this._popper = null; } const { default: Popper } = await this.constructor.popperModule; this._popper = new Popper(this._referenceNode, this._contentWrapperNode, { ...this.config.popperConfig, }); } __fakeExtendsEventTarget() { const delegate = document.createDocumentFragment(); ['addEventListener', 'dispatchEvent', 'removeEventListener'].forEach(funcName => { this[funcName] = (...args) => delegate[funcName](...args); }); } }