Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions core/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1219,6 +1219,8 @@ ion-modal,css-prop,--min-height,ios
ion-modal,css-prop,--min-height,md
ion-modal,css-prop,--min-width,ios
ion-modal,css-prop,--min-width,md
ion-modal,css-prop,--sheet-inset,ios
ion-modal,css-prop,--sheet-inset,md
ion-modal,css-prop,--width,ios
ion-modal,css-prop,--width,md
ion-modal,part,backdrop
Expand Down
2 changes: 1 addition & 1 deletion core/src/components/modal/animations/ios.enter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptio
const { presentingEl, currentBreakpoint, expandToScroll } = opts;
const root = getElementRoot(baseEl);
const { wrapperAnimation, backdropAnimation, contentAnimation } =
currentBreakpoint !== undefined ? createSheetEnterAnimation(opts) : createEnterAnimation();
currentBreakpoint !== undefined ? createSheetEnterAnimation(opts, baseEl) : createEnterAnimation();

backdropAnimation.addElement(root.querySelector('ion-backdrop')!);

Expand Down
2 changes: 1 addition & 1 deletion core/src/components/modal/animations/ios.leave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const iosLeaveAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptio
const { presentingEl, currentBreakpoint } = opts;
const root = getElementRoot(baseEl);
const { wrapperAnimation, backdropAnimation } =
currentBreakpoint !== undefined ? createSheetLeaveAnimation(opts) : createLeaveAnimation();
currentBreakpoint !== undefined ? createSheetLeaveAnimation(opts, baseEl) : createLeaveAnimation();

backdropAnimation.addElement(root.querySelector('ion-backdrop')!);

Expand Down
66 changes: 66 additions & 0 deletions core/src/components/modal/animations/sheet-geometry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { getIonMode } from '@global/ionic-global';
import { getElementRoot } from '@utils/helpers';

// The enter animation keeps its fill until dismissal. Explicitly override only
// properties this feature previously owned when a responsive inset becomes zero.
const insetSheets = new WeakSet<HTMLElement>();

/**
* Keep the visible sheet edge and its hit area together throughout a gesture.
* The full-height wrapper still defines Ionic's breakpoint/drag coordinate space.
* Only its presentation is scaled and clipped; content scrolling keeps its existing
* expandToScroll contract.
*/
export const getSheetWrapperKeyframes = (baseEl: HTMLElement, from: number, to: number) => {
const wrapper = getElementRoot(baseEl).querySelector<HTMLElement>('.modal-wrapper');
const value = getComputedStyle(baseEl).getPropertyValue('--sheet-inset').trim();
let inset = 0;
if (getIonMode(baseEl) === 'ios' && value) {
// Resolve calc()/relative lengths the same way as modal safe-area values.
const probe = baseEl.ownerDocument.createElement('div');
probe.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;padding-top:var(--sheet-inset,0px)';
baseEl.appendChild(probe);
inset = parseFloat(getComputedStyle(probe).paddingTop) || 0;
probe.remove();
}
const width = wrapper?.clientWidth ?? 0;
const height = wrapper?.clientHeight ?? 0;
const points = [from, to];
const plainFrames = () =>
points.map((point, offset) => ({
offset,
transform: `translateY(${(1 - point) * 100}%)`,
...(insetSheets.has(baseEl) ? { clipPath: 'none', transformOrigin: 'center top' } : {}),
}));

if (getIonMode(baseEl) !== 'ios' || !Number.isFinite(inset) || inset <= 0 || width <= inset * 2 || height <= 0) {
return plainFrames();
}

insetSheets.add(baseEl);
const breakpoints = (baseEl as HTMLIonModalElement).breakpoints ?? [];
const lastPartial = Math.max(0, ...breakpoints.filter((point) => point > 0 && point < 1));
// Preserve the inset at every partial detent; remove it over the final stretch to 1.
const hasFullDetent = breakpoints.includes(1);
if (hasFullDetent && lastPartial > Math.min(from, to) && lastPartial < Math.max(from, to)) {
points.splice(1, 0, lastPartial);
}

return points.map((point, index) => {
const fraction = hasFullDetent ? Math.min(1, Math.max(0, (1 - point) / (1 - lastPartial))) : 1;
const edge = inset * fraction;
const scale = 1 - (edge * 2) / width;
// Scaling around the visible top shortens the sheet. Compensate to keep its
// visible bottom exactly `edge` pixels above its original bottom edge.
const y = (1 - point) * height + point * height * (1 - scale) - edge;
const bottomRadius = `calc(var(--border-radius, 0px) * ${fraction})`;
return {
offset: from === to ? index : (point - from) / (to - from),
transform: `translateY(${y}px) scale(${scale})`,
transformOrigin: 'center top',
clipPath: `inset(0px 0px ${
(1 - point) * 100
}% 0px round var(--border-radius, 0px) var(--border-radius, 0px) ${bottomRadius} ${bottomRadius})`,
};
});
};
30 changes: 20 additions & 10 deletions core/src/components/modal/animations/sheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { createAnimation } from '@utils/animation/animation';
import type { ModalAnimationOptions } from '../modal-interface';
import { getBackdropValueForSheet } from '../utils';

export const createSheetEnterAnimation = (opts: ModalAnimationOptions) => {
import { getSheetWrapperKeyframes } from './sheet-geometry';

export const createSheetEnterAnimation = (opts: ModalAnimationOptions, baseEl?: HTMLElement) => {
const { currentBreakpoint, backdropBreakpoint, expandToScroll } = opts;

/**
Expand All @@ -24,10 +26,14 @@ export const createSheetEnterAnimation = (opts: ModalAnimationOptions) => {
.afterClearStyles(['pointer-events']);
}

const wrapperAnimation = createAnimation('wrapperAnimation').keyframes([
{ offset: 0, opacity: 1, transform: 'translateY(100%)' },
{ offset: 1, opacity: 1, transform: `translateY(${100 - currentBreakpoint! * 100}%)` },
]);
const wrapperAnimation = createAnimation('wrapperAnimation').keyframes(
baseEl
? getSheetWrapperKeyframes(baseEl, 0, currentBreakpoint!)
: [
{ offset: 0, opacity: 1, transform: 'translateY(100%)' },
{ offset: 1, opacity: 1, transform: `translateY(${100 - currentBreakpoint! * 100}%)` },
]
);

/**
* This allows the content to be scrollable at any breakpoint.
Expand All @@ -42,7 +48,7 @@ export const createSheetEnterAnimation = (opts: ModalAnimationOptions) => {
return { wrapperAnimation, backdropAnimation, contentAnimation };
};

export const createSheetLeaveAnimation = (opts: ModalAnimationOptions) => {
export const createSheetLeaveAnimation = (opts: ModalAnimationOptions, baseEl?: HTMLElement) => {
const { currentBreakpoint, backdropBreakpoint } = opts;

/**
Expand All @@ -69,10 +75,14 @@ export const createSheetLeaveAnimation = (opts: ModalAnimationOptions) => {
backdropBreakpoint !== 0 ? customBackdrop : defaultBackdrop
);

const wrapperAnimation = createAnimation('wrapperAnimation').keyframes([
{ offset: 0, opacity: 1, transform: `translateY(${100 - currentBreakpoint! * 100}%)` },
{ offset: 1, opacity: 1, transform: `translateY(100%)` },
]);
const wrapperAnimation = createAnimation('wrapperAnimation').keyframes(
baseEl
? getSheetWrapperKeyframes(baseEl, currentBreakpoint!, 0)
: [
{ offset: 0, opacity: 1, transform: `translateY(${100 - currentBreakpoint! * 100}%)` },
{ offset: 1, opacity: 1, transform: 'translateY(100%)' },
]
);

return { wrapperAnimation, backdropAnimation };
};
33 changes: 24 additions & 9 deletions core/src/components/modal/gestures/sheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { FOCUS_TRAP_DISABLE_CLASS } from '@utils/overlays';

import type { Animation, ModalDragEventDetail } from '../../../interface';
import type { GestureDetail } from '../../../utils/gesture';
import { getSheetWrapperKeyframes } from '../animations/sheet-geometry';
import { getBackdropValueForSheet } from '../utils';

import { calculateSpringStep, canSwipeOnContent, handleCanDismiss } from './utils';
Expand Down Expand Up @@ -73,10 +74,7 @@ export const createSheetGesture = (
];

const SheetDefaults = {
WRAPPER_KEYFRAMES: [
{ offset: 0, transform: 'translateY(0%)' },
{ offset: 1, transform: 'translateY(100%)' },
],
WRAPPER_KEYFRAMES: getSheetWrapperKeyframes(baseEl, 1, 0),
BACKDROP_KEYFRAMES: backdropBreakpoint !== 0 ? customBackdrop : defaultBackdrop,
CONTENT_KEYFRAMES: [
{ offset: 0, maxHeight: '100%' },
Expand All @@ -92,7 +90,8 @@ export const createSheetGesture = (
* rather than the host element, which is stretched to the full viewport no
* matter how tall the sheet itself is.
*/
const height = wrapperEl.clientHeight;
let height = wrapperEl.clientHeight;
let dragging = false;
let currentBreakpoint = initialBreakpoint;
let offset = 0;
let canDismissBlocksGesture = false;
Expand Down Expand Up @@ -145,6 +144,9 @@ export const createSheetGesture = (
* @param newPosition Whether the footer is in a moving or stationary position.
*/
const swapFooterPosition = (newPosition: 'moving' | 'stationary') => {
if (SheetDefaults.WRAPPER_KEYFRAMES.some((frame) => 'clipPath' in frame && frame.clipPath !== 'none')) {
return;
}
if (!cachedFooterEls) {
cachedFooterEls = Array.from(baseEl.querySelectorAll('ion-footer'));
if (!cachedFooterEls.length) {
Expand Down Expand Up @@ -290,6 +292,10 @@ export const createSheetGesture = (
};

const onStart = (detail: GestureDetail) => {
dragging = true;
height = wrapperEl.clientHeight;
SheetDefaults.WRAPPER_KEYFRAMES = getSheetWrapperKeyframes(baseEl, 1, 0);
wrapperAnimation?.keyframes([...SheetDefaults.WRAPPER_KEYFRAMES]);
/**
* Firefox automatically selects the header text during drag
* due to the focusable wrapper (tabindex="-1"). Remove any
Expand Down Expand Up @@ -441,6 +447,7 @@ export const createSheetGesture = (
};

const onEnd = (detail: GestureDetail) => {
dragging = false;
const snapBreakpoint = calculateSnapBreakpoint(detail.deltaY, detail.velocityY);

/**
Expand Down Expand Up @@ -520,10 +527,7 @@ export const createSheetGesture = (
* the last offset to the closest snap point.
*/
if (wrapperAnimation && backdropAnimation) {
wrapperAnimation.keyframes([
{ offset: 0, transform: `translateY(${breakpointOffset * 100}%)` },
{ offset: 1, transform: `translateY(${(1 - snapToBreakpoint) * 100}%)` },
]);
wrapperAnimation.keyframes(getSheetWrapperKeyframes(baseEl, 1 - breakpointOffset, snapToBreakpoint));

backdropAnimation.keyframes([
{
Expand Down Expand Up @@ -613,6 +617,7 @@ export const createSheetGesture = (
*/
if (wrapperAnimation && backdropAnimation) {
raf(() => {
SheetDefaults.WRAPPER_KEYFRAMES = getSheetWrapperKeyframes(baseEl, 1, 0);
wrapperAnimation.keyframes([...SheetDefaults.WRAPPER_KEYFRAMES]);
backdropAnimation.keyframes([...SheetDefaults.BACKDROP_KEYFRAMES]);
contentAnimation?.keyframes([...SheetDefaults.CONTENT_KEYFRAMES]);
Expand Down Expand Up @@ -728,7 +733,17 @@ export const createSheetGesture = (
}
};

const refreshGeometry = () => {
// Do not interrupt a drag or a snap. Their end path refreshes the geometry.
if (dragging || animation.isRunning()) return;
height = wrapperEl.clientHeight;
SheetDefaults.WRAPPER_KEYFRAMES = getSheetWrapperKeyframes(baseEl, 1, 0);
wrapperAnimation?.keyframes([...SheetDefaults.WRAPPER_KEYFRAMES]);
animation.progressStart(true, 1 - getCurrentBreakpoint());
};

return {
refreshGeometry,
gesture,
moveSheetToBreakpoint,
resetContentScroll,
Expand Down
2 changes: 2 additions & 0 deletions core/src/components/modal/modal.scss
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
* @prop --height: Height of the modal
* @prop --max-height: Maximum height of the modal
*
* @prop --sheet-inset: Inset in pixels for iOS sheet presentation. Defaults to 0. Partial detents are scaled and clipped; breakpoint 1 returns to full width.
*
* @prop --backdrop-opacity: Opacity of the backdrop
*/
--width: 100%;
Expand Down
6 changes: 5 additions & 1 deletion core/src/components/modal/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
private keyboardOpenCallback?: () => void;
private moveSheetToBreakpoint?: (options: MoveSheetToBreakpointOptions) => Promise<void>;
private resetSheetContentScroll?: () => void;
private refreshSheetGeometry?: () => void;
private inheritedAttributes: Attributes = {};
private statusBarStyle?: StatusBarStyle;

Expand Down Expand Up @@ -321,6 +322,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
// values may change on device rotation (e.g., portrait notch vs landscape).
if (context.isSheetModal) {
this.updateSheetOffsetTop();
this.refreshSheetGeometry?.();
}

// Regular (non-sheet, non-card) modals: update safe-area overrides
Expand Down Expand Up @@ -820,7 +822,7 @@ export class Modal implements ComponentInterface, OverlayInterface {

ani.progressStart(true, 1);

const { gesture, moveSheetToBreakpoint, resetContentScroll } = createSheetGesture(
const { gesture, moveSheetToBreakpoint, resetContentScroll, refreshGeometry } = createSheetGesture(
this.el,
this.backdropEl!,
wrapperEl,
Expand All @@ -845,6 +847,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
this.gesture = gesture;
this.moveSheetToBreakpoint = moveSheetToBreakpoint;
this.resetSheetContentScroll = resetContentScroll;
this.refreshSheetGeometry = refreshGeometry;

this.gesture.enable(true);

Expand Down Expand Up @@ -1078,6 +1081,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
*/
this.resetSheetContentScroll?.();
this.resetSheetContentScroll = undefined;
this.refreshSheetGeometry = undefined;
this.cleanupViewTransitionListener();
this.cleanupParentRemovalObserver();
this.cleanupSafeAreaOverrides();
Expand Down
20 changes: 20 additions & 0 deletions core/src/components/modal/test/sheet-inset/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Inset sheet presentation

`--sheet-inset` opts an iOS sheet into a scaled, rounded presentation at partial breakpoints. It defaults to zero, so existing sheets and MD presentations keep their appearance. For example:

```css
ion-modal.ios.phone-sheet {
--sheet-inset: 8px;
--border-radius: 32px;
}
```

Configure the modal with `breakpoints: [0, 0.5, 0.75, 1]` and `initialBreakpoint: 0.75`. The inset is measured from the unscaled sheet's side/bottom edges. CSS lengths, including `calc()`, are resolved before creating keyframes. An inset that consumes the entire sheet width is ignored.

At partial detents the content is scaled, not reflowed into a narrower layout. The inset interpolates to zero between the last partial detent and breakpoint 1. If no breakpoint 1 exists, partial detents retain the inset. Enter, leave, drag, snap, and programmatic changes share these keyframes. The existing breakpoint-height and `expandToScroll` semantics are retained.

The wrapper is clipped at its visible bottom, so content below a partial detent cannot capture taps in the gap. With `expandToScroll=false`, footers remain in the scaled page rather than being reparented into the unscaled document body. Window resizing recomputes geometry; drag/snap completion also refreshes it without introducing a second animation loop. Changing a CSS variable alone is not a reactive API: it is sampled during presentation, resize, or a subsequent gesture/breakpoint transition.

This is an opt-in presentation mechanism, not automatic iPhone/iPad detection. Native measurements in [theme Issue #269](https://github.com/rdlabo-dev/ionic-theme-ios27/issues/269) were independently reproduced on iOS 27.0: iPhone partial sheets have about 8pt side/bottom gaps, while large sheets have none. iPad has different dimensions and retains a bottom gap at large. Do not apply a phone inset globally to iPad. The interpolation is not a claim to reproduce Apple's private spring curve or SwiftUI detent-height calculation.

Run `npx playwright test src/components/modal/test/sheet-inset/modal.e2e.ts` from `core`. The tests cover both `expandToScroll` values, CSS length resolution, partial/full detents, mid-drag scaling, backdrop hit testing, footer layout, content scroll state, dismissal/re-presentation, resize, disabling/re-enabling the inset, `canDismiss=false`, backdrop dismissal, and unchanged default/MD presentation.
Loading
Loading