@ionic/core 8.8.4 → 8.8.5-dev.11776871786.1e73ab78

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,79 @@ 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
+ applyFullscreenSafeAreaTo(contentEl, hasFooter) {
2895
+ // Only apply for standard Ionic layouts (has ion-content but no
2896
+ // ion-footer). When a footer is present it handles its own safe-area
2897
+ // padding. Custom modals with raw HTML are developer-controlled.
2898
+ if (!contentEl || hasFooter)
2899
+ return;
2900
+ contentEl.style.setProperty('--ion-content-safe-area-padding-bottom', 'var(--ion-safe-area-bottom, 0px)');
2901
+ }
2902
+ /**
2903
+ * Removes the internal --ion-content-safe-area-padding-bottom property
2904
+ * from an already-located ion-content. Callers do their own
2905
+ * findContentAndFooter() so they can also read hasFooter if needed.
2906
+ */
2907
+ clearContentSafeAreaPadding(contentEl) {
2908
+ if (!contentEl)
2909
+ return;
2910
+ contentEl.style.removeProperty('--ion-content-safe-area-padding-bottom');
2911
+ }
2912
+ /**
2913
+ * Finds ion-content and ion-footer among direct children and one level of
2914
+ * grandchildren (for wrapped components like <app-footer><ion-footer>).
2915
+ *
2916
+ * Intentionally does NOT use findIonContent() or querySelector() because
2917
+ * those search the full subtree and would match ion-content inside nested
2918
+ * routes/pages. We only want direct slot children (+ one wrapper level).
2919
+ *
2920
+ * Uses a manual loop instead of querySelector(':scope > ...') because
2921
+ * Stencil's mock-doc (used in spec tests) does not support :scope.
2922
+ */
2923
+ findContentAndFooter() {
2924
+ let contentEl = null;
2864
2925
  let hasFooter = false;
2865
- for (const child of Array.from(el.children)) {
2926
+ for (const child of Array.from(this.el.children)) {
2866
2927
  if (child.tagName === 'ION-CONTENT')
2867
- hasContent = true;
2928
+ contentEl = child;
2868
2929
  if (child.tagName === 'ION-FOOTER')
2869
2930
  hasFooter = true;
2870
2931
  for (const grandchild of Array.from(child.children)) {
2871
- if (grandchild.tagName === 'ION-CONTENT')
2872
- hasContent = true;
2932
+ if (grandchild.tagName === 'ION-CONTENT' && !contentEl)
2933
+ contentEl = grandchild;
2873
2934
  if (grandchild.tagName === 'ION-FOOTER')
2874
2935
  hasFooter = true;
2875
2936
  }
2876
2937
  }
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)');
2938
+ return { contentEl, hasFooter };
2889
2939
  }
2890
2940
  /**
2891
- * Clears all safe-area overrides and padding from wrapper.
2941
+ * Clears all safe-area overrides and padding.
2892
2942
  */
2893
2943
  cleanupSafeAreaOverrides() {
2894
2944
  clearSafeAreaOverrides(this.el);
2895
2945
  // Remove internal sheet offset property
2896
2946
  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
- }
2947
+ const { contentEl } = this.findContentAndFooter();
2948
+ this.clearContentSafeAreaPadding(contentEl);
2901
2949
  }
2902
2950
  render() {
2903
2951
  const { handle, isSheetModal, presentingElement, htmlAttributes, handleBehavior, inheritedAttributes, focusTrap, expandToScroll, } = this;
@@ -2906,20 +2954,20 @@ const Modal = class {
2906
2954
  const isCardModal = presentingElement !== undefined && mode === 'ios';
2907
2955
  const isHandleCycle = handleBehavior === 'cycle';
2908
2956
  const isSheetModalWithHandle = isSheetModal && showHandle;
2909
- return (index$3.h(index$3.Host, Object.assign({ key: '1a53e8f87532abccc169ca4b24973a39c5f9ba16', "no-router": true,
2957
+ return (index$3.h(index$3.Host, Object.assign({ key: 'b665328614ae3a0d27ec15ecb8334d14e0d517e7', "no-router": true,
2910
2958
  // Allow the modal to be navigable when the handle is focusable
2911
2959
  tabIndex: isHandleCycle && isSheetModalWithHandle ? 0 : -1 }, htmlAttributes, { style: {
2912
2960
  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',
2961
+ }, 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: '263b41858dc0ad44e5a84cd83cf6eaaf32a804d2', ref: (el) => (this.backdropEl = el), visible: this.showBackdrop, tappable: this.backdropDismiss, part: "backdrop" }), mode === 'ios' && index$3.h("div", { key: '65eb2a58f20576941e49f5499de644b4c45ad50e', class: "modal-shadow" }), index$3.h("div", Object.assign({ key: '2817d6cc8015ad013204941fea5ee96c331841f5',
2914
2962
  /*
2915
2963
  role and aria-modal must be used on the
2916
2964
  same element. They must also be set inside the
2917
2965
  shadow DOM otherwise ion-button will not be highlighted
2918
2966
  when using VoiceOver: https://bugs.webkit.org/show_bug.cgi?id=247134
2919
2967
  */
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",
2968
+ 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: '318f2c1e903cb41c977fbcce529f509c82378079', class: "modal-handle",
2921
2969
  // 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 }))));
2970
+ 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: 'de8e6b0d4126820cc6a02be0af229a3e62afa1dd', onSlotchange: this.onSlotChange }))));
2923
2971
  }
2924
2972
  get el() { return index$3.getElement(this); }
2925
2973
  static get watchers() { return {
@@ -106,6 +106,12 @@
106
106
  background: var(--background);
107
107
  }
108
108
 
109
+ /**
110
+ * --ion-content-safe-area-padding-bottom is an internal property set by
111
+ * modal.tsx for fullscreen modals without an ion-footer. This decouples
112
+ * safe-area-bottom scroll padding from --padding-bottom (which is a
113
+ * public property consumers may override).
114
+ */
109
115
  .inner-scroll {
110
116
  left: 0px;
111
117
  right: 0px;
@@ -116,7 +122,7 @@
116
122
  -webkit-padding-end: var(--padding-end);
117
123
  padding-inline-end: var(--padding-end);
118
124
  padding-top: calc(var(--padding-top) + var(--offset-top));
119
- padding-bottom: calc(var(--padding-bottom) + var(--keyboard-offset) + var(--offset-bottom));
125
+ padding-bottom: calc(var(--padding-bottom) + var(--keyboard-offset) + var(--offset-bottom) + var(--ion-content-safe-area-padding-bottom, 0px));
120
126
  position: absolute;
121
127
  color: var(--color);
122
128
  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,79 @@ 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
+ applyFullscreenSafeAreaTo(contentEl, hasFooter) {
1097
+ // Only apply for standard Ionic layouts (has ion-content but no
1098
+ // ion-footer). When a footer is present it handles its own safe-area
1099
+ // padding. Custom modals with raw HTML are developer-controlled.
1100
+ if (!contentEl || hasFooter)
1101
+ return;
1102
+ contentEl.style.setProperty('--ion-content-safe-area-padding-bottom', 'var(--ion-safe-area-bottom, 0px)');
1103
+ }
1104
+ /**
1105
+ * Removes the internal --ion-content-safe-area-padding-bottom property
1106
+ * from an already-located ion-content. Callers do their own
1107
+ * findContentAndFooter() so they can also read hasFooter if needed.
1108
+ */
1109
+ clearContentSafeAreaPadding(contentEl) {
1110
+ if (!contentEl)
1111
+ return;
1112
+ contentEl.style.removeProperty('--ion-content-safe-area-padding-bottom');
1113
+ }
1114
+ /**
1115
+ * Finds ion-content and ion-footer among direct children and one level of
1116
+ * grandchildren (for wrapped components like <app-footer><ion-footer>).
1117
+ *
1118
+ * Intentionally does NOT use findIonContent() or querySelector() because
1119
+ * those search the full subtree and would match ion-content inside nested
1120
+ * routes/pages. We only want direct slot children (+ one wrapper level).
1121
+ *
1122
+ * Uses a manual loop instead of querySelector(':scope > ...') because
1123
+ * Stencil's mock-doc (used in spec tests) does not support :scope.
1124
+ */
1125
+ findContentAndFooter() {
1126
+ let contentEl = null;
1091
1127
  let hasFooter = false;
1092
- for (const child of Array.from(el.children)) {
1128
+ for (const child of Array.from(this.el.children)) {
1093
1129
  if (child.tagName === 'ION-CONTENT')
1094
- hasContent = true;
1130
+ contentEl = child;
1095
1131
  if (child.tagName === 'ION-FOOTER')
1096
1132
  hasFooter = true;
1097
1133
  for (const grandchild of Array.from(child.children)) {
1098
- if (grandchild.tagName === 'ION-CONTENT')
1099
- hasContent = true;
1134
+ if (grandchild.tagName === 'ION-CONTENT' && !contentEl)
1135
+ contentEl = grandchild;
1100
1136
  if (grandchild.tagName === 'ION-FOOTER')
1101
1137
  hasFooter = true;
1102
1138
  }
1103
1139
  }
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)');
1140
+ return { contentEl, hasFooter };
1116
1141
  }
1117
1142
  /**
1118
- * Clears all safe-area overrides and padding from wrapper.
1143
+ * Clears all safe-area overrides and padding.
1119
1144
  */
1120
1145
  cleanupSafeAreaOverrides() {
1121
1146
  clearSafeAreaOverrides(this.el);
1122
1147
  // Remove internal sheet offset property
1123
1148
  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
- }
1149
+ const { contentEl } = this.findContentAndFooter();
1150
+ this.clearContentSafeAreaPadding(contentEl);
1128
1151
  }
1129
1152
  render() {
1130
1153
  const { handle, isSheetModal, presentingElement, htmlAttributes, handleBehavior, inheritedAttributes, focusTrap, expandToScroll, } = this;
@@ -1133,20 +1156,20 @@ export class Modal {
1133
1156
  const isCardModal = presentingElement !== undefined && mode === 'ios';
1134
1157
  const isHandleCycle = handleBehavior === 'cycle';
1135
1158
  const isSheetModalWithHandle = isSheetModal && showHandle;
1136
- return (h(Host, Object.assign({ key: '1a53e8f87532abccc169ca4b24973a39c5f9ba16', "no-router": true,
1159
+ return (h(Host, Object.assign({ key: 'b665328614ae3a0d27ec15ecb8334d14e0d517e7', "no-router": true,
1137
1160
  // Allow the modal to be navigable when the handle is focusable
1138
1161
  tabIndex: isHandleCycle && isSheetModalWithHandle ? 0 : -1 }, htmlAttributes, { style: {
1139
1162
  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',
1163
+ }, 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: '263b41858dc0ad44e5a84cd83cf6eaaf32a804d2', ref: (el) => (this.backdropEl = el), visible: this.showBackdrop, tappable: this.backdropDismiss, part: "backdrop" }), mode === 'ios' && h("div", { key: '65eb2a58f20576941e49f5499de644b4c45ad50e', class: "modal-shadow" }), h("div", Object.assign({ key: '2817d6cc8015ad013204941fea5ee96c331841f5',
1141
1164
  /*
1142
1165
  role and aria-modal must be used on the
1143
1166
  same element. They must also be set inside the
1144
1167
  shadow DOM otherwise ion-button will not be highlighted
1145
1168
  when using VoiceOver: https://bugs.webkit.org/show_bug.cgi?id=247134
1146
1169
  */
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",
1170
+ role: "dialog" }, inheritedAttributes, { "aria-modal": "true", class: "modal-wrapper ion-overlay-wrapper", part: "content", ref: (el) => (this.wrapperEl = el) }), showHandle && (h("button", { key: '318f2c1e903cb41c977fbcce529f509c82378079', class: "modal-handle",
1148
1171
  // 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 }))));
1172
+ 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: 'de8e6b0d4126820cc6a02be0af229a3e62afa1dd', onSlotchange: this.onSlotChange }))));
1150
1173
  }
1151
1174
  static get is() { return "ion-modal"; }
1152
1175
  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-15T18:07:39",
2
+ "timestamp": "2026-04-22T15:31:51",
3
3
  "compiler": {
4
4
  "name": "@stencil/core",
5
5
  "version": "4.43.0",
@@ -8379,6 +8379,11 @@
8379
8379
  "annotation": "prop",
8380
8380
  "docs": "Color of the content"
8381
8381
  },
8382
+ {
8383
+ "name": "--ion-content-safe-area-padding-bottom is an internal property set by modal.tsx for fullscreen modals without an ion-footer. This decouples safe-area-bottom scroll padding from --padding-bottom (which is a public property consumers may override).",
8384
+ "annotation": "prop",
8385
+ "docs": ""
8386
+ },
8382
8387
  {
8383
8388
  "name": "--keyboard-offset",
8384
8389
  "annotation": "prop",
@@ -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) {