Skip to content

Commit f9159e1

Browse files
committed
fix(content): support side safe area content
1 parent 095b72e commit f9159e1

File tree

4 files changed

+92
-26
lines changed

4 files changed

+92
-26
lines changed

core/src/components/content/content.scss

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,8 @@
239239
// --------------------------------------------------
240240
// When content has no sibling header, offset from top safe-area.
241241
// When content has no sibling footer/tab-bar, offset from bottom safe-area.
242-
// This prevents content from overlapping device safe areas (status bar, nav bar).
242+
// Left/right safe-areas always apply to main content (for landscape notched devices).
243+
// This prevents content from overlapping device safe areas (status bar, nav bar, notch).
243244

244245
:host(.safe-area-top) #background-content,
245246
:host(.safe-area-top) .inner-scroll {
@@ -251,6 +252,16 @@
251252
bottom: var(--ion-safe-area-bottom, 0px);
252253
}
253254

255+
:host(.safe-area-left) #background-content,
256+
:host(.safe-area-left) .inner-scroll {
257+
left: var(--ion-safe-area-left, 0px);
258+
}
259+
260+
:host(.safe-area-right) #background-content,
261+
:host(.safe-area-right) .inner-scroll {
262+
right: var(--ion-safe-area-right, 0px);
263+
}
264+
254265

255266
// Content: Fixed
256267
// --------------------------------------------------

core/src/components/content/content.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,8 @@ export class Content implements ComponentInterface {
581581
[`content-${rtl}`]: true,
582582
'safe-area-top': isMainContent && !hasHeader,
583583
'safe-area-bottom': isMainContent && !hasFooter,
584+
'safe-area-left': isMainContent,
585+
'safe-area-right': isMainContent,
584586
})}
585587
style={{
586588
'--offset-top': `${this.cTop}px`,

core/src/components/content/test/safe-area/content.e2e.ts

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ import { configs, test } from '@utils/test/playwright';
55
* Safe-area tests verify that ion-content correctly applies safe-area classes
66
* based on the presence/absence of sibling ion-header and ion-footer elements.
77
*
8+
* Safe-area class logic:
9+
* - safe-area-top: main content without header
10+
* - safe-area-bottom: main content without footer/tab-bar
11+
* - safe-area-left: always on main content (for landscape notched devices)
12+
* - safe-area-right: always on main content (for landscape notched devices)
13+
*
814
* These tests verify the FW-6830 feature: automatic safe-area handling for content.
915
*/
1016

@@ -23,6 +29,9 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
2329
const content = page.locator('#content-no-header');
2430
await expect(content).toHaveClass(/safe-area-top/);
2531
await expect(content).not.toHaveClass(/safe-area-bottom/);
32+
// Left/right always apply to main content
33+
await expect(content).toHaveClass(/safe-area-left/);
34+
await expect(content).toHaveClass(/safe-area-right/);
2635
});
2736

2837
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 }) => {
3443
const content = page.locator('#content-no-footer');
3544
await expect(content).not.toHaveClass(/safe-area-top/);
3645
await expect(content).toHaveClass(/safe-area-bottom/);
46+
// Left/right always apply to main content
47+
await expect(content).toHaveClass(/safe-area-left/);
48+
await expect(content).toHaveClass(/safe-area-right/);
3749
});
3850

39-
test('content with both header and footer should not have safe-area classes', async ({ page }, testInfo) => {
51+
test('content with both header and footer should not have top/bottom safe-area classes', async ({ page }, testInfo) => {
4052
testInfo.annotations.push({
4153
type: 'issue',
4254
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
@@ -45,9 +57,12 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
4557
const content = page.locator('#content-with-both');
4658
await expect(content).not.toHaveClass(/safe-area-top/);
4759
await expect(content).not.toHaveClass(/safe-area-bottom/);
60+
// Left/right still apply to main content even with header/footer
61+
await expect(content).toHaveClass(/safe-area-left/);
62+
await expect(content).toHaveClass(/safe-area-right/);
4863
});
4964

50-
test('content without header or footer should have both safe-area classes', async ({ page }, testInfo) => {
65+
test('content without header or footer should have all safe-area classes', async ({ page }, testInfo) => {
5166
testInfo.annotations.push({
5267
type: 'issue',
5368
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
@@ -56,6 +71,8 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
5671
const content = page.locator('#content-no-both');
5772
await expect(content).toHaveClass(/safe-area-top/);
5873
await expect(content).toHaveClass(/safe-area-bottom/);
74+
await expect(content).toHaveClass(/safe-area-left/);
75+
await expect(content).toHaveClass(/safe-area-right/);
5976
});
6077

6178
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 }) => {
6784
const content = page.locator('#content-wrapped-header');
6885
// Wrapped header detection should find the ion-header inside my-header
6986
await expect(content).not.toHaveClass(/safe-area-top/);
87+
// Left/right still apply to main content
88+
await expect(content).toHaveClass(/safe-area-left/);
89+
await expect(content).toHaveClass(/safe-area-right/);
7090
});
7191

7292
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 }) => {
7898
const content = page.locator('#content-wrapped-footer');
7999
// Wrapped footer detection should find the ion-footer inside my-footer
80100
await expect(content).not.toHaveClass(/safe-area-bottom/);
101+
// Left/right still apply to main content
102+
await expect(content).toHaveClass(/safe-area-left/);
103+
await expect(content).toHaveClass(/safe-area-right/);
81104
});
82105

83-
test('nested content should not have safe-area classes', async ({ page }, testInfo) => {
106+
test('nested content should not have any safe-area classes', async ({ page }, testInfo) => {
84107
testInfo.annotations.push({
85108
type: 'issue',
86109
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
87110
});
88111

89112
const nestedContent = page.locator('#content-nested');
90-
// Nested content should not be treated as main content
113+
// Nested content should not be treated as main content - no safe-area classes at all
91114
await expect(nestedContent).not.toHaveClass(/safe-area-top/);
92115
await expect(nestedContent).not.toHaveClass(/safe-area-bottom/);
116+
await expect(nestedContent).not.toHaveClass(/safe-area-left/);
117+
await expect(nestedContent).not.toHaveClass(/safe-area-right/);
93118
});
94119

95-
test('outer content should still have safe-area classes', async ({ page }, testInfo) => {
120+
test('outer content should have all safe-area classes', async ({ page }, testInfo) => {
96121
testInfo.annotations.push({
97122
type: 'issue',
98123
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
99124
});
100125

101126
const outerContent = page.locator('#content-outer');
102-
// Outer content has no sibling header/footer, so it should have safe-area classes
127+
// Outer content has no sibling header/footer, so it should have all safe-area classes
103128
await expect(outerContent).toHaveClass(/safe-area-top/);
104129
await expect(outerContent).toHaveClass(/safe-area-bottom/);
130+
await expect(outerContent).toHaveClass(/safe-area-left/);
131+
await expect(outerContent).toHaveClass(/safe-area-right/);
105132
});
106133

107-
test('content inside modal should not have safe-area classes', async ({ page }, testInfo) => {
134+
test('content inside modal should not have any safe-area classes', async ({ page }, testInfo) => {
108135
testInfo.annotations.push({
109136
type: 'issue',
110137
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
@@ -123,9 +150,11 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
123150
await ionModalDidPresent.next();
124151

125152
const modalContent = page.locator('#content-in-modal');
126-
// Content inside modal should not be treated as main content
153+
// Content inside modal should not be treated as main content - no safe-area classes at all
127154
await expect(modalContent).not.toHaveClass(/safe-area-top/);
128155
await expect(modalContent).not.toHaveClass(/safe-area-bottom/);
156+
await expect(modalContent).not.toHaveClass(/safe-area-left/);
157+
await expect(modalContent).not.toHaveClass(/safe-area-right/);
129158
});
130159

131160
test('dynamic header addition should update safe-area classes', async ({ page }, testInfo) => {
@@ -136,14 +165,19 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
136165

137166
const content = page.locator('#content-dynamic');
138167

139-
// Initially should have safe-area-top (no header)
168+
// Initially should have safe-area-top (no header) and left/right (always on main content)
140169
await expect(content).toHaveClass(/safe-area-top/);
170+
await expect(content).toHaveClass(/safe-area-left/);
171+
await expect(content).toHaveClass(/safe-area-right/);
141172

142173
// Add header dynamically (use evaluate to avoid pointer-events issues in Firefox)
143174
await page.evaluate(() => (window as any).addHeader());
144175

145176
// Wait for mutation observer to trigger and component to update
146177
await expect(content).not.toHaveClass(/safe-area-top/, { timeout: 1000 });
178+
// Left/right should remain
179+
await expect(content).toHaveClass(/safe-area-left/);
180+
await expect(content).toHaveClass(/safe-area-right/);
147181
});
148182

149183
test('dynamic header removal should update safe-area classes', async ({ page }, testInfo) => {
@@ -157,12 +191,17 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
157191
// Add header first (use evaluate to avoid pointer-events issues in Firefox)
158192
await page.evaluate(() => (window as any).addHeader());
159193
await expect(content).not.toHaveClass(/safe-area-top/, { timeout: 1000 });
194+
// Left/right should remain throughout
195+
await expect(content).toHaveClass(/safe-area-left/);
196+
await expect(content).toHaveClass(/safe-area-right/);
160197

161198
// Remove header
162199
await page.evaluate(() => (window as any).removeHeader());
163200

164-
// Should have safe-area-top again
201+
// Should have safe-area-top again, left/right should remain
165202
await expect(content).toHaveClass(/safe-area-top/, { timeout: 1000 });
203+
await expect(content).toHaveClass(/safe-area-left/);
204+
await expect(content).toHaveClass(/safe-area-right/);
166205
});
167206

168207
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 }) => {
174213
const content = page.locator('#content-dynamic-tabs');
175214
// Tab bar is present, so content should not have safe-area-bottom
176215
await expect(content).not.toHaveClass(/safe-area-bottom/);
216+
// But left/right should still apply (main content)
217+
await expect(content).toHaveClass(/safe-area-left/);
218+
await expect(content).toHaveClass(/safe-area-right/);
177219
});
178220

179221
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 }) => {
186228

187229
// Initially tab bar is present, so no safe-area-bottom
188230
await expect(content).not.toHaveClass(/safe-area-bottom/);
231+
// Left/right should be present throughout
232+
await expect(content).toHaveClass(/safe-area-left/);
233+
await expect(content).toHaveClass(/safe-area-right/);
189234

190235
// Remove tab bar
191236
await page.evaluate(() => (window as any).removeTabBar());
192237

193-
// Should have safe-area-bottom now
238+
// Should have safe-area-bottom now, left/right remain
194239
await expect(content).toHaveClass(/safe-area-bottom/, { timeout: 1000 });
240+
await expect(content).toHaveClass(/safe-area-left/);
241+
await expect(content).toHaveClass(/safe-area-right/);
195242
});
196243

197244
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 }) => {
205252
// Remove tab bar first
206253
await page.evaluate(() => (window as any).removeTabBar());
207254
await expect(content).toHaveClass(/safe-area-bottom/, { timeout: 1000 });
255+
// Left/right should be present throughout
256+
await expect(content).toHaveClass(/safe-area-left/);
257+
await expect(content).toHaveClass(/safe-area-right/);
208258

209259
// Add tab bar back
210260
await page.evaluate(() => (window as any).addTabBar());
211261

212-
// Should not have safe-area-bottom anymore
262+
// Should not have safe-area-bottom anymore, left/right remain
213263
await expect(content).not.toHaveClass(/safe-area-bottom/, { timeout: 1000 });
264+
await expect(content).toHaveClass(/safe-area-left/);
265+
await expect(content).toHaveClass(/safe-area-right/);
214266
});
215267
});
216268
});

core/src/components/content/test/safe-area/index.html

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@
1313
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
1414
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
1515
<style>
16-
/* Simulate safe-area insets for testing */
16+
/* Simulate safe-area insets for testing (typical phone in landscape) */
1717
:root {
1818
--ion-safe-area-top: 44px;
1919
--ion-safe-area-bottom: 34px;
20-
--ion-safe-area-left: 0px;
21-
--ion-safe-area-right: 0px;
20+
--ion-safe-area-left: 44px;
21+
--ion-safe-area-right: 44px;
2222
}
2323

2424
.test-section {
@@ -180,30 +180,31 @@
180180
</div>
181181

182182
<script>
183-
function addHeader() {
183+
// Expose functions globally for e2e tests
184+
window.addHeader = function addHeader() {
184185
const page = document.getElementById('dynamic-page');
185186
const content = document.getElementById('content-dynamic');
186187
if (!page.querySelector('ion-header')) {
187188
const header = document.createElement('ion-header');
188189
header.innerHTML = '<ion-toolbar><ion-title>Dynamic Header</ion-title></ion-toolbar>';
189190
page.insertBefore(header, content);
190191
}
191-
}
192+
};
192193

193-
function removeHeader() {
194+
window.removeHeader = function removeHeader() {
194195
const page = document.getElementById('dynamic-page');
195196
const header = page.querySelector('ion-header');
196197
if (header) {
197198
header.remove();
198199
}
199-
}
200+
};
200201

201202
// Helper to open modal for testing
202-
function openModal() {
203+
window.openModal = function openModal() {
203204
document.getElementById('test-modal').isOpen = true;
204-
}
205+
};
205206

206-
function addTabBar() {
207+
window.addTabBar = function addTabBar() {
207208
const tabs = document.getElementById('dynamic-tabs');
208209
if (!tabs.querySelector('ion-tab-bar')) {
209210
const tabBar = document.createElement('ion-tab-bar');
@@ -212,14 +213,14 @@
212213
tabBar.innerHTML = '<ion-tab-button tab="tab1"><ion-label>Tab 1</ion-label></ion-tab-button>';
213214
tabs.appendChild(tabBar);
214215
}
215-
}
216+
};
216217

217-
function removeTabBar() {
218+
window.removeTabBar = function removeTabBar() {
218219
const tabBar = document.getElementById('dynamic-tab-bar');
219220
if (tabBar) {
220221
tabBar.remove();
221222
}
222-
}
223+
};
223224
</script>
224225
</ion-app>
225226
</body>

0 commit comments

Comments
 (0)