fix(modal): apply safe-area padding to card modals on phones
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 60 KiB |
@@ -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>
|
||||
|
||||