feat(modal): add drag events for sheet and card modals (#30962)

Issue number: internal

---------

## What is the current behavior?
The sheet and card modal can be dragged to view content. However, there
are no events that determine when drag has started or ended.

## What is the new behavior?
- Added drag events for sheet and card modal: `ionDragStart`, `ionDragMove`, `ionDragEnd`
- Added a drag interface
- Added tests

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

---------

Co-authored-by: Shane <shane@shanessite.net>
Co-authored-by: Brandy Smith <brandyscarney@users.noreply.github.com>
This commit is contained in:
Maria Hutt
2026-03-04 11:53:50 -08:00
committed by GitHub
parent 5bcf921841
commit d29ac713fa
29 changed files with 505 additions and 51 deletions

View File

@@ -1187,6 +1187,9 @@ ion-modal,method,setCurrentBreakpoint,setCurrentBreakpoint(breakpoint: number) =
ion-modal,event,didDismiss,OverlayEventDetail<any>,true
ion-modal,event,didPresent,void,true
ion-modal,event,ionBreakpointDidChange,ModalBreakpointChangeEventDetail,true
ion-modal,event,ionDragEnd,ModalDragEventDetail,true
ion-modal,event,ionDragMove,ModalDragEventDetail,true
ion-modal,event,ionDragStart,void,true
ion-modal,event,ionModalDidDismiss,OverlayEventDetail<any>,true
ion-modal,event,ionModalDidPresent,void,true
ion-modal,event,ionModalWillDismiss,OverlayEventDetail<any>,true

View File

@@ -20,7 +20,7 @@ import { SpinnerTypes } from "./components/spinner/spinner-configs";
import { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface";
import { InputOtpChangeEventDetail, InputOtpCompleteEventDetail, InputOtpInputEventDetail } from "./components/input-otp/input-otp-interface";
import { MenuChangeEventDetail, MenuCloseEventDetail, MenuType, Side } from "./components/menu/menu-interface";
import { ModalBreakpointChangeEventDetail, ModalHandleBehavior } from "./components/modal/modal-interface";
import { ModalBreakpointChangeEventDetail, ModalDragEventDetail, ModalHandleBehavior } from "./components/modal/modal-interface";
import { NavComponent, NavComponentWithProps, NavOptions, RouterOutletOptions, SwipeGestureHandler, TransitionDoneFn, TransitionInstruction } from "./components/nav/nav-interface";
import { ViewController } from "./components/nav/view-controller";
import { PickerChangeEventDetail } from "./components/picker/picker-interfaces";
@@ -58,7 +58,7 @@ export { SpinnerTypes } from "./components/spinner/spinner-configs";
export { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface";
export { InputOtpChangeEventDetail, InputOtpCompleteEventDetail, InputOtpInputEventDetail } from "./components/input-otp/input-otp-interface";
export { MenuChangeEventDetail, MenuCloseEventDetail, MenuType, Side } from "./components/menu/menu-interface";
export { ModalBreakpointChangeEventDetail, ModalHandleBehavior } from "./components/modal/modal-interface";
export { ModalBreakpointChangeEventDetail, ModalDragEventDetail, ModalHandleBehavior } from "./components/modal/modal-interface";
export { NavComponent, NavComponentWithProps, NavOptions, RouterOutletOptions, SwipeGestureHandler, TransitionDoneFn, TransitionInstruction } from "./components/nav/nav-interface";
export { ViewController } from "./components/nav/view-controller";
export { PickerChangeEventDetail } from "./components/picker/picker-interfaces";
@@ -4534,6 +4534,9 @@ declare global {
"willDismiss": OverlayEventDetail;
"didDismiss": OverlayEventDetail;
"ionMount": void;
"ionDragStart": void;
"ionDragMove": ModalDragEventDetail;
"ionDragEnd": ModalDragEventDetail;
}
interface HTMLIonModalElement extends Components.IonModal, HTMLStencilElement {
addEventListener<K extends keyof HTMLIonModalElementEventMap>(type: K, listener: (this: HTMLIonModalElement, ev: IonModalCustomEvent<HTMLIonModalElementEventMap[K]>) => any, options?: boolean | AddEventListenerOptions): void;
@@ -7350,6 +7353,18 @@ declare namespace LocalJSX {
* Emitted after the modal breakpoint has changed.
*/
"onIonBreakpointDidChange"?: (event: IonModalCustomEvent<ModalBreakpointChangeEventDetail>) => void;
/**
* Event that is emitted when the sheet modal or card modal gesture ends.
*/
"onIonDragEnd"?: (event: IonModalCustomEvent<ModalDragEventDetail>) => void;
/**
* Event that is emitted when the sheet modal or card modal gesture moves.
*/
"onIonDragMove"?: (event: IonModalCustomEvent<ModalDragEventDetail>) => void;
/**
* Event that is emitted when the sheet modal or card modal gesture starts.
*/
"onIonDragStart"?: (event: IonModalCustomEvent<void>) => void;
/**
* Emitted after the modal has dismissed.
*/

View File

@@ -3,7 +3,7 @@ import { createGesture } from '@utils/gesture';
import { clamp, getElementRoot, raf } from '@utils/helpers';
import { FOCUS_TRAP_DISABLE_CLASS } from '@utils/overlays';
import type { Animation } from '../../../interface';
import type { Animation, ModalDragEventDetail } from '../../../interface';
import type { GestureDetail } from '../../../utils/gesture';
import { getBackdropValueForSheet } from '../utils';
@@ -52,7 +52,10 @@ export const createSheetGesture = (
expandToScroll: boolean,
getCurrentBreakpoint: () => number,
onDismiss: () => void,
onBreakpointChange: (breakpoint: number) => void
onBreakpointChange: (breakpoint: number) => void,
onDragStart: () => void,
onDragMove: (detail: ModalDragEventDetail) => void,
onDragEnd: (detail: ModalDragEventDetail) => void
) => {
// Defaults for the sheet swipe animation
const defaultBackdrop = [
@@ -347,6 +350,8 @@ export const createSheetGesture = (
});
animation.progressStart(true, 1 - currentBreakpoint);
onDragStart();
};
const onMove = (detail: GestureDetail) => {
@@ -423,9 +428,31 @@ export const createSheetGesture = (
offset = clamp(0.0001, processedStep, maxStep);
animation.progressStep(offset);
const snapBreakpoint = calculateSnapBreakpoint(detail.deltaY);
const eventDetail: ModalDragEventDetail = {
currentY: detail.currentY,
deltaY: detail.deltaY,
velocityY: detail.velocityY,
progress: calculateProgress(detail.currentY),
snapBreakpoint: snapBreakpoint,
};
onDragMove(eventDetail);
};
const onEnd = (detail: GestureDetail) => {
const snapBreakpoint = calculateSnapBreakpoint(detail.deltaY);
const eventDetail: ModalDragEventDetail = {
currentY: detail.currentY,
deltaY: detail.deltaY,
velocityY: detail.velocityY,
progress: calculateProgress(detail.currentY),
snapBreakpoint,
};
/**
* If expandToScroll is disabled, we should not allow the moveSheetToBreakpoint
* function to be called if the user is trying to swipe content upwards and the content
@@ -440,23 +467,13 @@ 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);
return;
}
/**
* When the gesture releases, we need to determine
* the closest breakpoint to snap to.
*/
const velocity = detail.velocityY;
const threshold = (detail.deltaY + velocity * 350) / height;
const diff = currentBreakpoint - threshold;
const closest = breakpoints.reduce((a, b) => {
return Math.abs(b - diff) < Math.abs(a - diff) ? b : a;
});
moveSheetToBreakpoint({
breakpoint: closest,
breakpoint: snapBreakpoint,
breakpointOffset: offset,
canDismiss: canDismissBlocksGesture,
@@ -466,6 +483,8 @@ export const createSheetGesture = (
*/
animated: true,
});
onDragEnd(eventDetail);
};
const moveSheetToBreakpoint = (options: MoveSheetToBreakpointOptions) => {
@@ -624,6 +643,112 @@ export const createSheetGesture = (
});
};
/**
* Calculates the breakpoint based on the current deltaY.
* This determines where the sheet should snap to when the user releases the
* gesture.
*
* @param deltaY The change in Y position since the gesture started.
* @returns The snap breakpoint value.
*/
const calculateSnapBreakpoint = (deltaY: number): number => {
/**
* Calculates the real-time vertical position of the modal.
* We combine the wrapper's current bounding box position with the
* gesture's deltaY to account for the physical movement during the drag.
*/
const currentY = wrapperEl.getBoundingClientRect().top + deltaY;
/**
* Convert that pixel position back into a 0 to 1 progress value.
*/
const currentProgress = calculateProgress(currentY);
/**
* Find and return the defined breakpoint that is closest to the
* current progress.
*/
const snapBreakpoint = breakpoints.reduce((a, b) => {
return Math.abs(b - currentProgress) < Math.abs(a - currentProgress) ? b : a;
});
return snapBreakpoint;
};
/**
* Calculates the progress of the swipe gesture.
*
* The progress is a value between 0 and 1 that represents how far
* the swipe has progressed towards closing the modal.
*
* A value closer to 1 means the modal is closer to being opened,
* while a value closer to 0 means the modal is closer to being closed.
*
* @param currentY The current Y position of the gesture
* @returns The progress of the sheet gesture
*/
const calculateProgress = (currentY: number): number => {
const minBreakpoint = breakpoints[0];
const maxBreakpoint = breakpoints[breakpoints.length - 1];
/**
* The lowest point the sheet can be dragged to aka the point at which
* the sheet is fully closed.
*/
const maxY = convertBreakpointToY(minBreakpoint);
/**
* The highest point the sheet can be dragged to aka the point at which
* the sheet is fully open.
*/
const minY = convertBreakpointToY(maxBreakpoint);
// The total distance between the fully open and fully closed positions.
const totalDistance = maxY - minY;
// The distance from the current position to the fully closed position.
const distanceFromBottom = maxY - currentY;
/**
* The progress represents how far the sheet is from the bottom relative
* to the total distance. When the user starts swiping up, the progress
* should be close to 1, and when the user has swiped all the way down,
* the progress should be close to 0.
*/
const progress = distanceFromBottom / totalDistance;
// Round to the nearest thousandth to avoid returning very small decimal
const roundedProgress = Math.round(progress * 1000) / 1000;
return Math.max(0, Math.min(1, roundedProgress));
};
/**
* Converts a breakpoint value (0 to 1) into a pixel Y coordinate
* on the screen.
*
* @param breakpoint The breakpoint value (e.g., 0.5 for half-open)
* @returns The pixel Y coordinate on the screen
*/
const convertBreakpointToY = (breakpoint: number): number => {
const rect = baseEl.getBoundingClientRect();
const modalHeight = rect.height;
// The bottom of the screen.
const viewportBottom = window.innerHeight;
/**
* The active height is how much of the modal is actually showing
* on the screen for this specific breakpoint.
*/
const activeHeight = modalHeight * breakpoint;
/**
* To find the Y coordinate, start at the bottom of the screen
* and move up by the active height of the modal.
*
* A breakpoint of 1.0 means the active height is the full modal height
* (fully open). A breakpoint of 0.0 means the active height is 0
* (fully closed).
*
* Since screen Y coordinates get smaller as you go up, we subtract the
* active height from the viewport bottom.
*/
return viewportBottom - activeHeight;
};
const gesture = createGesture({
el: wrapperEl,
gestureName: 'modalSheet',

View File

@@ -4,7 +4,7 @@ import { createGesture } from '@utils/gesture';
import { clamp, getElementRoot } from '@utils/helpers';
import { OVERLAY_GESTURE_PRIORITY } from '@utils/overlays';
import type { Animation } from '../../../interface';
import type { Animation, ModalDragEventDetail } from '../../../interface';
import type { GestureDetail } from '../../../utils/gesture';
import type { Style as StatusBarStyle } from '../../../utils/native/status-bar';
import { setCardStatusBarDark, setCardStatusBarDefault } from '../utils';
@@ -20,7 +20,10 @@ export const createSwipeToCloseGesture = (
el: HTMLIonModalElement,
animation: Animation,
statusBarStyle: StatusBarStyle,
onDismiss: () => void
onDismiss: () => void,
onDragStart: () => void,
onDragMove: (detail: ModalDragEventDetail) => void,
onDragEnd: (detail: ModalDragEventDetail) => void
) => {
/**
* The step value at which a card modal
@@ -142,6 +145,8 @@ export const createSwipeToCloseGesture = (
}
animation.progressStart(true, isOpen ? 1 : 0);
onDragStart();
};
const onMove = (detail: GestureDetail) => {
@@ -220,6 +225,15 @@ export const createSwipeToCloseGesture = (
}
lastStep = clampedStep;
const eventDetail: ModalDragEventDetail = {
currentY: detail.currentY,
deltaY: detail.deltaY,
velocityY: detail.velocityY,
progress: calculateProgress(el, detail.deltaY),
};
onDragMove(eventDetail);
};
const onEnd = (detail: GestureDetail) => {
@@ -288,6 +302,15 @@ export const createSwipeToCloseGesture = (
} else if (shouldComplete) {
onDismiss();
}
const eventDetail: ModalDragEventDetail = {
currentY: detail.currentY,
deltaY: detail.deltaY,
velocityY: detail.velocityY,
progress: calculateProgress(el, detail.deltaY),
};
onDragEnd(eventDetail);
};
const gesture = createGesture({
@@ -307,3 +330,43 @@ export const createSwipeToCloseGesture = (
const computeDuration = (remaining: number, velocity: number) => {
return clamp(400, remaining / Math.abs(velocity * 1.1), 500);
};
/**
* Calculates the progress of the swipe gesture.
*
* The progress is a value between 0 and 1 that represents how far
* the swipe has progressed towards closing the modal.
*
* A value closer to 1 means the modal is closer to being opened,
* while a value closer to 0 means the modal is closer to being closed.
*
* @param el The modal
* @param deltaY The change in Y position (positive when dragging down, negative when dragging up)
* @returns The progress of the swipe gesture
*/
const calculateProgress = (el: HTMLIonModalElement, deltaY: number): number => {
const windowHeight = window.innerHeight;
// Position when fully open
const modalTop = el.getBoundingClientRect().top;
/**
* The distance between the top of the modal and the bottom of the screen
* is the total distance the modal needs to travel to be fully closed.
*/
const totalDistance = windowHeight - modalTop;
/**
* The pull percentage is how far the user has swiped compared to the total
* distance needed to close the modal.
*/
const pullPercentage = deltaY / totalDistance;
/**
* The progress is the inverse of the pull percentage because
* when the user starts swiping up, the progress should be close to 1,
* and when the user has swiped all the way down, the progress should be
* close to 0.
*/
const progress = 1 - pullPercentage;
// Round to the nearest thousandth to avoid returning very small decimal
const roundedProgress = Math.round(progress * 1000) / 1000;
return Math.max(0, Math.min(1, roundedProgress));
};

View File

@@ -47,3 +47,29 @@ export interface ModalCustomEvent extends CustomEvent {
* The behavior setting for modals when the handle is pressed.
*/
export type ModalHandleBehavior = 'none' | 'cycle';
export interface ModalDragEventDetail {
/**
* The current Y coordinate of the drag event.
*/
currentY: number;
/**
* The change in Y coordinate since the last drag event.
*/
deltaY: number;
/**
* The velocity of the drag event in the Y direction.
*/
velocityY: number;
/**
* The progress of the drag event, represented as a value between 0 and 1.
* A value of 0 means the modal is at its lowest point (fully closed),
* while a value of 1 means the modal is at its highest point (fully open).
*/
progress: number;
/**
* The breakpoint that the sheet will snap to if the user releases
* the gesture.
*/
snapBreakpoint?: number;
}

View File

@@ -43,7 +43,7 @@ import { mdLeaveAnimation } from './animations/md.leave';
import type { MoveSheetToBreakpointOptions } from './gestures/sheet';
import { createSheetGesture } from './gestures/sheet';
import { createSwipeToCloseGesture, SwipeToCloseDefaults } from './gestures/swipe-to-close';
import type { ModalBreakpointChangeEventDetail, ModalHandleBehavior } from './modal-interface';
import type { ModalBreakpointChangeEventDetail, ModalHandleBehavior, ModalDragEventDetail } from './modal-interface';
import { setCardStatusBarDark, setCardStatusBarDefault } from './utils';
// TODO(FW-2832): types
@@ -72,6 +72,11 @@ export class Modal implements ComponentInterface, OverlayInterface {
private coreDelegate: FrameworkDelegate = CoreDelegate();
private sheetTransition?: Promise<any>;
@State() private isSheetModal = false;
/**
* The breakpoint value that has been committed for a sheet modal.
* This represents the modal's resting state when it is not being dragged
* or animating toward a new position.
*/
private currentBreakpoint?: number;
private wrapperEl?: HTMLElement;
private backdropEl?: HTMLIonBackdropElement;
@@ -390,6 +395,21 @@ export class Modal implements ComponentInterface, OverlayInterface {
*/
@Event() ionMount!: EventEmitter<void>;
/**
* Event that is emitted when the sheet modal or card modal gesture starts.
*/
@Event() ionDragStart!: EventEmitter<void>;
/**
* Event that is emitted when the sheet modal or card modal gesture moves.
*/
@Event() ionDragMove!: EventEmitter<ModalDragEventDetail>;
/**
* Event that is emitted when the sheet modal or card modal gesture ends.
*/
@Event() ionDragEnd!: EventEmitter<ModalDragEventDetail>;
breakpointsChanged(breakpoints: number[] | undefined) {
if (breakpoints !== undefined) {
this.sortedBreakpoints = breakpoints.sort((a, b) => a - b);
@@ -692,33 +712,15 @@ export class Modal implements ComponentInterface, OverlayInterface {
const statusBarStyle = this.statusBarStyle ?? StatusBarStyle.Default;
this.gesture = createSwipeToCloseGesture(el, ani, statusBarStyle, () => {
/**
* While the gesture animation is finishing
* it is possible for a user to tap the backdrop.
* This would result in the dismiss animation
* being played again. Typically this is avoided
* by setting `presented = false` on the overlay
* component; however, we cannot do that here as
* that would prevent the element from being
* removed from the DOM.
*/
this.gestureAnimationDismissing = true;
/**
* Reset the status bar style as the dismiss animation
* starts otherwise the status bar will be the wrong
* color for the duration of the dismiss animation.
* The dismiss method does this as well, but
* in this case it's only called once the animation
* has finished.
*/
setCardStatusBarDefault(this.statusBarStyle);
this.animation!.onFinish(async () => {
await this.dismiss(undefined, GESTURE);
this.gestureAnimationDismissing = false;
});
});
this.gesture = createSwipeToCloseGesture(
el,
ani,
statusBarStyle,
() => this.cardOnDismiss(),
() => this.onDragStart(),
(detail: ModalDragEventDetail) => this.onDragMove(detail),
(detail: ModalDragEventDetail) => this.onDragEnd(detail)
);
this.gesture.enable(true);
}
@@ -755,7 +757,10 @@ export class Modal implements ComponentInterface, OverlayInterface {
this.currentBreakpoint = breakpoint;
this.ionBreakpointDidChange.emit({ breakpoint });
}
}
},
() => this.onDragStart(),
(detail: ModalDragEventDetail) => this.onDragMove(detail),
(detail: ModalDragEventDetail) => this.onDragEnd(detail)
);
this.gesture = gesture;
@@ -869,6 +874,34 @@ export class Modal implements ComponentInterface, OverlayInterface {
});
}
private cardOnDismiss() {
/**
* While the gesture animation is finishing
* it is possible for a user to tap the backdrop.
* This would result in the dismiss animation
* being played again. Typically this is avoided
* by setting `presented = false` on the overlay
* component; however, we cannot do that here as
* that would prevent the element from being
* removed from the DOM.
*/
this.gestureAnimationDismissing = true;
/**
* Reset the status bar style as the dismiss animation
* starts otherwise the status bar will be the wrong
* color for the duration of the dismiss animation.
* The dismiss method does this as well, but
* in this case it's only called once the animation
* has finished.
*/
setCardStatusBarDefault(this.statusBarStyle);
this.animation!.onFinish(async () => {
await this.dismiss(undefined, GESTURE);
this.gestureAnimationDismissing = false;
});
}
/**
* Dismiss the modal overlay after it has been presented.
* This is a no-op if the overlay has not been presented yet. If you want
@@ -1335,6 +1368,18 @@ export class Modal implements ComponentInterface, OverlayInterface {
this.parentRemovalObserver = undefined;
}
private onDragStart() {
this.ionDragStart.emit();
}
private onDragMove(detail: ModalDragEventDetail) {
this.ionDragMove.emit(detail);
}
private onDragEnd(detail: ModalDragEventDetail) {
this.ionDragEnd.emit(detail);
}
render() {
const {
handle,

View File

@@ -40,6 +40,7 @@
</ion-header>
<ion-content class="ion-padding">
<h2>iOS only</h2>
<button class="expand" id="card" onclick="presentModal(document.querySelectorAll('.ion-page')[1])">
Card Modal
</button>
@@ -50,6 +51,7 @@
>
Card Modal Custom Radius
</button>
<button class="expand" id="drag-events" onclick="dragEvents()">Card Modal Drag Events</button>
</ion-content>
</div>
</ion-app>
@@ -162,6 +164,24 @@
const modal = await createModal(presentingEl, opts);
await modal.present();
}
async function dragEvents() {
const modal = await createModal(document.querySelectorAll('.ion-page')[1], { id: 'drag-events' });
modal.addEventListener('ionDragStart', (event) => {
console.log('Drag started');
});
modal.addEventListener('ionDragMove', (event) => {
console.log('Drag moved', event.detail);
});
modal.addEventListener('ionDragEnd', (event) => {
console.log('Drag ended', event.detail);
});
await modal.present();
}
</script>
</body>
</html>

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
import { configs, dragElementBy, test } from '@utils/test/playwright';
import { CardModalPage } from '../fixtures';
@@ -95,4 +95,50 @@ 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);
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#drag-events');
await ionModalDidPresent.next();
const ionDragStart = await page.spyOnEvent('ionDragStart');
const ionDragMove = await page.spyOnEvent('ionDragMove');
const ionDragEnd = await page.spyOnEvent('ionDragEnd');
const header = page.locator('.modal-card ion-header');
// Start the drag to verify it emits the events before the gesture ends
await dragElementBy(header, page, 0, 50, undefined, undefined, false);
await ionDragStart.next();
const dragMoveEvent = await ionDragMove.next();
expect(ionDragStart.length).toBe(1);
expect(ionDragMove.length).toBeGreaterThan(0);
expect(Object.keys(dragMoveEvent.detail).length).toBe(4);
expect(ionDragEnd.length).toBe(0);
/**
* Drage the modal further to verify it does:
* - not emit the event again for `ionDragStart`
* - emit more `ionDragMove` events
* - emit the `ionDragEnd` event when the gesture ends
*/
await dragElementBy(header, page, 0, 100);
const dragEndEvent = await ionDragEnd.next();
expect(ionDragStart.length).toBe(1);
expect(ionDragMove.length).toBeGreaterThan(0);
expect(ionDragEnd.length).toBe(1);
expect(Object.keys(dragEndEvent.detail).length).toBe(4);
});
});
});

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 122 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 200 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 251 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 122 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 200 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

After

Width:  |  Height:  |  Size: 251 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 120 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 197 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 248 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 120 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 198 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 248 KiB

View File

@@ -152,6 +152,8 @@
Backdrop is inactive
</button>
<button id="drag-events" onclick="dragEvents()">Drag Events</button>
<div class="grid">
<div class="grid-item red"></div>
<div class="grid-item green"></div>
@@ -246,6 +248,27 @@
});
await modal.present();
}
function dragEvents() {
const modal = createModal({
initialBreakpoint: 0.5,
breakpoints: [0, 0.25, 0.5, 0.75, 1],
});
modal.addEventListener('ionDragStart', (event) => {
console.log('Drag started');
});
modal.addEventListener('ionDragMove', (event) => {
console.log('Drag moved', event.detail);
});
modal.addEventListener('ionDragEnd', (event) => {
console.log('Drag ended', event.detail);
});
modal.present();
}
</script>
</body>
</html>

View File

@@ -353,4 +353,50 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
await expect(dragHandle).toBeFocused();
});
});
test.describe(title('sheet modal: drag events'), () => {
test('should emit ionDragStart, ionDragMove, and ionDragEnd events', 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();
const ionDragStart = await page.spyOnEvent('ionDragStart');
const ionDragMove = await page.spyOnEvent('ionDragMove');
const ionDragEnd = await page.spyOnEvent('ionDragEnd');
const header = page.locator('.modal-sheet ion-header');
// Start the drag to verify it emits the events before the gesture ends
await dragElementBy(header, page, 0, 50, undefined, undefined, false);
await ionDragStart.next();
const dragMoveEvent = await ionDragMove.next();
expect(ionDragStart.length).toBe(1);
expect(ionDragMove.length).toBeGreaterThan(0);
expect(Object.keys(dragMoveEvent.detail).length).toBe(5);
expect(ionDragEnd.length).toBe(0);
/**
* Drage the modal further to verify it does:
* - not emit the event again for `ionDragStart`
* - emit more `ionDragMove` events
* - emit the `ionDragEnd` event when the gesture ends
*/
await dragElementBy(header, page, 0, 100);
const dragEndEvent = await ionDragEnd.next();
expect(ionDragStart.length).toBe(1);
expect(ionDragMove.length).toBeGreaterThan(0);
expect(ionDragEnd.length).toBe(1);
expect(Object.keys(dragEndEvent.detail).length).toBe(5);
});
});
});

View File

@@ -17,7 +17,7 @@ export { CounterFormatter } from './components/item/item-interface';
export { ItemSlidingCustomEvent } from './components/item-sliding/item-sliding-interface';
export { LoadingOptions } from './components/loading/loading-interface';
export { MenuCustomEvent, MenuI, MenuControllerI } from './components/menu/menu-interface';
export { ModalOptions, ModalCustomEvent } from './components/modal/modal-interface';
export { ModalOptions, ModalCustomEvent, ModalDragEventDetail } from './components/modal/modal-interface';
export { NavDirection, NavCustomEvent } from './components/nav/nav-interface';
export { PickerOptions, PickerColumnOption } from './components/picker-legacy/picker-interface';
export { PopoverOptions } from './components/popover/popover-interface';

View File

@@ -7,7 +7,7 @@ import {
NgZone,
TemplateRef,
} from '@angular/core';
import type { Components, ModalBreakpointChangeEventDetail } from '@ionic/core/components';
import type { Components, ModalBreakpointChangeEventDetail, ModalDragEventDetail } from '@ionic/core/components';
import { ProxyCmp, proxyOutputs } from '../utils/proxy';
@@ -32,6 +32,18 @@ export declare interface IonModal extends Components.IonModal {
* Emitted after the modal breakpoint has changed.
*/
ionBreakpointDidChange: EventEmitter<CustomEvent<ModalBreakpointChangeEventDetail>>;
/**
* Emitted when the sheet or card modal has started being dragged.
*/
ionDragStart: EventEmitter<void>;
/**
* Emitted while the sheet or card modal is being dragged.
*/
ionDragMove: EventEmitter<CustomEvent<ModalDragEventDetail>>;
/**
* Emitted when the sheet or card modal has finished being dragged.
*/
ionDragEnd: EventEmitter<CustomEvent<ModalDragEventDetail>>;
/**
* Emitted after the modal has presented. Shorthand for ionModalDidPresent.
*/
@@ -130,6 +142,9 @@ export class IonModal {
'willPresent',
'willDismiss',
'didDismiss',
'ionDragStart',
'ionDragMove',
'ionDragEnd',
]);
}
}

View File

@@ -99,6 +99,7 @@ export {
IonicSafeString,
LoadingOptions,
MenuCustomEvent,
ModalDragEventDetail,
NavCustomEvent,
PickerOptions,
PickerButton,

View File

@@ -97,6 +97,7 @@ export {
IonicSafeString,
LoadingOptions,
MenuCustomEvent,
ModalDragEventDetail,
NavCustomEvent,
PickerOptions,
PickerButton,

View File

@@ -54,6 +54,7 @@ export {
IonicSafeString,
LoadingOptions,
MenuCustomEvent,
ModalDragEventDetail,
ModalOptions,
NavCustomEvent,
PickerOptions,

View File

@@ -91,6 +91,7 @@ export {
IonicSafeString,
LoadingOptions,
MenuCustomEvent,
ModalDragEventDetail,
ModalOptions,
NavCustomEvent,
PickerOptions,

View File

@@ -109,6 +109,11 @@ export const defineOverlayContainer = <Props extends object>(
delete restOfProps.onDidPresent;
delete restOfProps.onWillDismiss;
delete restOfProps.onDidDismiss;
if (name === "ion-modal") {
delete restOfProps.onIonDragStart;
delete restOfProps.onIonDragMove;
delete restOfProps.onIonDragEnd;
}
const component = slots.default && slots.default()[0];
overlay.value = controller.create({
@@ -174,6 +179,24 @@ export const defineOverlayContainer = <Props extends object>(
emit("didPresent", ev);
emit(componentName + "DidPresent", ev);
});
/**
* Modal drag events:
* Adding these ensures they are re-emitted so developers can
* use @ionDragStart, @ionDragMove, etc. in their templates.
*/
if (name === "ion-modal") {
elementRef.value.addEventListener("ionDragStart", (ev: Event) => {
emit("ionDragStart", ev);
});
elementRef.value.addEventListener("ionDragMove", (ev: Event) => {
emit("ionDragMove", ev);
});
elementRef.value.addEventListener("ionDragEnd", (ev: Event) => {
emit("ionDragEnd", ev);
});
}
});
return () => {