-
Notifications
You must be signed in to change notification settings - Fork 13.4k
Expand file tree
/
Copy pathsafe-area-utils.ts
More file actions
233 lines (211 loc) · 8.12 KB
/
safe-area-utils.ts
File metadata and controls
233 lines (211 loc) · 8.12 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
import { win } from '@utils/browser';
import { raf } from '@utils/helpers';
type SafeAreaValue = '0px' | 'inherit';
/**
* Configuration for safe-area CSS custom properties.
* Each direction can inherit the root safe-area value or be zeroed out.
*/
export interface SafeAreaConfig {
top: SafeAreaValue;
bottom: SafeAreaValue;
left: SafeAreaValue;
right: SafeAreaValue;
}
/**
* Context information about the modal used to determine safe-area behavior.
*/
export interface ModalSafeAreaContext {
isSheetModal: boolean;
isCardModal: boolean;
presentingElement?: HTMLElement;
breakpoints?: number[];
currentBreakpoint?: number;
/**
* Only consulted by `getInitialSafeAreaConfig()`. Callers that only use the
* context for non-initial paths can omit this. See `hasCustomModalDimensions()`.
*/
hasCustomDimensions?: boolean;
}
/**
* These thresholds match the SCSS media query breakpoints in modal.vars.scss
* that trigger the centered dialog layout (non-fullscreen modal).
*
* SCSS defines two height breakpoints: $modal-inset-min-height-small (600px)
* and $modal-inset-min-height-large (768px). We use the smaller one because
* that's the threshold where the modal transitions from fullscreen to centered
* dialog — the larger breakpoint only increases the dialog's height.
*/
const MODAL_INSET_MIN_WIDTH = 768;
const MODAL_INSET_MIN_HEIGHT = 600;
const EDGE_THRESHOLD = 5;
/**
* CSS values for `--width` / `--height` that are treated as fullscreen
* (modal touches the corresponding screen edges). Empty string means the
* property was not overridden. See `hasCustomModalDimensions()`.
*/
const FULLSCREEN_SIZE_VALUES = new Set(['', '100%', '100vw', '100vh', '100dvw', '100dvh', '100svw', '100svh']);
/**
* Cache for resolved root safe-area-top value, invalidated once per frame.
*/
let cachedRootSafeAreaTop: number | null = null;
let cacheInvalidationScheduled = false;
/**
* Determines if the current viewport meets the CSS media query conditions
* that cause regular modals to render as centered dialogs instead of fullscreen.
* Matches: @media (min-width: 768px) and (min-height: 600px)
*/
const isCenteredDialogViewport = (): boolean => {
if (!win) return false;
return win.matchMedia(`(min-width: ${MODAL_INSET_MIN_WIDTH}px) and (min-height: ${MODAL_INSET_MIN_HEIGHT}px)`)
.matches;
};
/**
* Resolves the current root --ion-safe-area-top value to pixels.
* Uses a temporary element because getComputedStyle on :root returns
* the declared value of custom properties (e.g. "env(safe-area-inset-top)")
* rather than a resolved number.
*
* Results are cached for the current frame to avoid repeated reflows.
*/
export const getRootSafeAreaTop = (): number => {
if (cachedRootSafeAreaTop !== null) {
return cachedRootSafeAreaTop;
}
const doc = win?.document;
if (!doc?.body) {
return 0;
}
const el = doc.createElement('div');
el.style.cssText =
'position:fixed;visibility:hidden;pointer-events:none;top:0;left:0;' + 'padding-top:var(--ion-safe-area-top,0px);';
doc.body.appendChild(el);
const value = parseFloat(getComputedStyle(el).paddingTop) || 0;
el.remove();
cachedRootSafeAreaTop = value;
if (!cacheInvalidationScheduled) {
cacheInvalidationScheduled = true;
raf(() => {
cachedRootSafeAreaTop = null;
cacheInvalidationScheduled = false;
});
}
return value;
};
/**
* True when the modal host declares BOTH a non-fullscreen `--width` AND a
* non-fullscreen `--height` (i.e. a centered-dialog-like modal that doesn't
* touch any screen edge).
*
* The conservative "both axes" check avoids mis-zeroing safe-area for
* partial-custom modals where the modal still touches top/bottom edges
* (e.g. only `--width` overridden). Partial cases fall through to the
* existing position-based post-animation correction.
*/
export const hasCustomModalDimensions = (hostEl: HTMLElement): boolean => {
const styles = getComputedStyle(hostEl);
const width = styles.getPropertyValue('--width').trim();
const height = styles.getPropertyValue('--height').trim();
return !FULLSCREEN_SIZE_VALUES.has(width) && !FULLSCREEN_SIZE_VALUES.has(height);
};
/**
* Returns the initial safe-area configuration based on modal type.
* This is called before animation starts and uses configuration-based prediction.
*
* @param context - Modal context information
* @returns SafeAreaConfig with initial safe-area values
*/
export const getInitialSafeAreaConfig = (context: ModalSafeAreaContext): SafeAreaConfig => {
const { isSheetModal, isCardModal } = context;
// Sheet modals always zero top safe-area. The sheet height offset from the
// top edge is handled by --ion-modal-offset-top (set in modal.tsx) using
// the resolved root value, so --ion-safe-area-top is never needed for
// height calculation. Keeping it at 0px prevents header content from
// getting double-offset padding.
if (isSheetModal) {
return {
top: '0px',
bottom: 'inherit',
left: '0px',
right: '0px',
};
}
// Card modals need safe-area for height calculation.
// Note: isCardModal is already gated on mode === 'ios' by the caller.
if (isCardModal) {
return {
top: 'inherit',
bottom: 'inherit',
left: '0px',
right: '0px',
};
}
// On viewports that meet the centered dialog media query breakpoints,
// regular modals render as centered dialogs (not fullscreen), so they
// don't touch any screen edges and don't need safe-area insets. Also
// applies to phone viewports when the modal declares custom --width and
// --height; these don't touch screen edges either, so the initial
// prediction must be zero to avoid a post-animation correction flash.
if (isCenteredDialogViewport() || context.hasCustomDimensions) {
return {
top: '0px',
bottom: '0px',
left: '0px',
right: '0px',
};
}
// Fullscreen modals on phone - inherit all safe areas
return {
top: 'inherit',
bottom: 'inherit',
left: 'inherit',
right: 'inherit',
};
};
/**
* Returns safe-area configuration based on actual modal position.
* Detects which edges the modal overlaps with and only applies safe-area to those edges.
*
* Note: On Android edge-to-edge (API 36+), getBoundingClientRect() may report
* inconsistent values. Sheet and card modals avoid this by using configuration-based
* prediction instead. Regular modals use coordinate detection which works reliably
* on web and iOS; Android edge-to-edge may need a configuration-based fallback
* once a reliable detection mechanism is available.
*
* @param wrapperEl - The modal wrapper element to measure
* @returns SafeAreaConfig based on position
*/
export const getPositionBasedSafeAreaConfig = (wrapperEl: HTMLElement): SafeAreaConfig => {
const rect = wrapperEl.getBoundingClientRect();
const vh = win?.innerHeight ?? 0;
const vw = win?.innerWidth ?? 0;
// Only apply safe-area to sides where modal overlaps with screen edge
return {
top: rect.top <= EDGE_THRESHOLD ? 'inherit' : '0px',
bottom: rect.bottom >= vh - EDGE_THRESHOLD ? 'inherit' : '0px',
left: rect.left <= EDGE_THRESHOLD ? 'inherit' : '0px',
right: rect.right >= vw - EDGE_THRESHOLD ? 'inherit' : '0px',
};
};
/**
* Applies safe-area CSS custom property overrides to the modal host element.
*
* @param hostEl - The modal host element (ion-modal)
* @param config - Safe-area configuration to apply
*/
export const applySafeAreaOverrides = (hostEl: HTMLElement, config: SafeAreaConfig): void => {
hostEl.style.setProperty('--ion-safe-area-top', config.top);
hostEl.style.setProperty('--ion-safe-area-bottom', config.bottom);
hostEl.style.setProperty('--ion-safe-area-left', config.left);
hostEl.style.setProperty('--ion-safe-area-right', config.right);
};
/**
* Clears safe-area CSS custom property overrides from the modal host element.
*
* @param hostEl - The modal host element (ion-modal)
*/
export const clearSafeAreaOverrides = (hostEl: HTMLElement): void => {
hostEl.style.removeProperty('--ion-safe-area-top');
hostEl.style.removeProperty('--ion-safe-area-bottom');
hostEl.style.removeProperty('--ion-safe-area-left');
hostEl.style.removeProperty('--ion-safe-area-right');
};