@ionic/core 8.7.18-dev.11770674094.18396f54 → 8.7.18-dev.11770916502.174efd7f

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.
@@ -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, } from "./safe-area-utils";
24
+ import { getInitialSafeAreaConfig, getPositionBasedSafeAreaConfig, applySafeAreaOverrides, clearSafeAreaOverrides, getRootSafeAreaTop, } from "./safe-area-utils";
25
25
  import { setCardStatusBarDark, setCardStatusBarDefault } from "./utils";
26
26
  // TODO(FW-2832): types
27
27
  /**
@@ -233,13 +233,31 @@ export class Modal {
233
233
  }
234
234
  }
235
235
  onWindowResize() {
236
- // Only handle resize for iOS card modals when no custom animations are provided
237
- if (getIonMode(this) !== 'ios' || !this.presentingElement || this.enterAnimation || this.leaveAnimation) {
236
+ if (!this.presented)
238
237
  return;
239
- }
240
238
  clearTimeout(this.resizeTimeout);
241
239
  this.resizeTimeout = setTimeout(() => {
242
- this.handleViewTransition();
240
+ const context = this.getSafeAreaContext();
241
+ // iOS card modals: handle portrait/landscape view transitions
242
+ if (context.isCardModal && !this.enterAnimation && !this.leaveAnimation) {
243
+ this.handleViewTransition();
244
+ }
245
+ // Sheet modals: re-compute the internal offset property since safe-area
246
+ // values may change on device rotation (e.g., portrait notch vs landscape).
247
+ if (context.isSheetModal) {
248
+ this.updateSheetOffsetTop();
249
+ }
250
+ // Regular (non-sheet, non-card) modals: update safe-area overrides
251
+ // since the viewport may have crossed the centered-dialog breakpoint.
252
+ if (!context.isSheetModal && !context.isCardModal) {
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();
260
+ }
243
261
  }, 50); // Debounce to avoid excessive calls during active resizing
244
262
  }
245
263
  breakpointsChanged(breakpoints) {
@@ -553,8 +571,6 @@ export class Modal {
553
571
  if (this.currentBreakpoint !== breakpoint) {
554
572
  this.currentBreakpoint = breakpoint;
555
573
  this.ionBreakpointDidChange.emit({ breakpoint });
556
- // Update safe-area overrides based on new position
557
- this.updateSafeAreaOverrides();
558
574
  }
559
575
  });
560
576
  this.gesture = gesture;
@@ -668,6 +684,9 @@ export class Modal {
668
684
  if (this.gestureAnimationDismissing && role !== GESTURE) {
669
685
  return false;
670
686
  }
687
+ // Cancel any pending resize timeout to prevent stale updates during dismiss
688
+ clearTimeout(this.resizeTimeout);
689
+ this.resizeTimeout = undefined;
671
690
  /**
672
691
  * Because the canDismiss check below is async,
673
692
  * we need to claim a lock before the check happens,
@@ -995,11 +1014,30 @@ export class Modal {
995
1014
  /**
996
1015
  * Sets initial safe-area overrides before modal animation.
997
1016
  * Called in present() before animation starts.
1017
+ *
1018
+ * For sheet modals, the SCSS --height formula uses --ion-modal-offset-top
1019
+ * (an internal property) instead of --ion-safe-area-top. We resolve the
1020
+ * root safe-area-top to pixels and set --ion-modal-offset-top, decoupling
1021
+ * the height calculation from --ion-safe-area-top (which is zeroed for
1022
+ * sheets to prevent header content from getting double-offset padding).
998
1023
  */
999
1024
  setInitialSafeAreaOverrides() {
1000
1025
  const context = this.getSafeAreaContext();
1001
1026
  const safeAreaConfig = getInitialSafeAreaConfig(context);
1002
1027
  applySafeAreaOverrides(this.el, safeAreaConfig);
1028
+ // Set the internal offset property with the resolved root safe-area-top value
1029
+ if (context.isSheetModal) {
1030
+ this.updateSheetOffsetTop();
1031
+ }
1032
+ }
1033
+ /**
1034
+ * Resolves the current root --ion-safe-area-top value and sets the
1035
+ * internal --ion-modal-offset-top property on the host element.
1036
+ * Called on present and on resize (e.g., device rotation changes safe-area).
1037
+ */
1038
+ updateSheetOffsetTop() {
1039
+ const safeAreaTop = getRootSafeAreaTop();
1040
+ this.el.style.setProperty('--ion-modal-offset-top', `${safeAreaTop}px`);
1003
1041
  }
1004
1042
  /**
1005
1043
  * Updates safe-area overrides during dynamic state changes.
@@ -1008,19 +1046,10 @@ export class Modal {
1008
1046
  updateSafeAreaOverrides() {
1009
1047
  const { wrapperEl, el } = this;
1010
1048
  const context = this.getSafeAreaContext();
1011
- // Sheet modals: the wrapper extends beyond the viewport and is translated
1012
- // via breakpoint gestures, making getBoundingClientRect unreliable for
1013
- // edge detection. Instead, use breakpoint value to determine top safe-area.
1014
- if (context.isSheetModal) {
1015
- const needsTopSafeArea = context.currentBreakpoint === 1;
1016
- applySafeAreaOverrides(el, {
1017
- top: needsTopSafeArea ? 'inherit' : '0px',
1018
- bottom: 'inherit',
1019
- left: '0px',
1020
- right: '0px',
1021
- });
1049
+ // Sheet modals: safe-area is fully determined at presentation time
1050
+ // (top is always 0px, height is frozen). Nothing to update.
1051
+ if (context.isSheetModal)
1022
1052
  return;
1023
- }
1024
1053
  // Card modals have fixed safe-area requirements set by initial prediction.
1025
1054
  if (context.isCardModal)
1026
1055
  return;
@@ -1046,6 +1075,8 @@ export class Modal {
1046
1075
  // Check for standard Ionic layout children (ion-content, ion-footer),
1047
1076
  // searching one level deep for wrapped components (e.g.,
1048
1077
  // <app-footer><ion-footer>...</ion-footer></app-footer>).
1078
+ // Note: uses a manual loop instead of querySelector(':scope > ...') because
1079
+ // Stencil's mock-doc (used in spec tests) does not support :scope.
1049
1080
  let hasContent = false;
1050
1081
  let hasFooter = false;
1051
1082
  for (const child of Array.from(el.children)) {
@@ -1078,6 +1109,8 @@ export class Modal {
1078
1109
  */
1079
1110
  cleanupSafeAreaOverrides() {
1080
1111
  clearSafeAreaOverrides(this.el);
1112
+ // Remove internal sheet offset property
1113
+ this.el.style.removeProperty('--ion-modal-offset-top');
1081
1114
  if (this.wrapperEl) {
1082
1115
  this.wrapperEl.style.removeProperty('height');
1083
1116
  this.wrapperEl.style.removeProperty('padding-bottom');
@@ -1090,20 +1123,20 @@ export class Modal {
1090
1123
  const isCardModal = presentingElement !== undefined && mode === 'ios';
1091
1124
  const isHandleCycle = handleBehavior === 'cycle';
1092
1125
  const isSheetModalWithHandle = isSheetModal && showHandle;
1093
- return (h(Host, Object.assign({ key: 'f6c1245b5458693f19a040b362a98140c571297f', "no-router": true,
1126
+ return (h(Host, Object.assign({ key: '038ae26c2cb040043c7d0c217a4de6a7ae909235', "no-router": true,
1094
1127
  // Allow the modal to be navigable when the handle is focusable
1095
1128
  tabIndex: isHandleCycle && isSheetModalWithHandle ? 0 : -1 }, htmlAttributes, { style: {
1096
1129
  zIndex: `${20000 + this.overlayIndex}`,
1097
- }, 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: '6866fb2a265d40c761ed6bfb661a217948c571ef', ref: (el) => (this.backdropEl = el), visible: this.showBackdrop, tappable: this.backdropDismiss, part: "backdrop" }), mode === 'ios' && h("div", { key: '00788de9267f1c064f16aa55f35250de16522ae6', class: "modal-shadow" }), h("div", Object.assign({ key: 'a575e1755bd25a7996cdb04b014a91d538e7bc94',
1130
+ }, 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: '6bab796467b370e79b24ce17c5a3d51adb07ee8c', ref: (el) => (this.backdropEl = el), visible: this.showBackdrop, tappable: this.backdropDismiss, part: "backdrop" }), mode === 'ios' && h("div", { key: 'b9a19e86a2c7f079c88ef7a914c7c890aa601887', class: "modal-shadow" }), h("div", Object.assign({ key: 'f4315c34aa78be4e3540361755964be2ddf43265',
1098
1131
  /*
1099
1132
  role and aria-modal must be used on the
1100
1133
  same element. They must also be set inside the
1101
1134
  shadow DOM otherwise ion-button will not be highlighted
1102
1135
  when using VoiceOver: https://bugs.webkit.org/show_bug.cgi?id=247134
1103
1136
  */
1104
- role: "dialog" }, inheritedAttributes, { "aria-modal": "true", class: "modal-wrapper ion-overlay-wrapper", part: "content", ref: (el) => (this.wrapperEl = el) }), showHandle && (h("button", { key: 'b99c64717037e9241479ea2f5bb84e79a466d918', class: "modal-handle",
1137
+ role: "dialog" }, inheritedAttributes, { "aria-modal": "true", class: "modal-wrapper ion-overlay-wrapper", part: "content", ref: (el) => (this.wrapperEl = el) }), showHandle && (h("button", { key: '3f7792bd65e0f8634e62bf27f9edcb636a03030a', class: "modal-handle",
1105
1138
  // Prevents the handle from receiving keyboard focus when it does not cycle
1106
- 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: 'ee672afd537b281aeeb73a55d61ceeb5dfae212a', onSlotchange: this.onSlotChange }))));
1139
+ 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: '001e5b01831e240bd98f75ff62446b90b64d8394', onSlotchange: this.onSlotChange }))));
1107
1140
  }
1108
1141
  static get is() { return "ion-modal"; }
1109
1142
  static get encapsulation() { return "shadow"; }
@@ -186,9 +186,14 @@ ion-backdrop {
186
186
  /**
187
187
  * Ensure that the sheet modal does not
188
188
  * completely cover the content.
189
+ *
190
+ * --ion-modal-offset-top is an internal property set by modal.tsx
191
+ * with the resolved root safe-area-top pixel value. This decouples
192
+ * the height calculation from --ion-safe-area-top (which is zeroed
193
+ * for sheet modals to prevent header double-padding).
189
194
  */
190
195
  :host(.modal-sheet) {
191
- --height: calc(100% - (var(--ion-safe-area-top) + 10px));
196
+ --height: calc(100% - (var(--ion-modal-offset-top, 0px) + 10px));
192
197
  }
193
198
 
194
199
  :host(.modal-sheet) .modal-wrapper,
@@ -2,6 +2,7 @@
2
2
  * (C) Ionic http://ionicframework.com - MIT License
3
3
  */
4
4
  import { win } from "../../utils/browser/index";
5
+ import { raf } from "../../utils/helpers";
5
6
  /**
6
7
  * These thresholds match the SCSS media query breakpoints in modal.vars.scss
7
8
  * that trigger the centered dialog layout (non-fullscreen modal).
@@ -14,6 +15,11 @@ import { win } from "../../utils/browser/index";
14
15
  const MODAL_INSET_MIN_WIDTH = 768;
15
16
  const MODAL_INSET_MIN_HEIGHT = 600;
16
17
  const EDGE_THRESHOLD = 5;
18
+ /**
19
+ * Cache for resolved root safe-area-top value, invalidated once per frame.
20
+ */
21
+ let cachedRootSafeAreaTop = null;
22
+ let cacheInvalidationScheduled = false;
17
23
  /**
18
24
  * Determines if the current viewport meets the CSS media query conditions
19
25
  * that cause regular modals to render as centered dialogs instead of fullscreen.
@@ -25,6 +31,38 @@ const isCenteredDialogViewport = () => {
25
31
  return win.matchMedia(`(min-width: ${MODAL_INSET_MIN_WIDTH}px) and (min-height: ${MODAL_INSET_MIN_HEIGHT}px)`)
26
32
  .matches;
27
33
  };
34
+ /**
35
+ * Resolves the current root --ion-safe-area-top value to pixels.
36
+ * Uses a temporary element because getComputedStyle on :root returns
37
+ * the declared value of custom properties (e.g. "env(safe-area-inset-top)")
38
+ * rather than a resolved number.
39
+ *
40
+ * Results are cached for the current frame to avoid repeated reflows.
41
+ */
42
+ export const getRootSafeAreaTop = () => {
43
+ if (cachedRootSafeAreaTop !== null) {
44
+ return cachedRootSafeAreaTop;
45
+ }
46
+ const doc = win === null || win === void 0 ? void 0 : win.document;
47
+ if (!(doc === null || doc === void 0 ? void 0 : doc.body)) {
48
+ return 0;
49
+ }
50
+ const el = doc.createElement('div');
51
+ el.style.cssText =
52
+ 'position:fixed;visibility:hidden;pointer-events:none;top:0;left:0;' + 'padding-top:var(--ion-safe-area-top,0px);';
53
+ doc.body.appendChild(el);
54
+ const value = parseFloat(getComputedStyle(el).paddingTop) || 0;
55
+ el.remove();
56
+ cachedRootSafeAreaTop = value;
57
+ if (!cacheInvalidationScheduled) {
58
+ cacheInvalidationScheduled = true;
59
+ raf(() => {
60
+ cachedRootSafeAreaTop = null;
61
+ cacheInvalidationScheduled = false;
62
+ });
63
+ }
64
+ return value;
65
+ };
28
66
  /**
29
67
  * Returns the initial safe-area configuration based on modal type.
30
68
  * This is called before animation starts and uses configuration-based prediction.
@@ -34,10 +72,14 @@ const isCenteredDialogViewport = () => {
34
72
  */
35
73
  export const getInitialSafeAreaConfig = (context) => {
36
74
  const { isSheetModal, isCardModal } = context;
37
- // Sheet modals use bottom safe-area, and top safe-area only when fully expanded
75
+ // Sheet modals always zero top safe-area. The sheet height offset from the
76
+ // top edge is handled by --ion-modal-offset-top (set in modal.tsx) using
77
+ // the resolved root value, so --ion-safe-area-top is never needed for
78
+ // height calculation. Keeping it at 0px prevents header content from
79
+ // getting double-offset padding.
38
80
  if (isSheetModal) {
39
81
  return {
40
- top: context.currentBreakpoint === 1 ? 'inherit' : '0px',
82
+ top: '0px',
41
83
  bottom: 'inherit',
42
84
  left: '0px',
43
85
  right: '0px',
package/dist/docs.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "timestamp": "2026-02-09T21:56:44",
2
+ "timestamp": "2026-02-12T17:17:06",
3
3
  "compiler": {
4
4
  "name": "@stencil/core",
5
5
  "version": "4.38.0",
@@ -1484,6 +1484,11 @@ const createSheetGesture = (baseEl, backdropEl, wrapperEl, initialBreakpoint, ba
1484
1484
  const MODAL_INSET_MIN_WIDTH = 768;
1485
1485
  const MODAL_INSET_MIN_HEIGHT = 600;
1486
1486
  const EDGE_THRESHOLD = 5;
1487
+ /**
1488
+ * Cache for resolved root safe-area-top value, invalidated once per frame.
1489
+ */
1490
+ let cachedRootSafeAreaTop = null;
1491
+ let cacheInvalidationScheduled = false;
1487
1492
  /**
1488
1493
  * Determines if the current viewport meets the CSS media query conditions
1489
1494
  * that cause regular modals to render as centered dialogs instead of fullscreen.
@@ -1495,6 +1500,38 @@ const isCenteredDialogViewport = () => {
1495
1500
  return win.matchMedia(`(min-width: ${MODAL_INSET_MIN_WIDTH}px) and (min-height: ${MODAL_INSET_MIN_HEIGHT}px)`)
1496
1501
  .matches;
1497
1502
  };
1503
+ /**
1504
+ * Resolves the current root --ion-safe-area-top value to pixels.
1505
+ * Uses a temporary element because getComputedStyle on :root returns
1506
+ * the declared value of custom properties (e.g. "env(safe-area-inset-top)")
1507
+ * rather than a resolved number.
1508
+ *
1509
+ * Results are cached for the current frame to avoid repeated reflows.
1510
+ */
1511
+ const getRootSafeAreaTop = () => {
1512
+ if (cachedRootSafeAreaTop !== null) {
1513
+ return cachedRootSafeAreaTop;
1514
+ }
1515
+ const doc = win === null || win === void 0 ? void 0 : win.document;
1516
+ if (!(doc === null || doc === void 0 ? void 0 : doc.body)) {
1517
+ return 0;
1518
+ }
1519
+ const el = doc.createElement('div');
1520
+ el.style.cssText =
1521
+ 'position:fixed;visibility:hidden;pointer-events:none;top:0;left:0;' + 'padding-top:var(--ion-safe-area-top,0px);';
1522
+ doc.body.appendChild(el);
1523
+ const value = parseFloat(getComputedStyle(el).paddingTop) || 0;
1524
+ el.remove();
1525
+ cachedRootSafeAreaTop = value;
1526
+ if (!cacheInvalidationScheduled) {
1527
+ cacheInvalidationScheduled = true;
1528
+ raf(() => {
1529
+ cachedRootSafeAreaTop = null;
1530
+ cacheInvalidationScheduled = false;
1531
+ });
1532
+ }
1533
+ return value;
1534
+ };
1498
1535
  /**
1499
1536
  * Returns the initial safe-area configuration based on modal type.
1500
1537
  * This is called before animation starts and uses configuration-based prediction.
@@ -1504,10 +1541,14 @@ const isCenteredDialogViewport = () => {
1504
1541
  */
1505
1542
  const getInitialSafeAreaConfig = (context) => {
1506
1543
  const { isSheetModal, isCardModal } = context;
1507
- // Sheet modals use bottom safe-area, and top safe-area only when fully expanded
1544
+ // Sheet modals always zero top safe-area. The sheet height offset from the
1545
+ // top edge is handled by --ion-modal-offset-top (set in modal.tsx) using
1546
+ // the resolved root value, so --ion-safe-area-top is never needed for
1547
+ // height calculation. Keeping it at 0px prevents header content from
1548
+ // getting double-offset padding.
1508
1549
  if (isSheetModal) {
1509
1550
  return {
1510
- top: context.currentBreakpoint === 1 ? 'inherit' : '0px',
1551
+ top: '0px',
1511
1552
  bottom: 'inherit',
1512
1553
  left: '0px',
1513
1554
  right: '0px',
@@ -1592,9 +1633,9 @@ const clearSafeAreaOverrides = (hostEl) => {
1592
1633
  hostEl.style.removeProperty('--ion-safe-area-right');
1593
1634
  };
1594
1635
 
1595
- const modalIosCss = ":host{--width:100%;--min-width:auto;--max-width:auto;--height:100%;--min-height:auto;--max-height:auto;--overflow:hidden;--border-radius:0;--border-width:0;--border-style:none;--border-color:transparent;--background:var(--ion-background-color, #fff);--box-shadow:none;--backdrop-opacity:0;left:0;right:0;top:0;bottom:0;display:-ms-flexbox;display:flex;position:absolute;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;outline:none;color:var(--ion-text-color, #000);contain:strict}.modal-wrapper,ion-backdrop{pointer-events:auto}:host(.overlay-hidden){display:none}.modal-wrapper,.modal-shadow{border-radius:var(--border-radius);width:var(--width);min-width:var(--min-width);max-width:var(--max-width);height:var(--height);min-height:var(--min-height);max-height:var(--max-height);border-width:var(--border-width);border-style:var(--border-style);border-color:var(--border-color);background:var(--background);-webkit-box-shadow:var(--box-shadow);box-shadow:var(--box-shadow);overflow:var(--overflow);z-index:10}.modal-shadow{position:absolute;background:transparent}@media only screen and (min-width: 768px) and (min-height: 600px){:host{--width:600px;--height:500px}}@media only screen and (min-width: 768px) and (min-height: 768px){:host{--width:600px;--height:600px}}.modal-handle{left:0px;right:0px;top:5px;border-radius:8px;-webkit-margin-start:auto;margin-inline-start:auto;-webkit-margin-end:auto;margin-inline-end:auto;position:absolute;width:36px;height:5px;-webkit-transform:translateZ(0);transform:translateZ(0);border:0;background:var(--ion-color-step-350, var(--ion-background-color-step-350, #c0c0be));cursor:pointer;z-index:11}.modal-handle::before{-webkit-padding-start:4px;padding-inline-start:4px;-webkit-padding-end:4px;padding-inline-end:4px;padding-top:4px;padding-bottom:4px;position:absolute;width:36px;height:5px;-webkit-transform:translate(-50%, -50%);transform:translate(-50%, -50%);content:\"\"}:host(.modal-sheet){--height:calc(100% - (var(--ion-safe-area-top) + 10px))}:host(.modal-sheet) .modal-wrapper,:host(.modal-sheet) .modal-shadow{position:absolute;bottom:0}:host(.modal-sheet.modal-no-expand-scroll) ion-footer{position:absolute;bottom:0;width:var(--width)}:host{--backdrop-opacity:var(--ion-backdrop-opacity, 0.4)}:host(.modal-card),:host(.modal-sheet){--border-radius:10px}@media only screen and (min-width: 768px) and (min-height: 600px){:host{--border-radius:10px}}.modal-wrapper{-webkit-transform:translate3d(0, 100%, 0);transform:translate3d(0, 100%, 0)}@media screen and (max-width: 767px){@supports (width: max(0px, 1px)){:host(.modal-card){--height:calc(100% - max(30px, var(--ion-safe-area-top)) - 10px)}}@supports not (width: max(0px, 1px)){:host(.modal-card){--height:calc(100% - 40px)}}:host(.modal-card) .modal-wrapper{border-start-start-radius:var(--border-radius);border-start-end-radius:var(--border-radius);border-end-end-radius:0;border-end-start-radius:0}:host(.modal-card){--backdrop-opacity:0;--width:100%;-ms-flex-align:end;align-items:flex-end}:host(.modal-card) .modal-shadow{display:none}:host(.modal-card) ion-backdrop{pointer-events:none}}@media screen and (min-width: 768px){:host(.modal-card){--width:calc(100% - 120px);--height:calc(100% - (120px + var(--ion-safe-area-top) + var(--ion-safe-area-bottom)));--max-width:720px;--max-height:1000px;--backdrop-opacity:0;--box-shadow:0px 0px 30px 10px rgba(0, 0, 0, 0.1);-webkit-transition:all 0.5s ease-in-out;transition:all 0.5s ease-in-out}:host(.modal-card) .modal-wrapper{-webkit-box-shadow:none;box-shadow:none}:host(.modal-card) .modal-shadow{-webkit-box-shadow:var(--box-shadow);box-shadow:var(--box-shadow)}}:host(.modal-sheet) .modal-wrapper{border-start-start-radius:var(--border-radius);border-start-end-radius:var(--border-radius);border-end-end-radius:0;border-end-start-radius:0}";
1636
+ const modalIosCss = ":host{--width:100%;--min-width:auto;--max-width:auto;--height:100%;--min-height:auto;--max-height:auto;--overflow:hidden;--border-radius:0;--border-width:0;--border-style:none;--border-color:transparent;--background:var(--ion-background-color, #fff);--box-shadow:none;--backdrop-opacity:0;left:0;right:0;top:0;bottom:0;display:-ms-flexbox;display:flex;position:absolute;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;outline:none;color:var(--ion-text-color, #000);contain:strict}.modal-wrapper,ion-backdrop{pointer-events:auto}:host(.overlay-hidden){display:none}.modal-wrapper,.modal-shadow{border-radius:var(--border-radius);width:var(--width);min-width:var(--min-width);max-width:var(--max-width);height:var(--height);min-height:var(--min-height);max-height:var(--max-height);border-width:var(--border-width);border-style:var(--border-style);border-color:var(--border-color);background:var(--background);-webkit-box-shadow:var(--box-shadow);box-shadow:var(--box-shadow);overflow:var(--overflow);z-index:10}.modal-shadow{position:absolute;background:transparent}@media only screen and (min-width: 768px) and (min-height: 600px){:host{--width:600px;--height:500px}}@media only screen and (min-width: 768px) and (min-height: 768px){:host{--width:600px;--height:600px}}.modal-handle{left:0px;right:0px;top:5px;border-radius:8px;-webkit-margin-start:auto;margin-inline-start:auto;-webkit-margin-end:auto;margin-inline-end:auto;position:absolute;width:36px;height:5px;-webkit-transform:translateZ(0);transform:translateZ(0);border:0;background:var(--ion-color-step-350, var(--ion-background-color-step-350, #c0c0be));cursor:pointer;z-index:11}.modal-handle::before{-webkit-padding-start:4px;padding-inline-start:4px;-webkit-padding-end:4px;padding-inline-end:4px;padding-top:4px;padding-bottom:4px;position:absolute;width:36px;height:5px;-webkit-transform:translate(-50%, -50%);transform:translate(-50%, -50%);content:\"\"}:host(.modal-sheet){--height:calc(100% - (var(--ion-modal-offset-top, 0px) + 10px))}:host(.modal-sheet) .modal-wrapper,:host(.modal-sheet) .modal-shadow{position:absolute;bottom:0}:host(.modal-sheet.modal-no-expand-scroll) ion-footer{position:absolute;bottom:0;width:var(--width)}:host{--backdrop-opacity:var(--ion-backdrop-opacity, 0.4)}:host(.modal-card),:host(.modal-sheet){--border-radius:10px}@media only screen and (min-width: 768px) and (min-height: 600px){:host{--border-radius:10px}}.modal-wrapper{-webkit-transform:translate3d(0, 100%, 0);transform:translate3d(0, 100%, 0)}@media screen and (max-width: 767px){@supports (width: max(0px, 1px)){:host(.modal-card){--height:calc(100% - max(30px, var(--ion-safe-area-top)) - 10px)}}@supports not (width: max(0px, 1px)){:host(.modal-card){--height:calc(100% - 40px)}}:host(.modal-card) .modal-wrapper{border-start-start-radius:var(--border-radius);border-start-end-radius:var(--border-radius);border-end-end-radius:0;border-end-start-radius:0}:host(.modal-card){--backdrop-opacity:0;--width:100%;-ms-flex-align:end;align-items:flex-end}:host(.modal-card) .modal-shadow{display:none}:host(.modal-card) ion-backdrop{pointer-events:none}}@media screen and (min-width: 768px){:host(.modal-card){--width:calc(100% - 120px);--height:calc(100% - (120px + var(--ion-safe-area-top) + var(--ion-safe-area-bottom)));--max-width:720px;--max-height:1000px;--backdrop-opacity:0;--box-shadow:0px 0px 30px 10px rgba(0, 0, 0, 0.1);-webkit-transition:all 0.5s ease-in-out;transition:all 0.5s ease-in-out}:host(.modal-card) .modal-wrapper{-webkit-box-shadow:none;box-shadow:none}:host(.modal-card) .modal-shadow{-webkit-box-shadow:var(--box-shadow);box-shadow:var(--box-shadow)}}:host(.modal-sheet) .modal-wrapper{border-start-start-radius:var(--border-radius);border-start-end-radius:var(--border-radius);border-end-end-radius:0;border-end-start-radius:0}";
1596
1637
 
1597
- const modalMdCss = ":host{--width:100%;--min-width:auto;--max-width:auto;--height:100%;--min-height:auto;--max-height:auto;--overflow:hidden;--border-radius:0;--border-width:0;--border-style:none;--border-color:transparent;--background:var(--ion-background-color, #fff);--box-shadow:none;--backdrop-opacity:0;left:0;right:0;top:0;bottom:0;display:-ms-flexbox;display:flex;position:absolute;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;outline:none;color:var(--ion-text-color, #000);contain:strict}.modal-wrapper,ion-backdrop{pointer-events:auto}:host(.overlay-hidden){display:none}.modal-wrapper,.modal-shadow{border-radius:var(--border-radius);width:var(--width);min-width:var(--min-width);max-width:var(--max-width);height:var(--height);min-height:var(--min-height);max-height:var(--max-height);border-width:var(--border-width);border-style:var(--border-style);border-color:var(--border-color);background:var(--background);-webkit-box-shadow:var(--box-shadow);box-shadow:var(--box-shadow);overflow:var(--overflow);z-index:10}.modal-shadow{position:absolute;background:transparent}@media only screen and (min-width: 768px) and (min-height: 600px){:host{--width:600px;--height:500px}}@media only screen and (min-width: 768px) and (min-height: 768px){:host{--width:600px;--height:600px}}.modal-handle{left:0px;right:0px;top:5px;border-radius:8px;-webkit-margin-start:auto;margin-inline-start:auto;-webkit-margin-end:auto;margin-inline-end:auto;position:absolute;width:36px;height:5px;-webkit-transform:translateZ(0);transform:translateZ(0);border:0;background:var(--ion-color-step-350, var(--ion-background-color-step-350, #c0c0be));cursor:pointer;z-index:11}.modal-handle::before{-webkit-padding-start:4px;padding-inline-start:4px;-webkit-padding-end:4px;padding-inline-end:4px;padding-top:4px;padding-bottom:4px;position:absolute;width:36px;height:5px;-webkit-transform:translate(-50%, -50%);transform:translate(-50%, -50%);content:\"\"}:host(.modal-sheet){--height:calc(100% - (var(--ion-safe-area-top) + 10px))}:host(.modal-sheet) .modal-wrapper,:host(.modal-sheet) .modal-shadow{position:absolute;bottom:0}:host(.modal-sheet.modal-no-expand-scroll) ion-footer{position:absolute;bottom:0;width:var(--width)}:host{--backdrop-opacity:var(--ion-backdrop-opacity, 0.32)}@media only screen and (min-width: 768px) and (min-height: 600px){:host{--border-radius:2px;--box-shadow:0 28px 48px rgba(0, 0, 0, 0.4)}}.modal-wrapper{-webkit-transform:translate3d(0, 40px, 0);transform:translate3d(0, 40px, 0);opacity:0.01}";
1638
+ const modalMdCss = ":host{--width:100%;--min-width:auto;--max-width:auto;--height:100%;--min-height:auto;--max-height:auto;--overflow:hidden;--border-radius:0;--border-width:0;--border-style:none;--border-color:transparent;--background:var(--ion-background-color, #fff);--box-shadow:none;--backdrop-opacity:0;left:0;right:0;top:0;bottom:0;display:-ms-flexbox;display:flex;position:absolute;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;outline:none;color:var(--ion-text-color, #000);contain:strict}.modal-wrapper,ion-backdrop{pointer-events:auto}:host(.overlay-hidden){display:none}.modal-wrapper,.modal-shadow{border-radius:var(--border-radius);width:var(--width);min-width:var(--min-width);max-width:var(--max-width);height:var(--height);min-height:var(--min-height);max-height:var(--max-height);border-width:var(--border-width);border-style:var(--border-style);border-color:var(--border-color);background:var(--background);-webkit-box-shadow:var(--box-shadow);box-shadow:var(--box-shadow);overflow:var(--overflow);z-index:10}.modal-shadow{position:absolute;background:transparent}@media only screen and (min-width: 768px) and (min-height: 600px){:host{--width:600px;--height:500px}}@media only screen and (min-width: 768px) and (min-height: 768px){:host{--width:600px;--height:600px}}.modal-handle{left:0px;right:0px;top:5px;border-radius:8px;-webkit-margin-start:auto;margin-inline-start:auto;-webkit-margin-end:auto;margin-inline-end:auto;position:absolute;width:36px;height:5px;-webkit-transform:translateZ(0);transform:translateZ(0);border:0;background:var(--ion-color-step-350, var(--ion-background-color-step-350, #c0c0be));cursor:pointer;z-index:11}.modal-handle::before{-webkit-padding-start:4px;padding-inline-start:4px;-webkit-padding-end:4px;padding-inline-end:4px;padding-top:4px;padding-bottom:4px;position:absolute;width:36px;height:5px;-webkit-transform:translate(-50%, -50%);transform:translate(-50%, -50%);content:\"\"}:host(.modal-sheet){--height:calc(100% - (var(--ion-modal-offset-top, 0px) + 10px))}:host(.modal-sheet) .modal-wrapper,:host(.modal-sheet) .modal-shadow{position:absolute;bottom:0}:host(.modal-sheet.modal-no-expand-scroll) ion-footer{position:absolute;bottom:0;width:var(--width)}:host{--backdrop-opacity:var(--ion-backdrop-opacity, 0.32)}@media only screen and (min-width: 768px) and (min-height: 600px){:host{--border-radius:2px;--box-shadow:0 28px 48px rgba(0, 0, 0, 0.4)}}.modal-wrapper{-webkit-transform:translate3d(0, 40px, 0);transform:translate3d(0, 40px, 0);opacity:0.01}";
1598
1639
 
1599
1640
  const Modal = class {
1600
1641
  constructor(hostRef) {
@@ -1807,13 +1848,31 @@ const Modal = class {
1807
1848
  }
1808
1849
  }
1809
1850
  onWindowResize() {
1810
- // Only handle resize for iOS card modals when no custom animations are provided
1811
- if (getIonMode(this) !== 'ios' || !this.presentingElement || this.enterAnimation || this.leaveAnimation) {
1851
+ if (!this.presented)
1812
1852
  return;
1813
- }
1814
1853
  clearTimeout(this.resizeTimeout);
1815
1854
  this.resizeTimeout = setTimeout(() => {
1816
- this.handleViewTransition();
1855
+ const context = this.getSafeAreaContext();
1856
+ // iOS card modals: handle portrait/landscape view transitions
1857
+ if (context.isCardModal && !this.enterAnimation && !this.leaveAnimation) {
1858
+ this.handleViewTransition();
1859
+ }
1860
+ // Sheet modals: re-compute the internal offset property since safe-area
1861
+ // values may change on device rotation (e.g., portrait notch vs landscape).
1862
+ if (context.isSheetModal) {
1863
+ this.updateSheetOffsetTop();
1864
+ }
1865
+ // Regular (non-sheet, non-card) modals: update safe-area overrides
1866
+ // since the viewport may have crossed the centered-dialog breakpoint.
1867
+ if (!context.isSheetModal && !context.isCardModal) {
1868
+ this.updateSafeAreaOverrides();
1869
+ // Re-evaluate fullscreen safe-area padding: clear first, then re-apply
1870
+ if (this.wrapperEl) {
1871
+ this.wrapperEl.style.removeProperty('height');
1872
+ this.wrapperEl.style.removeProperty('padding-bottom');
1873
+ }
1874
+ this.applyFullscreenSafeArea();
1875
+ }
1817
1876
  }, 50); // Debounce to avoid excessive calls during active resizing
1818
1877
  }
1819
1878
  breakpointsChanged(breakpoints) {
@@ -2127,8 +2186,6 @@ const Modal = class {
2127
2186
  if (this.currentBreakpoint !== breakpoint) {
2128
2187
  this.currentBreakpoint = breakpoint;
2129
2188
  this.ionBreakpointDidChange.emit({ breakpoint });
2130
- // Update safe-area overrides based on new position
2131
- this.updateSafeAreaOverrides();
2132
2189
  }
2133
2190
  });
2134
2191
  this.gesture = gesture;
@@ -2242,6 +2299,9 @@ const Modal = class {
2242
2299
  if (this.gestureAnimationDismissing && role !== GESTURE) {
2243
2300
  return false;
2244
2301
  }
2302
+ // Cancel any pending resize timeout to prevent stale updates during dismiss
2303
+ clearTimeout(this.resizeTimeout);
2304
+ this.resizeTimeout = undefined;
2245
2305
  /**
2246
2306
  * Because the canDismiss check below is async,
2247
2307
  * we need to claim a lock before the check happens,
@@ -2561,11 +2621,30 @@ const Modal = class {
2561
2621
  /**
2562
2622
  * Sets initial safe-area overrides before modal animation.
2563
2623
  * Called in present() before animation starts.
2624
+ *
2625
+ * For sheet modals, the SCSS --height formula uses --ion-modal-offset-top
2626
+ * (an internal property) instead of --ion-safe-area-top. We resolve the
2627
+ * root safe-area-top to pixels and set --ion-modal-offset-top, decoupling
2628
+ * the height calculation from --ion-safe-area-top (which is zeroed for
2629
+ * sheets to prevent header content from getting double-offset padding).
2564
2630
  */
2565
2631
  setInitialSafeAreaOverrides() {
2566
2632
  const context = this.getSafeAreaContext();
2567
2633
  const safeAreaConfig = getInitialSafeAreaConfig(context);
2568
2634
  applySafeAreaOverrides(this.el, safeAreaConfig);
2635
+ // Set the internal offset property with the resolved root safe-area-top value
2636
+ if (context.isSheetModal) {
2637
+ this.updateSheetOffsetTop();
2638
+ }
2639
+ }
2640
+ /**
2641
+ * Resolves the current root --ion-safe-area-top value and sets the
2642
+ * internal --ion-modal-offset-top property on the host element.
2643
+ * Called on present and on resize (e.g., device rotation changes safe-area).
2644
+ */
2645
+ updateSheetOffsetTop() {
2646
+ const safeAreaTop = getRootSafeAreaTop();
2647
+ this.el.style.setProperty('--ion-modal-offset-top', `${safeAreaTop}px`);
2569
2648
  }
2570
2649
  /**
2571
2650
  * Updates safe-area overrides during dynamic state changes.
@@ -2574,19 +2653,10 @@ const Modal = class {
2574
2653
  updateSafeAreaOverrides() {
2575
2654
  const { wrapperEl, el } = this;
2576
2655
  const context = this.getSafeAreaContext();
2577
- // Sheet modals: the wrapper extends beyond the viewport and is translated
2578
- // via breakpoint gestures, making getBoundingClientRect unreliable for
2579
- // edge detection. Instead, use breakpoint value to determine top safe-area.
2580
- if (context.isSheetModal) {
2581
- const needsTopSafeArea = context.currentBreakpoint === 1;
2582
- applySafeAreaOverrides(el, {
2583
- top: needsTopSafeArea ? 'inherit' : '0px',
2584
- bottom: 'inherit',
2585
- left: '0px',
2586
- right: '0px',
2587
- });
2656
+ // Sheet modals: safe-area is fully determined at presentation time
2657
+ // (top is always 0px, height is frozen). Nothing to update.
2658
+ if (context.isSheetModal)
2588
2659
  return;
2589
- }
2590
2660
  // Card modals have fixed safe-area requirements set by initial prediction.
2591
2661
  if (context.isCardModal)
2592
2662
  return;
@@ -2612,6 +2682,8 @@ const Modal = class {
2612
2682
  // Check for standard Ionic layout children (ion-content, ion-footer),
2613
2683
  // searching one level deep for wrapped components (e.g.,
2614
2684
  // <app-footer><ion-footer>...</ion-footer></app-footer>).
2685
+ // Note: uses a manual loop instead of querySelector(':scope > ...') because
2686
+ // Stencil's mock-doc (used in spec tests) does not support :scope.
2615
2687
  let hasContent = false;
2616
2688
  let hasFooter = false;
2617
2689
  for (const child of Array.from(el.children)) {
@@ -2644,6 +2716,8 @@ const Modal = class {
2644
2716
  */
2645
2717
  cleanupSafeAreaOverrides() {
2646
2718
  clearSafeAreaOverrides(this.el);
2719
+ // Remove internal sheet offset property
2720
+ this.el.style.removeProperty('--ion-modal-offset-top');
2647
2721
  if (this.wrapperEl) {
2648
2722
  this.wrapperEl.style.removeProperty('height');
2649
2723
  this.wrapperEl.style.removeProperty('padding-bottom');
@@ -2656,20 +2730,20 @@ const Modal = class {
2656
2730
  const isCardModal = presentingElement !== undefined && mode === 'ios';
2657
2731
  const isHandleCycle = handleBehavior === 'cycle';
2658
2732
  const isSheetModalWithHandle = isSheetModal && showHandle;
2659
- return (h(Host, Object.assign({ key: 'f6c1245b5458693f19a040b362a98140c571297f', "no-router": true,
2733
+ return (h(Host, Object.assign({ key: '038ae26c2cb040043c7d0c217a4de6a7ae909235', "no-router": true,
2660
2734
  // Allow the modal to be navigable when the handle is focusable
2661
2735
  tabIndex: isHandleCycle && isSheetModalWithHandle ? 0 : -1 }, htmlAttributes, { style: {
2662
2736
  zIndex: `${20000 + this.overlayIndex}`,
2663
- }, 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: '6866fb2a265d40c761ed6bfb661a217948c571ef', ref: (el) => (this.backdropEl = el), visible: this.showBackdrop, tappable: this.backdropDismiss, part: "backdrop" }), mode === 'ios' && h("div", { key: '00788de9267f1c064f16aa55f35250de16522ae6', class: "modal-shadow" }), h("div", Object.assign({ key: 'a575e1755bd25a7996cdb04b014a91d538e7bc94',
2737
+ }, 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: '6bab796467b370e79b24ce17c5a3d51adb07ee8c', ref: (el) => (this.backdropEl = el), visible: this.showBackdrop, tappable: this.backdropDismiss, part: "backdrop" }), mode === 'ios' && h("div", { key: 'b9a19e86a2c7f079c88ef7a914c7c890aa601887', class: "modal-shadow" }), h("div", Object.assign({ key: 'f4315c34aa78be4e3540361755964be2ddf43265',
2664
2738
  /*
2665
2739
  role and aria-modal must be used on the
2666
2740
  same element. They must also be set inside the
2667
2741
  shadow DOM otherwise ion-button will not be highlighted
2668
2742
  when using VoiceOver: https://bugs.webkit.org/show_bug.cgi?id=247134
2669
2743
  */
2670
- role: "dialog" }, inheritedAttributes, { "aria-modal": "true", class: "modal-wrapper ion-overlay-wrapper", part: "content", ref: (el) => (this.wrapperEl = el) }), showHandle && (h("button", { key: 'b99c64717037e9241479ea2f5bb84e79a466d918', class: "modal-handle",
2744
+ role: "dialog" }, inheritedAttributes, { "aria-modal": "true", class: "modal-wrapper ion-overlay-wrapper", part: "content", ref: (el) => (this.wrapperEl = el) }), showHandle && (h("button", { key: '3f7792bd65e0f8634e62bf27f9edcb636a03030a', class: "modal-handle",
2671
2745
  // Prevents the handle from receiving keyboard focus when it does not cycle
2672
- 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: 'ee672afd537b281aeeb73a55d61ceeb5dfae212a', onSlotchange: this.onSlotChange }))));
2746
+ 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: '001e5b01831e240bd98f75ff62446b90b64d8394', onSlotchange: this.onSlotChange }))));
2673
2747
  }
2674
2748
  get el() { return getElement(this); }
2675
2749
  static get watchers() { return {