mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2026-03-13 10:22:08 +08:00
fix(content): support side safe area content
This commit is contained in:
@@ -239,7 +239,8 @@
|
||||
// --------------------------------------------------
|
||||
// When content has no sibling header, offset from top safe-area.
|
||||
// When content has no sibling footer/tab-bar, offset from bottom safe-area.
|
||||
// This prevents content from overlapping device safe areas (status bar, nav bar).
|
||||
// Left/right safe-areas always apply to main content (for landscape notched devices).
|
||||
// This prevents content from overlapping device safe areas (status bar, nav bar, notch).
|
||||
|
||||
:host(.safe-area-top) #background-content,
|
||||
:host(.safe-area-top) .inner-scroll {
|
||||
@@ -251,6 +252,16 @@
|
||||
bottom: var(--ion-safe-area-bottom, 0px);
|
||||
}
|
||||
|
||||
:host(.safe-area-left) #background-content,
|
||||
:host(.safe-area-left) .inner-scroll {
|
||||
left: var(--ion-safe-area-left, 0px);
|
||||
}
|
||||
|
||||
:host(.safe-area-right) #background-content,
|
||||
:host(.safe-area-right) .inner-scroll {
|
||||
right: var(--ion-safe-area-right, 0px);
|
||||
}
|
||||
|
||||
|
||||
// Content: Fixed
|
||||
// --------------------------------------------------
|
||||
|
||||
@@ -581,6 +581,8 @@ export class Content implements ComponentInterface {
|
||||
[`content-${rtl}`]: true,
|
||||
'safe-area-top': isMainContent && !hasHeader,
|
||||
'safe-area-bottom': isMainContent && !hasFooter,
|
||||
'safe-area-left': isMainContent,
|
||||
'safe-area-right': isMainContent,
|
||||
})}
|
||||
style={{
|
||||
'--offset-top': `${this.cTop}px`,
|
||||
|
||||
@@ -5,6 +5,12 @@ import { configs, test } from '@utils/test/playwright';
|
||||
* Safe-area tests verify that ion-content correctly applies safe-area classes
|
||||
* based on the presence/absence of sibling ion-header and ion-footer elements.
|
||||
*
|
||||
* Safe-area class logic:
|
||||
* - safe-area-top: main content without header
|
||||
* - safe-area-bottom: main content without footer/tab-bar
|
||||
* - safe-area-left: always on main content (for landscape notched devices)
|
||||
* - safe-area-right: always on main content (for landscape notched devices)
|
||||
*
|
||||
* These tests verify the FW-6830 feature: automatic safe-area handling for content.
|
||||
*/
|
||||
|
||||
@@ -23,6 +29,9 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
const content = page.locator('#content-no-header');
|
||||
await expect(content).toHaveClass(/safe-area-top/);
|
||||
await expect(content).not.toHaveClass(/safe-area-bottom/);
|
||||
// Left/right always apply to main content
|
||||
await expect(content).toHaveClass(/safe-area-left/);
|
||||
await expect(content).toHaveClass(/safe-area-right/);
|
||||
});
|
||||
|
||||
test('content without footer should have safe-area-bottom class', async ({ page }, testInfo) => {
|
||||
@@ -34,9 +43,12 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
const content = page.locator('#content-no-footer');
|
||||
await expect(content).not.toHaveClass(/safe-area-top/);
|
||||
await expect(content).toHaveClass(/safe-area-bottom/);
|
||||
// Left/right always apply to main content
|
||||
await expect(content).toHaveClass(/safe-area-left/);
|
||||
await expect(content).toHaveClass(/safe-area-right/);
|
||||
});
|
||||
|
||||
test('content with both header and footer should not have safe-area classes', async ({ page }, testInfo) => {
|
||||
test('content with both header and footer should not have top/bottom safe-area classes', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
|
||||
@@ -45,9 +57,12 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
const content = page.locator('#content-with-both');
|
||||
await expect(content).not.toHaveClass(/safe-area-top/);
|
||||
await expect(content).not.toHaveClass(/safe-area-bottom/);
|
||||
// Left/right still apply to main content even with header/footer
|
||||
await expect(content).toHaveClass(/safe-area-left/);
|
||||
await expect(content).toHaveClass(/safe-area-right/);
|
||||
});
|
||||
|
||||
test('content without header or footer should have both safe-area classes', async ({ page }, testInfo) => {
|
||||
test('content without header or footer should have all safe-area classes', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
|
||||
@@ -56,6 +71,8 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
const content = page.locator('#content-no-both');
|
||||
await expect(content).toHaveClass(/safe-area-top/);
|
||||
await expect(content).toHaveClass(/safe-area-bottom/);
|
||||
await expect(content).toHaveClass(/safe-area-left/);
|
||||
await expect(content).toHaveClass(/safe-area-right/);
|
||||
});
|
||||
|
||||
test('content with wrapped header should not have safe-area-top class', async ({ page }, testInfo) => {
|
||||
@@ -67,6 +84,9 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
const content = page.locator('#content-wrapped-header');
|
||||
// Wrapped header detection should find the ion-header inside my-header
|
||||
await expect(content).not.toHaveClass(/safe-area-top/);
|
||||
// Left/right still apply to main content
|
||||
await expect(content).toHaveClass(/safe-area-left/);
|
||||
await expect(content).toHaveClass(/safe-area-right/);
|
||||
});
|
||||
|
||||
test('content with wrapped footer should not have safe-area-bottom class', async ({ page }, testInfo) => {
|
||||
@@ -78,33 +98,40 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
const content = page.locator('#content-wrapped-footer');
|
||||
// Wrapped footer detection should find the ion-footer inside my-footer
|
||||
await expect(content).not.toHaveClass(/safe-area-bottom/);
|
||||
// Left/right still apply to main content
|
||||
await expect(content).toHaveClass(/safe-area-left/);
|
||||
await expect(content).toHaveClass(/safe-area-right/);
|
||||
});
|
||||
|
||||
test('nested content should not have safe-area classes', async ({ page }, testInfo) => {
|
||||
test('nested content should not have any safe-area classes', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
|
||||
});
|
||||
|
||||
const nestedContent = page.locator('#content-nested');
|
||||
// Nested content should not be treated as main content
|
||||
// Nested content should not be treated as main content - no safe-area classes at all
|
||||
await expect(nestedContent).not.toHaveClass(/safe-area-top/);
|
||||
await expect(nestedContent).not.toHaveClass(/safe-area-bottom/);
|
||||
await expect(nestedContent).not.toHaveClass(/safe-area-left/);
|
||||
await expect(nestedContent).not.toHaveClass(/safe-area-right/);
|
||||
});
|
||||
|
||||
test('outer content should still have safe-area classes', async ({ page }, testInfo) => {
|
||||
test('outer content should have all safe-area classes', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
|
||||
});
|
||||
|
||||
const outerContent = page.locator('#content-outer');
|
||||
// Outer content has no sibling header/footer, so it should have safe-area classes
|
||||
// Outer content has no sibling header/footer, so it should have all safe-area classes
|
||||
await expect(outerContent).toHaveClass(/safe-area-top/);
|
||||
await expect(outerContent).toHaveClass(/safe-area-bottom/);
|
||||
await expect(outerContent).toHaveClass(/safe-area-left/);
|
||||
await expect(outerContent).toHaveClass(/safe-area-right/);
|
||||
});
|
||||
|
||||
test('content inside modal should not have safe-area classes', async ({ page }, testInfo) => {
|
||||
test('content inside modal should not have any safe-area classes', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
|
||||
@@ -123,9 +150,11 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
const modalContent = page.locator('#content-in-modal');
|
||||
// Content inside modal should not be treated as main content
|
||||
// Content inside modal should not be treated as main content - no safe-area classes at all
|
||||
await expect(modalContent).not.toHaveClass(/safe-area-top/);
|
||||
await expect(modalContent).not.toHaveClass(/safe-area-bottom/);
|
||||
await expect(modalContent).not.toHaveClass(/safe-area-left/);
|
||||
await expect(modalContent).not.toHaveClass(/safe-area-right/);
|
||||
});
|
||||
|
||||
test('dynamic header addition should update safe-area classes', async ({ page }, testInfo) => {
|
||||
@@ -136,14 +165,19 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
|
||||
const content = page.locator('#content-dynamic');
|
||||
|
||||
// Initially should have safe-area-top (no header)
|
||||
// Initially should have safe-area-top (no header) and left/right (always on main content)
|
||||
await expect(content).toHaveClass(/safe-area-top/);
|
||||
await expect(content).toHaveClass(/safe-area-left/);
|
||||
await expect(content).toHaveClass(/safe-area-right/);
|
||||
|
||||
// Add header dynamically (use evaluate to avoid pointer-events issues in Firefox)
|
||||
await page.evaluate(() => (window as any).addHeader());
|
||||
|
||||
// Wait for mutation observer to trigger and component to update
|
||||
await expect(content).not.toHaveClass(/safe-area-top/, { timeout: 1000 });
|
||||
// Left/right should remain
|
||||
await expect(content).toHaveClass(/safe-area-left/);
|
||||
await expect(content).toHaveClass(/safe-area-right/);
|
||||
});
|
||||
|
||||
test('dynamic header removal should update safe-area classes', async ({ page }, testInfo) => {
|
||||
@@ -157,12 +191,17 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
// Add header first (use evaluate to avoid pointer-events issues in Firefox)
|
||||
await page.evaluate(() => (window as any).addHeader());
|
||||
await expect(content).not.toHaveClass(/safe-area-top/, { timeout: 1000 });
|
||||
// Left/right should remain throughout
|
||||
await expect(content).toHaveClass(/safe-area-left/);
|
||||
await expect(content).toHaveClass(/safe-area-right/);
|
||||
|
||||
// Remove header
|
||||
await page.evaluate(() => (window as any).removeHeader());
|
||||
|
||||
// Should have safe-area-top again
|
||||
// Should have safe-area-top again, left/right should remain
|
||||
await expect(content).toHaveClass(/safe-area-top/, { timeout: 1000 });
|
||||
await expect(content).toHaveClass(/safe-area-left/);
|
||||
await expect(content).toHaveClass(/safe-area-right/);
|
||||
});
|
||||
|
||||
test('content inside ion-tabs with tab bar should not have safe-area-bottom', async ({ page }, testInfo) => {
|
||||
@@ -174,6 +213,9 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
const content = page.locator('#content-dynamic-tabs');
|
||||
// Tab bar is present, so content should not have safe-area-bottom
|
||||
await expect(content).not.toHaveClass(/safe-area-bottom/);
|
||||
// But left/right should still apply (main content)
|
||||
await expect(content).toHaveClass(/safe-area-left/);
|
||||
await expect(content).toHaveClass(/safe-area-right/);
|
||||
});
|
||||
|
||||
test('dynamic tab bar removal should update safe-area classes', async ({ page }, testInfo) => {
|
||||
@@ -186,12 +228,17 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
|
||||
// Initially tab bar is present, so no safe-area-bottom
|
||||
await expect(content).not.toHaveClass(/safe-area-bottom/);
|
||||
// Left/right should be present throughout
|
||||
await expect(content).toHaveClass(/safe-area-left/);
|
||||
await expect(content).toHaveClass(/safe-area-right/);
|
||||
|
||||
// Remove tab bar
|
||||
await page.evaluate(() => (window as any).removeTabBar());
|
||||
|
||||
// Should have safe-area-bottom now
|
||||
// Should have safe-area-bottom now, left/right remain
|
||||
await expect(content).toHaveClass(/safe-area-bottom/, { timeout: 1000 });
|
||||
await expect(content).toHaveClass(/safe-area-left/);
|
||||
await expect(content).toHaveClass(/safe-area-right/);
|
||||
});
|
||||
|
||||
test('dynamic tab bar addition should update safe-area classes', async ({ page }, testInfo) => {
|
||||
@@ -205,12 +252,17 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
// Remove tab bar first
|
||||
await page.evaluate(() => (window as any).removeTabBar());
|
||||
await expect(content).toHaveClass(/safe-area-bottom/, { timeout: 1000 });
|
||||
// Left/right should be present throughout
|
||||
await expect(content).toHaveClass(/safe-area-left/);
|
||||
await expect(content).toHaveClass(/safe-area-right/);
|
||||
|
||||
// Add tab bar back
|
||||
await page.evaluate(() => (window as any).addTabBar());
|
||||
|
||||
// Should not have safe-area-bottom anymore
|
||||
// Should not have safe-area-bottom anymore, left/right remain
|
||||
await expect(content).not.toHaveClass(/safe-area-bottom/, { timeout: 1000 });
|
||||
await expect(content).toHaveClass(/safe-area-left/);
|
||||
await expect(content).toHaveClass(/safe-area-right/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,12 +13,12 @@
|
||||
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
|
||||
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
|
||||
<style>
|
||||
/* Simulate safe-area insets for testing */
|
||||
/* Simulate safe-area insets for testing (typical phone in landscape) */
|
||||
:root {
|
||||
--ion-safe-area-top: 44px;
|
||||
--ion-safe-area-bottom: 34px;
|
||||
--ion-safe-area-left: 0px;
|
||||
--ion-safe-area-right: 0px;
|
||||
--ion-safe-area-left: 44px;
|
||||
--ion-safe-area-right: 44px;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
@@ -180,7 +180,8 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function addHeader() {
|
||||
// Expose functions globally for e2e tests
|
||||
window.addHeader = function addHeader() {
|
||||
const page = document.getElementById('dynamic-page');
|
||||
const content = document.getElementById('content-dynamic');
|
||||
if (!page.querySelector('ion-header')) {
|
||||
@@ -188,22 +189,22 @@
|
||||
header.innerHTML = '<ion-toolbar><ion-title>Dynamic Header</ion-title></ion-toolbar>';
|
||||
page.insertBefore(header, content);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function removeHeader() {
|
||||
window.removeHeader = function removeHeader() {
|
||||
const page = document.getElementById('dynamic-page');
|
||||
const header = page.querySelector('ion-header');
|
||||
if (header) {
|
||||
header.remove();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to open modal for testing
|
||||
function openModal() {
|
||||
window.openModal = function openModal() {
|
||||
document.getElementById('test-modal').isOpen = true;
|
||||
}
|
||||
};
|
||||
|
||||
function addTabBar() {
|
||||
window.addTabBar = function addTabBar() {
|
||||
const tabs = document.getElementById('dynamic-tabs');
|
||||
if (!tabs.querySelector('ion-tab-bar')) {
|
||||
const tabBar = document.createElement('ion-tab-bar');
|
||||
@@ -212,14 +213,14 @@
|
||||
tabBar.innerHTML = '<ion-tab-button tab="tab1"><ion-label>Tab 1</ion-label></ion-tab-button>';
|
||||
tabs.appendChild(tabBar);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function removeTabBar() {
|
||||
window.removeTabBar = function removeTabBar() {
|
||||
const tabBar = document.getElementById('dynamic-tab-bar');
|
||||
if (tabBar) {
|
||||
tabBar.remove();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</ion-app>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user