@ionic/core
Version:
Base components for Ionic
204 lines (203 loc) • 8.32 kB
JavaScript
/*!
* (C) Ionic http://ionicframework.com - MIT License
*/
import { win } from "../../utils/browser/index";
import { onCustomPropertyChange, raf } from "../../utils/helpers";
import { getOverlaySizeType } from "../../utils/overlays";
const EDGE_THRESHOLD = 5;
/**
* Cache for resolved root safe-area-top value, invalidated once per frame.
*/
let cachedRootSafeAreaTop = null;
let cacheInvalidationScheduled = false;
/**
* 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 = () => {
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;
};
/**
* Calls back when the resolved root `--ion-safe-area-top` changes. The value
* the caller already applied is passed as the baseline, so a change between
* that read and the observer starting is still reported.
*/
export const onRootSafeAreaTopChange = (callback) => {
return onCustomPropertyChange(win?.document?.body, '--ion-safe-area-top', callback, getRootSafeAreaTop());
};
/**
* Determines which viewport axes the modal spans so safe-area requirements
* can be predicted independently for each axis.
*
* A modal that spans an axis reaches both edges on that axis and needs the
* corresponding safe-area insets. A modal that does not span an axis reaches
* neither edge on that axis.
*
* When both `--width` and `--height` are `fullscreen`, coverage can be
* determined directly. Otherwise, coverage is based on the rendered wrapper,
* including cases where content sizing or `--max-height` causes the modal
* to reach the viewport.
*/
export const getModalCoveredAxes = (hostEl) => {
const styles = getComputedStyle(hostEl);
const width = getOverlaySizeType(styles.getPropertyValue('--width'));
const height = getOverlaySizeType(styles.getPropertyValue('--height'));
if (width === 'fullscreen' && height === 'fullscreen') {
return { vertical: true, horizontal: true };
}
return measureCoveredAxes(hostEl);
};
/**
* Measures the modal wrapper to determine whether it spans the viewport
* on each axis.
*
* The wrapper has no box while the modal is hidden, so `overlay-hidden`
* is temporarily removed to allow the wrapper to be measured. The class
* is restored in the same task before the browser can paint.
*
* Only the wrapper's size is measured. Its position is affected by the
* enter animation, which initially translates it by its own height, while
* the translation does not affect its measured size.
*/
const measureCoveredAxes = (hostEl) => {
const wrapperEl = hostEl.shadowRoot?.querySelector('.modal-wrapper');
if (wrapperEl == null || win === undefined) {
return { vertical: false, horizontal: false };
}
const wasHidden = hostEl.classList.contains('overlay-hidden');
if (wasHidden) {
hostEl.classList.remove('overlay-hidden');
}
const { width, height } = wrapperEl.getBoundingClientRect();
if (wasHidden) {
hostEl.classList.add('overlay-hidden');
}
return {
vertical: height >= win.innerHeight - EDGE_THRESHOLD,
horizontal: width >= win.innerWidth - EDGE_THRESHOLD,
};
};
/**
* 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) => {
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',
};
}
/**
* Each axis is evaluated independently because a modal can span one axis
* without spanning the other. This allows the initial safe-area configuration
* to match the modal's expected dimensions and avoids correcting an incorrect
* pair of insets after presentation.
*
* A modal can span the horizontal axis while remaining inset vertically, or
* span the vertical axis while remaining inset horizontally. Wide viewports
* can also render regular modals as centered dialogs, while content-sized
* modals may still be clamped to the viewport.
*/
const { vertical, horizontal } = context.coveredAxes ?? { vertical: true, horizontal: true };
return {
top: vertical ? 'inherit' : '0px',
bottom: vertical ? 'inherit' : '0px',
left: horizontal ? 'inherit' : '0px',
right: horizontal ? 'inherit' : '0px',
};
};
/**
* 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) => {
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, config) => {
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) => {
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');
};