diff --git a/core/src/components.d.ts b/core/src/components.d.ts index a6c8a7032d3..cf24c5fcd4f 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -867,7 +867,7 @@ export namespace Components { */ "getScrollElement": () => Promise; /** - * Recalculate content dimensions. Called by overlays (e.g., popover) when sibling elements like headers or footers have finished rendering and their heights are available, ensuring accurate offset-top calculations. + * Recalculates the content dimensions and whether it should size itself to its content. Called by overlays when something they own changes, such as a header finishing its render or `--height` being updated. */ "recalculateDimensions": () => Promise; /** diff --git a/core/src/components/content/content.tsx b/core/src/components/content/content.tsx index b0673b414d5..0386847e86d 100644 --- a/core/src/components/content/content.tsx +++ b/core/src/components/content/content.tsx @@ -8,6 +8,7 @@ import { Listen, Method, Prop, + State, Watch, forceUpdate, h, @@ -15,6 +16,7 @@ import { } from '@stencil/core'; import { componentOnReady, hasLazyBuild, inheritAriaAttributes } from '@utils/helpers'; import type { Attributes } from '@utils/helpers'; +import { getOverlaySizeType } from '@utils/overlays'; import { isPlatform } from '@utils/platform'; import { isRTL } from '@utils/rtl'; import { createColorClasses, hostContext } from '@utils/theme'; @@ -77,6 +79,11 @@ export class Content implements ComponentInterface { @Element() el!: HTMLIonContentElement; + /** + * Whether the host is sized to its content. + */ + @State() sizeToContent = false; + /** * The color to use from your application's color palette. * Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. @@ -148,6 +155,7 @@ export class Content implements ComponentInterface { componentWillLoad() { this.inheritedAttributes = inheritAriaAttributes(this.el); + this.sizeToContent = this.readSizeToContent(); } connectedCallback() { @@ -190,6 +198,7 @@ export class Content implements ComponentInterface { // Re-observe on reattach, since componentDidLoad only fires once. this.setupFullscreenResizeObserver(); + this.updateSizeToContent(); } componentDidLoad() { @@ -258,6 +267,17 @@ export class Content implements ComponentInterface { this.fullscreenResizeObserver.observe(this.el); } + /** + * Picks up an overlay that is no longer sized the way the last render + * assumed, re-rendering only when the answer changes. Read in a `readTask` + * because resolving the custom property forces a style recalculation. + */ + private updateSizeToContent() { + readTask(() => { + this.sizeToContent = this.readSizeToContent(); + }); + } + private destroyFullscreenResizeObserver() { if (this.fullscreenResizeObserver !== undefined) { this.fullscreenResizeObserver.disconnect(); @@ -310,6 +330,34 @@ export class Content implements ComponentInterface { return forceOverscroll === undefined ? mode === 'ios' && isPlatform('ios') : forceOverscroll; } + /** + * Reads whether to size the component to its content height. Forces a style + * recalculation, so it belongs in a read task or before the first render. + * + * This applies inside popovers and modals with a content-based `--height`, + * where the overlay does not provide the content with a definite height + * to fill. + * + * Only `--height` is consulted. Styling the wrapper directly, such as + * `ion-modal::part(content) { height: fit-content; }`, does not change + * `--height` and therefore cannot be observed. `--height` is the only + * supported way to opt into content-based sizing. + */ + private readSizeToContent() { + if (hostContext('ion-popover', this.el)) { + return true; + } + + const modal = this.el.closest('ion-modal'); + if (modal === null) { + return false; + } + + const height = getComputedStyle(modal).getPropertyValue('--height'); + + return getOverlaySizeType(height) === 'content'; + } + private resize() { /** * Only force update if the component is rendered in a browser context. @@ -320,6 +368,13 @@ export class Content implements ComponentInterface { * TODO: Remove if STENCIL-834 determines Stencil will account for this. */ if (Build.isBrowser) { + /** + * A window resize can cross a media query that changes the modal's + * `--height`. The content's own offsets are unchanged, so neither branch + * below re-renders and the class from the last render would go stale. + */ + this.updateSizeToContent(); + if (this.fullscreen) { readTask(() => this.readDimensions()); } else if (this.cTop !== 0 || this.cBottom !== 0) { @@ -330,14 +385,16 @@ export class Content implements ComponentInterface { } /** - * Recalculate content dimensions. Called by overlays (e.g., popover) when - * sibling elements like headers or footers have finished rendering and their - * heights are available, ensuring accurate offset-top calculations. + * Recalculates the content dimensions and whether it should size itself to + * its content. Called by overlays when something they own changes, such as + * a header finishing its render or `--height` being updated. + * * @internal */ @Method() async recalculateDimensions(): Promise { readTask(() => this.readDimensions()); + this.updateSizeToContent(); } private readDimensions() { @@ -538,7 +595,7 @@ export class Content implements ComponentInterface { class={createColorClasses(this.color, { [mode]: true, 'content-fullscreen': this.fullscreen, - 'content-sizing': hostContext('ion-popover', this.el), + 'content-sizing': this.sizeToContent, overscroll: forceOverscroll, [`content-${rtl}`]: true, })} diff --git a/core/src/components/modal/modal.scss b/core/src/components/modal/modal.scss index 0df4a448cd3..a7456acc1c2 100644 --- a/core/src/components/modal/modal.scss +++ b/core/src/components/modal/modal.scss @@ -27,7 +27,12 @@ --max-width: auto; --height: 100%; --min-height: auto; - --max-height: auto; + /** + * Clamps a content-sized `--height` (auto, fit-content, ...) to the + * overlay, giving the wrapper's flex children something to shrink + * toward so `ion-content` scrolls instead of overflowing. + */ + --max-height: 100%; --overflow: hidden; --border-radius: 0; --border-width: 0; @@ -87,8 +92,16 @@ ion-backdrop { /** * The wrapper receives programmatic focus for screen readers but should not * show a visible focus ring, which is meant only for keyboard navigation. + * + * A flex layout is required for the wrapper to size itself to its content + * when the modal is content-sized (`--height` is auto, fit-content, ...). + * This makes it so that the content can scroll when it overflows the wrapper. */ .modal-wrapper { + display: flex; + + flex-direction: column; + outline: none; } diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index 5db2f8b6c3e..94c85a43811 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -53,10 +53,10 @@ import { clearSafeAreaOverrides, getRootSafeAreaTop, onRootSafeAreaTopChange, - hasCustomModalDimensions, + getModalCoveredAxes, type ModalSafeAreaContext, } from './safe-area-utils'; -import { setCardStatusBarDark, setCardStatusBarDefault } from './utils'; +import { onModalHeightChange, setCardStatusBarDark, setCardStatusBarDefault } from './utils'; // TODO(FW-2832): types @@ -114,6 +114,7 @@ export class Modal implements ComponentInterface, OverlayInterface { private viewTransitionAnimation?: Animation; private resizeTimeout?: any; private unsubscribeRootSafeAreaTop?: () => void; + private unsubscribeHeightChange?: () => void; // True from the first safe-area write in `present()` until the enter // animation settles. A position-based read in that window is not the rest position. private isPresenting = false; @@ -1487,7 +1488,7 @@ export class Modal implements ComponentInterface, OverlayInterface { /** * Creates the context object for safe-area utilities. * - * `hasCustomDimensions` is only set by `setInitialSafeAreaOverrides()` + * `coveredAxes` is only set by `setInitialSafeAreaOverrides()` * because it is only read by `getInitialSafeAreaConfig()`. Other callers * (resize handler, post-animation update, fullscreen-padding apply) would * pay a `getComputedStyle()` cost for a value they never consult. @@ -1502,6 +1503,28 @@ export class Modal implements ComponentInterface, OverlayInterface { }; } + /** + * Keeps the content's sizing in sync with `--height`. The content reads the + * property to determine whether it should size itself to its content, and + * changes to `--height` on an ancestor or the root can change that behavior + * without changing the modal itself. + */ + private watchHeightForContent(): void { + /** + * A sheet's height comes from its breakpoints, so its content never sizes + * itself to `--height`. Watching it would cause the drag to recalculate on + * every frame. + */ + if (this.isSheetModal) { + return; + } + + this.unsubscribeHeightChange?.(); + this.unsubscribeHeightChange = onModalHeightChange(this.el, () => { + this.el.querySelectorAll('ion-content').forEach((contentEl) => contentEl.recalculateDimensions()); + }); + } + /** * Sets initial safe-area overrides before modal animation. * Called in present() before animation starts. @@ -1515,11 +1538,13 @@ export class Modal implements ComponentInterface, OverlayInterface { private setInitialSafeAreaOverrides(): void { const context: ModalSafeAreaContext = { ...this.getSafeAreaContext(), - hasCustomDimensions: hasCustomModalDimensions(this.el), + coveredAxes: getModalCoveredAxes(this.el), }; const safeAreaConfig = getInitialSafeAreaConfig(context); applySafeAreaOverrides(this.el, safeAreaConfig); + this.watchHeightForContent(); + // Set the internal offset property with the resolved root safe-area-top value if (context.isSheetModal) { this.updateSheetOffsetTop(); @@ -1646,6 +1671,9 @@ export class Modal implements ComponentInterface, OverlayInterface { this.unsubscribeRootSafeAreaTop?.(); this.unsubscribeRootSafeAreaTop = undefined; + this.unsubscribeHeightChange?.(); + this.unsubscribeHeightChange = undefined; + // Remove internal sheet offset property this.el.style.removeProperty('--ion-modal-offset-top'); diff --git a/core/src/components/modal/safe-area-utils.spec.ts b/core/src/components/modal/safe-area-utils.spec.ts new file mode 100644 index 00000000000..af69bfcc7fd --- /dev/null +++ b/core/src/components/modal/safe-area-utils.spec.ts @@ -0,0 +1,130 @@ +import { getModalCoveredAxes } from './safe-area-utils'; + +/** + * Tests `getModalCoveredAxes()` across fullscreen, fixed-size, and + * content-sized modals. The helper uses computed CSS sizes when both + * dimensions are fullscreen and measures the rendered wrapper otherwise, + * so the tests mock both sources of size information as needed. + */ +describe('modal: getModalCoveredAxes', () => { + const VIEWPORT_WIDTH = window.innerWidth; + const VIEWPORT_HEIGHT = window.innerHeight; + + let host: HTMLElement; + let wrapper: HTMLElement; + let hiddenDuringMeasurement: boolean; + let originalGetComputedStyle: PropertyDescriptor | undefined; + let sizes: Record; + + const setSize = (width: string, height: string) => { + sizes = { '--width': width, '--height': height }; + }; + + const setWrapperBox = (width: number, height: number) => { + wrapper.getBoundingClientRect = () => { + hiddenDuringMeasurement = host.classList.contains('overlay-hidden'); + return { width, height } as DOMRect; + }; + }; + + beforeEach(() => { + host = document.createElement('ion-modal'); + host.classList.add('overlay-hidden'); + document.body.appendChild(host); + + wrapper = document.createElement('div'); + wrapper.classList.add('modal-wrapper'); + host.attachShadow({ mode: 'open' }).appendChild(wrapper); + + hiddenDuringMeasurement = true; + setWrapperBox(0, 0); + + /** + * Replace the mocked `getComputedStyle` getter so tests can control + * the modal's `--width` and `--height` values. + */ + sizes = {}; + originalGetComputedStyle = Object.getOwnPropertyDescriptor(globalThis, 'getComputedStyle'); + Object.defineProperty(globalThis, 'getComputedStyle', { + value: () => ({ getPropertyValue: (property: string) => sizes[property] ?? '' }), + configurable: true, + writable: true, + }); + }); + + afterEach(() => { + if (originalGetComputedStyle) { + Object.defineProperty(globalThis, 'getComputedStyle', originalGetComputedStyle); + } + host.remove(); + }); + + it('should cover both axes when the sizes span the viewport', () => { + setSize('100%', '100%'); + + expect(getModalCoveredAxes(host)).toEqual({ vertical: true, horizontal: true }); + }); + + it('should cover neither axis for a dialog that stays clear of the viewport edges', () => { + setSize('300px', '200px'); + setWrapperBox(300, 200); + + expect(getModalCoveredAxes(host)).toEqual({ vertical: false, horizontal: false }); + }); + + it('should cover only the horizontal axis for a full-width dialog that fits its content', () => { + setSize('100%', 'fit-content'); + setWrapperBox(VIEWPORT_WIDTH, 244); + + expect(getModalCoveredAxes(host)).toEqual({ vertical: false, horizontal: true }); + }); + + it('should cover only the vertical axis for a narrow modal that fills the viewport', () => { + setSize('300px', 'fit-content'); + setWrapperBox(300, VIEWPORT_HEIGHT); + + expect(getModalCoveredAxes(host)).toEqual({ vertical: true, horizontal: false }); + }); + + it('should cover an axis whose definite size reaches the viewport', () => { + setSize('300px', `${VIEWPORT_HEIGHT}px`); + setWrapperBox(300, VIEWPORT_HEIGHT); + + expect(getModalCoveredAxes(host)).toEqual({ vertical: true, horizontal: false }); + }); + + it('should allow a few pixels of tolerance when comparing to the viewport', () => { + setSize('300px', 'fit-content'); + setWrapperBox(300, VIEWPORT_HEIGHT - 4); + + expect(getModalCoveredAxes(host)).toEqual({ vertical: true, horizontal: false }); + }); + + it('should temporarily show a hidden modal while measuring and hide it again', () => { + setSize('300px', 'fit-content'); + setWrapperBox(300, VIEWPORT_HEIGHT); + + getModalCoveredAxes(host); + + expect(hiddenDuringMeasurement).toBe(false); + expect(host.classList.contains('overlay-hidden')).toBe(true); + }); + + it('should not change visibility when measuring an already visible modal', () => { + host.classList.remove('overlay-hidden'); + setSize('300px', 'fit-content'); + setWrapperBox(300, 244); + + getModalCoveredAxes(host); + + expect(host.classList.contains('overlay-hidden')).toBe(false); + }); + + it('should not measure when both sizes span the viewport', () => { + setSize('100%', '100vh'); + setWrapperBox(0, 0); + + expect(getModalCoveredAxes(host)).toEqual({ vertical: true, horizontal: true }); + expect(hiddenDuringMeasurement).toBe(true); + }); +}); diff --git a/core/src/components/modal/safe-area-utils.ts b/core/src/components/modal/safe-area-utils.ts index b06b2315b63..5d0f7d9150d 100644 --- a/core/src/components/modal/safe-area-utils.ts +++ b/core/src/components/modal/safe-area-utils.ts @@ -1,5 +1,6 @@ import { win } from '@utils/browser'; -import { raf } from '@utils/helpers'; +import { onCustomPropertyChange, raf } from '@utils/helpers'; +import { getOverlaySizeType } from '@utils/overlays'; type SafeAreaValue = '0px' | 'inherit'; @@ -14,6 +15,17 @@ export interface SafeAreaConfig { right: SafeAreaValue; } +/** + * Indicates whether the modal spans the viewport on each axis. + * + * `vertical` means the modal reaches both the top and bottom edges. + * `horizontal` means the modal reaches both the left and right edges. + */ +export interface ModalCoveredAxes { + vertical: boolean; + horizontal: boolean; +} + /** * Context information about the modal used to determine safe-area behavior. */ @@ -23,50 +35,23 @@ export interface ModalSafeAreaContext { presentingElement?: HTMLElement; breakpoints?: number[]; currentBreakpoint?: number; + /** - * Only consulted by `getInitialSafeAreaConfig()`. Callers that only use the - * context for non-initial paths can omit this. See `hasCustomModalDimensions()`. + * Only used by `getInitialSafeAreaConfig()` to predict safe-area + * requirements before the modal is presented. Callers that only use + * the context for non-initial paths can omit this. */ - hasCustomDimensions?: boolean; + coveredAxes?: ModalCoveredAxes; } -/** - * These thresholds match the SCSS media query breakpoints in modal.vars.scss - * that trigger the centered dialog layout (non-fullscreen modal). - * - * SCSS defines two height breakpoints: $modal-inset-min-height-small (600px) - * and $modal-inset-min-height-large (768px). We use the smaller one because - * that's the threshold where the modal transitions from fullscreen to centered - * dialog — the larger breakpoint only increases the dialog's height. - */ -const MODAL_INSET_MIN_WIDTH = 768; -const MODAL_INSET_MIN_HEIGHT = 600; const EDGE_THRESHOLD = 5; -/** - * CSS values for `--width` / `--height` that are treated as fullscreen - * (modal touches the corresponding screen edges). Empty string means the - * property was not overridden. See `hasCustomModalDimensions()`. - */ -const FULLSCREEN_SIZE_VALUES = new Set(['', '100%', '100vw', '100vh', '100dvw', '100dvh', '100svw', '100svh']); - /** * Cache for resolved root safe-area-top value, invalidated once per frame. */ let cachedRootSafeAreaTop: number | null = null; let cacheInvalidationScheduled = false; -/** - * Determines if the current viewport meets the CSS media query conditions - * that cause regular modals to render as centered dialogs instead of fullscreen. - * Matches: @media (min-width: 768px) and (min-height: 600px) - */ -const isCenteredDialogViewport = (): boolean => { - if (!win) return false; - return win.matchMedia(`(min-width: ${MODAL_INSET_MIN_WIDTH}px) and (min-height: ${MODAL_INSET_MIN_HEIGHT}px)`) - .matches; -}; - /** * Resolves the current root --ion-safe-area-top value to pixels. * Uses a temporary element because getComputedStyle on :root returns @@ -105,59 +90,72 @@ export const getRootSafeAreaTop = (): number => { }; /** - * Calls back when the resolved root `--ion-safe-area-top` changes, which no - * event and no window resize covers. The probe's height tracks the variable, so - * a change to it becomes a size change the observer can see. + * Calls back when the resolved root `--ion-safe-area-top` changes. The value + * the caller already applied is passed as the baseline, so a change between + * that read and the observer starting is still reported. */ export const onRootSafeAreaTopChange = (callback: (safeAreaTop: number) => void): (() => void) => { - const doc = win?.document; - if (!doc?.body || typeof ResizeObserver === 'undefined') { - return () => undefined; - } + return onCustomPropertyChange(win?.document?.body, '--ion-safe-area-top', callback, getRootSafeAreaTop()); +}; - const probe = doc.createElement('div'); - probe.style.cssText = - 'position:fixed;visibility:hidden;pointer-events:none;top:0;left:0;width:0;' + - 'height:var(--ion-safe-area-top,0px);'; - doc.body.appendChild(probe); +/** + * Determines which viewport axes the modal spans so safe-area requirements + * can be predicted independently for each axis. + * + * A modal that spans an axis reaches both edges on that axis and needs the + * corresponding safe-area insets. A modal that does not span an axis reaches + * neither edge on that axis. + * + * When both `--width` and `--height` are `fullscreen`, coverage can be + * determined directly. Otherwise, coverage is based on the rendered wrapper, + * including cases where content sizing or `--max-height` causes the modal + * to reach the viewport. + */ +export const getModalCoveredAxes = (hostEl: HTMLElement): ModalCoveredAxes => { + const styles = getComputedStyle(hostEl); + const width = getOverlaySizeType(styles.getPropertyValue('--width')); + const height = getOverlaySizeType(styles.getPropertyValue('--height')); - /** - * Seeded with the value the caller has already applied, so a change that - * lands before the observer's first delivery still gets reported. Comparing - * against an unset value instead would consume that first delivery and treat - * the new inset as the baseline. - */ - let lastHeight = getRootSafeAreaTop(); - const observer = new ResizeObserver((entries) => { - const { height } = entries[0].contentRect; - if (height !== lastHeight) { - lastHeight = height; - callback(height); - } - }); - observer.observe(probe); + if (width === 'fullscreen' && height === 'fullscreen') { + return { vertical: true, horizontal: true }; + } - return () => { - observer.disconnect(); - probe.remove(); - }; + return measureCoveredAxes(hostEl); }; /** - * True when the modal host declares BOTH a non-fullscreen `--width` AND a - * non-fullscreen `--height` (i.e. a centered-dialog-like modal that doesn't - * touch any screen edge). + * Measures the modal wrapper to determine whether it spans the viewport + * on each axis. + * + * The wrapper has no box while the modal is hidden, so `overlay-hidden` + * is temporarily removed to allow the wrapper to be measured. The class + * is restored in the same task before the browser can paint. * - * The conservative "both axes" check avoids mis-zeroing safe-area for - * partial-custom modals where the modal still touches top/bottom edges - * (e.g. only `--width` overridden). Partial cases fall through to the - * existing position-based post-animation correction. + * Only the wrapper's size is measured. Its position is affected by the + * enter animation, which initially translates it by its own height, while + * the translation does not affect its measured size. */ -export const hasCustomModalDimensions = (hostEl: HTMLElement): boolean => { - const styles = getComputedStyle(hostEl); - const width = styles.getPropertyValue('--width').trim(); - const height = styles.getPropertyValue('--height').trim(); - return !FULLSCREEN_SIZE_VALUES.has(width) && !FULLSCREEN_SIZE_VALUES.has(height); +const measureCoveredAxes = (hostEl: HTMLElement): ModalCoveredAxes => { + const wrapperEl = hostEl.shadowRoot?.querySelector('.modal-wrapper'); + if (wrapperEl == null || win === undefined) { + return { vertical: false, horizontal: false }; + } + + const wasHidden = hostEl.classList.contains('overlay-hidden'); + if (wasHidden) { + hostEl.classList.remove('overlay-hidden'); + } + + const { width, height } = wrapperEl.getBoundingClientRect(); + + if (wasHidden) { + hostEl.classList.add('overlay-hidden'); + } + + return { + vertical: height >= win.innerHeight - EDGE_THRESHOLD, + horizontal: width >= win.innerWidth - EDGE_THRESHOLD, + }; }; /** @@ -195,27 +193,24 @@ export const getInitialSafeAreaConfig = (context: ModalSafeAreaContext): SafeAre }; } - // On viewports that meet the centered dialog media query breakpoints, - // regular modals render as centered dialogs (not fullscreen), so they - // don't touch any screen edges and don't need safe-area insets. Also - // applies to phone viewports when the modal declares custom --width and - // --height; these don't touch screen edges either, so the initial - // prediction must be zero to avoid a post-animation correction flash. - if (isCenteredDialogViewport() || context.hasCustomDimensions) { - return { - top: '0px', - bottom: '0px', - left: '0px', - right: '0px', - }; - } + /** + * Each axis is evaluated independently because a modal can span one axis + * without spanning the other. This allows the initial safe-area configuration + * to match the modal's expected dimensions and avoids correcting an incorrect + * pair of insets after presentation. + * + * A modal can span the horizontal axis while remaining inset vertically, or + * span the vertical axis while remaining inset horizontally. Wide viewports + * can also render regular modals as centered dialogs, while content-sized + * modals may still be clamped to the viewport. + */ + const { vertical, horizontal } = context.coveredAxes ?? { vertical: true, horizontal: true }; - // Fullscreen modals on phone - inherit all safe areas return { - top: 'inherit', - bottom: 'inherit', - left: 'inherit', - right: 'inherit', + top: vertical ? 'inherit' : '0px', + bottom: vertical ? 'inherit' : '0px', + left: horizontal ? 'inherit' : '0px', + right: horizontal ? 'inherit' : '0px', }; }; diff --git a/core/src/components/modal/test/content-height/index.html b/core/src/components/modal/test/content-height/index.html new file mode 100644 index 00000000000..9cbef076967 --- /dev/null +++ b/core/src/components/modal/test/content-height/index.html @@ -0,0 +1,403 @@ + + + + + Modal - Content Height + + + + + + + + + + + + + + +
+ + + Modal - Content Height + + + + +

Content-based heights

+ + + + + +

Definite heights

+ + + + +

Overflowing content

+ + + +

Other content-based cases

+ + + + +

Known gaps

+ + + + + + fit-content + + + + + + + + + + + auto + + + + + + + + + + + min-content + + + + + + + + + + + max-content + + + + + + + + + + + + default height + + + + + + + + + + + 300px + + + + + + + + + + + 2000px + + + + + + + + + + + fit-content + + + + + + + + + + + fit-content, max-height + + + + + + + + + + + +

Modal header

+ +
+ + + + + Toggled height + + + + + + + + + + + + ::part(content) + + + + + + +
+
+
+ + + + diff --git a/core/src/components/modal/test/content-height/modal.e2e.ts b/core/src/components/modal/test/content-height/modal.e2e.ts new file mode 100644 index 00000000000..788a725562c --- /dev/null +++ b/core/src/components/modal/test/content-height/modal.e2e.ts @@ -0,0 +1,478 @@ +import { expect } from '@playwright/test'; +import type { E2EPage } from '@utils/test/playwright'; +import { configs, test } from '@utils/test/playwright'; + +const ISSUE = 'https://github.com/ionic-team/ionic-framework/issues/31149'; + +/** Height of the child inside `ion-content`, so sizing can be asserted exactly. */ +const CHILD_HEIGHT = 200; + +/** Taller than any viewport under test, to force the overflow cases. */ +const TALL_CHILD_HEIGHT = 2000; + +/** + * Delays the remount long enough to trigger a fresh evaluation, but not long + * enough for the modal's later safe-area write to clear the stale class. + */ +const REMOUNT_TIMEOUT = 100; + +/** + * `setContent` has animations enabled by default, so `toBeVisible()` resolves as + * the modal starts animating in and everything after it is measured + * mid-animation. This turns animations off for each modal. + */ +const DISABLE_ANIMATIONS = ``; + +const contentModal = (css = '', childHeight = CHILD_HEIGHT) => ` + ${DISABLE_ANIMATIONS} + ${css === '' ? '' : ``} + + + + Modal + + + +
height: ${childHeight}px
+
+
+`; + +/** + * Nav pages have to be registered before `ion-nav` resolves its root, and the + * nav has to arrive through the modal's `component` delegate. An `ion-nav` + * slotted inline renders no pages at all. + */ +const navModal = (css = '') => ` + ${css === '' ? '' : ``} + + +`; + +const getContentHeight = async (page: E2EPage) => { + const box = await page.locator('ion-modal ion-content').first().boundingBox(); + return box?.height ?? 0; +}; + +const getWrapperHeight = async (page: E2EPage) => { + const box = await page.locator('ion-modal .modal-wrapper').boundingBox(); + return box?.height ?? 0; +}; + +/** + * A content-sized modal has no definite height to hand down, so the scroll + * container only scrolls if it can shrink against the modal's `--max-height`. + * `scrollHeight > clientHeight` is what separates scrolling from clipping. + */ +const getScrollMetrics = (page: E2EPage) => { + return page.locator('ion-modal ion-content').evaluate(async (el: HTMLIonContentElement) => { + const scrollEl = await el.getScrollElement(); + return { scrollHeight: scrollEl.scrollHeight, clientHeight: scrollEl.clientHeight }; + }); +}; + +/** + * Simulates a framework-driven detach/reattach around a modal height change: + * removes the content from the DOM, updates the modal's `--height` while the + * content is detached, then restores it to its original parent. + * + * The same element has to come back for this to reach the reconnect path, the + * way a framework moves a subtree it owns instead of rebuilding it, such as + * Vue's ``. Conditional rendering that discards the element and + * creates a new one is sized by that element's first render instead. + */ +const setHeightWhileDetached = (page: E2EPage, height: string) => { + return page.locator('ion-modal').evaluate(async (el: HTMLElement, height: string) => { + const content = el.querySelector('ion-content')!; + const parent = content.parentElement!; + + content.remove(); + el.style.setProperty('--height', height); + + await new Promise((resolve) => setTimeout(resolve, 200)); + parent.appendChild(content); + }, height); +}; + +/** Presents a nav modal through the delegate and waits for its first page. */ +const presentNavModal = async (page: E2EPage) => { + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.locator('ion-modal').evaluate((modal: HTMLIonModalElement) => { + modal.component = document.createElement('nav-host'); + return modal.present(); + }); + + await ionModalDidPresent.next(); + await page.locator('ion-modal ion-nav nav-page-one').waitFor(); +}; + +/** + * This behavior does not vary across directions + */ +configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { + test.describe(title('modal: content height'), () => { + test.describe('content-based heights', () => { + /** + * Each of these leaves the content an indefinite height to resolve + * against, which is what used to collapse it. The content holds a single + * fixed height child, so a correct result is exactly that height: + * collapsed content measures 0, and a modal that ignored the height would + * fill the screen. + */ + const expectSizedToContent = async (page: E2EPage, height: string) => { + await page.setContent(contentModal(`ion-modal { --height: ${height}; }`), config); + await expect(page.locator('ion-modal')).toBeVisible(); + + await expect(page.locator('ion-modal ion-content')).toHaveClass(/content-sizing/); + await expect.poll(() => getContentHeight(page)).toBe(CHILD_HEIGHT); + }; + + test('should size the content with fit-content', async ({ page }) => { + test.info().annotations.push({ type: 'issue', description: ISSUE }); + + await expectSizedToContent(page, 'fit-content'); + }); + + test('should size the content with auto', async ({ page }) => { + test.info().annotations.push({ type: 'issue', description: ISSUE }); + + await expectSizedToContent(page, 'auto'); + }); + + test('should size the content with min-content', async ({ page }) => { + test.info().annotations.push({ type: 'issue', description: ISSUE }); + + await expectSizedToContent(page, 'min-content'); + }); + + test('should size the content with max-content', async ({ page }) => { + test.info().annotations.push({ type: 'issue', description: ISSUE }); + + await expectSizedToContent(page, 'max-content'); + }); + }); + + test.describe('definite heights', () => { + test('should fill the screen with the default height', async ({ page }) => { + await page.setContent(contentModal(), config); + await expect(page.locator('ion-modal')).toBeVisible(); + + const viewport = page.viewportSize()!; + + // Content sizing should not be applied by default. + await expect(page.locator('ion-modal ion-content')).not.toHaveClass(/content-sizing/); + await expect.poll(() => getWrapperHeight(page)).toBe(viewport.height); + }); + + test('should fill and scroll a pixel height', async ({ page }) => { + await page.setContent(contentModal('ion-modal { --height: 300px; }', TALL_CHILD_HEIGHT), config); + await expect(page.locator('ion-modal')).toBeVisible(); + + // A definite height is not content-sized, so the ion-content + // should fill the modal the way it always has. + await expect(page.locator('ion-modal ion-content')).not.toHaveClass(/content-sizing/); + await expect.poll(() => getWrapperHeight(page)).toBe(300); + + // The scroll container takes what the header leaves of the modal. + const headerHeight = (await page.locator('ion-modal ion-header').boundingBox())!.height; + const { scrollHeight, clientHeight } = await getScrollMetrics(page); + expect(clientHeight).toBe(300 - headerHeight); + expect(scrollHeight).toBeGreaterThan(clientHeight); + }); + + test('should clamp a pixel height taller than the overlay', async ({ page }) => { + await page.setContent(contentModal('ion-modal { --height: 2000px; }'), config); + await expect(page.locator('ion-modal')).toBeVisible(); + + const viewport = page.viewportSize()!; + + // 2000px exceeds the overlay, so the default --max-height: 100% should + // clamp the height rather than letting it run off screen. + await expect.poll(() => getWrapperHeight(page)).toBe(viewport.height); + }); + }); + + test.describe('overflowing content', () => { + test('should scroll rather than overflow the screen', async ({ page }) => { + await page.setContent(contentModal('ion-modal { --height: fit-content; }', TALL_CHILD_HEIGHT), config); + await expect(page.locator('ion-modal')).toBeVisible(); + + const viewport = page.viewportSize()!; + const headerHeight = (await page.locator('ion-modal ion-header').boundingBox())!.height; + + // The default --max-height keeps a content-sized modal inside the + // overlay, so overflowing content leaves it exactly as tall as the + // viewport rather than any height up to it. + await expect.poll(() => getWrapperHeight(page)).toBe(viewport.height); + + // The content takes what the header leaves and scrolls the child + // inside it, where a collapsed content would measure zero. + const { scrollHeight, clientHeight } = await getScrollMetrics(page); + expect(clientHeight).toBe(viewport.height - headerHeight); + expect(scrollHeight).toBeGreaterThan(clientHeight); + }); + + test('should honor a smaller --max-height', async ({ page }) => { + await page.setContent( + contentModal('ion-modal { --height: fit-content; --max-height: 50%; }', TALL_CHILD_HEIGHT), + config + ); + await expect(page.locator('ion-modal')).toBeVisible(); + + const viewport = page.viewportSize()!; + const headerHeight = (await page.locator('ion-modal ion-header').boundingBox())!.height; + + // Setting --max-height to 50% shrinks the modal to half the viewport. + // Half of an odd viewport lands on a sub-pixel, which the wrapper + // keeps and `clientHeight` rounds. + expect(await getWrapperHeight(page)).toBeCloseTo(viewport.height * 0.5, 0); + + // The content takes what the header leaves and scrolls the child + // inside it, where a collapsed content would measure zero. + const { scrollHeight, clientHeight } = await getScrollMetrics(page); + expect(clientHeight).toBe(Math.round(viewport.height * 0.5 - headerHeight)); + expect(scrollHeight).toBeGreaterThan(clientHeight); + }); + }); + + test.describe('structure and reactivity', () => { + test('should size a modal that has no ion-content', async ({ page }) => { + await page.setContent( + ` + ${DISABLE_ANIMATIONS} + + +
+
+ `, + config + ); + await expect(page.locator('ion-modal')).toBeVisible(); + + // Sized through `ion-modal > .ion-page` alone, with none of the + // content-sizing detection involved. + await expect(page.locator('ion-modal ion-content')).toHaveCount(0); + await expect.poll(() => getWrapperHeight(page)).toBe(CHILD_HEIGHT); + }); + + test('should size a modal around an ion-nav and follow it between pages', async ({ page }) => { + await page.setContent(navModal('ion-modal { --height: fit-content; }'), config); + await presentNavModal(page); + + // Without the nav being positioned relatively it has no intrinsic + // height, so the modal would be 0. + const pageOneHeight = await getWrapperHeight(page); + expect(pageOneHeight).toBeGreaterThan(100); + + // Page two is taller, so the modal grows to follow the active page. + await page.locator('ion-modal ion-nav').evaluate((nav: HTMLIonNavElement) => nav.push('nav-page-two')); + await page.locator('ion-modal #tall-block').waitFor(); + + expect(await getWrapperHeight(page)).toBeGreaterThan(pageOneHeight); + }); + + test('should overlap nav pages mid-transition rather than stack them', async ({ page }) => { + /** + * The nav fixture keeps animations enabled so both pages are in the + * tree at once during the transition, which is what makes it possible + * to catch them laid out one below the other. + */ + await page.setContent(navModal('ion-modal { --height: fit-content; }'), config); + await presentNavModal(page); + + const tops = await page.locator('ion-modal ion-nav').evaluate(async (nav: HTMLIonNavElement) => { + const pushed = nav.push('nav-page-two'); + + /** + * Both pages are in the tree from the first frame of the transition, + * which runs for around half a second, so one frame is enough to + * catch them together. A page that has been hidden reports a zero + * rect, so only pages with a real box count. + */ + await new Promise((resolve) => requestAnimationFrame(resolve)); + const laidOut = Array.from(nav.children).filter((child) => child.getBoundingClientRect().height > 0); + const tops = laidOut.map((child) => Math.round(child.getBoundingClientRect().top)); + + // Awaiting the push surfaces a rejected transition as a test failure. + await pushed; + + return tops; + }); + + // Both pages are laid out during the slide and must share an origin. + expect(tops).toHaveLength(2); + expect(new Set(tops).size).toBe(1); + }); + + /** + * Nav pages carried these properties once before, at `height: 100%`, and + * it left titles animating to the wrong place (#25677, #25688). This + * covers where a transition ends up, with the arriving page and its title + * resting against the modal. + */ + test('should settle a nav transition with the new page in place', async ({ page }) => { + await page.setContent(navModal('ion-modal { --height: fit-content; }'), config); + await presentNavModal(page); + + // Awaiting the push resolves once the transition is done. + await page.locator('ion-modal ion-nav').evaluate((nav: HTMLIonNavElement) => nav.push('nav-page-two')); + + const arrived = page.locator('ion-modal nav-page-two'); + await expect(arrived.locator('ion-title')).toBeVisible(); + await expect(page.locator('ion-modal nav-page-one')).toBeHidden(); + + // A page left mid-slide still has a box, so the box has to line up with + // the modal on both axes for the transition to have actually landed. + const pageBox = (await arrived.boundingBox())!; + const wrapperBox = (await page.locator('ion-modal .modal-wrapper').boundingBox())!; + expect(pageBox.x).toBeCloseTo(wrapperBox.x, 0); + expect(pageBox.y).toBeCloseTo(wrapperBox.y, 0); + expect(pageBox.height).toBeGreaterThan(0); + + /** + * The title drifting down the viewport is the reported symptom, so the + * header has to sit at the top of the modal with the title inside it. + * Each mode insets the title by a different amount. + */ + const headerBox = (await arrived.locator('ion-header').boundingBox())!; + const titleBox = (await arrived.locator('ion-title').boundingBox())!; + expect(headerBox.y).toBeCloseTo(wrapperBox.y, 0); + expect(titleBox.y).toBeGreaterThanOrEqual(headerBox.y); + expect(titleBox.y + titleBox.height).toBeLessThanOrEqual(headerBox.y + headerBox.height + 1); + + // Going back has to land the same way, since the pop animates too. + await page.locator('ion-modal ion-nav').evaluate((nav: HTMLIonNavElement) => nav.pop()); + + await expect(page.locator('ion-modal nav-page-one ion-title')).toBeVisible(); + await expect(arrived).toBeHidden(); + expect((await page.locator('ion-modal nav-page-one').boundingBox())!.x).toBeCloseTo(wrapperBox.x, 0); + }); + + test('should respect a --height set on the modal at runtime', async ({ page }) => { + await page.setContent(contentModal(), config); + await expect(page.locator('ion-modal')).toBeVisible(); + + const viewport = page.viewportSize()!; + const modal = page.locator('ion-modal'); + const content = page.locator('ion-modal ion-content'); + + // No --height of its own, so the modal is on its default full height. + await expect(content).not.toHaveClass(/content-sizing/); + await expect.poll(() => getWrapperHeight(page)).toBe(viewport.height); + + // Set the --height and verify the observer is picking it up and + // adding the content-sizing class to the content. + await modal.evaluate((el: HTMLElement) => el.style.setProperty('--height', 'fit-content')); + await expect(content).toHaveClass(/content-sizing/); + await expect.poll(() => getContentHeight(page)).toBe(CHILD_HEIGHT); + + // Removing it falls back to the default, so a class left behind in + // either direction is caught. + await modal.evaluate((el: HTMLElement) => el.style.removeProperty('--height')); + await expect(content).not.toHaveClass(/content-sizing/); + await expect.poll(() => getWrapperHeight(page)).toBe(viewport.height); + }); + + test('should respect a --height that changed while the content was detached', async ({ page }) => { + await page.setContent(contentModal(), config); + await expect(page.locator('ion-modal')).toBeVisible(); + + const viewport = page.viewportSize()!; + const content = page.locator('ion-modal ion-content'); + + // Coming back to a content-based height should size the content to its + // child rather than collapse it. + await setHeightWhileDetached(page, 'fit-content'); + await expect(content).toHaveClass(/content-sizing/, { timeout: REMOUNT_TIMEOUT }); + await expect.poll(() => getContentHeight(page)).toBe(CHILD_HEIGHT); + + // Coming back to a definite height should fill the modal again, so a + // class left behind in either direction is caught. + await setHeightWhileDetached(page, '100%'); + await expect(content).not.toHaveClass(/content-sizing/, { timeout: REMOUNT_TIMEOUT }); + await expect.poll(() => getWrapperHeight(page)).toBe(viewport.height); + }); + + test('should respect a dynamically added body class that sets --height', async ({ page }) => { + await page.setContent(contentModal('body.custom-class ion-modal { --height: fit-content; }'), config); + await expect(page.locator('ion-modal')).toBeVisible(); + + const viewport = page.viewportSize()!; + const content = page.locator('ion-modal ion-content'); + + await expect(content).not.toHaveClass(/content-sizing/); + await expect.poll(() => getWrapperHeight(page)).toBe(viewport.height); + + await page.evaluate(() => document.body.classList.add('custom-class')); + + await expect(content).toHaveClass(/content-sizing/); + await expect.poll(() => getContentHeight(page)).toBe(CHILD_HEIGHT); + }); + }); + }); + + test.describe(title('modal: content height rendering'), () => { + test('should render a modal sized to its content', async ({ page }) => { + await page.setContent(contentModal('ion-modal { --height: fit-content; }'), config); + await expect(page.locator('ion-modal')).toBeVisible(); + + await expect(page).toHaveScreenshot(screenshot('modal-content-height-basic')); + }); + + test('should render a content-sized modal whose content overflows', async ({ page }) => { + await page.setContent(contentModal('ion-modal { --height: fit-content; }', TALL_CHILD_HEIGHT), config); + await expect(page.locator('ion-modal')).toBeVisible(); + + await expect(page).toHaveScreenshot(screenshot('modal-content-height-overflow')); + }); + + test('should render a content-sized modal with an ion-nav', async ({ page }) => { + await page.setContent(navModal('ion-modal { --height: fit-content; }'), config); + await presentNavModal(page); + + await expect(page).toHaveScreenshot(screenshot('modal-content-height-nav')); + }); + }); +}); diff --git a/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-basic-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-basic-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..4b987641db4 Binary files /dev/null and b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-basic-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-basic-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-basic-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..f7908d0d9b6 Binary files /dev/null and b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-basic-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-basic-ios-ltr-Mobile-Safari-linux.png b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-basic-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..2a35d7e437d Binary files /dev/null and b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-basic-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-basic-md-ltr-Mobile-Chrome-linux.png b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-basic-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..d826f723182 Binary files /dev/null and b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-basic-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-basic-md-ltr-Mobile-Firefox-linux.png b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-basic-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..654f6470aad Binary files /dev/null and b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-basic-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-basic-md-ltr-Mobile-Safari-linux.png b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-basic-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..5f601dd4757 Binary files /dev/null and b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-basic-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-nav-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-nav-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..eb8d2a12ff0 Binary files /dev/null and b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-nav-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-nav-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-nav-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..69a17bc29d5 Binary files /dev/null and b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-nav-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-nav-ios-ltr-Mobile-Safari-linux.png b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-nav-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..912aaec64b5 Binary files /dev/null and b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-nav-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-nav-md-ltr-Mobile-Chrome-linux.png b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-nav-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..fe0a14a3bba Binary files /dev/null and b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-nav-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-nav-md-ltr-Mobile-Firefox-linux.png b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-nav-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..908a72af971 Binary files /dev/null and b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-nav-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-nav-md-ltr-Mobile-Safari-linux.png b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-nav-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..9430111477d Binary files /dev/null and b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-nav-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-overflow-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-overflow-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..ede48236109 Binary files /dev/null and b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-overflow-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-overflow-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-overflow-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..39a951afbcc Binary files /dev/null and b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-overflow-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-overflow-ios-ltr-Mobile-Safari-linux.png b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-overflow-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..a4c44d457b9 Binary files /dev/null and b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-overflow-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-overflow-md-ltr-Mobile-Chrome-linux.png b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-overflow-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..72ab1dc9a83 Binary files /dev/null and b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-overflow-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-overflow-md-ltr-Mobile-Firefox-linux.png b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-overflow-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..9aeb4618795 Binary files /dev/null and b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-overflow-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-overflow-md-ltr-Mobile-Safari-linux.png b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-overflow-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..239e39bff1c Binary files /dev/null and b/core/src/components/modal/test/content-height/modal.e2e.ts-snapshots/modal-content-height-overflow-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/test/safe-area/index.html b/core/src/components/modal/test/safe-area/index.html index 14681f3820f..f5b1f55d57d 100644 --- a/core/src/components/modal/test/safe-area/index.html +++ b/core/src/components/modal/test/safe-area/index.html @@ -71,6 +71,20 @@

Card Modals (iOS)

Centered Dialog (Tablet)

+

Content Sized Dialog

+ + + + +

Diagnostic Info

Window Width:

@@ -228,6 +242,55 @@

Modal Safe-Area Overrides:

modal.remove(); } + /** + * A dialog with custom dimensions on both axes and a content-sized + * height. Short content keeps it away from the screen edges, while + * overflowing content causes `--max-height` to clamp it to the + * viewport, where it reaches the top and bottom edges. + */ + async function presentContentSizedDialog(childHeight, width = '300px') { + const element = document.createElement('div'); + element.innerHTML = ` + + + Content Sized Dialog + + Close + + + + +

Content sized dialog.

+
+

Last line of content, which the home indicator must not cover.

+
+ `; + + const modal = Object.assign(document.createElement('ion-modal'), { + component: element, + cssClass: 'content-sized-dialog', + }); + + const style = document.createElement('style'); + style.textContent = ` + .content-sized-dialog { + --width: ${width}; + --height: fit-content; + } + `; + document.head.appendChild(style); + + element.querySelector('.dismiss').addEventListener('click', () => modal.dismiss()); + document.body.appendChild(modal); + + await modal.present(); + updateModalDiagnostics(modal); + + await modal.onDidDismiss(); + modal.remove(); + style.remove(); + } + async function presentCenteredDialog() { const element = createModalContent('Centered Dialog'); // Centered dialog uses custom dimensions diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts b/core/src/components/modal/test/safe-area/modal.e2e.ts index 4905f645aa3..740eb83c298 100644 --- a/core/src/components/modal/test/safe-area/modal.e2e.ts +++ b/core/src/components/modal/test/safe-area/modal.e2e.ts @@ -1,5 +1,6 @@ import { expect } from '@playwright/test'; import type { Locator } from '@playwright/test'; +import type { E2EPage } from '@utils/test/playwright'; import { configs, detachAndReattach, test, Viewports } from '@utils/test/playwright'; /** @@ -25,6 +26,49 @@ configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config await page.goto('/src/components/modal/test/safe-area', config); }); + /** + * The safe-area prediction is applied before the modal is shown, so + * reading it when the modal starts presenting captures the prediction + * before the position-based correction runs. + */ + const getPredictedSafeArea = async (page: E2EPage, trigger: string) => { + await page.evaluate(() => { + document.addEventListener( + 'ionModalWillPresent', + (ev) => { + const modal = ev.target as HTMLElement; + (window as any).predictedSafeArea = { + top: modal.style.getPropertyValue('--ion-safe-area-top'), + bottom: modal.style.getPropertyValue('--ion-safe-area-bottom'), + left: modal.style.getPropertyValue('--ion-safe-area-left'), + right: modal.style.getPropertyValue('--ion-safe-area-right'), + }; + }, + { once: true } + ); + }); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + await page.click(trigger); + await ionModalDidPresent.next(); + + return page.evaluate(() => (window as any).predictedSafeArea); + }; + + /** + * The safe-area values after the modal has finished presenting. + * These reflect the modal's actual position and are the values that + * the initial prediction should converge to. + */ + const getSettledSafeArea = (page: E2EPage) => { + return page.locator('ion-modal').evaluate((el: HTMLElement) => ({ + top: el.style.getPropertyValue('--ion-safe-area-top'), + bottom: el.style.getPropertyValue('--ion-safe-area-bottom'), + left: el.style.getPropertyValue('--ion-safe-area-left'), + right: el.style.getPropertyValue('--ion-safe-area-right'), + })); + }; + test('fullscreen modal should inherit all safe-area values on phone', async ({ page }, testInfo) => { testInfo.annotations.push({ type: 'issue', @@ -50,7 +94,20 @@ configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config expect(safeAreaBottom).toBe('inherit'); }); - test('regular modal should have safe-area zeroed on tablet (centered dialog)', async ({ page }, testInfo) => { + test('regular modal should predict zeroed safe-area on tablet (centered dialog)', async ({ page }) => { + // The viewport gives it centered dialog dimensions, so it stays clear + // of every edge. + await page.setViewportSize(Viewports.tablet.portrait); + + expect(await getPredictedSafeArea(page, '#fullscreen-modal')).toEqual({ + top: '0px', + bottom: '0px', + left: '0px', + right: '0px', + }); + }); + + test('regular modal should have zeroed safe-area on tablet (centered dialog)', async ({ page }, testInfo) => { testInfo.annotations.push({ type: 'issue', description: 'https://github.com/ionic-team/ionic-framework/issues/30900', @@ -445,6 +502,94 @@ configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config await modal.evaluate((el: HTMLIonModalElement) => el.remove()); }); + test.describe('content sized dialogs', () => { + test('should predict a zeroed safe-area for a dialog that fits its content', async ({ page }) => { + expect(await getPredictedSafeArea(page, '#content-sized-dialog')).toEqual({ + top: '0px', + bottom: '0px', + left: '0px', + right: '0px', + }); + }); + + /** + * Overflowing content causes the dialog to be clamped to the viewport + * and reach the top and bottom edges, so the insets must be applied + * from the first frame. Predicting zero here would cause the header + * to change height once the modal has finished presenting. + */ + test('should predict an inherited safe-area for a dialog whose content overflows', async ({ page }) => { + expect(await getPredictedSafeArea(page, '#content-sized-dialog-tall')).toEqual({ + top: 'inherit', + bottom: 'inherit', + left: '0px', + right: '0px', + }); + }); + + /** + * A full-width dialog reaches the horizontal edges while staying clear + * of the top and bottom, so the safe-area values differ by edge. + */ + test('should predict per edge for a full width dialog that fits its content', async ({ page }) => { + expect(await getPredictedSafeArea(page, '#content-sized-dialog-full-width')).toEqual({ + top: '0px', + bottom: '0px', + left: 'inherit', + right: 'inherit', + }); + }); + + test('should predict an inherited safe-area for a full width dialog that overflows', async ({ page }) => { + expect(await getPredictedSafeArea(page, '#content-sized-dialog-full-width-tall')).toEqual({ + top: 'inherit', + bottom: 'inherit', + left: 'inherit', + right: 'inherit', + }); + }); + + /** + * A wide viewport gives regular modals the dimensions of a centered + * dialog, but a modal sized to its content can still be clamped to the + * viewport by `--max-height` and reach the edges. + */ + test('should predict per axis on a wide viewport', async ({ page }) => { + await page.setViewportSize(Viewports.tablet.portrait); + + expect(await getPredictedSafeArea(page, '#content-sized-dialog-tall')).toEqual({ + top: 'inherit', + bottom: 'inherit', + left: '0px', + right: '0px', + }); + }); + + test('should predict an inherited safe-area for a full width overflowing dialog on a wide viewport', async ({ + page, + }) => { + await page.setViewportSize(Viewports.tablet.portrait); + + expect(await getPredictedSafeArea(page, '#content-sized-dialog-full-width-tall')).toEqual({ + top: 'inherit', + bottom: 'inherit', + left: 'inherit', + right: 'inherit', + }); + }); + + /** + * A position-based pass replaces the prediction once the modal has + * presented. Any edge where the two disagree changes value at that + * point, which can cause the header to grow or shrink. + */ + test('should predict what the modal settles on', async ({ page }) => { + const predicted = await getPredictedSafeArea(page, '#content-sized-dialog-full-width'); + + expect(predicted).toEqual(await getSettledSafeArea(page)); + }); + }); + test.describe('moving a presented modal', () => { const moveModal = (modal: Locator) => detachAndReattach(modal, 'ion-app'); diff --git a/core/src/components/modal/utils.ts b/core/src/components/modal/utils.ts index ac01b3eebe8..ba426c1f186 100644 --- a/core/src/components/modal/utils.ts +++ b/core/src/components/modal/utils.ts @@ -1,4 +1,5 @@ import { win } from '@utils/browser'; +import { onCustomPropertyChange } from '@utils/helpers'; import { StatusBar, Style } from '@utils/native/status-bar'; /** @@ -84,3 +85,10 @@ export const setCardStatusBarDefault = (defaultStyle = Style.Default) => { StatusBar.setStyle({ style: defaultStyle }); }; + +/** + * Calls back when the modal's resolved `--height` changes. + */ +export const onModalHeightChange = (hostEl: HTMLElement, callback: () => void): (() => void) => { + return onCustomPropertyChange(hostEl, '--height', () => callback()); +}; diff --git a/core/src/css/core.scss b/core/src/css/core.scss index c7f7357ab46..b68ff4d3ae6 100644 --- a/core/src/css/core.scss +++ b/core/src/css/core.scss @@ -203,9 +203,46 @@ ion-modal > .ion-page { contain: layout style; + /** + * Override the minimum height a flex item gets, which defaults to + * use the height of its own content. Without this, a modal sized + * to its content clips its overflow instead of scrolling it. + */ + min-height: 0; + height: 100%; } +/** + * Position the `ion-nav` and its page relatively when inside of an + * `ion-content` that is sized to its content. This allows the `ion-nav` + * to take its height from its page and size itself correctly. Without + * this, the modal will not appear as the nav will be 0 height. + */ +ion-modal ion-content.content-sizing ion-nav, +ion-modal ion-content.content-sizing ion-nav > .ion-page { + position: relative; + + contain: layout style; + + height: auto; +} + +/** + * Place every page in the same grid cell so they overlap, while still + * letting the nav take its height from the tallest of them. Without + * this, a transition that has two pages in the tree at once would + * render them one below the other. + */ +ion-modal ion-content.content-sizing ion-nav { + display: grid; +} + +ion-modal ion-content.content-sizing ion-nav > .ion-page { + grid-row: 1; + grid-column: 1; +} + .split-pane-visible > .ion-page.split-pane-main { position: relative; } diff --git a/core/src/utils/helpers.ts b/core/src/utils/helpers.ts index 9c6052b466f..5fcbc5184ef 100644 --- a/core/src/utils/helpers.ts +++ b/core/src/utils/helpers.ts @@ -1,4 +1,5 @@ import type { EventEmitter } from '@stencil/core'; +import { win } from '@utils/browser'; import { printIonError } from '@utils/logging'; import { isRTL } from '@utils/rtl'; @@ -199,6 +200,55 @@ export const removeEventListener = (el: any, eventName: string, callback: any, o return el.removeEventListener(eventName, callback, opts); }; +/** + * Calls back when a CSS custom property that resolves to a length changes, + * which no event covers. The probe inherits the property from `hostEl` and + * uses it as its height, turning a property change into a size change that + * `ResizeObserver` can detect. + * + * The callback receives the probe's height. For length values, this matches + * the resolved property value. For other values, such as `fit-content`, the + * probe remains at zero, so the value only signals that the property changed. + * Percentages resolve against the probe's containing block, not the element + * where the property is ultimately used. + * + * Pass `initialValue` when the caller has already read the property so that + * changes occurring before the observer's first delivery are not missed. + * Without it, the first delivery establishes the baseline. + */ +export const onCustomPropertyChange = ( + hostEl: HTMLElement | null | undefined, + property: string, + callback: (value: number) => void, + initialValue?: number +): (() => void) => { + const doc = win?.document; + if (!doc || !hostEl || typeof ResizeObserver === 'undefined') { + return () => undefined; + } + + const probe = doc.createElement('div'); + probe.style.cssText = `position:fixed;visibility:hidden;pointer-events:none;top:0;left:0;width:0;height:var(${property},0px);`; + hostEl.appendChild(probe); + + let lastHeight = initialValue; + const observer = new ResizeObserver((entries) => { + const { height } = entries[0].contentRect; + + if (lastHeight !== undefined && height !== lastHeight) { + callback(height); + } + + lastHeight = height; + }); + observer.observe(probe); + + return () => { + observer.disconnect(); + probe.remove(); + }; +}; + /** * Gets the root context of a shadow dom element * On newer browsers this will be the shadowRoot, diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index 5149e119c95..40bbb7b88fc 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -912,6 +912,48 @@ export const safeCall = (handler: any, arg?: any) => { return undefined; }; +/** + * `--width` and `--height` values that leave an overlay spanning the viewport + * on that axis, so it reaches both edges. An empty value means the property + * was never overridden. + */ +const FULLSCREEN_SIZES = ['', '100%', '100vw', '100vh', '100dvw', '100dvh', '100svw', '100svh']; + +/** + * `--width` and `--height` values that size an overlay to its content, leaving + * the rendered size dependent on the content and on `--max-width` or + * `--max-height`. + */ +const CONTENT_SIZES = ['auto', 'fit-content', 'min-content', 'max-content']; + +type OverlaySizeType = 'fullscreen' | 'content' | 'definite'; + +/** + * How an overlay's `--width` or `--height` determines its used size: + * + * `fullscreen` spans the viewport on that axis. `content` depends on the + * overlay's content, so its used size is not known until layout. `definite` + * resolves independently of the overlay's content size. + * + * Values are lowercased because CSS keywords are case-insensitive, while a + * custom property preserves the case in which it was authored. Content values + * are matched as a suffix so vendor-prefixed values such as `-moz-fit-content` + * are recognized. + */ +export const getOverlaySizeType = (size: string): OverlaySizeType => { + const value = size.trim().toLowerCase(); + + if (FULLSCREEN_SIZES.includes(value)) { + return 'fullscreen'; + } + + if (CONTENT_SIZES.some((keyword) => value.endsWith(keyword))) { + return 'content'; + } + + return 'definite'; +}; + export const BACKDROP = 'backdrop'; export const GESTURE = 'gesture'; export const OVERLAY_GESTURE_PRIORITY = 39; diff --git a/core/src/utils/test/overlays/overlays-size-type.spec.ts b/core/src/utils/test/overlays/overlays-size-type.spec.ts new file mode 100644 index 00000000000..d2103e1e576 --- /dev/null +++ b/core/src/utils/test/overlays/overlays-size-type.spec.ts @@ -0,0 +1,52 @@ +import { getOverlaySizeType } from '../../overlays'; + +describe('overlays: getOverlaySizeType', () => { + it('should return fullscreen for a value that spans the viewport', () => { + expect(getOverlaySizeType('100%')).toBe('fullscreen'); + expect(getOverlaySizeType('100vw')).toBe('fullscreen'); + expect(getOverlaySizeType('100vh')).toBe('fullscreen'); + expect(getOverlaySizeType('100dvw')).toBe('fullscreen'); + expect(getOverlaySizeType('100dvh')).toBe('fullscreen'); + expect(getOverlaySizeType('100svw')).toBe('fullscreen'); + expect(getOverlaySizeType('100svh')).toBe('fullscreen'); + }); + + // getPropertyValue returns an empty string for a property that was never + // set, and an overlay without an override spans the viewport. + it('should return fullscreen when the property is unset', () => { + expect(getOverlaySizeType('')).toBe('fullscreen'); + }); + + it('should return content for a value that sizes to the content', () => { + expect(getOverlaySizeType('auto')).toBe('content'); + expect(getOverlaySizeType('fit-content')).toBe('content'); + expect(getOverlaySizeType('min-content')).toBe('content'); + expect(getOverlaySizeType('max-content')).toBe('content'); + }); + + it('should return content for a vendor prefixed value', () => { + expect(getOverlaySizeType('-moz-fit-content')).toBe('content'); + expect(getOverlaySizeType('-webkit-fit-content')).toBe('content'); + }); + + // CSS keywords are case-insensitive, while a custom property keeps the + // case it was authored with. + it('should match keywords written in any case', () => { + expect(getOverlaySizeType('FIT-CONTENT')).toBe('content'); + expect(getOverlaySizeType('Auto')).toBe('content'); + expect(getOverlaySizeType('100VH')).toBe('fullscreen'); + }); + + it('should ignore whitespace around a value', () => { + expect(getOverlaySizeType(' fit-content ')).toBe('content'); + expect(getOverlaySizeType(' 100% ')).toBe('fullscreen'); + }); + + it('should return definite for a length or percentage', () => { + expect(getOverlaySizeType('300px')).toBe('definite'); + expect(getOverlaySizeType('50%')).toBe('definite'); + expect(getOverlaySizeType('20rem')).toBe('definite'); + expect(getOverlaySizeType('50vh')).toBe('definite'); + expect(getOverlaySizeType('calc(100% - 40px)')).toBe('definite'); + }); +});