diff --git a/core/src/components/content/content.tsx b/core/src/components/content/content.tsx index b0673b414d5..5a93be5108f 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, @@ -48,6 +49,7 @@ export class Content implements ComponentInterface { private isMainContent = true; private resizeTimeout: ReturnType | null = null; private fullscreenResizeObserver?: ResizeObserver; + private sizeToContentObserver?: MutationObserver; private inheritedAttributes: Attributes = {}; private tabsElement: HTMLElement | null = null; @@ -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,8 @@ export class Content implements ComponentInterface { // Re-observe on reattach, since componentDidLoad only fires once. this.setupFullscreenResizeObserver(); + this.setupSizeToContentObserver(); + this.updateSizeToContent(); } componentDidLoad() { @@ -222,6 +232,7 @@ export class Content implements ComponentInterface { } this.destroyFullscreenResizeObserver(); + this.destroySizeToContentObserver(); } /** @@ -258,6 +269,49 @@ export class Content implements ComponentInterface { this.fullscreenResizeObserver.observe(this.el); } + /** + * A modal's `--height` can be changed at runtime with no event to react + * to, either by setting the property directly or by toggling a class that + * changes which rule wins. Both of those mutate an attribute on the modal, + * so watch for that and re-evaluate. Viewport driven changes are already + * covered by the `resize` listener. + */ + private setupSizeToContentObserver() { + if (!Build.isBrowser || typeof MutationObserver === 'undefined') { + return; + } + + if (this.sizeToContentObserver !== undefined) { + return; + } + + const modal = this.el.closest('ion-modal'); + if (modal === null) { + return; + } + + this.sizeToContentObserver = new MutationObserver(() => this.updateSizeToContent()); + this.sizeToContentObserver.observe(modal, { attributes: true, attributeFilter: ['style', 'class'] }); + } + + private destroySizeToContentObserver() { + if (this.sizeToContentObserver !== undefined) { + this.sizeToContentObserver.disconnect(); + this.sizeToContentObserver = undefined; + } + } + + /** + * 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 +364,39 @@ 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').trim().toLowerCase(); + + /** + * Compared as a suffix so a value carrying a vendor prefix is still + * recognized, such as `-webkit-fit-content` or the `-moz-fit-content` + * that Firefox needed before 94. + */ + return CONTENT_SIZED_HEIGHTS.some((value) => height.endsWith(value)); + } + private resize() { /** * Only force update if the component is rendered in a browser context. @@ -320,6 +407,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) { @@ -538,7 +632,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, })} @@ -579,6 +673,12 @@ export class Content implements ComponentInterface { } } +/** + * `ion-modal` `--height` values that size the modal to its contents, leaving + * children an indefinite height to resolve against. + */ +const CONTENT_SIZED_HEIGHTS = ['auto', 'fit-content', 'min-content', 'max-content']; + const getParentElement = (el: any) => { if (el.parentElement) { // normal element with a parent element 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/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..6acc8b4b50e --- /dev/null +++ b/core/src/components/modal/test/content-height/modal.e2e.ts @@ -0,0 +1,468 @@ +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 = (style: string, childHeight = CHILD_HEIGHT) => ` + ${DISABLE_ANIMATIONS} + + + + + 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 NAV_MODAL = ` + + +`; + +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(`--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('should size the content with a prefixed fit-content', async ({ page }) => { + /** + * Firefox only took `fit-content` unprefixed in 94, so a value carrying + * only the `-moz-` prefix still has to be recognized. + */ + await expectSizedToContent(page, '-moz-fit-content'); + }); + + test('should size the content with an uppercase keyword', async ({ page }) => { + /** + * CSS property values are case-insensitive, so `FIT-CONTENT` should + * size the modal to its content just like the lowercase value. + */ + await expectSizedToContent(page, 'FIT-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('--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('--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('--height: fit-content;', TALL_CHILD_HEIGHT), config); + await expect(page.locator('ion-modal')).toBeVisible(); + + const viewport = page.viewportSize()!; + + // The default --max-height keeps a content-sized modal inside the + // overlay. Rounded up by one, since the clamp lands on a sub-pixel. + expect(await getWrapperHeight(page)).toBeLessThanOrEqual(viewport.height + 1); + + // The content shrinks to reach that cap, leaving the child scrollable. + const { scrollHeight, clientHeight } = await getScrollMetrics(page); + expect(scrollHeight).toBeGreaterThan(clientHeight); + }); + + test('should honor a smaller --max-height', async ({ page }) => { + await page.setContent(contentModal('--height: fit-content; --max-height: 50%;', TALL_CHILD_HEIGHT), config); + await expect(page.locator('ion-modal')).toBeVisible(); + + const viewport = page.viewportSize()!; + + // Setting --max-height to 50% should shrink the modal to half the + // viewport, rounded up by one. + expect(await getWrapperHeight(page)).toBeLessThanOrEqual(viewport.height * 0.5 + 1); + + // The content shrinks to reach that cap, leaving the child scrollable. + const { scrollHeight, clientHeight } = await getScrollMetrics(page); + 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(`${NAV_MODAL}`, 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(`${NAV_MODAL}`, 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(`${NAV_MODAL}`, 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 render a modal sized to its content', async ({ page }) => { + await page.setContent(contentModal('--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('--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(`${NAV_MODAL}`, 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/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; }