fix(modal): apply safe-area padding to card modals on phones

This commit is contained in:
ShaneK
2026-01-02 06:47:40 -08:00
parent 3fac5ccbf8
commit d6eb8ce8e9
29 changed files with 839 additions and 1127 deletions

View File

@@ -887,9 +887,18 @@ export class Modal implements ComponentInterface, OverlayInterface {
return;
}
// Card modals are inset from all edges
// Card modals have rounded top corners
if (isCardModal) {
this.zeroAllSafeAreas();
style.setProperty('--ion-safe-area-top', '0px');
if (isTablet) {
// On tablets, card modals are inset from all edges
this.zeroAllSafeAreas();
} else {
// On phones, card modals still extend to the bottom edge
style.setProperty('--ion-safe-area-left', '0px');
style.setProperty('--ion-safe-area-right', '0px');
this.applyFullscreenSafeArea();
}
return;
}

View File

@@ -41,56 +41,118 @@
</ion-header>
<ion-content class="ion-padding">
<p>Test safe-area handling in modals on tablet-sized screens.</p>
<p>Test safe-area handling in modals.</p>
<ion-list>
<ion-item>
<ion-label>
<h2>Default Modal</h2>
<p>Centered dialog on tablet - should NOT have safe-area padding</p>
</ion-label>
<ion-button slot="end" id="default-modal" onclick="presentDefaultModal()">Present</ion-button>
</ion-item>
<ion-item-group>
<ion-item-divider>
<ion-label>With Footer</ion-label>
</ion-item-divider>
<ion-item>
<ion-label>
<h2>Fullscreen Modal</h2>
<p>Full screen on tablet - should have safe-area padding</p>
</ion-label>
<ion-button slot="end" id="fullscreen-modal" onclick="presentFullscreenModal()">Present</ion-button>
</ion-item>
<ion-item>
<ion-label>
<h2>Default Modal</h2>
<p>Centered dialog on tablet - should NOT have safe-area padding</p>
</ion-label>
<ion-button slot="end" id="default-modal" onclick="presentDefaultModal()">Present</ion-button>
</ion-item>
<ion-item>
<ion-label>
<h2>Sheet Modal (Partial)</h2>
<p>At 0.5 breakpoint - should have bottom safe-area only</p>
</ion-label>
<ion-button slot="end" id="sheet-modal-partial" onclick="presentSheetModalPartial()">Present</ion-button>
</ion-item>
<ion-item>
<ion-label>
<h2>Fullscreen Modal</h2>
<p>Full screen - footer handles safe-area</p>
</ion-label>
<ion-button slot="end" id="fullscreen-modal" onclick="presentFullscreenModal()">Present</ion-button>
</ion-item>
<ion-item>
<ion-label>
<h2>Sheet Modal (Full)</h2>
<p>At 1.0 breakpoint - should have bottom safe-area (handle creates top gap)</p>
</ion-label>
<ion-button slot="end" id="sheet-modal-full" onclick="presentSheetModalFull()">Present</ion-button>
</ion-item>
<ion-item>
<ion-label>
<h2>Sheet Modal (Partial)</h2>
<p>At 0.5 breakpoint - should have bottom safe-area only</p>
</ion-label>
<ion-button slot="end" id="sheet-modal-partial" onclick="presentSheetModalPartial()"
>Present</ion-button
>
</ion-item>
<ion-item>
<ion-label>
<h2>Card Modal (iOS)</h2>
<p>Card presentation - inset from edges, no safe-area padding</p>
</ion-label>
<ion-button slot="end" id="card-modal" onclick="presentCardModal()">Present</ion-button>
</ion-item>
<ion-item>
<ion-label>
<h2>Sheet Modal (Full)</h2>
<p>At 1.0 breakpoint - should have bottom safe-area</p>
</ion-label>
<ion-button slot="end" id="sheet-modal-full" onclick="presentSheetModalFull()">Present</ion-button>
</ion-item>
<ion-item>
<ion-label>
<h2>Card Modal (iOS)</h2>
<p>Card presentation with presentingElement</p>
</ion-label>
<ion-button slot="end" id="card-modal" onclick="presentCardModal()">Present</ion-button>
</ion-item>
</ion-item-group>
<ion-item-group>
<ion-item-divider>
<ion-label>Without Footer (wrapper padding)</ion-label>
</ion-item-divider>
<ion-item>
<ion-label>
<h2>Fullscreen Modal (no footer)</h2>
<p>Wrapper padding should prevent content overlap</p>
</ion-label>
<ion-button slot="end" id="fullscreen-no-footer" onclick="presentFullscreenNoFooter()"
>Present</ion-button
>
</ion-item>
<ion-item>
<ion-label>
<h2>Card Modal (no footer)</h2>
<p>On phones, wrapper padding should prevent content overlap</p>
</ion-label>
<ion-button slot="end" id="card-modal-no-footer" onclick="presentCardModalNoFooter()"
>Present</ion-button
>
</ion-item>
<ion-item>
<ion-label>
<h2>Default Modal (no footer)</h2>
<p>On phones, wrapper padding should prevent content overlap</p>
</ion-label>
<ion-button slot="end" id="default-no-footer" onclick="presentDefaultNoFooter()">Present</ion-button>
</ion-item>
</ion-item-group>
</ion-list>
</ion-content>
</div>
</ion-app>
<script>
function createModalContent(title) {
function createModalContent(title, includeFooter = true) {
const element = document.createElement('div');
const footerHtml = includeFooter
? `
<ion-footer>
<ion-toolbar>
<ion-title>Footer</ion-title>
</ion-toolbar>
</ion-footer>
`
: '';
// Create multiple items to ensure scrollable content
const items = Array.from(
{ length: 20 },
(_, i) => `
<ion-item>
<ion-label>Item ${i + 1}</ion-label>
</ion-item>
`
).join('');
element.innerHTML = `
<ion-header>
<ion-toolbar>
@@ -103,14 +165,12 @@
<ion-content class="ion-padding">
<h1>Modal Content</h1>
<p>This modal tests safe-area handling.</p>
<p>The header should respect safe-area-top when the modal touches the top edge.</p>
<p>The footer should respect safe-area-bottom when the modal touches the bottom edge.</p>
<ion-list>
${items}
</ion-list>
<p class="last-item">Last item - should not overlap safe area</p>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-title>Footer</ion-title>
</ion-toolbar>
</ion-footer>
${footerHtml}
`;
return element;
}
@@ -170,6 +230,34 @@
document.body.appendChild(currentModal);
await currentModal.present();
}
// Modals without footer - test wrapper padding
async function presentFullscreenNoFooter() {
currentModal = Object.assign(document.createElement('ion-modal'), {
component: createModalContent('Fullscreen (No Footer)', false),
});
currentModal.classList.add('fullscreen-modal');
document.body.appendChild(currentModal);
await currentModal.present();
}
async function presentCardModalNoFooter() {
const presentingElement = document.getElementById('main-page');
currentModal = Object.assign(document.createElement('ion-modal'), {
component: createModalContent('Card Modal (No Footer)', false),
presentingElement: presentingElement,
});
document.body.appendChild(currentModal);
await currentModal.present();
}
async function presentDefaultNoFooter() {
currentModal = Object.assign(document.createElement('ion-modal'), {
component: createModalContent('Default (No Footer)', false),
});
document.body.appendChild(currentModal);
await currentModal.present();
}
</script>
</body>
</html>

View File

@@ -3,104 +3,196 @@ import { configs, test, Viewports } from '@utils/test/playwright';
/**
* Safe-area tests verify that modals correctly handle safe-area insets
* based on whether they're touching the screen edges.
* based on modal type and screen size.
*
* These tests use simulated safe-area values (34px bottom) set in index.html.
* They verify the modal wrapper has correct padding applied.
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
// Helper to get the modal wrapper's computed padding-bottom
async function getWrapperPaddingBottom(page: any): Promise<string> {
const modal = page.locator('ion-modal');
return modal.evaluate((el: HTMLIonModalElement) => {
const wrapper = el.shadowRoot?.querySelector('.modal-wrapper');
if (wrapper === null) return '0px';
return getComputedStyle(wrapper).paddingBottom;
});
}
// Helper to check if modal has a footer
async function modalHasFooter(page: any): Promise<boolean> {
const modal = page.locator('ion-modal');
return modal.evaluate((el: HTMLIonModalElement) => {
return el.querySelector('ion-footer') !== null;
});
}
// =============================================================================
// Phone Tests - Fullscreen modals need wrapper padding when no footer
// =============================================================================
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('modal: safe-area - phone'), () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize(Viewports.large);
await page.goto('/src/components/modal/test/safe-area', config);
});
test('fullscreen modal without footer should have wrapper padding', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#fullscreen-no-footer');
await ionModalDidPresent.next();
const hasFooter = await modalHasFooter(page);
expect(hasFooter).toBe(false);
const paddingBottom = await getWrapperPaddingBottom(page);
// Should have safe-area padding (34px as set in test HTML)
expect(paddingBottom).toBe('34px');
});
test('fullscreen modal with footer should not have wrapper padding', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#fullscreen-modal');
await ionModalDidPresent.next();
const hasFooter = await modalHasFooter(page);
expect(hasFooter).toBe(true);
const paddingBottom = await getWrapperPaddingBottom(page);
// Footer handles safe-area, wrapper should have no padding
expect(paddingBottom).toBe('0px');
});
test('default modal without footer should have wrapper padding on phone', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#default-no-footer');
await ionModalDidPresent.next();
// On phones, default modals are fullscreen
const paddingBottom = await getWrapperPaddingBottom(page);
expect(paddingBottom).toBe('34px');
});
});
});
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('modal: safe-area - card modal on phone'), () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize(Viewports.large);
await page.goto('/src/components/modal/test/safe-area', config);
});
test('card modal without footer should have wrapper padding on phone', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#card-modal-no-footer');
await ionModalDidPresent.next();
// Card modals on phones still extend to bottom edge
const paddingBottom = await getWrapperPaddingBottom(page);
expect(paddingBottom).toBe('34px');
});
test('card modal with footer should not have wrapper padding', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#card-modal');
await ionModalDidPresent.next();
const paddingBottom = await getWrapperPaddingBottom(page);
expect(paddingBottom).toBe('0px');
});
});
});
// =============================================================================
// Tablet Tests - Centered dialogs don't need safe-area, fullscreen does
// =============================================================================
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('modal: safe-area - tablet'), () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize(Viewports.tablet.portrait);
await page.goto('/src/components/modal/test/safe-area', config);
});
test('default modal should not have safe-area padding on tablet', async ({ page }) => {
test('default modal should not have wrapper padding on tablet', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#default-modal');
await ionModalDidPresent.next();
await expect(page).toHaveScreenshot(screenshot(`modal-safe-area-default-tablet`));
// Centered dialog on tablet - inset from edges, no padding needed
const paddingBottom = await getWrapperPaddingBottom(page);
expect(paddingBottom).toBe('0px');
});
test('fullscreen modal should have safe-area padding on tablet', async ({ page }) => {
test('fullscreen modal without footer should have wrapper padding on tablet', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#fullscreen-no-footer');
await ionModalDidPresent.next();
const paddingBottom = await getWrapperPaddingBottom(page);
expect(paddingBottom).toBe('34px');
});
test('fullscreen modal with footer should not have wrapper padding', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#fullscreen-modal');
await ionModalDidPresent.next();
await expect(page).toHaveScreenshot(screenshot(`modal-safe-area-fullscreen-tablet`));
});
test('sheet modal at partial breakpoint should have bottom safe-area only', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#sheet-modal-partial');
await ionModalDidPresent.next();
await expect(page).toHaveScreenshot(screenshot(`modal-safe-area-sheet-partial-tablet`));
});
test('sheet modal at full breakpoint should have bottom safe-area', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#sheet-modal-full');
await ionModalDidPresent.next();
await expect(page).toHaveScreenshot(screenshot(`modal-safe-area-sheet-full-tablet`));
const paddingBottom = await getWrapperPaddingBottom(page);
expect(paddingBottom).toBe('0px');
});
});
});
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('modal: safe-area - card modal'), () => {
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('modal: safe-area - card modal on tablet'), () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize(Viewports.tablet.portrait);
await page.goto('/src/components/modal/test/safe-area', config);
});
test('card modal should not have safe-area padding', async ({ page }) => {
test('card modal should not have wrapper padding on tablet', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#card-modal');
await ionModalDidPresent.next();
await expect(page).toHaveScreenshot(screenshot(`modal-safe-area-card-tablet`));
// Card modals on tablets are inset from all edges
const paddingBottom = await getWrapperPaddingBottom(page);
expect(paddingBottom).toBe('0px');
});
});
});
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('modal: safe-area - tablet (MD mode)'), () => {
// =============================================================================
// Sheet Modal Tests - Always touch bottom edge
// =============================================================================
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('modal: safe-area - sheet modal'), () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize(Viewports.tablet.portrait);
await page.goto('/src/components/modal/test/safe-area', config);
});
test('default modal should not have safe-area padding on tablet', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#default-modal');
await ionModalDidPresent.next();
await expect(page).toHaveScreenshot(screenshot(`modal-safe-area-default-tablet`));
});
test('fullscreen modal should have safe-area padding on tablet', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#fullscreen-modal');
await ionModalDidPresent.next();
await expect(page).toHaveScreenshot(screenshot(`modal-safe-area-fullscreen-tablet`));
});
test('sheet modal at full breakpoint should have bottom safe-area', async ({ page }) => {
test('sheet modal should not have wrapper padding (footer handles safe-area)', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#sheet-modal-full');
await ionModalDidPresent.next();
await expect(page).toHaveScreenshot(screenshot(`modal-safe-area-sheet-full-tablet`));
// Sheet modals with footer - footer handles the safe area
const paddingBottom = await getWrapperPaddingBottom(page);
expect(paddingBottom).toBe('0px');
});
});
});

View File

@@ -154,26 +154,6 @@
generateItems('large-list', 15);
generateItems('bottom-list', 10);
generateItems('near-bottom-list', 8);
// Log positioning info for debugging
document.querySelectorAll('ion-popover').forEach((popover) => {
popover.addEventListener('ionPopoverDidPresent', () => {
const content = popover.shadowRoot.querySelector('.popover-content');
if (content) {
const rect = content.getBoundingClientRect();
const bottomSafeArea =
parseInt(getComputedStyle(document.documentElement).getPropertyValue('--ion-safe-area-bottom')) || 0;
console.log('Popover position:', {
top: rect.top,
bottom: rect.bottom,
windowHeight: window.innerHeight,
bottomSafeArea,
distanceFromBottom: window.innerHeight - rect.bottom,
overlapsBottomSafeArea: rect.bottom > window.innerHeight - bottomSafeArea,
});
}
});
});
</script>
</body>
</html>

View File

File diff suppressed because it is too large Load Diff