diff --git a/core/api.txt b/core/api.txt index 5a79c3903aa..c4743ea132e 100644 --- a/core/api.txt +++ b/core/api.txt @@ -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 diff --git a/core/src/components/modal/animations/ios.enter.ts b/core/src/components/modal/animations/ios.enter.ts index c79e8752e22..c180925bb59 100644 --- a/core/src/components/modal/animations/ios.enter.ts +++ b/core/src/components/modal/animations/ios.enter.ts @@ -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')!); diff --git a/core/src/components/modal/animations/ios.leave.ts b/core/src/components/modal/animations/ios.leave.ts index de543acaa54..fc79b8efe6e 100644 --- a/core/src/components/modal/animations/ios.leave.ts +++ b/core/src/components/modal/animations/ios.leave.ts @@ -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')!); diff --git a/core/src/components/modal/animations/sheet-geometry.ts b/core/src/components/modal/animations/sheet-geometry.ts new file mode 100644 index 00000000000..b72f144f1f7 --- /dev/null +++ b/core/src/components/modal/animations/sheet-geometry.ts @@ -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(); + +/** + * 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('.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})`, + }; + }); +}; diff --git a/core/src/components/modal/animations/sheet.ts b/core/src/components/modal/animations/sheet.ts index e827846a7be..e14bf4ce3b6 100644 --- a/core/src/components/modal/animations/sheet.ts +++ b/core/src/components/modal/animations/sheet.ts @@ -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; /** @@ -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. @@ -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; /** @@ -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 }; }; diff --git a/core/src/components/modal/gestures/sheet.ts b/core/src/components/modal/gestures/sheet.ts index deb08cf4046..89e8b6c00d5 100644 --- a/core/src/components/modal/gestures/sheet.ts +++ b/core/src/components/modal/gestures/sheet.ts @@ -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'; @@ -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%' }, @@ -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; @@ -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) { @@ -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 @@ -441,6 +447,7 @@ export const createSheetGesture = ( }; const onEnd = (detail: GestureDetail) => { + dragging = false; const snapBreakpoint = calculateSnapBreakpoint(detail.deltaY, detail.velocityY); /** @@ -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([ { @@ -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]); @@ -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, diff --git a/core/src/components/modal/modal.scss b/core/src/components/modal/modal.scss index a7456acc1c2..7a10e576943 100644 --- a/core/src/components/modal/modal.scss +++ b/core/src/components/modal/modal.scss @@ -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%; diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index 94c85a43811..efac6624945 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -97,6 +97,7 @@ export class Modal implements ComponentInterface, OverlayInterface { private keyboardOpenCallback?: () => void; private moveSheetToBreakpoint?: (options: MoveSheetToBreakpointOptions) => Promise; private resetSheetContentScroll?: () => void; + private refreshSheetGeometry?: () => void; private inheritedAttributes: Attributes = {}; private statusBarStyle?: StatusBarStyle; @@ -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 @@ -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, @@ -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); @@ -1078,6 +1081,7 @@ export class Modal implements ComponentInterface, OverlayInterface { */ this.resetSheetContentScroll?.(); this.resetSheetContentScroll = undefined; + this.refreshSheetGeometry = undefined; this.cleanupViewTransitionListener(); this.cleanupParentRemovalObserver(); this.cleanupSafeAreaOverrides(); diff --git a/core/src/components/modal/test/sheet-inset/README.md b/core/src/components/modal/test/sheet-inset/README.md new file mode 100644 index 00000000000..2999773d89d --- /dev/null +++ b/core/src/components/modal/test/sheet-inset/README.md @@ -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. diff --git a/core/src/components/modal/test/sheet-inset/modal.e2e.ts b/core/src/components/modal/test/sheet-inset/modal.e2e.ts new file mode 100644 index 00000000000..71932785b92 --- /dev/null +++ b/core/src/components/modal/test/sheet-inset/modal.e2e.ts @@ -0,0 +1,118 @@ +import { expect } from '@playwright/test'; +import { dragElementBy, test } from '@utils/test/playwright'; + +for (const expandToScroll of [true, false]) { + test(`sheet inset follows presentation, drag and programmatic detents (expandToScroll=${expandToScroll})`, async ({ + page, + }) => { + await page.setViewportSize({ width: 402, height: 874 }); + await page.setContent('Background', { + mode: 'ios', + direction: 'ltr', + palette: 'light', + }); + await page.evaluate(async (expand) => { + const modal = document.createElement('ion-modal'); + modal.style.cssText = '--height: 800px; --sheet-inset: calc(4px + 4px); --border-radius: 32px'; + modal.breakpoints = [0, 0.5, 0.75, 1]; + modal.initialBreakpoint = 0.75; + modal.expandToScroll = expand; + const content = document.createElement('div'); + content.innerHTML = + 'Sheet
Scrollable content
Footer'; + modal.component = content; + document.body.append(modal); + await modal.present(); + }, expandToScroll); + const modal = page.locator('ion-modal'); + const wrapper = modal.locator('.modal-wrapper'); + const geometry = () => + wrapper.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { left: Math.round(rect.left), width: Math.round(rect.width) }; + }); + await expect.poll(geometry).toEqual({ left: 8, width: 386 }); + await expect + .poll(() => modal.locator('ion-content').evaluate((element: HTMLIonContentElement) => element.scrollY)) + .toBe(!expandToScroll); + if (!expandToScroll) { + await expect + .poll(() => + modal.locator('ion-footer').evaluate((element) => Math.round(element.getBoundingClientRect().bottom)) + ) + .toBe(866); + } + // The gap and clipped lower corners must hit the backdrop, not invisible sheet content. + await expect + .poll(() => modal.evaluate((element) => element.shadowRoot!.elementFromPoint(201, 870)?.tagName)) + .toBe('ION-BACKDROP'); + for (const breakpoint of [1, 0.5, 0.75]) { + await modal.evaluate((element: HTMLIonModalElement, value) => element.setCurrentBreakpoint(value), breakpoint); + await expect.poll(geometry).toEqual(breakpoint === 1 ? { left: 0, width: 402 } : { left: 8, width: 386 }); + } + const handle = (await modal.locator('.modal-handle').boundingBox())!; + const x = handle.x + handle.width / 2; + const y = handle.y + handle.height / 2; + await dragElementBy(modal.locator('.modal-handle'), page, 0, -100, undefined, undefined, false); + await expect.poll(async () => (await geometry()).left).toBeGreaterThan(0); + await expect.poll(async () => (await geometry()).left).toBeLessThan(8); + await expect(modal.locator('.ion-page ion-footer')).toHaveCount(1); + for (let i = 1; i <= 14; i++) { + await page.mouse.move(x, y - 100 - i * 10); + await page.evaluate(() => new Promise(requestAnimationFrame)); + } + await page.mouse.up(); + await expect.poll(() => modal.evaluate((element: HTMLIonModalElement) => element.getCurrentBreakpoint())).toBe(1); + await expect.poll(geometry).toEqual({ left: 0, width: 402 }); + await modal.evaluate(async (element: HTMLIonModalElement) => { + await element.setCurrentBreakpoint(0.75); + await element.dismiss(); + }); + await expect(modal).toBeHidden(); + await modal.evaluate((element: HTMLIonModalElement) => element.present()); + await expect.poll(geometry).toEqual({ left: 8, width: 386 }); + await page.setViewportSize({ width: 430, height: 932 }); + await expect.poll(geometry).toEqual({ left: 8, width: 414 }); + await modal.evaluate((element) => { + element.style.setProperty('--sheet-inset', '0px'); + window.dispatchEvent(new Event('resize')); + }); + await expect.poll(geometry).toEqual({ left: 0, width: 430 }); + await expect(wrapper).toHaveCSS('clip-path', 'none'); + await modal.evaluate((element: HTMLIonModalElement) => { + element.style.setProperty('--sheet-inset', '8px'); + element.canDismiss = false; + window.dispatchEvent(new Event('resize')); + }); + await expect.poll(geometry).toEqual({ left: 8, width: 414 }); + await dragElementBy(modal.locator('.modal-handle'), page, 0, 400); + await expect + .poll(() => modal.evaluate((element: HTMLIonModalElement) => element.getCurrentBreakpoint())) + .toBe(0.75); + await expect(modal).toBeVisible(); + await modal.evaluate((element: HTMLIonModalElement) => { + element.canDismiss = true; + }); + await page.mouse.click(200, 50); + await expect(modal).toBeHidden(); + }); +} + +for (const mode of ['ios', 'md'] as const) { + test(`sheet inset is opt-in and restricted to iOS (${mode})`, async ({ page }) => { + await page.setViewportSize({ width: 402, height: 874 }); + await page.setContent('', { mode, direction: 'ltr', palette: 'light' }); + await page.evaluate(async (mode) => { + const modal = document.createElement('ion-modal'); + modal.style.setProperty('--sheet-inset', mode === 'md' ? '8px' : '0px'); + modal.breakpoints = [0, 0.75, 1]; + modal.initialBreakpoint = 0.75; + modal.component = 'ion-content'; + document.body.append(modal); + await modal.present(); + }, mode); + const wrapper = page.locator('ion-modal .modal-wrapper'); + await expect.poll(() => wrapper.evaluate((element) => Math.round(element.getBoundingClientRect().width))).toBe(402); + await expect(wrapper).toHaveCSS('clip-path', 'none'); + }); +}