@ionic/core 8.8.5-nightly.20260427 → 8.8.5-nightly.20260428

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1647,6 +1647,12 @@ const createSheetGesture = (baseEl, backdropEl, wrapperEl, initialBreakpoint, ba
1647
1647
  const MODAL_INSET_MIN_WIDTH = 768;
1648
1648
  const MODAL_INSET_MIN_HEIGHT = 600;
1649
1649
  const EDGE_THRESHOLD = 5;
1650
+ /**
1651
+ * CSS values for `--width` / `--height` that are treated as fullscreen
1652
+ * (modal touches the corresponding screen edges). Empty string means the
1653
+ * property was not overridden. See `hasCustomModalDimensions()`.
1654
+ */
1655
+ const FULLSCREEN_SIZE_VALUES = new Set(['', '100%', '100vw', '100vh', '100dvw', '100dvh', '100svw', '100svh']);
1650
1656
  /**
1651
1657
  * Cache for resolved root safe-area-top value, invalidated once per frame.
1652
1658
  */
@@ -1695,6 +1701,22 @@ const getRootSafeAreaTop = () => {
1695
1701
  }
1696
1702
  return value;
1697
1703
  };
1704
+ /**
1705
+ * True when the modal host declares BOTH a non-fullscreen `--width` AND a
1706
+ * non-fullscreen `--height` (i.e. a centered-dialog-like modal that doesn't
1707
+ * touch any screen edge).
1708
+ *
1709
+ * The conservative "both axes" check avoids mis-zeroing safe-area for
1710
+ * partial-custom modals where the modal still touches top/bottom edges
1711
+ * (e.g. only `--width` overridden). Partial cases fall through to the
1712
+ * existing position-based post-animation correction.
1713
+ */
1714
+ const hasCustomModalDimensions = (hostEl) => {
1715
+ const styles = getComputedStyle(hostEl);
1716
+ const width = styles.getPropertyValue('--width').trim();
1717
+ const height = styles.getPropertyValue('--height').trim();
1718
+ return !FULLSCREEN_SIZE_VALUES.has(width) && !FULLSCREEN_SIZE_VALUES.has(height);
1719
+ };
1698
1720
  /**
1699
1721
  * Returns the initial safe-area configuration based on modal type.
1700
1722
  * This is called before animation starts and uses configuration-based prediction.
@@ -1729,8 +1751,11 @@ const getInitialSafeAreaConfig = (context) => {
1729
1751
  }
1730
1752
  // On viewports that meet the centered dialog media query breakpoints,
1731
1753
  // regular modals render as centered dialogs (not fullscreen), so they
1732
- // don't touch any screen edges and don't need safe-area insets.
1733
- if (isCenteredDialogViewport()) {
1754
+ // don't touch any screen edges and don't need safe-area insets. Also
1755
+ // applies to phone viewports when the modal declares custom --width and
1756
+ // --height; these don't touch screen edges either, so the initial
1757
+ // prediction must be zero to avoid a post-animation correction flash.
1758
+ if (isCenteredDialogViewport() || context.hasCustomDimensions) {
1734
1759
  return {
1735
1760
  top: '0px',
1736
1761
  bottom: '0px',
@@ -2032,12 +2057,10 @@ const Modal = class {
2032
2057
  // since the viewport may have crossed the centered-dialog breakpoint.
2033
2058
  if (!context.isSheetModal && !context.isCardModal) {
2034
2059
  this.updateSafeAreaOverrides();
2035
- // Re-evaluate fullscreen safe-area padding: clear first, then re-apply
2036
- if (this.wrapperEl) {
2037
- this.wrapperEl.style.removeProperty('height');
2038
- this.wrapperEl.style.removeProperty('padding-bottom');
2039
- }
2040
- this.applyFullscreenSafeArea();
2060
+ // Re-evaluate fullscreen safe-area padding: clear first, then re-apply.
2061
+ const { contentEl, hasFooter } = this.findContentAndFooter();
2062
+ this.clearContentSafeAreaPadding(contentEl);
2063
+ this.applyFullscreenSafeAreaTo(contentEl, hasFooter);
2041
2064
  }
2042
2065
  }, 50); // Debounce to avoid excessive calls during active resizing
2043
2066
  }
@@ -2784,6 +2807,11 @@ const Modal = class {
2784
2807
  }
2785
2808
  /**
2786
2809
  * Creates the context object for safe-area utilities.
2810
+ *
2811
+ * `hasCustomDimensions` is only set by `setInitialSafeAreaOverrides()`
2812
+ * because it is only read by `getInitialSafeAreaConfig()`. Other callers
2813
+ * (resize handler, post-animation update, fullscreen-padding apply) would
2814
+ * pay a `getComputedStyle()` cost for a value they never consult.
2787
2815
  */
2788
2816
  getSafeAreaContext() {
2789
2817
  return {
@@ -2805,7 +2833,7 @@ const Modal = class {
2805
2833
  * sheets to prevent header content from getting double-offset padding).
2806
2834
  */
2807
2835
  setInitialSafeAreaOverrides() {
2808
- const context = this.getSafeAreaContext();
2836
+ const context = Object.assign(Object.assign({}, this.getSafeAreaContext()), { hasCustomDimensions: hasCustomModalDimensions(this.el) });
2809
2837
  const safeAreaConfig = getInitialSafeAreaConfig(context);
2810
2838
  applySafeAreaOverrides(this.el, safeAreaConfig);
2811
2839
  // Set the internal offset property with the resolved root safe-area-top value
@@ -2845,59 +2873,85 @@ const Modal = class {
2845
2873
  applySafeAreaOverrides(el, safeAreaConfig);
2846
2874
  }
2847
2875
  /**
2848
- * Applies padding-bottom to fullscreen modal wrapper to prevent
2849
- * content from overlapping system navigation bar.
2876
+ * Applies safe-area-bottom scroll padding to ion-content inside
2877
+ * fullscreen modals that have no ion-footer. This prevents content
2878
+ * from being hidden behind the system navigation bar while keeping
2879
+ * the modal background edge-to-edge (no visible gap).
2850
2880
  */
2851
2881
  applyFullscreenSafeArea() {
2852
- const { wrapperEl, el } = this;
2853
- if (!wrapperEl)
2854
- return;
2855
2882
  const context = this.getSafeAreaContext();
2856
2883
  if (context.isSheetModal || context.isCardModal)
2857
2884
  return;
2858
- // Check for standard Ionic layout children (ion-content, ion-footer),
2859
- // searching one level deep for wrapped components (e.g.,
2860
- // <app-footer><ion-footer>...</ion-footer></app-footer>).
2861
- // Note: uses a manual loop instead of querySelector(':scope > ...') because
2862
- // Stencil's mock-doc (used in spec tests) does not support :scope.
2863
- let hasContent = false;
2885
+ const { contentEl, hasFooter } = this.findContentAndFooter();
2886
+ this.applyFullscreenSafeAreaTo(contentEl, hasFooter);
2887
+ }
2888
+ /**
2889
+ * Sets --ion-content-safe-area-padding-bottom on the given ion-content
2890
+ * when no footer is present, so ion-content's .inner-scroll includes
2891
+ * safe-area-bottom in its scroll padding. This keeps the modal background
2892
+ * edge-to-edge while ensuring content scrolls clear of the system nav bar.
2893
+ *
2894
+ * --ion-content-safe-area-padding-bottom is an internal CSS property used
2895
+ * only by this code path. It is not part of ion-content's public API and
2896
+ * should not be set by consumers. The default of 0px makes it a no-op
2897
+ * when unset, which is the expected state for ion-content used outside of
2898
+ * a fullscreen modal without a footer.
2899
+ */
2900
+ applyFullscreenSafeAreaTo(contentEl, hasFooter) {
2901
+ // Only apply for standard Ionic layouts (has ion-content but no
2902
+ // ion-footer). When a footer is present it handles its own safe-area
2903
+ // padding. Custom modals with raw HTML are developer-controlled.
2904
+ if (!contentEl || hasFooter)
2905
+ return;
2906
+ contentEl.style.setProperty('--ion-content-safe-area-padding-bottom', 'var(--ion-safe-area-bottom, 0px)');
2907
+ }
2908
+ /**
2909
+ * Removes the internal --ion-content-safe-area-padding-bottom property
2910
+ * from an already-located ion-content. Callers do their own
2911
+ * findContentAndFooter() so they can also read hasFooter if needed.
2912
+ */
2913
+ clearContentSafeAreaPadding(contentEl) {
2914
+ if (!contentEl)
2915
+ return;
2916
+ contentEl.style.removeProperty('--ion-content-safe-area-padding-bottom');
2917
+ }
2918
+ /**
2919
+ * Finds ion-content and ion-footer among direct children and one level of
2920
+ * grandchildren (for wrapped components like <app-footer><ion-footer>).
2921
+ *
2922
+ * Intentionally does NOT use findIonContent() or querySelector() because
2923
+ * those search the full subtree and would match ion-content inside nested
2924
+ * routes/pages. We only want direct slot children (+ one wrapper level).
2925
+ *
2926
+ * Uses a manual loop instead of querySelector(':scope > ...') because
2927
+ * Stencil's mock-doc (used in spec tests) does not support :scope.
2928
+ */
2929
+ findContentAndFooter() {
2930
+ let contentEl = null;
2864
2931
  let hasFooter = false;
2865
- for (const child of Array.from(el.children)) {
2932
+ for (const child of Array.from(this.el.children)) {
2866
2933
  if (child.tagName === 'ION-CONTENT')
2867
- hasContent = true;
2934
+ contentEl = child;
2868
2935
  if (child.tagName === 'ION-FOOTER')
2869
2936
  hasFooter = true;
2870
2937
  for (const grandchild of Array.from(child.children)) {
2871
- if (grandchild.tagName === 'ION-CONTENT')
2872
- hasContent = true;
2938
+ if (grandchild.tagName === 'ION-CONTENT' && !contentEl)
2939
+ contentEl = grandchild;
2873
2940
  if (grandchild.tagName === 'ION-FOOTER')
2874
2941
  hasFooter = true;
2875
2942
  }
2876
2943
  }
2877
- // Only apply wrapper padding for standard Ionic layouts (has ion-content
2878
- // but no ion-footer). Custom modals with raw HTML are fully
2879
- // developer-controlled and should not be modified.
2880
- if (!hasContent || hasFooter)
2881
- return;
2882
- // Reduce wrapper height by safe-area and add equivalent padding so the
2883
- // total visual size stays the same but the flex content area shrinks.
2884
- // Using height + padding instead of box-sizing: border-box avoids
2885
- // breaking custom modals that set --border-width (border-box would
2886
- // include the border inside the height, changing the layout).
2887
- wrapperEl.style.setProperty('height', 'calc(var(--height) - var(--ion-safe-area-bottom, 0px))');
2888
- wrapperEl.style.setProperty('padding-bottom', 'var(--ion-safe-area-bottom, 0px)');
2944
+ return { contentEl, hasFooter };
2889
2945
  }
2890
2946
  /**
2891
- * Clears all safe-area overrides and padding from wrapper.
2947
+ * Clears all safe-area overrides and padding.
2892
2948
  */
2893
2949
  cleanupSafeAreaOverrides() {
2894
2950
  clearSafeAreaOverrides(this.el);
2895
2951
  // Remove internal sheet offset property
2896
2952
  this.el.style.removeProperty('--ion-modal-offset-top');
2897
- if (this.wrapperEl) {
2898
- this.wrapperEl.style.removeProperty('height');
2899
- this.wrapperEl.style.removeProperty('padding-bottom');
2900
- }
2953
+ const { contentEl } = this.findContentAndFooter();
2954
+ this.clearContentSafeAreaPadding(contentEl);
2901
2955
  }
2902
2956
  render() {
2903
2957
  const { handle, isSheetModal, presentingElement, htmlAttributes, handleBehavior, inheritedAttributes, focusTrap, expandToScroll, } = this;
@@ -2906,20 +2960,20 @@ const Modal = class {
2906
2960
  const isCardModal = presentingElement !== undefined && mode === 'ios';
2907
2961
  const isHandleCycle = handleBehavior === 'cycle';
2908
2962
  const isSheetModalWithHandle = isSheetModal && showHandle;
2909
- return (index$3.h(index$3.Host, Object.assign({ key: '1a53e8f87532abccc169ca4b24973a39c5f9ba16', "no-router": true,
2963
+ return (index$3.h(index$3.Host, Object.assign({ key: '4bf38aa67df9a3f977163bba5423960bbafd16de', "no-router": true,
2910
2964
  // Allow the modal to be navigable when the handle is focusable
2911
2965
  tabIndex: isHandleCycle && isSheetModalWithHandle ? 0 : -1 }, htmlAttributes, { style: {
2912
2966
  zIndex: `${20000 + this.overlayIndex}`,
2913
- }, class: Object.assign({ [mode]: true, ['modal-default']: !isCardModal && !isSheetModal, [`modal-card`]: isCardModal, [`modal-sheet`]: isSheetModal, [`modal-no-expand-scroll`]: isSheetModal && !expandToScroll, 'overlay-hidden': true, [overlays.FOCUS_TRAP_DISABLE_CLASS]: focusTrap === false }, theme.getClassMap(this.cssClass)), onIonBackdropTap: this.onBackdropTap, onIonModalDidPresent: this.onLifecycle, onIonModalWillPresent: this.onLifecycle, onIonModalWillDismiss: this.onLifecycle, onIonModalDidDismiss: this.onLifecycle, onFocus: this.onModalFocus }), index$3.h("ion-backdrop", { key: 'fa8e0a436c0d458331402e1850f87af3dc97b582', ref: (el) => (this.backdropEl = el), visible: this.showBackdrop, tappable: this.backdropDismiss, part: "backdrop" }), mode === 'ios' && index$3.h("div", { key: 'f00de6027d3c8b5bc93db3b0f7a50a87628d40bb', class: "modal-shadow" }), index$3.h("div", Object.assign({ key: 'ae5e33bd6c58e541edb2edbca92420ea02dd5175',
2967
+ }, class: Object.assign({ [mode]: true, ['modal-default']: !isCardModal && !isSheetModal, [`modal-card`]: isCardModal, [`modal-sheet`]: isSheetModal, [`modal-no-expand-scroll`]: isSheetModal && !expandToScroll, 'overlay-hidden': true, [overlays.FOCUS_TRAP_DISABLE_CLASS]: focusTrap === false }, theme.getClassMap(this.cssClass)), onIonBackdropTap: this.onBackdropTap, onIonModalDidPresent: this.onLifecycle, onIonModalWillPresent: this.onLifecycle, onIonModalWillDismiss: this.onLifecycle, onIonModalDidDismiss: this.onLifecycle, onFocus: this.onModalFocus }), index$3.h("ion-backdrop", { key: '866da40cc5fc8d3e36637098fb3066a5bc9f4e0f', ref: (el) => (this.backdropEl = el), visible: this.showBackdrop, tappable: this.backdropDismiss, part: "backdrop" }), mode === 'ios' && index$3.h("div", { key: '5a2a05514ea8592c8feb0465e504aa7c7af17963', class: "modal-shadow" }), index$3.h("div", Object.assign({ key: '4d327115306451f57d190b06ab8cbb6191a6f1d7',
2914
2968
  /*
2915
2969
  role and aria-modal must be used on the
2916
2970
  same element. They must also be set inside the
2917
2971
  shadow DOM otherwise ion-button will not be highlighted
2918
2972
  when using VoiceOver: https://bugs.webkit.org/show_bug.cgi?id=247134
2919
2973
  */
2920
- role: "dialog" }, inheritedAttributes, { "aria-modal": "true", class: "modal-wrapper ion-overlay-wrapper", part: "content", ref: (el) => (this.wrapperEl = el) }), showHandle && (index$3.h("button", { key: '141cdd8f8522331f4b764e2a4d79ec6596b1eb3a', class: "modal-handle",
2974
+ role: "dialog" }, inheritedAttributes, { "aria-modal": "true", class: "modal-wrapper ion-overlay-wrapper", part: "content", ref: (el) => (this.wrapperEl = el) }), showHandle && (index$3.h("button", { key: 'd1882835cc049232c0d957e3ba1e79676a07d179', class: "modal-handle",
2921
2975
  // Prevents the handle from receiving keyboard focus when it does not cycle
2922
- tabIndex: !isHandleCycle ? -1 : 0, "aria-label": "Activate to adjust the size of the dialog overlaying the screen", onClick: isHandleCycle ? this.onHandleClick : undefined, part: "handle", ref: (el) => (this.dragHandleEl = el) })), index$3.h("slot", { key: '7de20298b61abee67a16d275c9ebd9a25ce7dd26', onSlotchange: this.onSlotChange }))));
2976
+ tabIndex: !isHandleCycle ? -1 : 0, "aria-label": "Activate to adjust the size of the dialog overlaying the screen", onClick: isHandleCycle ? this.onHandleClick : undefined, part: "handle", ref: (el) => (this.dragHandleEl = el) })), index$3.h("slot", { key: '81dc58b09cf7d7022b04cd170f53113604364d5e', onSlotchange: this.onSlotChange }))));
2923
2977
  }
2924
2978
  get el() { return index$3.getElement(this); }
2925
2979
  static get watchers() { return {
@@ -116,7 +116,7 @@
116
116
  -webkit-padding-end: var(--padding-end);
117
117
  padding-inline-end: var(--padding-end);
118
118
  padding-top: calc(var(--padding-top) + var(--offset-top));
119
- padding-bottom: calc(var(--padding-bottom) + var(--keyboard-offset) + var(--offset-bottom));
119
+ padding-bottom: calc(var(--padding-bottom) + var(--keyboard-offset) + var(--offset-bottom) + var(--ion-content-safe-area-padding-bottom, 0px));
120
120
  position: absolute;
121
121
  color: var(--color);
122
122
  box-sizing: border-box;
@@ -21,7 +21,7 @@ import { mdEnterAnimation } from "./animations/md.enter";
21
21
  import { mdLeaveAnimation } from "./animations/md.leave";
22
22
  import { createSheetGesture } from "./gestures/sheet";
23
23
  import { createSwipeToCloseGesture, SwipeToCloseDefaults } from "./gestures/swipe-to-close";
24
- import { getInitialSafeAreaConfig, getPositionBasedSafeAreaConfig, applySafeAreaOverrides, clearSafeAreaOverrides, getRootSafeAreaTop, } from "./safe-area-utils";
24
+ import { getInitialSafeAreaConfig, getPositionBasedSafeAreaConfig, applySafeAreaOverrides, clearSafeAreaOverrides, getRootSafeAreaTop, hasCustomModalDimensions, } from "./safe-area-utils";
25
25
  import { setCardStatusBarDark, setCardStatusBarDefault } from "./utils";
26
26
  // TODO(FW-2832): types
27
27
  /**
@@ -251,12 +251,10 @@ export class Modal {
251
251
  // since the viewport may have crossed the centered-dialog breakpoint.
252
252
  if (!context.isSheetModal && !context.isCardModal) {
253
253
  this.updateSafeAreaOverrides();
254
- // Re-evaluate fullscreen safe-area padding: clear first, then re-apply
255
- if (this.wrapperEl) {
256
- this.wrapperEl.style.removeProperty('height');
257
- this.wrapperEl.style.removeProperty('padding-bottom');
258
- }
259
- this.applyFullscreenSafeArea();
254
+ // Re-evaluate fullscreen safe-area padding: clear first, then re-apply.
255
+ const { contentEl, hasFooter } = this.findContentAndFooter();
256
+ this.clearContentSafeAreaPadding(contentEl);
257
+ this.applyFullscreenSafeAreaTo(contentEl, hasFooter);
260
258
  }
261
259
  }, 50); // Debounce to avoid excessive calls during active resizing
262
260
  }
@@ -1011,6 +1009,11 @@ export class Modal {
1011
1009
  }
1012
1010
  /**
1013
1011
  * Creates the context object for safe-area utilities.
1012
+ *
1013
+ * `hasCustomDimensions` is only set by `setInitialSafeAreaOverrides()`
1014
+ * because it is only read by `getInitialSafeAreaConfig()`. Other callers
1015
+ * (resize handler, post-animation update, fullscreen-padding apply) would
1016
+ * pay a `getComputedStyle()` cost for a value they never consult.
1014
1017
  */
1015
1018
  getSafeAreaContext() {
1016
1019
  return {
@@ -1032,7 +1035,7 @@ export class Modal {
1032
1035
  * sheets to prevent header content from getting double-offset padding).
1033
1036
  */
1034
1037
  setInitialSafeAreaOverrides() {
1035
- const context = this.getSafeAreaContext();
1038
+ const context = Object.assign(Object.assign({}, this.getSafeAreaContext()), { hasCustomDimensions: hasCustomModalDimensions(this.el) });
1036
1039
  const safeAreaConfig = getInitialSafeAreaConfig(context);
1037
1040
  applySafeAreaOverrides(this.el, safeAreaConfig);
1038
1041
  // Set the internal offset property with the resolved root safe-area-top value
@@ -1072,59 +1075,85 @@ export class Modal {
1072
1075
  applySafeAreaOverrides(el, safeAreaConfig);
1073
1076
  }
1074
1077
  /**
1075
- * Applies padding-bottom to fullscreen modal wrapper to prevent
1076
- * content from overlapping system navigation bar.
1078
+ * Applies safe-area-bottom scroll padding to ion-content inside
1079
+ * fullscreen modals that have no ion-footer. This prevents content
1080
+ * from being hidden behind the system navigation bar while keeping
1081
+ * the modal background edge-to-edge (no visible gap).
1077
1082
  */
1078
1083
  applyFullscreenSafeArea() {
1079
- const { wrapperEl, el } = this;
1080
- if (!wrapperEl)
1081
- return;
1082
1084
  const context = this.getSafeAreaContext();
1083
1085
  if (context.isSheetModal || context.isCardModal)
1084
1086
  return;
1085
- // Check for standard Ionic layout children (ion-content, ion-footer),
1086
- // searching one level deep for wrapped components (e.g.,
1087
- // <app-footer><ion-footer>...</ion-footer></app-footer>).
1088
- // Note: uses a manual loop instead of querySelector(':scope > ...') because
1089
- // Stencil's mock-doc (used in spec tests) does not support :scope.
1090
- let hasContent = false;
1087
+ const { contentEl, hasFooter } = this.findContentAndFooter();
1088
+ this.applyFullscreenSafeAreaTo(contentEl, hasFooter);
1089
+ }
1090
+ /**
1091
+ * Sets --ion-content-safe-area-padding-bottom on the given ion-content
1092
+ * when no footer is present, so ion-content's .inner-scroll includes
1093
+ * safe-area-bottom in its scroll padding. This keeps the modal background
1094
+ * edge-to-edge while ensuring content scrolls clear of the system nav bar.
1095
+ *
1096
+ * --ion-content-safe-area-padding-bottom is an internal CSS property used
1097
+ * only by this code path. It is not part of ion-content's public API and
1098
+ * should not be set by consumers. The default of 0px makes it a no-op
1099
+ * when unset, which is the expected state for ion-content used outside of
1100
+ * a fullscreen modal without a footer.
1101
+ */
1102
+ applyFullscreenSafeAreaTo(contentEl, hasFooter) {
1103
+ // Only apply for standard Ionic layouts (has ion-content but no
1104
+ // ion-footer). When a footer is present it handles its own safe-area
1105
+ // padding. Custom modals with raw HTML are developer-controlled.
1106
+ if (!contentEl || hasFooter)
1107
+ return;
1108
+ contentEl.style.setProperty('--ion-content-safe-area-padding-bottom', 'var(--ion-safe-area-bottom, 0px)');
1109
+ }
1110
+ /**
1111
+ * Removes the internal --ion-content-safe-area-padding-bottom property
1112
+ * from an already-located ion-content. Callers do their own
1113
+ * findContentAndFooter() so they can also read hasFooter if needed.
1114
+ */
1115
+ clearContentSafeAreaPadding(contentEl) {
1116
+ if (!contentEl)
1117
+ return;
1118
+ contentEl.style.removeProperty('--ion-content-safe-area-padding-bottom');
1119
+ }
1120
+ /**
1121
+ * Finds ion-content and ion-footer among direct children and one level of
1122
+ * grandchildren (for wrapped components like <app-footer><ion-footer>).
1123
+ *
1124
+ * Intentionally does NOT use findIonContent() or querySelector() because
1125
+ * those search the full subtree and would match ion-content inside nested
1126
+ * routes/pages. We only want direct slot children (+ one wrapper level).
1127
+ *
1128
+ * Uses a manual loop instead of querySelector(':scope > ...') because
1129
+ * Stencil's mock-doc (used in spec tests) does not support :scope.
1130
+ */
1131
+ findContentAndFooter() {
1132
+ let contentEl = null;
1091
1133
  let hasFooter = false;
1092
- for (const child of Array.from(el.children)) {
1134
+ for (const child of Array.from(this.el.children)) {
1093
1135
  if (child.tagName === 'ION-CONTENT')
1094
- hasContent = true;
1136
+ contentEl = child;
1095
1137
  if (child.tagName === 'ION-FOOTER')
1096
1138
  hasFooter = true;
1097
1139
  for (const grandchild of Array.from(child.children)) {
1098
- if (grandchild.tagName === 'ION-CONTENT')
1099
- hasContent = true;
1140
+ if (grandchild.tagName === 'ION-CONTENT' && !contentEl)
1141
+ contentEl = grandchild;
1100
1142
  if (grandchild.tagName === 'ION-FOOTER')
1101
1143
  hasFooter = true;
1102
1144
  }
1103
1145
  }
1104
- // Only apply wrapper padding for standard Ionic layouts (has ion-content
1105
- // but no ion-footer). Custom modals with raw HTML are fully
1106
- // developer-controlled and should not be modified.
1107
- if (!hasContent || hasFooter)
1108
- return;
1109
- // Reduce wrapper height by safe-area and add equivalent padding so the
1110
- // total visual size stays the same but the flex content area shrinks.
1111
- // Using height + padding instead of box-sizing: border-box avoids
1112
- // breaking custom modals that set --border-width (border-box would
1113
- // include the border inside the height, changing the layout).
1114
- wrapperEl.style.setProperty('height', 'calc(var(--height) - var(--ion-safe-area-bottom, 0px))');
1115
- wrapperEl.style.setProperty('padding-bottom', 'var(--ion-safe-area-bottom, 0px)');
1146
+ return { contentEl, hasFooter };
1116
1147
  }
1117
1148
  /**
1118
- * Clears all safe-area overrides and padding from wrapper.
1149
+ * Clears all safe-area overrides and padding.
1119
1150
  */
1120
1151
  cleanupSafeAreaOverrides() {
1121
1152
  clearSafeAreaOverrides(this.el);
1122
1153
  // Remove internal sheet offset property
1123
1154
  this.el.style.removeProperty('--ion-modal-offset-top');
1124
- if (this.wrapperEl) {
1125
- this.wrapperEl.style.removeProperty('height');
1126
- this.wrapperEl.style.removeProperty('padding-bottom');
1127
- }
1155
+ const { contentEl } = this.findContentAndFooter();
1156
+ this.clearContentSafeAreaPadding(contentEl);
1128
1157
  }
1129
1158
  render() {
1130
1159
  const { handle, isSheetModal, presentingElement, htmlAttributes, handleBehavior, inheritedAttributes, focusTrap, expandToScroll, } = this;
@@ -1133,20 +1162,20 @@ export class Modal {
1133
1162
  const isCardModal = presentingElement !== undefined && mode === 'ios';
1134
1163
  const isHandleCycle = handleBehavior === 'cycle';
1135
1164
  const isSheetModalWithHandle = isSheetModal && showHandle;
1136
- return (h(Host, Object.assign({ key: '1a53e8f87532abccc169ca4b24973a39c5f9ba16', "no-router": true,
1165
+ return (h(Host, Object.assign({ key: '4bf38aa67df9a3f977163bba5423960bbafd16de', "no-router": true,
1137
1166
  // Allow the modal to be navigable when the handle is focusable
1138
1167
  tabIndex: isHandleCycle && isSheetModalWithHandle ? 0 : -1 }, htmlAttributes, { style: {
1139
1168
  zIndex: `${20000 + this.overlayIndex}`,
1140
- }, class: Object.assign({ [mode]: true, ['modal-default']: !isCardModal && !isSheetModal, [`modal-card`]: isCardModal, [`modal-sheet`]: isSheetModal, [`modal-no-expand-scroll`]: isSheetModal && !expandToScroll, 'overlay-hidden': true, [FOCUS_TRAP_DISABLE_CLASS]: focusTrap === false }, getClassMap(this.cssClass)), onIonBackdropTap: this.onBackdropTap, onIonModalDidPresent: this.onLifecycle, onIonModalWillPresent: this.onLifecycle, onIonModalWillDismiss: this.onLifecycle, onIonModalDidDismiss: this.onLifecycle, onFocus: this.onModalFocus }), h("ion-backdrop", { key: 'fa8e0a436c0d458331402e1850f87af3dc97b582', ref: (el) => (this.backdropEl = el), visible: this.showBackdrop, tappable: this.backdropDismiss, part: "backdrop" }), mode === 'ios' && h("div", { key: 'f00de6027d3c8b5bc93db3b0f7a50a87628d40bb', class: "modal-shadow" }), h("div", Object.assign({ key: 'ae5e33bd6c58e541edb2edbca92420ea02dd5175',
1169
+ }, class: Object.assign({ [mode]: true, ['modal-default']: !isCardModal && !isSheetModal, [`modal-card`]: isCardModal, [`modal-sheet`]: isSheetModal, [`modal-no-expand-scroll`]: isSheetModal && !expandToScroll, 'overlay-hidden': true, [FOCUS_TRAP_DISABLE_CLASS]: focusTrap === false }, getClassMap(this.cssClass)), onIonBackdropTap: this.onBackdropTap, onIonModalDidPresent: this.onLifecycle, onIonModalWillPresent: this.onLifecycle, onIonModalWillDismiss: this.onLifecycle, onIonModalDidDismiss: this.onLifecycle, onFocus: this.onModalFocus }), h("ion-backdrop", { key: '866da40cc5fc8d3e36637098fb3066a5bc9f4e0f', ref: (el) => (this.backdropEl = el), visible: this.showBackdrop, tappable: this.backdropDismiss, part: "backdrop" }), mode === 'ios' && h("div", { key: '5a2a05514ea8592c8feb0465e504aa7c7af17963', class: "modal-shadow" }), h("div", Object.assign({ key: '4d327115306451f57d190b06ab8cbb6191a6f1d7',
1141
1170
  /*
1142
1171
  role and aria-modal must be used on the
1143
1172
  same element. They must also be set inside the
1144
1173
  shadow DOM otherwise ion-button will not be highlighted
1145
1174
  when using VoiceOver: https://bugs.webkit.org/show_bug.cgi?id=247134
1146
1175
  */
1147
- role: "dialog" }, inheritedAttributes, { "aria-modal": "true", class: "modal-wrapper ion-overlay-wrapper", part: "content", ref: (el) => (this.wrapperEl = el) }), showHandle && (h("button", { key: '141cdd8f8522331f4b764e2a4d79ec6596b1eb3a', class: "modal-handle",
1176
+ role: "dialog" }, inheritedAttributes, { "aria-modal": "true", class: "modal-wrapper ion-overlay-wrapper", part: "content", ref: (el) => (this.wrapperEl = el) }), showHandle && (h("button", { key: 'd1882835cc049232c0d957e3ba1e79676a07d179', class: "modal-handle",
1148
1177
  // Prevents the handle from receiving keyboard focus when it does not cycle
1149
- tabIndex: !isHandleCycle ? -1 : 0, "aria-label": "Activate to adjust the size of the dialog overlaying the screen", onClick: isHandleCycle ? this.onHandleClick : undefined, part: "handle", ref: (el) => (this.dragHandleEl = el) })), h("slot", { key: '7de20298b61abee67a16d275c9ebd9a25ce7dd26', onSlotchange: this.onSlotChange }))));
1178
+ tabIndex: !isHandleCycle ? -1 : 0, "aria-label": "Activate to adjust the size of the dialog overlaying the screen", onClick: isHandleCycle ? this.onHandleClick : undefined, part: "handle", ref: (el) => (this.dragHandleEl = el) })), h("slot", { key: '81dc58b09cf7d7022b04cd170f53113604364d5e', onSlotchange: this.onSlotChange }))));
1150
1179
  }
1151
1180
  static get is() { return "ion-modal"; }
1152
1181
  static get encapsulation() { return "shadow"; }
@@ -15,6 +15,12 @@ import { raf } from "../../utils/helpers";
15
15
  const MODAL_INSET_MIN_WIDTH = 768;
16
16
  const MODAL_INSET_MIN_HEIGHT = 600;
17
17
  const EDGE_THRESHOLD = 5;
18
+ /**
19
+ * CSS values for `--width` / `--height` that are treated as fullscreen
20
+ * (modal touches the corresponding screen edges). Empty string means the
21
+ * property was not overridden. See `hasCustomModalDimensions()`.
22
+ */
23
+ const FULLSCREEN_SIZE_VALUES = new Set(['', '100%', '100vw', '100vh', '100dvw', '100dvh', '100svw', '100svh']);
18
24
  /**
19
25
  * Cache for resolved root safe-area-top value, invalidated once per frame.
20
26
  */
@@ -63,6 +69,22 @@ export const getRootSafeAreaTop = () => {
63
69
  }
64
70
  return value;
65
71
  };
72
+ /**
73
+ * True when the modal host declares BOTH a non-fullscreen `--width` AND a
74
+ * non-fullscreen `--height` (i.e. a centered-dialog-like modal that doesn't
75
+ * touch any screen edge).
76
+ *
77
+ * The conservative "both axes" check avoids mis-zeroing safe-area for
78
+ * partial-custom modals where the modal still touches top/bottom edges
79
+ * (e.g. only `--width` overridden). Partial cases fall through to the
80
+ * existing position-based post-animation correction.
81
+ */
82
+ export const hasCustomModalDimensions = (hostEl) => {
83
+ const styles = getComputedStyle(hostEl);
84
+ const width = styles.getPropertyValue('--width').trim();
85
+ const height = styles.getPropertyValue('--height').trim();
86
+ return !FULLSCREEN_SIZE_VALUES.has(width) && !FULLSCREEN_SIZE_VALUES.has(height);
87
+ };
66
88
  /**
67
89
  * Returns the initial safe-area configuration based on modal type.
68
90
  * This is called before animation starts and uses configuration-based prediction.
@@ -97,8 +119,11 @@ export const getInitialSafeAreaConfig = (context) => {
97
119
  }
98
120
  // On viewports that meet the centered dialog media query breakpoints,
99
121
  // regular modals render as centered dialogs (not fullscreen), so they
100
- // don't touch any screen edges and don't need safe-area insets.
101
- if (isCenteredDialogViewport()) {
122
+ // don't touch any screen edges and don't need safe-area insets. Also
123
+ // applies to phone viewports when the modal declares custom --width and
124
+ // --height; these don't touch screen edges either, so the initial
125
+ // prediction must be zero to avoid a post-animation correction flash.
126
+ if (isCenteredDialogViewport() || context.hasCustomDimensions) {
102
127
  return {
103
128
  top: '0px',
104
129
  bottom: '0px',
package/dist/docs.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "timestamp": "2026-04-27T06:57:23",
2
+ "timestamp": "2026-04-28T06:58:48",
3
3
  "compiler": {
4
4
  "name": "@stencil/core",
5
5
  "version": "4.43.0",
@@ -157,7 +157,7 @@ Buttons.style = {
157
157
  md: buttonsMdCss()
158
158
  };
159
159
 
160
- const contentCss = () => `:host{--background:var(--ion-background-color, #fff);--color:var(--ion-text-color, #000);--padding-top:0px;--padding-bottom:0px;--padding-start:0px;--padding-end:0px;--keyboard-offset:0px;--offset-top:0px;--offset-bottom:0px;--overflow:auto;display:block;position:relative;-ms-flex:1;flex:1;width:100%;height:100%;margin:0 !important;padding:0 !important;font-family:var(--ion-font-family, inherit);contain:size style}:host(.ion-color) .inner-scroll{background:var(--ion-color-base);color:var(--ion-color-contrast)}#background-content{left:0px;right:0px;top:calc(var(--offset-top) * -1);bottom:calc(var(--offset-bottom) * -1);position:absolute;background:var(--background)}.inner-scroll{left:0px;right:0px;top:calc(var(--offset-top) * -1);bottom:calc(var(--offset-bottom) * -1);-webkit-padding-start:var(--padding-start);padding-inline-start:var(--padding-start);-webkit-padding-end:var(--padding-end);padding-inline-end:var(--padding-end);padding-top:calc(var(--padding-top) + var(--offset-top));padding-bottom:calc(var(--padding-bottom) + var(--keyboard-offset) + var(--offset-bottom));position:absolute;color:var(--color);-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden;-ms-touch-action:pan-x pan-y pinch-zoom;touch-action:pan-x pan-y pinch-zoom}.scroll-y,.scroll-x{-webkit-overflow-scrolling:touch;z-index:0;will-change:scroll-position}.scroll-y{overflow-y:var(--overflow);overscroll-behavior-y:contain}.scroll-x{overflow-x:var(--overflow);overscroll-behavior-x:contain}.overscroll::before,.overscroll::after{position:absolute;width:1px;height:1px;content:""}.overscroll::before{bottom:-1px}.overscroll::after{top:-1px}:host(.content-sizing){display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;min-height:0;contain:none}:host(.content-sizing) .inner-scroll{position:relative;top:0;bottom:0;margin-top:calc(var(--offset-top) * -1);margin-bottom:calc(var(--offset-bottom) * -1)}.transition-effect{display:none;position:absolute;width:100%;height:100vh;opacity:0;pointer-events:none}:host(.content-ltr) .transition-effect{left:-100%;}:host(.content-rtl) .transition-effect{right:-100%;}.transition-cover{position:absolute;right:0;width:100%;height:100%;background:black;opacity:0.1}.transition-shadow{display:block;position:absolute;width:100%;height:100%;-webkit-box-shadow:inset -9px 0 9px 0 rgba(0, 0, 100, 0.03);box-shadow:inset -9px 0 9px 0 rgba(0, 0, 100, 0.03)}:host(.content-ltr) .transition-shadow{right:0;}:host(.content-rtl) .transition-shadow{left:0;-webkit-transform:scaleX(-1);transform:scaleX(-1)}::slotted([slot=fixed]){position:absolute;-webkit-transform:translateZ(0);transform:translateZ(0)}`;
160
+ const contentCss = () => `:host{--background:var(--ion-background-color, #fff);--color:var(--ion-text-color, #000);--padding-top:0px;--padding-bottom:0px;--padding-start:0px;--padding-end:0px;--keyboard-offset:0px;--offset-top:0px;--offset-bottom:0px;--overflow:auto;display:block;position:relative;-ms-flex:1;flex:1;width:100%;height:100%;margin:0 !important;padding:0 !important;font-family:var(--ion-font-family, inherit);contain:size style}:host(.ion-color) .inner-scroll{background:var(--ion-color-base);color:var(--ion-color-contrast)}#background-content{left:0px;right:0px;top:calc(var(--offset-top) * -1);bottom:calc(var(--offset-bottom) * -1);position:absolute;background:var(--background)}.inner-scroll{left:0px;right:0px;top:calc(var(--offset-top) * -1);bottom:calc(var(--offset-bottom) * -1);-webkit-padding-start:var(--padding-start);padding-inline-start:var(--padding-start);-webkit-padding-end:var(--padding-end);padding-inline-end:var(--padding-end);padding-top:calc(var(--padding-top) + var(--offset-top));padding-bottom:calc(var(--padding-bottom) + var(--keyboard-offset) + var(--offset-bottom) + var(--ion-content-safe-area-padding-bottom, 0px));position:absolute;color:var(--color);-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden;-ms-touch-action:pan-x pan-y pinch-zoom;touch-action:pan-x pan-y pinch-zoom}.scroll-y,.scroll-x{-webkit-overflow-scrolling:touch;z-index:0;will-change:scroll-position}.scroll-y{overflow-y:var(--overflow);overscroll-behavior-y:contain}.scroll-x{overflow-x:var(--overflow);overscroll-behavior-x:contain}.overscroll::before,.overscroll::after{position:absolute;width:1px;height:1px;content:""}.overscroll::before{bottom:-1px}.overscroll::after{top:-1px}:host(.content-sizing){display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;min-height:0;contain:none}:host(.content-sizing) .inner-scroll{position:relative;top:0;bottom:0;margin-top:calc(var(--offset-top) * -1);margin-bottom:calc(var(--offset-bottom) * -1)}.transition-effect{display:none;position:absolute;width:100%;height:100vh;opacity:0;pointer-events:none}:host(.content-ltr) .transition-effect{left:-100%;}:host(.content-rtl) .transition-effect{right:-100%;}.transition-cover{position:absolute;right:0;width:100%;height:100%;background:black;opacity:0.1}.transition-shadow{display:block;position:absolute;width:100%;height:100%;-webkit-box-shadow:inset -9px 0 9px 0 rgba(0, 0, 100, 0.03);box-shadow:inset -9px 0 9px 0 rgba(0, 0, 100, 0.03)}:host(.content-ltr) .transition-shadow{right:0;}:host(.content-rtl) .transition-shadow{left:0;-webkit-transform:scaleX(-1);transform:scaleX(-1)}::slotted([slot=fixed]){position:absolute;-webkit-transform:translateZ(0);transform:translateZ(0)}`;
161
161
 
162
162
  const Content = class {
163
163
  constructor(hostRef) {