Repository: sweetalert2/sweetalert2 (js)

Issue:
Draggable message windows
### New feature motivation

The dragging feature is useful to view the information behind the message window.

### New feature description

```
Swal.fire({
  imageUrl: 'https://placeholder.pics/svg/300x1500',
  imageHeight: 1500,
  imageAlt: 'A tall image',
 **draggable:true**
})
```

### New feature implementation

_No response_

Pull request:
feat: add draggable param
closes #2673 

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- **New Features**
	- Introduced draggable functionality for popups, allowing users to move them freely.
	- Added a new `draggable` property to configuration options for enhanced customization.

- **Bug Fixes**
	- Improved clarity and documentation for popup options in the SweetAlert2 interface.

- **Documentation**
	- Updated TypeScript declaration files for better usability and understanding of popup options.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Patch:
diff --git a/cypress/utils.js b/cypress/utils.js
index a8cb7a92..cfb27fa6 100644
--- a/cypress/utils.js
+++ b/cypress/utils.js
@@ -20,6 +20,30 @@ export const dispatchCustomEvent = (elem, eventName, eventDetail = {}) => {
   elem.dispatchEvent(event)
 }
 
+export const dispatchMouseEvent = (elem, eventName, eventDetail = {}) => {
+  const event = new MouseEvent(eventName, {
+    bubbles: true,
+    cancelable: true,
+    ...eventDetail,
+  })
+  elem.dispatchEvent(event)
+}
+
+export const dispatchTouchEvent = (elem, eventName, touchDetail = {}) => {
+  const touch = new Touch({
+    identifier: JSON.stringify(touchDetail),
+    target: elem,
+    ...touchDetail,
+  });
+
+  const event = new TouchEvent(eventName, {
+    bubbles: true,
+    cancelable: true,
+    touches: [touch],
+  })
+  elem.dispatchEvent(event)
+}
+
 export const triggerKeydownEvent = (target, key, params = {}) => {
   const event = document.createEvent('HTMLEvents')
   event.key = key
diff --git a/src/utils/dom/renderers/renderPopup.js b/src/utils/dom/renderers/renderPopup.js
index faa56881..ad80b350 100644
--- a/src/utils/dom/renderers/renderPopup.js
+++ b/src/utils/dom/renderers/renderPopup.js
@@ -1,4 +1,5 @@
 import { swalClasses } from '../../classes.js'
+import { addDraggableListeners, removeDraggableListeners } from '../../draggable.js'
 import * as dom from '../../dom/index.js'
 
 /**
@@ -42,6 +43,12 @@ export const renderPopup = (instance, params) => {
 
   // Classes
   addClasses(popup, params)
+
+  if (params.draggable && !params.toast) {
+    addDraggableListeners(popup)
+  } else {
+    removeDraggableListeners(popup)
+  }
 }
 
 /**
diff --git a/src/utils/draggable.js b/src/utils/draggable.js
new file mode 100644
index 00000000..680df978
--- /dev/null
+++ b/src/utils/draggable.js
@@ -0,0 +1,83 @@
+import * as dom from './dom/index.js'
+
+let dragging = false
+let mousedownX = 0
+let mousedownY = 0
+let initialX = 0
+let initialY = 0
+
+/**
+ * @param {HTMLElement} popup
+ */
+export const addDraggableListeners = (popup) => {
+  popup.addEventListener('mousedown', down)
+  document.body.addEventListener('mousemove', move)
+  popup.addEventListener('mouseup', up)
+
+  popup.addEventListener('touchstart', down)
+  document.body.addEventListener('touchmove', move)
+  popup.addEventListener('touchend', up)
+}
+
+/**
+ * @param {HTMLElement} popup
+ */
+export const removeDraggableListeners = (popup) => {
+  popup.removeEventListener('mousedown', down)
+  document.body.removeEventListener('mousemove', move)
+  popup.removeEventListener('mouseup', up)
+
+  popup.removeEventListener('touchstart', down)
+  document.body.removeEventListener('touchmove', move)
+  popup.removeEventListener('touchend', up)
+}
+
+/**
+ * @param {MouseEvent | TouchEvent} event
+ */
+const down = (event) => {
+  const popup = dom.getPopup()
+
+  if (event.target === popup || dom.getIcon().contains(/** @type {HTMLElement} */ (event.target))) {
+    dragging = true
+    const clientXY = getClientXY(event)
+    mousedownX = clientXY.clientX
+    mousedownY = clientXY.clientY
+    initialX = parseInt(popup.style.insetInlineStart) || 0
+    initialY = parseInt(popup.style.insetBlockStart) || 0
+  }
+}
+
+/**
+ * @param {MouseEvent | TouchEvent} event
+ */
+const move = (event) => {
+  const popup = dom.getPopup()
+
+  if (dragging) {
+    let { clientX, clientY } = getClientXY(event)
+    popup.style.insetInlineStart = `${initialX + (clientX - mousedownX)}px`
+    popup.style.insetBlockStart = `${initialY + (clientY - mousedownY)}px`
+  }
+}
+
+const up = () => {
+  dragging = false
+}
+
+/**
+ * @param {MouseEvent | TouchEvent} event
+ * @returns {{ clientX: number, clientY: number }}
+ */
+const getClientXY = (event) => {
+  let clientX = 0,
+    clientY = 0
+  if (event.type.startsWith('mouse')) {
+    clientX = /** @type {MouseEvent} */ (event).clientX
+    clientY = /** @type {MouseEvent} */ (event).clientY
+  } else if (event.type.startsWith('touch')) {
+    clientX = /** @type {TouchEvent} */ (event).touches[0].clientX
+    clientY = /** @type {TouchEvent} */ (event).touches[0].clientY
+  }
+  return { clientX, clientY }
+}
diff --git a/src/utils/params.js b/src/utils/params.js
index 9e4ab042..dec7371c 100644
--- a/src/utils/params.js
+++ b/src/utils/params.js
@@ -11,6 +11,7 @@ export const defaultParams = {
   iconHtml: undefined,
   template: undefined,
   toast: false,
+  draggable: false,
   animation: true,
   showClass: {
     popup: 'swal2-show',
@@ -113,6 +114,7 @@ export const updatableParams = [
   'denyButtonText',
   'didClose',
   'didDestroy',
+  'draggable',
   'footer',
   'hideClass',
   'html',
@@ -147,6 +149,7 @@ const toastIncompatibleParams = [
   'allowOutsideClick',
   'allowEnterKey',
   'backdrop',
+  'draggable',
   'focusConfirm',
   'focusDeny',
   'focusCancel',
diff --git a/sweetalert2.d.ts b/sweetalert2.d.ts
index b583c5d5..01711257 100644
--- a/sweetalert2.d.ts
+++ b/sweetalert2.d.ts
@@ -636,14 +636,21 @@ declare module 'sweetalert2' {
     backdrop?: boolean | string | undefined
 
     /**
-     * Whether or not an alert should be treated as a toast notification.
+     * Whether or not a popup should be treated as a toast notification.
      * This option is normally coupled with the `position` and `timer` parameters.
-     * Toasts are NEVER autofocused.
+     * Toasts are NEVER autofocused and they don't block the document.
      *
      * @default false
      */
     toast?: boolean | undefined
 
+    /**
+     * Whether or not a popup should be draggabele.
+     *
+     * @default false
+     */
+    draggable?: boolean | undefined
+
     /**
      * The container element for adding popup into (query selector only).
      *


Test patch:
diff --git a/cypress/e2e/tests.cy.js b/cypress/e2e/tests.cy.js
index 3567834b..abcfab59 100644
--- a/cypress/e2e/tests.cy.js
+++ b/cypress/e2e/tests.cy.js
@@ -8,6 +8,8 @@ import {
   SwalWithoutAnimation,
   TIMEOUT,
   dispatchCustomEvent,
+  dispatchMouseEvent,
+  dispatchTouchEvent,
   ensureClosed,
   isHidden,
   triggerKeydownEvent,
@@ -463,6 +465,56 @@ describe('customClass', () => {
   })
 })
 
+describe('draggable', () => {
+  it('drag popup with mouse evnets', () => {
+    SwalWithoutAnimation.fire({
+      title: 'Drag me!',
+      draggable: true,
+    })
+
+    const popup = Swal.getPopup()
+    const initialRect = popup.getBoundingClientRect()
+    const initialX = initialRect.left
+    const initialY = initialRect.top
+
+    dispatchMouseEvent(popup, 'mousedown', { clientX: initialX, clientY: initialY })
+    dispatchMouseEvent(document.body, 'mousemove', { clientX: initialX + 10, clientY: initialY + 10 })
+    dispatchMouseEvent(popup, 'mouseup')
+    dispatchMouseEvent(document.body, 'mousemove', { clientX: initialX + 20, clientY: initialY + 20 })
+
+    const finalRect = popup.getBoundingClientRect()
+    const finalX = finalRect.left
+    const finalY = finalRect.top
+
+    expect(finalX).to.equal(initialX + 10)
+    expect(finalY).to.equal(initialY + 10)
+  })
+
+  it('drag popup with touch events', () => {
+    SwalWithoutAnimation.fire({
+      title: 'Drag me!',
+      draggable: true,
+    })
+
+    const popup = Swal.getPopup()
+    const initialRect = popup.getBoundingClientRect()
+    const initialX = initialRect.left
+    const initialY = initialRect.top
+
+    dispatchTouchEvent(popup, 'touchstart', { clientX: initialX, clientY: initialY })
+    dispatchTouchEvent(document.body, 'touchmove', { clientX: initialX + 10, clientY: initialY + 10 })
+    dispatchTouchEvent(popup, 'touchend')
+    dispatchTouchEvent(document.body, 'touchmove', { clientX: initialX + 20, clientY: initialY + 20 })
+
+    const finalRect = popup.getBoundingClientRect()
+    const finalX = finalRect.left
+    const finalY = finalRect.top
+
+    expect(finalX).to.equal(initialX + 10)
+    expect(finalY).to.equal(initialY + 10)
+  })
+})
+
 describe('grow', () => {
   it('grow row', () => {
     Swal.fire({