Repository: sweetalert2/sweetalert2 (js)

Issue:
Fire global events that could be subscribed to
### New feature motivation

I am writing an automated test suite that, in part, covers code which uses swal2 to display modals. I wanted to capture a list of all the times the modal is opened and closed across the tests... a list of `[ "show", "hide", "show", ... ]` if you will. That way I could compare such a list to the expected number of shows and hides.

Unfortunately, I don't see any events being fired, which I can subscribe to, that let me know when the modal is shown or hidden -- from the outside, as opposed to injecting event handler (`willShow`, etc) into each `.fire(..)` call.

Here's the sorta hack/work-around I came up with:

```js
var events = [];

var observer = new MutationObserver(mutationList => {
    events.push(
        ...(
            mutationList.filter(mutation => (
                mutation.type == "attributes" &&
                mutation.attributeName == "class" &&
                mutation.target.matches(".swal2-show, .swal2-hide")
            ))
            .map(mutation => (
                mutation.target.className.match(/\bswal2-(show|hide)\b/)[1]
            ))
        )
    );
});

observer.observe(document.body,{ attributes: true, subtree: true, });

// later, after the test finishes, remove consecutive duplicates
events = events.reduce((list,v) => (
    list.length == 0 || list[list.length - 1] != v ?
        [ ...list, v ] :
        list
),[]);

events;   // [ "show", "hide", "show", ... ]
```

This seems to work for my usage, but... is far from ideal (especially the mutation observer part).

### New feature description

I'd love it if I could avoid the `MutationObserver`, and instead the `Swal` object was an event emitter (like `EventEmitter3`, etc), so that I could do:

```js
var events = [];

Swal.on("show",() => events.push("show"));
Swal.on("hide",() => events.push("hide"));

// later, after the test finishes, remove consecutive duplicates
events = events.reduce((list,v) => (
    list.length == 0 || list[list.length - 1] != v ?
        [ ...list, v ] :
        list
),[]);

events;   // [ "show", "hide", "show", ... ]
```

Of course, the methods could be `on()` / `off()`, or `addListener()` / `removeListener()`, or whatever naming is preferred.

### New feature implementation

At a minimum, I think you'd need to add two methods (again, named `on()` / `off()`, etc) for registering an event listener, and removing an event listener. You *could* add more sugar helpers, like `removeAll()`, etc... but I think that's optional as a minimal feature set here.

There are several OSS libraries that already do event-emitter interfaces well, such as [EventEmitter3](https://github.com/primus/eventemitter3). I think you could find one of those and either bundle it in and use out-of-the-box, or at least pull a subset of its implementation from and drop-in to `Swal`'s definition.

Then in each place internally where Swal is doing things like adding `swal2-show` / `swal2-hide` classes, that's where you'd need to also have `emit(..)` calls to emit the proper events to the interface. The arguments to pass along should probably match/mirror the other internal event handlers (`willShow`, etc).

Also TBD would be which events need to be published on the emitter. I need "show" and "hide" minimally, but there may be others that are useful.

----

I'd be happy to try my hand at a PR, if this feature is considered something useful in scope for the project.

Pull request:
feat: add global events
closes #2760 

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

## Summary by CodeRabbit

- **New Features**
	- Enhanced SweetAlert2 integration with improved event handling for modal lifecycle events.
	- Introduced new methods for event registration and deregistration, allowing for greater control over event listeners.
	- Added types for lifecycle events in the SweetAlert2 module, improving type safety and usability.

- **Tests**
	- Added a new test suite for verifying event handling capabilities in the SweetAlert library, ensuring expected behavior for various lifecycle events.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Patch:
diff --git a/src/instanceMethods/_destroy.js b/src/instanceMethods/_destroy.js
index b14e2347..b15eb3c9 100644
--- a/src/instanceMethods/_destroy.js
+++ b/src/instanceMethods/_destroy.js
@@ -23,6 +23,7 @@ export function _destroy() {
   if (typeof innerParams.didDestroy === 'function') {
     innerParams.didDestroy()
   }
+  globalState.eventEmitter.emit('didDestroy')
   disposeSwal(this)
 }
 
diff --git a/src/instanceMethods/close.js b/src/instanceMethods/close.js
index a12b7cff..86cd9e91 100644
--- a/src/instanceMethods/close.js
+++ b/src/instanceMethods/close.js
@@ -162,6 +162,7 @@ const handlePopupAnimation = (instance, popup, innerParams) => {
   if (typeof innerParams.willClose === 'function') {
     innerParams.willClose(popup)
   }
+  globalState.eventEmitter.emit('willClose', popup)
 
   if (animationIsSupported) {
     animatePopup(instance, popup, container, innerParams.returnFocus, innerParams.didClose)
@@ -206,6 +207,7 @@ const triggerDidCloseAndDispose = (instance, didClose) => {
     if (typeof didClose === 'function') {
       didClose.bind(instance.params)()
     }
+    globalState.eventEmitter.emit('didClose')
     // instance might have been destroyed already
     if (instance._destroy) {
       instance._destroy()
diff --git a/src/staticMethods.js b/src/staticMethods.js
index dee0f907..5a04ee0d 100644
--- a/src/staticMethods.js
+++ b/src/staticMethods.js
@@ -5,4 +5,5 @@ export * from './staticMethods/mixin.js'
 export * from './staticMethods/showLoading.js'
 export * from './staticMethods/timer.js'
 export * from './staticMethods/bindClickHandler.js'
+export * from './staticMethods/eventHandlers.js'
 export { isValidParameter, isUpdatableParameter, isDeprecatedParameter } from './utils/params.js'
diff --git a/src/staticMethods/eventHandlers.js b/src/staticMethods/eventHandlers.js
new file mode 100644
index 00000000..cb863af0
--- /dev/null
+++ b/src/staticMethods/eventHandlers.js
@@ -0,0 +1,40 @@
+import globalState from '../globalState.js'
+import EventEmitter from '../utils/EventEmitter.js'
+
+globalState.eventEmitter = new EventEmitter()
+
+/**
+ * @param {string} eventName
+ * @param {EventHandler} eventHandler
+ */
+export const on = (eventName, eventHandler) => {
+  globalState.eventEmitter.on(eventName, eventHandler)
+}
+
+/**
+ * @param {string} eventName
+ * @param {EventHandler} eventHandler
+ */
+export const once = (eventName, eventHandler) => {
+  globalState.eventEmitter.once(eventName, eventHandler)
+}
+
+/**
+ * @param {string} [eventName]
+ * @param {EventHandler} [eventHandler]
+ */
+export const off = (eventName, eventHandler) => {
+  // Remove all handlers for all events
+  if (!eventName) {
+    globalState.eventEmitter.reset()
+    return
+  }
+
+  if (eventHandler) {
+    // Remove a specific handler
+    globalState.eventEmitter.removeListener(eventName, eventHandler)
+  } else {
+    // Remove all handlers for a specific event
+    globalState.eventEmitter.removeAllListeners(eventName)
+  }
+}
diff --git a/src/types.js b/src/types.js
index 10c2083f..45a99bae 100644
--- a/src/types.js
+++ b/src/types.js
@@ -10,6 +10,7 @@
 
 /**
  * @typedef { import('./utils/Timer').default } Timer
+ * @typedef { import('./utils/EventEmitter').default } EventEmitter
  */
 
 /**
@@ -17,6 +18,7 @@
  * @property {SweetAlert} [currentInstance]
  * @property {Element | null} [previousActiveElement]
  * @property {Timer} [timeout]
+ * @property {EventEmitter} [eventEmitter]
  * @property {NodeJS.Timeout} [restoreFocusTimeout]
  * @property {(this: HTMLElement, event: KeyboardEvent) => any} [keydownHandler]
  * @property {HTMLElement | (Window & typeof globalThis)} [keydownTarget]
@@ -112,3 +114,9 @@
  *   | 'icon-error'
  * } SwalClass
  */
+
+/**
+ * @typedef {(eventName: string) => void} EventHandler
+ * @typedef {EventHandler[]} EventHandlers
+ * @typedef {Record<string, EventHandlers>} Events
+ */
diff --git a/src/utils/EventEmitter.js b/src/utils/EventEmitter.js
new file mode 100644
index 00000000..d6b8cccd
--- /dev/null
+++ b/src/utils/EventEmitter.js
@@ -0,0 +1,92 @@
+// Source: https://gist.github.com/mudge/5830382?permalink_comment_id=2691957#gistcomment-2691957
+
+export default class EventEmitter {
+  constructor() {
+    /** @type {Events} */
+    this.events = {}
+  }
+
+  /**
+   * @param {string} eventName
+   * @returns {EventHandlers}
+   */
+  _getHandlersByEventName(eventName) {
+    if (typeof this.events[eventName] === 'undefined') {
+      // not Set because we need to keep the FIFO order
+      // https://github.com/sweetalert2/sweetalert2/pull/2763#discussion_r1748990334
+      this.events[eventName] = []
+    }
+    return this.events[eventName]
+  }
+
+  /**
+   * @param {string} eventName
+   * @param {EventHandler} eventHandler
+   */
+  on(eventName, eventHandler) {
+    const currentHandlers = this._getHandlersByEventName(eventName)
+    if (!currentHandlers.includes(eventHandler)) {
+      currentHandlers.push(eventHandler)
+    }
+  }
+
+  /**
+   * @param {string} eventName
+   * @param {EventHandler} eventHandler
+   */
+  once(eventName, eventHandler) {
+    /**
+     * @param {Array} args
+     */
+    const onceFn = (...args) => {
+      this.removeListener(eventName, onceFn)
+      eventHandler.apply(this, args)
+    }
+    this.on(eventName, onceFn)
+  }
+
+  /**
+   * @param {string} eventName
+   * @param {Array} args
+   */
+  emit(eventName, ...args) {
+    this._getHandlersByEventName(eventName).forEach(
+      /**
+       * @param {EventHandler} eventHandler
+       */
+      (eventHandler) => {
+        try {
+          eventHandler.apply(this, args)
+        } catch (error) {
+          console.error(error)
+        }
+      }
+    )
+  }
+
+  /**
+   * @param {string} eventName
+   * @param {EventHandler} eventHandler
+   */
+  removeListener(eventName, eventHandler) {
+    const currentHandlers = this._getHandlersByEventName(eventName)
+    const index = currentHandlers.indexOf(eventHandler)
+    if (index > -1) {
+      currentHandlers.splice(index, 1)
+    }
+  }
+
+  /**
+   * @param {string} eventName
+   */
+  removeAllListeners(eventName) {
+    if (this.events[eventName] !== undefined) {
+      // https://github.com/sweetalert2/sweetalert2/pull/2763#discussion_r1749239222
+      this.events[eventName].length = 0
+    }
+  }
+
+  reset() {
+    this.events = {}
+  }
+}
diff --git a/src/utils/dom/renderers/render.js b/src/utils/dom/renderers/render.js
index a2e32859..0d0e6715 100644
--- a/src/utils/dom/renderers/render.js
+++ b/src/utils/dom/renderers/render.js
@@ -1,3 +1,4 @@
+import globalState from '../../../globalState.js'
 import { getPopup } from '../getters.js'
 import { renderActions } from './renderActions.js'
 import { renderCloseButton } from './renderCloseButton.js'
@@ -32,4 +33,5 @@ export const render = (instance, params) => {
   if (typeof params.didRender === 'function' && popup) {
     params.didRender(popup)
   }
+  globalState.eventEmitter.emit('didRender', popup)
 }
diff --git a/src/utils/openPopup.js b/src/utils/openPopup.js
index 95fd1daf..8166961c 100644
--- a/src/utils/openPopup.js
+++ b/src/utils/openPopup.js
@@ -19,6 +19,7 @@ export const openPopup = (params) => {
   if (typeof params.willOpen === 'function') {
     params.willOpen(popup)
   }
+  globalState.eventEmitter.emit('willOpen', popup)
 
   const bodyStyles = window.getComputedStyle(document.body)
   const initialBodyOverflow = bodyStyles.overflowY
@@ -41,6 +42,7 @@ export const openPopup = (params) => {
   if (typeof params.didOpen === 'function') {
     setTimeout(() => params.didOpen(popup))
   }
+  globalState.eventEmitter.emit('didOpen', popup)
 
   dom.removeClass(container, swalClasses['no-transition'])
 }
diff --git a/sweetalert2.d.ts b/sweetalert2.d.ts
index 009821a0..b583c5d5 100644
--- a/sweetalert2.d.ts
+++ b/sweetalert2.d.ts
@@ -360,6 +360,8 @@ declare module 'sweetalert2' {
 
   export type SweetAlertIcon = 'success' | 'error' | 'warning' | 'info' | 'question'
 
+  export type SweetAlertEventName = 'didRender' | 'willOpen' | 'didOpen' | 'willClose' | 'didClose' | 'didDestroy'
+
   export type SweetAlertInput =
     | 'text'
     | 'email'
@@ -1214,6 +1216,24 @@ declare module 'sweetalert2' {
      */
     didDestroy?(): void
 
+    /**
+     * Attach a handler function for a lifecycle event.
+     */
+    on?(event: SweetAlertEventName, handler: () => void): void
+
+    /**
+     * Attach a handler function for a lifecycle event. The handler is executed at most once.
+     */
+    once?(event: SweetAlertEventName, handler: () => void): void
+
+    /**
+     * Remove event handlers.
+     * If neither `event` nor `handler` is provided, all handlers for all events will be removed.
+     * If `event` is provided, but `handler` is not, all handlers for the given event will be removed.
+     * If both `event` and `handler` are provided, only the given handler for the given event will be removed.
+     */
+    off?(event?: SweetAlertEventName, handler?: () => void): void
+
     /**
      * Set to `false` to disable body padding adjustment when scrollbar is present.
      *


Test patch:
diff --git a/cypress/e2e/tests.cy.js b/cypress/e2e/tests.cy.js
index 0ac5a04d..3567834b 100644
--- a/cypress/e2e/tests.cy.js
+++ b/cypress/e2e/tests.cy.js
@@ -1836,6 +1836,171 @@ describe('timerProgressBar', () => {
   })
 })
 
+describe('global events and listeners', () => {
+  it('should attach event handlers with .on()', (done) => {
+    const spyDidRender = cy.spy()
+    const spyWillOpen = cy.spy()
+    const spyDidOpen = cy.spy()
+    const spyWillClose = cy.spy()
+    const spyDidClose = cy.spy()
+    const spyDidDestroy = cy.spy()
+    Swal.on('didRender', spyDidRender)
+    Swal.on('willOpen', spyWillOpen)
+    Swal.on('didOpen', spyDidOpen)
+    Swal.on('willClose', spyWillClose)
+    Swal.on('didClose', spyDidClose)
+    Swal.on('didDestroy', spyDidDestroy)
+    let popup
+    SwalWithoutAnimation.fire({
+      didOpen: () => {
+        popup = Swal.getPopup()
+        expect(spyDidRender).to.be.calledWith(popup)
+        expect(spyWillOpen).to.be.calledWith(popup)
+        expect(spyDidOpen).to.be.calledWith(popup)
+        Swal.clickConfirm()
+      },
+      didClose: () => {
+        expect(spyWillClose).to.be.calledWith()
+      },
+      didDestroy: () => {
+        setTimeout(() => {
+          expect(spyDidClose).to.be.calledWith()
+          expect(spyDidDestroy).to.be.calledWith()
+          done()
+        })
+      },
+    })
+  })
+
+  it('should attach multiple handler for the same event', (done) => {
+    const spyWillOpen1 = cy.spy()
+    const spyWillOpen2 = cy.spy()
+
+    Swal.on('willOpen', spyWillOpen1)
+    Swal.on('willOpen', spyWillOpen2)
+
+    SwalWithoutAnimation.fire({
+      didOpen: () => {
+        expect(spyWillOpen1).to.be.called
+        expect(spyWillOpen2).to.be.called
+        done()
+      },
+    })
+  })
+
+  it('should handle exeptions from handlers properly', (done) => {
+    const spyDidOpen = cy.spy()
+    const spyConsoleError = cy.spy(console, 'error')
+
+    Swal.on('willOpen', () => {
+      throw new Error('error from willOpen')
+    })
+    Swal.on('didOpen', spyDidOpen)
+
+    SwalWithoutAnimation.fire({
+      didOpen: () => {
+        expect(spyConsoleError).to.be.calledOnce
+        const calls = spyConsoleError.getCalls()
+        expect(calls[0].args[0].toString()).to.equal('Error: error from willOpen')
+        expect(spyDidOpen).to.be.called
+        done()
+      },
+    })
+  })
+
+  it('should call handlers added with .once()', (done) => {
+    const spyDidRender = cy.spy()
+    const spyWillOpen = cy.spy()
+    const spyDidOpen = cy.spy()
+    const spyWillClose = cy.spy()
+    const spyDidClose = cy.spy()
+    const spyDidDestroy = cy.spy()
+    Swal.once('didRender', spyDidRender)
+    Swal.once('willOpen', spyWillOpen)
+    Swal.once('didOpen', spyDidOpen)
+    Swal.once('willClose', spyWillClose)
+    Swal.once('didClose', spyDidClose)
+    Swal.once('didDestroy', spyDidDestroy)
+    let popup
+    SwalWithoutAnimation.fire({
+      title: 'first swal',
+      willOpen: () => {
+        popup = Swal.getPopup()
+      },
+    }).then(() => {
+      SwalWithoutAnimation.fire({
+        title: 'second swal',
+        didOpen: () => {
+          expect(spyDidRender).to.be.calledOnceWith(popup)
+          expect(spyWillOpen).to.be.calledOnceWith(popup)
+          expect(spyDidOpen).to.be.calledOnceWith(popup)
+          Swal.clickConfirm()
+        },
+        didClose: () => {
+          expect(spyWillClose).to.be.calledOnceWith()
+        },
+        didDestroy: () => {
+          setTimeout(() => {
+            expect(spyDidClose).to.be.calledOnceWith()
+            expect(spyDidDestroy).to.be.calledOnceWith()
+            done()
+          })
+        },
+      })
+    })
+    Swal.clickConfirm()
+  })
+
+  it('should unset all handlers for all events .off()', (done) => {
+    const spyWillOpen = cy.spy()
+    const spyDidOpen = cy.spy()
+    Swal.on('willOpen', spyWillOpen)
+    Swal.on('didOpen', spyDidOpen)
+    Swal.off()
+
+    SwalWithoutAnimation.fire({
+      didOpen: () => {
+        expect(spyWillOpen).not.to.be.called
+        expect(spyDidOpen).not.to.be.called
+        done()
+      },
+    })
+  })
+
+  it('should unset all handlers for a specifi event .off(eventName)', (done) => {
+    const spyWillOpen = cy.spy()
+    const spyDidOpen = cy.spy()
+    Swal.on('willOpen', spyWillOpen)
+    Swal.on('didOpen', spyDidOpen)
+    Swal.off('willOpen')
+
+    SwalWithoutAnimation.fire({
+      didOpen: () => {
+        expect(spyWillOpen).not.to.be.called
+        expect(spyDidOpen).to.be.called
+        done()
+      },
+    })
+  })
+
+  it('should unset a specific handler for a specifi event .off(eventName, eventHandler)', (done) => {
+    const spyWillOpen1 = cy.spy()
+    const spyWillOpen2 = cy.spy()
+
+    Swal.on('willOpen', spyWillOpen1)
+    Swal.on('willOpen', spyWillOpen2)
+    Swal.off('willOpen', spyWillOpen1)
+
+    SwalWithoutAnimation.fire({
+      didOpen: () => {
+        expect(spyWillOpen1).not.to.be.called
+        expect(spyWillOpen2).to.be.called
+        done()
+      },
+    })
+  })
+})
+
 describe('update()', () => {
   it('all updatableParams are valid', () => {
     expect(updatableParams.length).not.to.equal(0)