Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
dbba21c
feat(modal): add drag events
thetaPC Feb 20, 2026
962ba1c
refactor(modal): cleanup
thetaPC Feb 20, 2026
7b597ba
test(modal): add drag events tests
thetaPC Feb 20, 2026
9b2cde1
test(modal): update card snapshots
thetaPC Feb 20, 2026
ee4f499
feat(modal): expose drag event type
thetaPC Feb 25, 2026
4fddd38
feat(modal): export ModalDragEventDetail for all frameworks
thetaPC Feb 25, 2026
8af9626
feat(modal): add events to Vue overlays
thetaPC Feb 26, 2026
b6d5227
fix(vue): use correct event name
thetaPC Feb 26, 2026
81221d4
feat(vue): update drag events logic
thetaPC Feb 26, 2026
7223e05
Merge branch 'feature-8.8' of github.com:ionic-team/ionic-framework i…
thetaPC Feb 26, 2026
9ff2658
fix(vue): delete correct keys
thetaPC Feb 27, 2026
4bb9d83
feat(modal): update to predictedBreakpoint
thetaPC Feb 27, 2026
baea4c0
fix(modal): emit dragEnd on early return
thetaPC Feb 27, 2026
0b73f03
feat(modal): add drag events to Angular common
thetaPC Feb 27, 2026
47a377b
feat(modal): improve calculatePredictedBreakpoint
thetaPC Mar 3, 2026
f128fe7
Update core/src/components/modal/test/sheet/index.html
thetaPC Mar 3, 2026
f18849d
test(modal): remove testinfo
thetaPC Mar 3, 2026
9384c75
docs(modal): remove double comment
thetaPC Mar 3, 2026
8b033f6
feat(vue): remove long name version
thetaPC Mar 3, 2026
e2a2c9e
feat(modal): rename to snapBreakpoint
thetaPC Mar 3, 2026
d7a4aea
test(modal): check multiple event emits
thetaPC Mar 3, 2026
e32c1ac
test(modal): remove only
thetaPC Mar 3, 2026
b3ab42c
Merge branch 'feature-8.8' of github.com:ionic-team/ionic-framework i…
thetaPC Mar 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions core/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 17 additions & 2 deletions core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand Down
155 changes: 140 additions & 15 deletions core/src/components/modal/gestures/sheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -347,6 +350,8 @@ export const createSheetGesture = (
});

animation.progressStart(true, 1 - currentBreakpoint);

onDragStart();
};

const onMove = (detail: GestureDetail) => {
Expand Down Expand Up @@ -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
Expand All @@ -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,

Expand All @@ -466,6 +483,8 @@ export const createSheetGesture = (
*/
animated: true,
});

onDragEnd(eventDetail);
};

const moveSheetToBreakpoint = (options: MoveSheetToBreakpointOptions) => {
Expand Down Expand Up @@ -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',
Expand Down
67 changes: 65 additions & 2 deletions core/src/components/modal/gestures/swipe-to-close.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -142,6 +145,8 @@ export const createSwipeToCloseGesture = (
}

animation.progressStart(true, isOpen ? 1 : 0);

onDragStart();
};

const onMove = (detail: GestureDetail) => {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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({
Expand All @@ -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));
};
12 changes: 12 additions & 0 deletions core/src/components/modal/modal-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,15 @@ export interface ModalCustomEvent extends CustomEvent {
* The behavior setting for modals when the handle is pressed.
*/
export type ModalHandleBehavior = 'none' | 'cycle';

export interface ModalDragEventDetail {
currentY: number;
deltaY: number;
velocityY: number;
progress: number;
/**
* The breakpoint that the sheet will snap to if the user releases
* the gesture.
*/
snapBreakpoint?: number;
}
Loading
Loading