diff --git a/core/src/components/modal/gestures/sheet.ts b/core/src/components/modal/gestures/sheet.ts index cacf61c5b43..7d4d434488c 100644 --- a/core/src/components/modal/gestures/sheet.ts +++ b/core/src/components/modal/gestures/sheet.ts @@ -452,12 +452,23 @@ export const createSheetGesture = ( const onEnd = (detail: GestureDetail) => { const snapBreakpoint = calculateSnapBreakpoint(detail.deltaY); + /** + * `moveSheetToBreakpoint` only dismisses when the sheet snaps to + * breakpoint 0. When canDismiss blocks the gesture it keeps the sheet + * from snapping there and dismisses through `handleCanDismiss` instead, + * which only dismisses when canDismiss is a function. A canDismiss + * function can still cancel the dismiss after this event is emitted. + */ + const shouldPreventDismiss = canDismissBlocksGesture && snapBreakpoint === 0; + const isDismissing = shouldPreventDismiss ? typeof baseEl.canDismiss === 'function' : snapBreakpoint === 0; + const eventDetail: ModalDragEventDetail = { currentY: detail.currentY, deltaY: detail.deltaY, velocityY: detail.velocityY, progress: calculateProgress(detail.currentY), snapBreakpoint, + isDismissing, }; /** @@ -474,7 +485,12 @@ export const createSheetGesture = ( * swap to moving on drag and if we don't swap back here then the footer will get stuck. */ swapFooterPosition('stationary'); - onDragEnd(eventDetail); + + /** + * The sheet does not move in this case, so the gesture + * never dismisses the modal. + */ + onDragEnd({ ...eventDetail, isDismissing: false }); return; } diff --git a/core/src/components/modal/gestures/swipe-to-close.ts b/core/src/components/modal/gestures/swipe-to-close.ts index d04220c88e7..4a8d938aa34 100644 --- a/core/src/components/modal/gestures/swipe-to-close.ts +++ b/core/src/components/modal/gestures/swipe-to-close.ts @@ -304,17 +304,32 @@ export const createSwipeToCloseGesture = ( * check canDismiss. 25% was chosen * to avoid accidental swipes. */ - if (isAttemptingDismissWithCanDismiss && clampedStep > maxStep / 4) { + const isAttemptingCanDismiss = isAttemptingDismissWithCanDismiss && clampedStep > maxStep / 4; + + if (isAttemptingCanDismiss) { handleCanDismiss(el, animation); } else if (shouldComplete) { onDismiss(); } + /** + * `shouldComplete` is always `false` when canDismiss blocks the + * gesture, since it only describes a dismiss that happens right + * away. In that case the modal dismisses through `handleCanDismiss` + * instead, which only dismisses when canDismiss is a function. + * A canDismiss function can still cancel the dismiss after this + * event is emitted. + */ + const isDismissing = isAttemptingDismissWithCanDismiss + ? isAttemptingCanDismiss && typeof el.canDismiss === 'function' + : shouldComplete; + const eventDetail: ModalDragEventDetail = { currentY: detail.currentY, deltaY: detail.deltaY, velocityY: detail.velocityY, progress: calculateProgress(el, detail.deltaY), + isDismissing, }; onDragEnd(eventDetail); diff --git a/core/src/components/modal/modal-interface.ts b/core/src/components/modal/modal-interface.ts index 97831de0b04..f31d911ad60 100644 --- a/core/src/components/modal/modal-interface.ts +++ b/core/src/components/modal/modal-interface.ts @@ -72,4 +72,13 @@ export interface ModalDragEventDetail { * the gesture. */ snapBreakpoint?: number; + /** + * Whether the modal is attempting to dismiss when the drag gesture + * ends. A `canDismiss` callback can still cancel the dismiss after + * this event is emitted, so use `ionModalDidDismiss` to confirm that + * the modal actually closed. + * + * This is only set on `ionDragEnd`. + */ + isDismissing?: boolean; } diff --git a/core/src/components/modal/test/can-dismiss/modal-card.e2e.ts b/core/src/components/modal/test/can-dismiss/modal-card.e2e.ts index 383e70d6cbc..7f8c8da2e62 100644 --- a/core/src/components/modal/test/can-dismiss/modal-card.e2e.ts +++ b/core/src/components/modal/test/can-dismiss/modal-card.e2e.ts @@ -50,6 +50,58 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => await ionModalDidDismiss.next(); }); + test('should report isDismissing as true when canDismiss is Promise', async ({ page }) => { + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss'); + + await page.click('#card-can-dismiss-promise-true'); + + await ionModalDidPresent.next(); + + const ionDragEnd = await page.spyOnEvent('ionDragEnd'); + + const modalHeader = page.locator('#modal-header'); + await dragElementBy(modalHeader, page, 0, 500); + + const dragEndEvent = await ionDragEnd.next(); + + /** + * canDismiss forces the swipe animation to stop before it completes, + * so the event has to report the dismiss that canDismiss goes on to + * perform. Waiting for `ionModalDidDismiss` proves the modal really + * did close. + */ + expect(dragEndEvent.detail.isDismissing).toBe(true); + + await ionModalDidDismiss.next(); + }); + test('should report isDismissing as true when canDismiss cancels the dismiss', async ({ page }) => { + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const ionHandlerDone = await page.spyOnEvent('ionHandlerDone'); + + await page.click('#card-can-dismiss-promise-false'); + + await ionModalDidPresent.next(); + + const ionDragEnd = await page.spyOnEvent('ionDragEnd'); + + const modalHeader = page.locator('#modal-header'); + await dragElementBy(modalHeader, page, 0, 500); + + const dragEndEvent = await ionDragEnd.next(); + + /** + * `ionDragEnd` fires before canDismiss resolves, so it reports the + * dismiss the gesture is attempting rather than the outcome. This is + * the one case where the two differ: canDismiss goes on to cancel the + * dismiss and the modal stays open. + */ + expect(dragEndEvent.detail.isDismissing).toBe(true); + + await ionHandlerDone.next(); + + await expect(page.locator('ion-modal')).toBeVisible(); + }); test('should not dismiss on swipe when canDismiss is Promise', async ({ page }) => { const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); const ionHandlerDone = await page.spyOnEvent('ionHandlerDone'); diff --git a/core/src/components/modal/test/can-dismiss/modal-sheet.e2e.ts b/core/src/components/modal/test/can-dismiss/modal-sheet.e2e.ts index b282645740d..865b5b5a8de 100644 --- a/core/src/components/modal/test/can-dismiss/modal-sheet.e2e.ts +++ b/core/src/components/modal/test/can-dismiss/modal-sheet.e2e.ts @@ -49,6 +49,56 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => await ionModalDidDismiss.next(); }); + test('should report isDismissing as true when canDismiss is Promise', async ({ page }) => { + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss'); + + await page.click('#sheet-can-dismiss-promise-true'); + + await ionModalDidPresent.next(); + + const ionDragEnd = await page.spyOnEvent('ionDragEnd'); + + const modalHeader = page.locator('#modal-header'); + await dragElementBy(modalHeader, page, 0, 500); + + const dragEndEvent = await ionDragEnd.next(); + + /** + * canDismiss keeps the sheet from snapping to breakpoint 0, so the + * event has to report the dismiss that canDismiss goes on to perform. + */ + expect(dragEndEvent.detail.isDismissing).toBe(true); + + await ionModalDidDismiss.next(); + }); + test('should report isDismissing as true when canDismiss cancels the dismiss', async ({ page }) => { + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const ionHandlerDone = await page.spyOnEvent('ionHandlerDone'); + + await page.click('#sheet-can-dismiss-promise-false'); + + await ionModalDidPresent.next(); + + const ionDragEnd = await page.spyOnEvent('ionDragEnd'); + + const modalHeader = page.locator('#modal-header'); + await dragElementBy(modalHeader, page, 0, 500); + + const dragEndEvent = await ionDragEnd.next(); + + /** + * `ionDragEnd` fires before canDismiss resolves, so it reports the + * dismiss the gesture is attempting rather than the outcome. This is + * the one case where the two differ: canDismiss goes on to cancel the + * dismiss and the sheet stays open. + */ + expect(dragEndEvent.detail.isDismissing).toBe(true); + + await ionHandlerDone.next(); + + await expect(page.locator('ion-modal')).toBeVisible(); + }); test('should not dismiss on swipe when canDismiss is Promise', async ({ page }) => { const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); const ionHandlerDone = await page.spyOnEvent('ionHandlerDone'); diff --git a/core/src/components/modal/test/card/modal-card.e2e.ts b/core/src/components/modal/test/card/modal-card.e2e.ts index b07de14cfb4..ade6979c2f7 100644 --- a/core/src/components/modal/test/card/modal-card.e2e.ts +++ b/core/src/components/modal/test/card/modal-card.e2e.ts @@ -107,13 +107,15 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, c }); test.describe(title('card modal: drag events'), () => { - test('should emit ionDragStart, ionDragMove, and ionDragEnd events', async ({ page }) => { - await page.goto('/src/components/modal/test/card', config); + let cardModalPage: CardModalPage; - const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + test.beforeEach(async ({ page }) => { + cardModalPage = new CardModalPage(page); + await cardModalPage.navigate('/src/components/modal/test/card', config); + }); - await page.click('#drag-events'); - await ionModalDidPresent.next(); + test('should emit ionDragStart, ionDragMove, and ionDragEnd events', async ({ page }) => { + await cardModalPage.openModalByTrigger('#drag-events'); const ionDragStart = await page.spyOnEvent('ionDragStart'); const ionDragMove = await page.spyOnEvent('ionDragMove'); @@ -130,7 +132,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, c expect(ionDragStart.length).toBe(1); expect(ionDragMove.length).toBeGreaterThan(0); - expect(Object.keys(dragMoveEvent.detail).length).toBe(4); + expect(Object.keys(dragMoveEvent.detail).sort()).toEqual(['currentY', 'deltaY', 'progress', 'velocityY']); expect(ionDragEnd.length).toBe(0); @@ -148,7 +150,44 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, c expect(ionDragMove.length).toBeGreaterThan(0); expect(ionDragEnd.length).toBe(1); - expect(Object.keys(dragEndEvent.detail).length).toBe(4); + expect(Object.keys(dragEndEvent.detail).sort()).toEqual([ + 'currentY', + 'deltaY', + 'isDismissing', + 'progress', + 'velocityY', + ]); + }); + + test('should report isDismissing as true when the gesture dismisses the modal', async ({ page }) => { + await cardModalPage.openModalByTrigger('#card'); + + const ionDragEnd = await page.spyOnEvent('ionDragEnd'); + + /** + * `swipeToCloseModal` drags 300px and waits for `ionModalDidDismiss`, + * so the modal is known to have dismissed by the time the event + * detail is checked. + */ + await cardModalPage.swipeToCloseModal('ion-modal ion-header'); + + const dragEndEvent = await ionDragEnd.next(); + + expect(dragEndEvent.detail.isDismissing).toBe(true); + }); + + test('should report isDismissing as false when the modal settles back open', async ({ page }) => { + const modal = await cardModalPage.openModalByTrigger('#card'); + + const ionDragEnd = await page.spyOnEvent('ionDragEnd'); + + // 20px is short enough that the gesture never passes the dismiss threshold + await cardModalPage.swipeToCloseModal('ion-modal ion-header', false, 20); + + const dragEndEvent = await ionDragEnd.next(); + + expect(dragEndEvent.detail.isDismissing).toBe(false); + await expect(modal).toBeVisible(); }); }); }); diff --git a/core/src/components/modal/test/sheet/modal.e2e.ts b/core/src/components/modal/test/sheet/modal.e2e.ts index dd47cb4fb54..7e127bc39ab 100644 --- a/core/src/components/modal/test/sheet/modal.e2e.ts +++ b/core/src/components/modal/test/sheet/modal.e2e.ts @@ -355,14 +355,15 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => }); test.describe(title('sheet modal: drag events'), () => { - test('should emit ionDragStart, ionDragMove, and ionDragEnd events', async ({ page }) => { + test.beforeEach(async ({ page }) => { await page.goto('/src/components/modal/test/sheet', config); const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); await page.click('#drag-events'); await ionModalDidPresent.next(); - + }); + test('should emit ionDragStart, ionDragMove, and ionDragEnd events', async ({ page }) => { const ionDragStart = await page.spyOnEvent('ionDragStart'); const ionDragMove = await page.spyOnEvent('ionDragMove'); const ionDragEnd = await page.spyOnEvent('ionDragEnd'); @@ -378,7 +379,13 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => expect(ionDragStart.length).toBe(1); expect(ionDragMove.length).toBeGreaterThan(0); - expect(Object.keys(dragMoveEvent.detail).length).toBe(5); + expect(Object.keys(dragMoveEvent.detail).sort()).toEqual([ + 'currentY', + 'deltaY', + 'progress', + 'snapBreakpoint', + 'velocityY', + ]); expect(ionDragEnd.length).toBe(0); @@ -396,7 +403,51 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => expect(ionDragMove.length).toBeGreaterThan(0); expect(ionDragEnd.length).toBe(1); - expect(Object.keys(dragEndEvent.detail).length).toBe(5); + expect(Object.keys(dragEndEvent.detail).sort()).toEqual([ + 'currentY', + 'deltaY', + 'isDismissing', + 'progress', + 'snapBreakpoint', + 'velocityY', + ]); + }); + test('should report isDismissing as true when the gesture dismisses the modal', async ({ page }) => { + const ionDragEnd = await page.spyOnEvent('ionDragEnd'); + const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss'); + + const header = page.locator('.modal-sheet ion-header'); + const headerBox = (await header.boundingBox())!; + const viewport = page.viewportSize()!; + + /** + * The sheet starts at breakpoint 0.5, so its header sits near the middle + * of the viewport and the drag has to stay inside the bottom half. + * Dragging as far as the viewport allows moves the sheet well past the + * midpoint between breakpoints 0 and 0.25, so it snaps to 0 and dismisses. + */ + const dragByY = viewport.height - (headerBox.y + headerBox.height / 2) - 10; + + await dragElementBy(header, page, 0, dragByY); + + const dragEndEvent = await ionDragEnd.next(); + + expect(dragEndEvent.detail.isDismissing).toBe(true); + + await ionModalDidDismiss.next(); + }); + test('should report isDismissing as false when the sheet snaps to another breakpoint', async ({ page }) => { + const ionDragEnd = await page.spyOnEvent('ionDragEnd'); + + const header = page.locator('.modal-sheet ion-header'); + + // Dragging this little keeps the sheet above breakpoint 0 + await dragElementBy(header, page, 0, 50); + + const dragEndEvent = await ionDragEnd.next(); + + expect(dragEndEvent.detail.isDismissing).toBe(false); + await expect(page.locator('ion-modal')).toBeVisible(); }); });