fix(modal): card modal can now be swiped to close on the content (#25185)

resolves #22046
This commit is contained in:
Liam DeBeasi
2022-04-27 12:34:12 -04:00
committed by GitHub
parent 56a07f6d1d
commit 7633ddbc84
24 changed files with 442 additions and 51 deletions

View File

@@ -1,5 +1,6 @@
import type { Animation } from '../../../interface';
import { getTimeGivenProgression } from '../../../utils/animation/cubic-bezier';
import { isIonContent } from '../../../utils/content';
import type { GestureDetail } from '../../../utils/gesture';
import { createGesture } from '../../../utils/gesture';
import { clamp } from '../../../utils/helpers';
@@ -11,11 +12,46 @@ export const SwipeToCloseDefaults = {
MIN_PRESENTING_SCALE: 0.93,
};
export const createSwipeToCloseGesture = (el: HTMLIonModalElement, animation: Animation, onDismiss: () => void) => {
export const createSwipeToCloseGesture = (
el: HTMLIonModalElement,
contentEl: HTMLElement,
scrollEl: HTMLElement,
animation: Animation,
onDismiss: () => void
) => {
const height = el.offsetHeight;
let isOpen = false;
let canDismissBlocksGesture = false;
const canDismissMaxStep = 0.2;
const getScrollY = () => {
if (isIonContent(contentEl)) {
return (contentEl as HTMLIonContentElement).scrollY;
/**
* Custom scroll containers are intended to be
* used with virtual scrolling, so we assume
* there is scrolling in this case.
*/
} else {
return true;
}
};
const initialScrollY = getScrollY();
const disableContentScroll = () => {
if (isIonContent(contentEl)) {
(contentEl as HTMLIonContentElement).scrollY = false;
} else {
contentEl.style.setProperty('overflow', 'hidden');
}
};
const resetContentScroll = () => {
if (isIonContent(contentEl)) {
(contentEl as HTMLIonContentElement).scrollY = initialScrollY;
} else {
contentEl.style.removeProperty('overflow');
}
};
const canStart = (detail: GestureDetail) => {
const target = detail.event.target as HTMLElement | null;
@@ -24,17 +60,32 @@ export const createSwipeToCloseGesture = (el: HTMLIonModalElement, animation: An
return true;
}
const contentOrFooter = target.closest('ion-content, ion-footer');
if (contentOrFooter === null) {
/**
* If we are swiping on the content,
* swiping should only be possible if
* the content is scrolled all the way
* to the top so that we do not interfere
* with scrolling.
*/
const content = target.closest('ion-content');
if (content) {
return scrollEl.scrollTop === 0;
}
/**
* Card should be swipeable on all
* parts of the modal except for the footer.
*/
const footer = target.closest('ion-footer');
if (footer === null) {
return true;
}
// Target is in the content or the footer so do not start the gesture.
// We could be more nuanced here and allow it for content that
// does not need to scroll.
return false;
};
const onStart = () => {
const onStart = (detail: GestureDetail) => {
const { deltaY } = detail;
/**
* If canDismiss is anything other than `true`
* then users should be able to swipe down
@@ -43,11 +94,46 @@ export const createSwipeToCloseGesture = (el: HTMLIonModalElement, animation: An
* TODO (FW-937)
* Remove undefined check
*/
canDismissBlocksGesture = el.canDismiss !== undefined && el.canDismiss !== true;
/**
* If we are pulling down, then
* it is possible we are pulling on the
* content. We do not want scrolling to
* happen at the same time as the gesture.
*/
if (deltaY > 0) {
disableContentScroll();
}
animation.progressStart(true, isOpen ? 1 : 0);
};
const onMove = (detail: GestureDetail) => {
const { deltaY } = detail;
/**
* If we are pulling down, then
* it is possible we are pulling on the
* content. We do not want scrolling to
* happen at the same time as the gesture.
*/
if (deltaY > 0) {
disableContentScroll();
}
/**
* If we are swiping on the content
* then the swipe gesture should only
* happen if we are pulling down.
*
* However, if we pull up and
* then down such that the scroll position
* returns to 0, we should be able to swipe
* the card.
*/
const step = detail.deltaY / height;
/**
@@ -117,6 +203,8 @@ export const createSwipeToCloseGesture = (el: HTMLIonModalElement, animation: An
gesture.enable(false);
resetContentScroll();
animation
.onFinish(() => {
if (!shouldComplete) {

View File

@@ -15,6 +15,7 @@ import type {
OverlayEventDetail,
OverlayInterface,
} from '../../interface';
import { getScrollElement, findIonContent, printIonContentErrorMsg } from '../../utils/content';
import { CoreDelegate, attachComponent, detachComponent } from '../../utils/framework-delegate';
import { raf } from '../../utils/helpers';
import { KEYBOARD_DID_OPEN } from '../../utils/keyboard/keyboard';
@@ -283,11 +284,11 @@ export class Modal implements ComponentInterface, OverlayInterface {
@Event({ eventName: 'didDismiss' }) didDismissShorthand!: EventEmitter<OverlayEventDetail>;
@Watch('swipeToClose')
swipeToCloseChanged(enable: boolean) {
async swipeToCloseChanged(enable: boolean) {
if (this.gesture) {
this.gesture.enable(enable);
} else if (enable) {
this.initSwipeToClose();
await this.initSwipeToClose();
}
}
@@ -474,7 +475,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
* not run canDismiss on swipe as there would be no swipe gesture created.
*/
} else if (this.swipeToClose || (this.canDismiss !== undefined && this.presentingElement !== undefined)) {
this.initSwipeToClose();
await this.initSwipeToClose();
}
/* tslint:disable-next-line */
@@ -504,17 +505,27 @@ export class Modal implements ComponentInterface, OverlayInterface {
this.currentTransition = undefined;
}
private initSwipeToClose() {
private async initSwipeToClose() {
if (getIonMode(this) !== 'ios') {
return;
}
const { el } = this;
// All of the elements needed for the swipe gesture
// should be in the DOM and referenced by now, except
// for the presenting el
const animationBuilder = this.leaveAnimation || config.get('modalLeave', iosLeaveAnimation);
const ani = (this.animation = animationBuilder(this.el, { presentingEl: this.presentingElement }));
this.gesture = createSwipeToCloseGesture(this.el, ani, () => {
const ani = (this.animation = animationBuilder(el, { presentingEl: this.presentingElement }));
const contentEl = findIonContent(el);
if (!contentEl) {
printIonContentErrorMsg(el);
return;
}
const scrollEl = await getScrollElement(contentEl);
this.gesture = createSwipeToCloseGesture(el, contentEl, scrollEl, ani, () => {
/**
* While the gesture animation is finishing
* it is possible for a user to tap the backdrop.

View File

@@ -0,0 +1,134 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Modal - Card</title>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta
name="viewport"
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../../scripts/testing/scripts.js"></script>
<script type="module" src="../../../../../../dist/ionic/ionic.esm.js"></script>
<script type="module">
import { modalController } from '../../../../../dist/ionic/index.esm.js';
window.modalController = modalController;
</script>
<style>
#content {
position: relative;
display: block;
flex: 1;
height: 100%;
overflow-y: auto;
contain: size style;
}
</style>
</head>
<body>
<ion-app>
<div class="ion-page">
<ion-header>
<ion-toolbar>
<ion-title>Card</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-button expand="block" id="card" onclick="presentModal(document.querySelectorAll('.ion-page')[1])"
>Card Modal</ion-button
>
</ion-content>
</div>
</ion-app>
<script>
async function createModal(presentingEl, opts) {
// create component to open
const element = document.createElement('div');
element.innerHTML = `
<ion-header id="modal-header">
<ion-toolbar>
<ion-title>Contacts</ion-title>
<ion-buttons slot="end">
<ion-button class="add">
<ion-icon name="add" slot="icon-only"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content scroll-y="false">
<div id="content" class="ion-padding ion-content-scroll-host">
Hello World!
<ion-button class="dismiss">Dismiss Modal</ion-button>
<br />
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec vitae lobortis felis, eu sodales enim. Nam
risus nibh, placerat at rutrum ac, vehicula vel velit. Lorem ipsum dolor sit amet, consectetur adipiscing
elit. Vestibulum quis elementum ligula, ac aliquet nulla. Mauris non placerat mauris. Aenean dignissim lacinia
porttitor. Praesent fringilla at est et ullamcorper. In ac ante ac massa porta venenatis ut id nibh. Fusce
felis neque, aliquet in velit vitae, venenatis euismod libero. Donec vulputate, urna sed sagittis tempor, mi
arcu tristique lacus, eget fringilla urna sem eget felis. Fusce dignissim lacus a scelerisque vehicula. Nulla
nec enim nunc. Quisque nec dui eu nibh pulvinar bibendum quis ut nunc. Duis ex odio, sollicitudin ac mollis
nec, fringilla non lacus. Maecenas sed tincidunt urna. Nunc feugiat maximus venenatis. Donec porttitor, felis
eget porttitor tempor, quam nulla dapibus nisl, sit amet posuere sapien sapien malesuada tortor. Pellentesque
habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Quisque luctus, sapien nec
tincidunt efficitur, nibh turpis faucibus felis, in sodales massa augue nec erat. Morbi sollicitudin nisi ex,
et gravida nisi euismod eu. Suspendisse hendrerit dapibus orci, non viverra neque vestibulum id. Quisque vitae
interdum ligula, quis consectetur nibh. Phasellus in mi at erat ultrices semper. Fusce sollicitudin at dolor
ac lobortis. Morbi sit amet sem quis nulla pellentesque imperdiet. Nullam eu sem a enim maximus eleifend non
vulputate leo. Proin quis congue lacus. Pellentesque placerat, quam at tempus pulvinar, nisl ligula tempor
risus, quis pretium arcu odio et nulla. Nullam mollis consequat pharetra. Phasellus dictum velit sed purus
mattis maximus. In molestie eget massa ut dignissim. In a interdum elit. In finibus nibh a mauris lobortis
aliquet. Proin rutrum varius consequat. In mollis dapibus nisl, eu finibus urna viverra ac. Quisque
scelerisque nisl eu suscipit consectetur.
</p>
</div>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-title>Footer</ion-title>
</ion-toolbar>
</ion-footer>
`;
// listen for close event
const button = element.querySelector('ion-button.dismiss');
button.addEventListener('click', () => {
modalController.dismiss();
});
const create = element.querySelector('ion-button.add');
create.addEventListener('click', async () => {
const topModal = await modalController.getTop();
presentModal(topModal, opts);
});
// present the modal
const modalElement = await modalController.create({
presentingElement: presentingEl,
component: element,
swipeToClose: true,
...opts,
});
return modalElement;
}
async function presentModal(presentingEl, opts) {
const modal = await createModal(presentingEl, opts);
await modal.present();
}
</script>
</body>
</html>

View File

@@ -0,0 +1,64 @@
import { expect } from '@playwright/test';
import { dragElementBy, test } from '@utils/test/playwright';
test.describe('card modal - scroll target', () => {
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.metadata.mode !== 'ios', 'Card style modal is only available on iOS');
await page.goto('/src/components/modal/test/card-scroll-target');
});
test.describe('card modal: swipe to close', () => {
test('it should swipe to close when swiped on the header', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
await page.click('#card');
await ionModalDidPresent.next();
const header = await page.locator('ion-modal ion-header');
await dragElementBy(header, page, 0, 500);
await ionModalDidDismiss.next();
});
test('it should swipe to close when swiped on the content', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
await page.click('#card');
await ionModalDidPresent.next();
const content = await page.locator('ion-modal .ion-content-scroll-host');
await dragElementBy(content, page, 0, 500);
await ionModalDidDismiss.next();
});
test('it should not swipe to close when swiped on the content but the content is scrolled', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#card');
await ionModalDidPresent.next();
const modal = await page.locator('ion-modal');
const content = (await page.$('ion-modal .ion-content-scroll-host'))!;
await content.evaluate((el: HTMLElement) => (el.scrollTop = 500));
await dragElementBy(content, page, 0, 500);
await content.waitForElementState('stable');
expect(modal).toBeVisible();
});
test('content should be scrollable after gesture ends', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#card');
await ionModalDidPresent.next();
const content = await page.locator('ion-modal .ion-content-scroll-host');
await dragElementBy(content, page, 0, 20);
expect(content).not.toHaveCSS('overflow', 'hidden');
});
});
});

View File

@@ -65,7 +65,39 @@
<ion-content class="ion-padding">
Hello World!
<ion-button class="dismiss">Dismiss Modal</ion-button>
<br />
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec vitae lobortis felis, eu sodales enim. Nam
risus nibh, placerat at rutrum ac, vehicula vel velit. Lorem ipsum dolor sit amet, consectetur adipiscing
elit. Vestibulum quis elementum ligula, ac aliquet nulla. Mauris non placerat mauris. Aenean dignissim lacinia
porttitor. Praesent fringilla at est et ullamcorper. In ac ante ac massa porta venenatis ut id nibh. Fusce
felis neque, aliquet in velit vitae, venenatis euismod libero. Donec vulputate, urna sed sagittis tempor, mi
arcu tristique lacus, eget fringilla urna sem eget felis. Fusce dignissim lacus a scelerisque vehicula. Nulla
nec enim nunc. Quisque nec dui eu nibh pulvinar bibendum quis ut nunc. Duis ex odio, sollicitudin ac mollis
nec, fringilla non lacus. Maecenas sed tincidunt urna. Nunc feugiat maximus venenatis. Donec porttitor, felis
eget porttitor tempor, quam nulla dapibus nisl, sit amet posuere sapien sapien malesuada tortor. Pellentesque
habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Quisque luctus, sapien nec
tincidunt efficitur, nibh turpis faucibus felis, in sodales massa augue nec erat. Morbi sollicitudin nisi ex,
et gravida nisi euismod eu. Suspendisse hendrerit dapibus orci, non viverra neque vestibulum id. Quisque vitae
interdum ligula, quis consectetur nibh. Phasellus in mi at erat ultrices semper. Fusce sollicitudin at dolor
ac lobortis. Morbi sit amet sem quis nulla pellentesque imperdiet. Nullam eu sem a enim maximus eleifend non
vulputate leo. Proin quis congue lacus. Pellentesque placerat, quam at tempus pulvinar, nisl ligula tempor
risus, quis pretium arcu odio et nulla. Nullam mollis consequat pharetra. Phasellus dictum velit sed purus
mattis maximus. In molestie eget massa ut dignissim. In a interdum elit. In finibus nibh a mauris lobortis
aliquet. Proin rutrum varius consequat. In mollis dapibus nisl, eu finibus urna viverra ac. Quisque
scelerisque nisl eu suscipit consectetur.
</p>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-title>Footer</ion-title>
</ion-toolbar>
</ion-footer>
`;
// listen for close event

View File

@@ -1,51 +1,108 @@
import { expect } from '@playwright/test';
import { test } from '@utils/test/playwright';
import { dragElementBy, test } from '@utils/test/playwright';
test.describe('card modal', () => {
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.metadata.mode !== 'ios', 'Card style modal is only available on iOS');
test.describe('card modal: rendering', () => {
test('should not have visual regressions', async ({ page }) => {
await page.goto('/src/components/modal/test/card');
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#card');
await ionModalDidPresent.next();
expect(await page.screenshot()).toMatchSnapshot(`modal-card-present-${page.getSnapshotSettings()}.png`);
});
test('should not have visual regressions with custom modal', async ({ page }) => {
await page.goto('/src/components/modal/test/card');
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
test.describe('card modal: rendering', () => {
test('should not have visual regressions', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#card-custom');
await page.click('#card');
await ionModalDidPresent.next();
await ionModalDidPresent.next();
expect(await page.screenshot()).toMatchSnapshot(`modal-card-custom-present-${page.getSnapshotSettings()}.png`);
expect(await page.screenshot()).toMatchSnapshot(`modal-card-present-${page.getSnapshotSettings()}.png`);
});
test('should not have visual regressions with custom modal', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#card-custom');
await ionModalDidPresent.next();
expect(await page.screenshot()).toMatchSnapshot(`modal-card-custom-present-${page.getSnapshotSettings()}.png`);
});
test('should not have visual regressions with stacked cards', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#card');
await ionModalDidPresent.next();
await page.click('.add');
await ionModalDidPresent.next();
expect(await page.screenshot()).toMatchSnapshot(`modal-card-stacked-present-${page.getSnapshotSettings()}.png`);
});
test('should not have visual regressions with stacked custom cards', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#card-custom');
await ionModalDidPresent.next();
await page.click('.add');
await ionModalDidPresent.next();
expect(await page.screenshot()).toMatchSnapshot(
`modal-card-custom-stacked-present-${page.getSnapshotSettings()}.png`
);
});
});
test('should not have visual regressions with stacked cards', async ({ page }) => {
await page.goto('/src/components/modal/test/card');
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
test.describe('card modal: swipe to close', () => {
test('it should swipe to close when swiped on the header', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
await page.click('#card');
await ionModalDidPresent.next();
await page.click('#card');
await ionModalDidPresent.next();
await page.click('.add');
await ionModalDidPresent.next();
const header = await page.locator('ion-modal ion-header');
await dragElementBy(header, page, 0, 500);
expect(await page.screenshot()).toMatchSnapshot(`modal-card-stacked-present-${page.getSnapshotSettings()}.png`);
});
test('should not have visual regressions with stacked custom cards', async ({ page }) => {
await page.goto('/src/components/modal/test/card');
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await ionModalDidDismiss.next();
});
test('it should swipe to close when swiped on the content', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
await page.click('#card-custom');
await ionModalDidPresent.next();
await page.click('#card');
await ionModalDidPresent.next();
await page.click('.add');
await ionModalDidPresent.next();
const content = await page.locator('ion-modal ion-content');
await dragElementBy(content, page, 0, 500);
expect(await page.screenshot()).toMatchSnapshot(
`modal-card-custom-stacked-present-${page.getSnapshotSettings()}.png`
);
await ionModalDidDismiss.next();
});
test('it should not swipe to close when swiped on the content but the content is scrolled', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#card');
await ionModalDidPresent.next();
const modal = await page.locator('ion-modal');
const content = (await page.$('ion-modal ion-content'))!;
await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0));
await dragElementBy(content, page, 0, 500);
await content.waitForElementState('stable');
expect(modal).toBeVisible();
});
test('content should be scrollable after gesture ends', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#card');
await ionModalDidPresent.next();
const content = await page.locator('ion-modal ion-content');
await dragElementBy(content, page, 0, 20);
expect(content).toHaveJSProperty('scrollY', true);
});
});
});

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 371 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 224 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 372 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 224 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 373 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 225 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 374 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 225 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 369 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 222 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 371 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 222 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 370 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 222 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 371 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 222 KiB

View File

@@ -13,7 +13,7 @@ const ION_CONTENT_CLASS_SELECTOR = '.ion-content-scroll-host';
*/
const ION_CONTENT_SELECTOR = `${ION_CONTENT_ELEMENT_SELECTOR}, ${ION_CONTENT_CLASS_SELECTOR}`;
const isIonContent = (el: Element) => el && el.tagName === ION_CONTENT_TAG_NAME;
export const isIonContent = (el: Element) => el && el.tagName === ION_CONTENT_TAG_NAME;
/**
* Waits for the element host fully initialize before

View File

@@ -1,8 +1,13 @@
import type { Locator } from '@playwright/test';
import type { ElementHandle, Locator } from '@playwright/test';
import type { E2EPage } from './';
export const dragElementBy = async (el: Locator, page: E2EPage, dragByX = 0, dragByY = 0) => {
export const dragElementBy = async (
el: Locator | ElementHandle<SVGElement | HTMLElement>,
page: E2EPage,
dragByX = 0,
dragByY = 0
) => {
const boundingBox = await el.boundingBox();
if (!boundingBox) {