@ionic/core 8.7.17-dev.11767895575.16ea7cef → 8.7.17-dev.11767897190.1ef0f479

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.
Files changed (42) hide show
  1. package/components/content.js +96 -8
  2. package/components/ion-tab-bar.js +3 -23
  3. package/components/modal.js +213 -12
  4. package/components/popover.js +83 -11
  5. package/dist/cjs/ion-app_8.cjs.entry.js +107 -20
  6. package/dist/cjs/ion-modal.cjs.entry.js +213 -12
  7. package/dist/cjs/ion-popover.cjs.entry.js +83 -11
  8. package/dist/cjs/ion-tab-bar_2.cjs.entry.js +3 -23
  9. package/dist/collection/components/content/content.css +10 -0
  10. package/dist/collection/components/content/content.js +94 -6
  11. package/dist/collection/components/modal/gestures/sheet.js +3 -1
  12. package/dist/collection/components/modal/gestures/swipe-to-close.js +3 -1
  13. package/dist/collection/components/modal/modal.ios.css +0 -4
  14. package/dist/collection/components/modal/modal.js +205 -7
  15. package/dist/collection/components/modal/modal.md.css +0 -4
  16. package/dist/collection/components/popover/animations/ios.enter.js +21 -5
  17. package/dist/collection/components/popover/animations/md.enter.js +30 -5
  18. package/dist/collection/components/popover/utils.js +32 -1
  19. package/dist/collection/components/tab-bar/tab-bar.js +3 -23
  20. package/dist/docs.json +1 -1
  21. package/dist/esm/ion-app_8.entry.js +96 -9
  22. package/dist/esm/ion-modal.entry.js +213 -12
  23. package/dist/esm/ion-popover.entry.js +83 -11
  24. package/dist/esm/ion-tab-bar_2.entry.js +3 -23
  25. package/dist/ionic/ionic.esm.js +1 -1
  26. package/dist/ionic/p-7268efa5.entry.js +4 -0
  27. package/dist/ionic/p-968a55d1.entry.js +4 -0
  28. package/dist/ionic/p-d9fd799f.entry.js +4 -0
  29. package/dist/ionic/p-ec9ca3fe.entry.js +4 -0
  30. package/dist/types/components/content/content.d.ts +24 -0
  31. package/dist/types/components/modal/gestures/sheet.d.ts +1 -1
  32. package/dist/types/components/modal/gestures/swipe-to-close.d.ts +1 -1
  33. package/dist/types/components/modal/modal.d.ts +45 -0
  34. package/dist/types/components/popover/utils.d.ts +2 -0
  35. package/dist/types/components/tab-bar/tab-bar.d.ts +0 -1
  36. package/hydrate/index.js +385 -52
  37. package/hydrate/index.mjs +385 -52
  38. package/package.json +1 -1
  39. package/dist/ionic/p-172a579f.entry.js +0 -4
  40. package/dist/ionic/p-732b2fd6.entry.js +0 -4
  41. package/dist/ionic/p-91840a80.entry.js +0 -4
  42. package/dist/ionic/p-f9061316.entry.js +0 -4
@@ -2,6 +2,7 @@
2
2
  * (C) Ionic http://ionicframework.com - MIT License
3
3
  */
4
4
  import { Build, Host, forceUpdate, h, readTask } from "@stencil/core";
5
+ import { win } from "../../utils/browser/index";
5
6
  import { componentOnReady, hasLazyBuild, inheritAriaAttributes } from "../../utils/helpers";
6
7
  import { isPlatform } from "../../utils/platform";
7
8
  import { isRTL } from "../../utils/rtl/index";
@@ -25,6 +26,12 @@ export class Content {
25
26
  this.isMainContent = true;
26
27
  this.resizeTimeout = null;
27
28
  this.inheritedAttributes = {};
29
+ /**
30
+ * Track whether this content has sibling header/footer elements.
31
+ * When absent, we need to apply safe-area padding directly.
32
+ */
33
+ this.hasHeader = false;
34
+ this.hasFooter = false;
28
35
  this.tabsElement = null;
29
36
  // Detail is used in a hot loop in the scroll event, by allocating it here
30
37
  // V8 will be able to inline any read/write to it since it's a monomorphic class.
@@ -79,7 +86,13 @@ export class Content {
79
86
  this.inheritedAttributes = inheritAriaAttributes(this.el);
80
87
  }
81
88
  connectedCallback() {
82
- this.isMainContent = this.el.closest('ion-menu, ion-popover, ion-modal') === null;
89
+ var _a;
90
+ // Content is "main" if not inside menu/popover/modal and not nested in another ion-content
91
+ this.isMainContent =
92
+ this.el.closest('ion-menu, ion-popover, ion-modal') === null &&
93
+ ((_a = this.el.parentElement) === null || _a === void 0 ? void 0 : _a.closest('ion-content')) === null;
94
+ // Detect sibling header/footer for safe-area handling
95
+ this.detectSiblingElements();
83
96
  /**
84
97
  * The fullscreen content offsets need to be
85
98
  * computed after the tab bar has loaded. Since
@@ -110,13 +123,86 @@ export class Content {
110
123
  * bubbles, we can catch any instances of child tab bars loading by listening
111
124
  * on IonTabs.
112
125
  */
113
- this.tabsLoadCallback = () => this.resize();
126
+ this.tabsLoadCallback = () => {
127
+ this.resize();
128
+ // Re-detect footer when tab bar loads (it may not exist during initial detection)
129
+ this.updateSiblingDetection();
130
+ forceUpdate(this);
131
+ };
114
132
  closestTabs.addEventListener('ionTabBarLoaded', this.tabsLoadCallback);
115
133
  }
116
134
  }
117
135
  }
136
+ /**
137
+ * Detects sibling ion-header and ion-footer elements and sets up
138
+ * a mutation observer to handle dynamic changes (e.g., conditional rendering).
139
+ */
140
+ detectSiblingElements() {
141
+ this.updateSiblingDetection();
142
+ // Watch for dynamic header/footer changes (common in React conditional rendering)
143
+ const parent = this.el.parentElement;
144
+ if (parent && !this.parentMutationObserver && win !== undefined && 'MutationObserver' in win) {
145
+ this.parentMutationObserver = new MutationObserver(() => {
146
+ const prevHasHeader = this.hasHeader;
147
+ const prevHasFooter = this.hasFooter;
148
+ this.updateSiblingDetection();
149
+ // Only trigger re-render if header/footer detection actually changed
150
+ if (prevHasHeader !== this.hasHeader || prevHasFooter !== this.hasFooter) {
151
+ forceUpdate(this);
152
+ }
153
+ });
154
+ this.parentMutationObserver.observe(parent, { childList: true });
155
+ }
156
+ }
157
+ /**
158
+ * Updates hasHeader/hasFooter based on current DOM state.
159
+ * Checks both direct siblings and elements wrapped in custom components
160
+ * (e.g., <my-header><ion-header>...</ion-header></my-header>).
161
+ */
162
+ updateSiblingDetection() {
163
+ const parent = this.el.parentElement;
164
+ if (parent) {
165
+ // First check for direct ion-header/ion-footer siblings
166
+ this.hasHeader = parent.querySelector(':scope > ion-header') !== null;
167
+ this.hasFooter = parent.querySelector(':scope > ion-footer') !== null;
168
+ // If not found, check if any sibling contains them (wrapped components)
169
+ if (!this.hasHeader) {
170
+ this.hasHeader = this.siblingContainsElement(parent, 'ion-header');
171
+ }
172
+ if (!this.hasFooter) {
173
+ this.hasFooter = this.siblingContainsElement(parent, 'ion-footer');
174
+ }
175
+ }
176
+ // If no footer found, check if we're inside ion-tabs which has ion-tab-bar
177
+ if (!this.hasFooter) {
178
+ const tabs = this.el.closest('ion-tabs');
179
+ if (tabs) {
180
+ this.hasFooter = tabs.querySelector(':scope > ion-tab-bar') !== null;
181
+ }
182
+ }
183
+ }
184
+ /**
185
+ * Checks if any sibling element of ion-content contains the specified element.
186
+ * Only searches one level deep to avoid finding elements in nested pages.
187
+ */
188
+ siblingContainsElement(parent, tagName) {
189
+ for (const sibling of parent.children) {
190
+ // Skip ion-content itself
191
+ if (sibling === this.el)
192
+ continue;
193
+ // Check if this sibling contains the target element as an immediate child
194
+ if (sibling.querySelector(`:scope > ${tagName}`) !== null) {
195
+ return true;
196
+ }
197
+ }
198
+ return false;
199
+ }
118
200
  disconnectedCallback() {
201
+ var _a;
119
202
  this.onScrollEnd();
203
+ // Clean up mutation observer to prevent memory leaks
204
+ (_a = this.parentMutationObserver) === null || _a === void 0 ? void 0 : _a.disconnect();
205
+ this.parentMutationObserver = undefined;
120
206
  if (hasLazyBuild(this.el)) {
121
207
  /**
122
208
  * The event listener and tabs caches need to
@@ -363,26 +449,28 @@ export class Content {
363
449
  }
364
450
  }
365
451
  render() {
366
- const { fixedSlotPlacement, inheritedAttributes, isMainContent, scrollX, scrollY, el } = this;
452
+ const { fixedSlotPlacement, hasFooter, hasHeader, inheritedAttributes, isMainContent, scrollX, scrollY, el } = this;
367
453
  const rtl = isRTL(el) ? 'rtl' : 'ltr';
368
454
  const mode = getIonMode(this);
369
455
  const forceOverscroll = this.shouldForceOverscroll();
370
456
  const transitionShadow = mode === 'ios';
371
457
  this.resize();
372
- return (h(Host, Object.assign({ key: 'cd8781f848d8dc926fe66f43d43c49564425a507', role: isMainContent ? 'main' : undefined, class: createColorClasses(this.color, {
458
+ return (h(Host, Object.assign({ key: 'f7218f733e4022a30875441bd949747537d28aa1', role: isMainContent ? 'main' : undefined, class: createColorClasses(this.color, {
373
459
  [mode]: true,
374
460
  'content-sizing': hostContext('ion-popover', this.el),
375
461
  overscroll: forceOverscroll,
376
462
  [`content-${rtl}`]: true,
463
+ 'safe-area-top': isMainContent && !hasHeader,
464
+ 'safe-area-bottom': isMainContent && !hasFooter,
377
465
  }), style: {
378
466
  '--offset-top': `${this.cTop}px`,
379
467
  '--offset-bottom': `${this.cBottom}px`,
380
- } }, inheritedAttributes), h("div", { key: '95b112d7cae30f22ef778ceffb88edb4d941c170', ref: (el) => (this.backgroundContentEl = el), id: "background-content", part: "background" }), fixedSlotPlacement === 'before' ? h("slot", { name: "fixed" }) : null, h("div", { key: '2fdfcbc39fb66f11b6191911f2941c660f4c12e5', class: {
468
+ } }, inheritedAttributes), h("div", { key: 'b735ec68c18c0b99c3595bb194029830e6542cde', ref: (el) => (this.backgroundContentEl = el), id: "background-content", part: "background" }), fixedSlotPlacement === 'before' ? h("slot", { name: "fixed" }) : null, h("div", { key: 'e76c00d030342d44ade6648c3f9e32ca990787ba', class: {
381
469
  'inner-scroll': true,
382
470
  'scroll-x': scrollX,
383
471
  'scroll-y': scrollY,
384
472
  overscroll: (scrollX || scrollY) && forceOverscroll,
385
- }, ref: (scrollEl) => (this.scrollEl = scrollEl), onScroll: this.scrollEvents ? (ev) => this.onScroll(ev) : undefined, part: "scroll" }, h("slot", { key: '6bc77e0054ec8e21635a7f2abfe0ca46e0962e03' })), transitionShadow ? (h("div", { class: "transition-effect" }, h("div", { class: "transition-cover" }), h("div", { class: "transition-shadow" }))) : null, fixedSlotPlacement === 'after' ? h("slot", { name: "fixed" }) : null));
473
+ }, ref: (scrollEl) => (this.scrollEl = scrollEl), onScroll: this.scrollEvents ? (ev) => this.onScroll(ev) : undefined, part: "scroll" }, h("slot", { key: '9049be4cea9b5da5ec1e1012248b05286fddeb7a' })), transitionShadow ? (h("div", { class: "transition-effect" }, h("div", { class: "transition-cover" }), h("div", { class: "transition-shadow" }))) : null, fixedSlotPlacement === 'after' ? h("slot", { name: "fixed" }) : null));
386
474
  }
387
475
  static get is() { return "ion-content"; }
388
476
  static get encapsulation() { return "shadow"; }
@@ -7,7 +7,7 @@ import { clamp, getElementRoot, raf } from "../../../utils/helpers";
7
7
  import { FOCUS_TRAP_DISABLE_CLASS } from "../../../utils/overlays";
8
8
  import { getBackdropValueForSheet } from "../utils";
9
9
  import { calculateSpringStep, handleCanDismiss } from "./utils";
10
- export const createSheetGesture = (baseEl, backdropEl, wrapperEl, initialBreakpoint, backdropBreakpoint, animation, breakpoints = [], expandToScroll, getCurrentBreakpoint, onDismiss, onBreakpointChange) => {
10
+ export const createSheetGesture = (baseEl, backdropEl, wrapperEl, initialBreakpoint, backdropBreakpoint, animation, breakpoints = [], expandToScroll, getCurrentBreakpoint, onDismiss, onBreakpointChange, onGestureMove) => {
11
11
  // Defaults for the sheet swipe animation
12
12
  const defaultBackdrop = [
13
13
  { offset: 0, opacity: 'var(--backdrop-opacity)' },
@@ -338,6 +338,8 @@ export const createSheetGesture = (baseEl, backdropEl, wrapperEl, initialBreakpo
338
338
  : step;
339
339
  offset = clamp(0.0001, processedStep, maxStep);
340
340
  animation.progressStep(offset);
341
+ // Notify modal of position change for safe-area updates
342
+ onGestureMove === null || onGestureMove === void 0 ? void 0 : onGestureMove();
341
343
  };
342
344
  const onEnd = (detail) => {
343
345
  /**
@@ -12,7 +12,7 @@ import { calculateSpringStep, handleCanDismiss } from "./utils";
12
12
  export const SwipeToCloseDefaults = {
13
13
  MIN_PRESENTING_SCALE: 0.915,
14
14
  };
15
- export const createSwipeToCloseGesture = (el, animation, statusBarStyle, onDismiss) => {
15
+ export const createSwipeToCloseGesture = (el, animation, statusBarStyle, onDismiss, onGestureMove) => {
16
16
  /**
17
17
  * The step value at which a card modal
18
18
  * is eligible for dismissing via gesture.
@@ -169,6 +169,8 @@ export const createSwipeToCloseGesture = (el, animation, statusBarStyle, onDismi
169
169
  const processedStep = isAttemptingDismissWithCanDismiss ? calculateSpringStep(step / maxStep) : step;
170
170
  const clampedStep = clamp(0.0001, processedStep, maxStep);
171
171
  animation.progressStep(clampedStep);
172
+ // Notify modal of position change for safe-area updates
173
+ onGestureMove === null || onGestureMove === void 0 ? void 0 : onGestureMove();
172
174
  /**
173
175
  * When swiping down half way, the status bar style
174
176
  * should be reset to its default value.
@@ -135,10 +135,6 @@ ion-backdrop {
135
135
  :host {
136
136
  --width: 600px;
137
137
  --height: 500px;
138
- --ion-safe-area-top: 0px;
139
- --ion-safe-area-bottom: 0px;
140
- --ion-safe-area-right: 0px;
141
- --ion-safe-area-left: 0px;
142
138
  }
143
139
  }
144
140
  @media only screen and (min-width: 768px) and (min-height: 768px) {
@@ -2,6 +2,7 @@
2
2
  * (C) Ionic http://ionicframework.com - MIT License
3
3
  */
4
4
  import { Host, h, writeTask } from "@stencil/core";
5
+ import { win } from "../../utils/browser/index";
5
6
  import { findIonContent, printIonContentErrorMsg } from "../../utils/content/index";
6
7
  import { CoreDelegate, attachComponent, detachComponent } from "../../utils/framework-delegate";
7
8
  import { raf, inheritAttributes, hasLazyBuild, getElementRoot } from "../../utils/helpers";
@@ -42,6 +43,10 @@ export class Modal {
42
43
  this.inline = false;
43
44
  // Whether or not modal is being dismissed via gesture
44
45
  this.gestureAnimationDismissing = false;
46
+ // Whether to skip coordinate-based safe-area detection (for fullscreen phone modals)
47
+ this.skipSafeAreaCoordinateDetection = false;
48
+ // Track previous safe-area state to avoid redundant DOM writes
49
+ this.prevSafeAreaState = { top: false, bottom: false, left: false, right: false };
45
50
  this.presented = false;
46
51
  /** @internal */
47
52
  this.hasController = false;
@@ -232,7 +237,10 @@ export class Modal {
232
237
  }
233
238
  }
234
239
  onWindowResize() {
235
- // Only handle resize for iOS card modals when no custom animations are provided
240
+ // Invalidate safe-area cache on resize (device rotation may change values)
241
+ this.cachedSafeAreas = undefined;
242
+ this.updateSafeAreaOverrides();
243
+ // Only handle view transition for iOS card modals when no custom animations are provided
236
244
  if (getIonMode(this) !== 'ios' || !this.presentingElement || this.enterAnimation || this.leaveAnimation) {
237
245
  return;
238
246
  }
@@ -255,6 +263,8 @@ export class Modal {
255
263
  this.triggerController.removeClickListener();
256
264
  this.cleanupViewTransitionListener();
257
265
  this.cleanupParentRemovalObserver();
266
+ // Reset safe-area state to handle removal without dismiss (e.g., framework unmount)
267
+ this.resetSafeAreaState();
258
268
  }
259
269
  componentWillLoad() {
260
270
  var _a;
@@ -414,6 +424,8 @@ export class Modal {
414
424
  else if (!this.keepContentsMounted) {
415
425
  await waitForMount();
416
426
  }
427
+ // Predict safe-area needs based on modal configuration to avoid visual snap
428
+ this.setInitialSafeAreaOverrides(presentingElement);
417
429
  writeTask(() => this.el.classList.add('show-modal'));
418
430
  const hasCardModal = presentingElement !== undefined;
419
431
  /**
@@ -475,6 +487,8 @@ export class Modal {
475
487
  else if (hasCardModal) {
476
488
  this.initSwipeToClose();
477
489
  }
490
+ // Now that animation is complete, update safe-area based on actual position
491
+ this.updateSafeAreaOverrides();
478
492
  // Initialize view transition listener for iOS card modals
479
493
  this.initViewTransitionListener();
480
494
  // Initialize parent removal observer
@@ -526,7 +540,7 @@ export class Modal {
526
540
  await this.dismiss(undefined, GESTURE);
527
541
  this.gestureAnimationDismissing = false;
528
542
  });
529
- });
543
+ }, () => this.updateSafeAreaOverrides());
530
544
  this.gesture.enable(true);
531
545
  }
532
546
  initSheetGesture() {
@@ -547,7 +561,8 @@ export class Modal {
547
561
  this.currentBreakpoint = breakpoint;
548
562
  this.ionBreakpointDidChange.emit({ breakpoint });
549
563
  }
550
- });
564
+ this.updateSafeAreaOverrides();
565
+ }, () => this.updateSafeAreaOverrides());
551
566
  this.gesture = gesture;
552
567
  this.moveSheetToBreakpoint = moveSheetToBreakpoint;
553
568
  this.gesture.enable(true);
@@ -625,6 +640,187 @@ export class Modal {
625
640
  // Clear the cached reference
626
641
  this.cachedPageParent = undefined;
627
642
  }
643
+ /**
644
+ * Sets initial safe-area overrides based on modal configuration before
645
+ * the modal becomes visible. This predicts whether the modal will touch
646
+ * screen edges to avoid a visual snap after animation completes.
647
+ */
648
+ setInitialSafeAreaOverrides(presentingElement) {
649
+ const style = this.el.style;
650
+ const mode = getIonMode(this);
651
+ const isSheetModal = this.breakpoints !== undefined && this.initialBreakpoint !== undefined;
652
+ // Card modals only exist in iOS mode - in MD mode, presentingElement is ignored
653
+ const isCardModal = presentingElement !== undefined && mode === 'ios';
654
+ const isTablet = window.innerWidth >= 768;
655
+ // Sheet modals always touch bottom edge, never top/left/right
656
+ if (isSheetModal) {
657
+ style.setProperty('--ion-safe-area-top', '0px');
658
+ style.setProperty('--ion-safe-area-left', '0px');
659
+ style.setProperty('--ion-safe-area-right', '0px');
660
+ return;
661
+ }
662
+ // Card modals have rounded top corners
663
+ if (isCardModal) {
664
+ style.setProperty('--ion-safe-area-top', '0px');
665
+ if (isTablet) {
666
+ // On tablets, card modals are inset from all edges
667
+ this.zeroAllSafeAreas();
668
+ }
669
+ else {
670
+ // On phones, card modals still extend to the bottom edge
671
+ style.setProperty('--ion-safe-area-left', '0px');
672
+ style.setProperty('--ion-safe-area-right', '0px');
673
+ this.applyFullscreenSafeArea();
674
+ }
675
+ return;
676
+ }
677
+ // Phone-sized fullscreen modals inherit safe areas and use wrapper padding
678
+ if (!isTablet) {
679
+ this.applyFullscreenSafeArea();
680
+ return;
681
+ }
682
+ // Check if tablet modal is fullscreen via CSS custom properties
683
+ const computedStyle = getComputedStyle(this.el);
684
+ const width = computedStyle.getPropertyValue('--width').trim();
685
+ const height = computedStyle.getPropertyValue('--height').trim();
686
+ const isFullscreen = width === '100%' && height === '100%';
687
+ if (isFullscreen) {
688
+ this.applyFullscreenSafeArea();
689
+ }
690
+ else {
691
+ // Centered dialog doesn't touch edges
692
+ this.zeroAllSafeAreas();
693
+ }
694
+ }
695
+ /**
696
+ * Applies safe-area handling for fullscreen modals.
697
+ * Adds wrapper padding when no footer is present to prevent
698
+ * content from overlapping system navigation areas.
699
+ */
700
+ applyFullscreenSafeArea() {
701
+ this.skipSafeAreaCoordinateDetection = true;
702
+ this.updateFooterPadding();
703
+ // Watch for dynamic footer additions/removals (e.g., async data loading)
704
+ // Use subtree:true to support wrapped footers in framework components
705
+ // (e.g., <my-footer><ion-footer>...</ion-footer></my-footer>)
706
+ if (!this.footerObserver && win !== undefined && 'MutationObserver' in win) {
707
+ this.footerObserver = new MutationObserver(() => this.updateFooterPadding());
708
+ this.footerObserver.observe(this.el, { childList: true, subtree: true });
709
+ }
710
+ }
711
+ /**
712
+ * Updates wrapper padding based on footer presence.
713
+ * Called initially and when footer is dynamically added/removed.
714
+ */
715
+ updateFooterPadding() {
716
+ if (!this.wrapperEl)
717
+ return;
718
+ const hasFooter = this.el.querySelector('ion-footer') !== null;
719
+ if (hasFooter) {
720
+ this.wrapperEl.style.removeProperty('padding-bottom');
721
+ this.wrapperEl.style.removeProperty('box-sizing');
722
+ }
723
+ else {
724
+ this.wrapperEl.style.setProperty('padding-bottom', 'var(--ion-safe-area-bottom, 0px)');
725
+ this.wrapperEl.style.setProperty('box-sizing', 'border-box');
726
+ }
727
+ }
728
+ /**
729
+ * Sets all safe-area CSS variables to 0px for modals that
730
+ * don't touch screen edges.
731
+ */
732
+ zeroAllSafeAreas() {
733
+ const style = this.el.style;
734
+ style.setProperty('--ion-safe-area-top', '0px');
735
+ style.setProperty('--ion-safe-area-bottom', '0px');
736
+ style.setProperty('--ion-safe-area-left', '0px');
737
+ style.setProperty('--ion-safe-area-right', '0px');
738
+ }
739
+ /**
740
+ * Resets all safe-area related state and styles.
741
+ * Called during dismiss and disconnectedCallback to ensure clean state
742
+ * for re-presentation of inline modals.
743
+ */
744
+ resetSafeAreaState() {
745
+ var _a;
746
+ this.skipSafeAreaCoordinateDetection = false;
747
+ this.cachedSafeAreas = undefined;
748
+ this.prevSafeAreaState = { top: false, bottom: false, left: false, right: false };
749
+ (_a = this.footerObserver) === null || _a === void 0 ? void 0 : _a.disconnect();
750
+ this.footerObserver = undefined;
751
+ // Clear wrapper styles that may have been set for safe-area handling
752
+ if (this.wrapperEl) {
753
+ this.wrapperEl.style.removeProperty('padding-bottom');
754
+ this.wrapperEl.style.removeProperty('box-sizing');
755
+ }
756
+ // Clear safe-area CSS variable overrides
757
+ const style = this.el.style;
758
+ style.removeProperty('--ion-safe-area-top');
759
+ style.removeProperty('--ion-safe-area-bottom');
760
+ style.removeProperty('--ion-safe-area-left');
761
+ style.removeProperty('--ion-safe-area-right');
762
+ }
763
+ /**
764
+ * Gets the root safe-area values from the document element.
765
+ * Uses cached values during gestures to avoid getComputedStyle calls.
766
+ */
767
+ getSafeAreaValues() {
768
+ if (!this.cachedSafeAreas) {
769
+ const rootStyle = getComputedStyle(document.documentElement);
770
+ this.cachedSafeAreas = {
771
+ top: parseFloat(rootStyle.getPropertyValue('--ion-safe-area-top')) || 0,
772
+ bottom: parseFloat(rootStyle.getPropertyValue('--ion-safe-area-bottom')) || 0,
773
+ left: parseFloat(rootStyle.getPropertyValue('--ion-safe-area-left')) || 0,
774
+ right: parseFloat(rootStyle.getPropertyValue('--ion-safe-area-right')) || 0,
775
+ };
776
+ }
777
+ return this.cachedSafeAreas;
778
+ }
779
+ /**
780
+ * Updates safe-area CSS variable overrides based on whether the modal
781
+ * extends into each safe-area region. Called after animation
782
+ * and during gestures to handle dynamic position changes.
783
+ *
784
+ * Optimized to avoid redundant DOM writes by tracking previous state.
785
+ */
786
+ updateSafeAreaOverrides() {
787
+ if (this.skipSafeAreaCoordinateDetection) {
788
+ return;
789
+ }
790
+ const wrapper = this.wrapperEl;
791
+ if (!wrapper) {
792
+ return;
793
+ }
794
+ const rect = wrapper.getBoundingClientRect();
795
+ const safeAreas = this.getSafeAreaValues();
796
+ const extendsIntoTop = rect.top < safeAreas.top;
797
+ const extendsIntoBottom = rect.bottom > window.innerHeight - safeAreas.bottom;
798
+ const extendsIntoLeft = rect.left < safeAreas.left;
799
+ const extendsIntoRight = rect.right > window.innerWidth - safeAreas.right;
800
+ // Only update DOM when state actually changes
801
+ const prev = this.prevSafeAreaState;
802
+ const style = this.el.style;
803
+ if (extendsIntoTop !== prev.top) {
804
+ extendsIntoTop ? style.removeProperty('--ion-safe-area-top') : style.setProperty('--ion-safe-area-top', '0px');
805
+ prev.top = extendsIntoTop;
806
+ }
807
+ if (extendsIntoBottom !== prev.bottom) {
808
+ extendsIntoBottom
809
+ ? style.removeProperty('--ion-safe-area-bottom')
810
+ : style.setProperty('--ion-safe-area-bottom', '0px');
811
+ prev.bottom = extendsIntoBottom;
812
+ }
813
+ if (extendsIntoLeft !== prev.left) {
814
+ extendsIntoLeft ? style.removeProperty('--ion-safe-area-left') : style.setProperty('--ion-safe-area-left', '0px');
815
+ prev.left = extendsIntoLeft;
816
+ }
817
+ if (extendsIntoRight !== prev.right) {
818
+ extendsIntoRight
819
+ ? style.removeProperty('--ion-safe-area-right')
820
+ : style.setProperty('--ion-safe-area-right', '0px');
821
+ prev.right = extendsIntoRight;
822
+ }
823
+ }
628
824
  sheetOnDismiss() {
629
825
  /**
630
826
  * While the gesture animation is finishing
@@ -717,6 +913,8 @@ export class Modal {
717
913
  }
718
914
  this.currentBreakpoint = undefined;
719
915
  this.animation = undefined;
916
+ // Reset safe-area state for potential re-presentation
917
+ this.resetSafeAreaState();
720
918
  unlock();
721
919
  return dismissed;
722
920
  }
@@ -974,20 +1172,20 @@ export class Modal {
974
1172
  const isCardModal = presentingElement !== undefined && mode === 'ios';
975
1173
  const isHandleCycle = handleBehavior === 'cycle';
976
1174
  const isSheetModalWithHandle = isSheetModal && showHandle;
977
- return (h(Host, Object.assign({ key: '87328006ea6c75ebc518ace300438492a567223e', "no-router": true,
1175
+ return (h(Host, Object.assign({ key: '44022099fcaf047b97d1c2cb45b9b51c930e707c', "no-router": true,
978
1176
  // Allow the modal to be navigable when the handle is focusable
979
1177
  tabIndex: isHandleCycle && isSheetModalWithHandle ? 0 : -1 }, htmlAttributes, { style: {
980
1178
  zIndex: `${20000 + this.overlayIndex}`,
981
- }, 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: 'ee94ff8e09b691dd4ad4e4db1720f06bc3c5a469', ref: (el) => (this.backdropEl = el), visible: this.showBackdrop, tappable: this.backdropDismiss, part: "backdrop" }), mode === 'ios' && h("div", { key: 'bffd69b4635c22d9f249725bd952c1e93d5615c7', class: "modal-shadow" }), h("div", Object.assign({ key: '1d394d3c68916e464ff1fbf5242419f4a3d3cca1',
1179
+ }, 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: 'ddd7e4f6eef51ac1f62ac70e0af10fb01e707f07', ref: (el) => (this.backdropEl = el), visible: this.showBackdrop, tappable: this.backdropDismiss, part: "backdrop" }), mode === 'ios' && h("div", { key: '58620980e3e4ec273c6787bde026e1c010b904b7', class: "modal-shadow" }), h("div", Object.assign({ key: '3fb7f6218644ba898fc504467775593eb89426a0',
982
1180
  /*
983
1181
  role and aria-modal must be used on the
984
1182
  same element. They must also be set inside the
985
1183
  shadow DOM otherwise ion-button will not be highlighted
986
1184
  when using VoiceOver: https://bugs.webkit.org/show_bug.cgi?id=247134
987
1185
  */
988
- role: "dialog" }, inheritedAttributes, { "aria-modal": "true", class: "modal-wrapper ion-overlay-wrapper", part: "content", ref: (el) => (this.wrapperEl = el) }), showHandle && (h("button", { key: '2dcf58792018e557e0c323baad2d672bc99c0bb1', class: "modal-handle",
1186
+ role: "dialog" }, inheritedAttributes, { "aria-modal": "true", class: "modal-wrapper ion-overlay-wrapper", part: "content", ref: (el) => (this.wrapperEl = el) }), showHandle && (h("button", { key: '9745cd590fdaa9d023a14b487ec2c87ddbafd7f7', class: "modal-handle",
989
1187
  // Prevents the handle from receiving keyboard focus when it does not cycle
990
- 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: '44164b1e8710c3895400ad9f44ecd99873874ad5', onSlotchange: this.onSlotChange }))));
1188
+ 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: 'b9a8b5d2d3d3c9b06f99179f496c9f08907d0bad', onSlotchange: this.onSlotChange }))));
991
1189
  }
992
1190
  static get is() { return "ion-modal"; }
993
1191
  static get encapsulation() { return "shadow"; }
@@ -135,10 +135,6 @@ ion-backdrop {
135
135
  :host {
136
136
  --width: 600px;
137
137
  --height: 500px;
138
- --ion-safe-area-top: 0px;
139
- --ion-safe-area-bottom: 0px;
140
- --ion-safe-area-right: 0px;
141
- --ion-safe-area-left: 0px;
142
138
  }
143
139
  }
144
140
  @media only screen and (min-width: 768px) and (min-height: 768px) {
@@ -31,7 +31,7 @@ export const iosEnterAnimation = (baseEl, opts) => {
31
31
  const results = getPopoverPosition(isRTL, contentWidth, contentHeight, arrowWidth, arrowHeight, reference, side, align, defaultPosition, trigger, ev);
32
32
  const padding = size === 'cover' ? 0 : POPOVER_IOS_BODY_PADDING;
33
33
  const margin = size === 'cover' ? 0 : 25;
34
- const { originX, originY, top, left, bottom, checkSafeAreaLeft, checkSafeAreaRight, arrowTop, arrowLeft, addPopoverBottomClass, } = calculateWindowAdjustment(side, results.top, results.left, padding, bodyWidth, bodyHeight, contentWidth, contentHeight, margin, results.originX, results.originY, results.referenceCoordinates, results.arrowTop, results.arrowLeft, arrowHeight);
34
+ const { originX, originY, top, left, bottom, checkSafeAreaTop, checkSafeAreaBottom, checkSafeAreaLeft, checkSafeAreaRight, arrowTop, arrowLeft, addPopoverBottomClass, } = calculateWindowAdjustment(side, results.top, results.left, padding, bodyWidth, bodyHeight, contentWidth, contentHeight, margin, results.originX, results.originY, results.referenceCoordinates, results.arrowTop, results.arrowLeft, arrowHeight);
35
35
  const baseAnimation = createAnimation();
36
36
  const backdropAnimation = createAnimation();
37
37
  const contentAnimation = createAnimation();
@@ -61,19 +61,35 @@ export const iosEnterAnimation = (baseEl, opts) => {
61
61
  if (addPopoverBottomClass) {
62
62
  baseEl.classList.add('popover-bottom');
63
63
  }
64
- if (bottom !== undefined) {
65
- contentEl.style.setProperty('bottom', `${bottom}px`);
66
- }
64
+ /**
65
+ * Safe area CSS variable adjustments.
66
+ * When the popover is positioned near an edge, we add the corresponding
67
+ * safe-area inset to ensure the popover doesn't overlap with system UI
68
+ * (status bars, home indicators, navigation bars on Android API 36+, etc.)
69
+ */
70
+ const safeAreaTop = ' + var(--ion-safe-area-top, 0)';
71
+ const safeAreaBottom = ' + var(--ion-safe-area-bottom, 0)';
67
72
  const safeAreaLeft = ' + var(--ion-safe-area-left, 0)';
68
73
  const safeAreaRight = ' - var(--ion-safe-area-right, 0)';
74
+ let topValue = `${top}px`;
75
+ let bottomValue = bottom !== undefined ? `${bottom}px` : undefined;
69
76
  let leftValue = `${left}px`;
77
+ if (checkSafeAreaTop) {
78
+ topValue = `${top}px${safeAreaTop}`;
79
+ }
80
+ if (checkSafeAreaBottom && bottomValue !== undefined) {
81
+ bottomValue = `${bottom}px${safeAreaBottom}`;
82
+ }
70
83
  if (checkSafeAreaLeft) {
71
84
  leftValue = `${left}px${safeAreaLeft}`;
72
85
  }
73
86
  if (checkSafeAreaRight) {
74
87
  leftValue = `${left}px${safeAreaRight}`;
75
88
  }
76
- contentEl.style.setProperty('top', `calc(${top}px + var(--offset-y, 0))`);
89
+ if (bottomValue !== undefined) {
90
+ contentEl.style.setProperty('bottom', `calc(${bottomValue})`);
91
+ }
92
+ contentEl.style.setProperty('top', `calc(${topValue} + var(--offset-y, 0))`);
77
93
  contentEl.style.setProperty('left', `calc(${leftValue} + var(--offset-x, 0))`);
78
94
  contentEl.style.setProperty('transform-origin', `${originY} ${originX}`);
79
95
  if (arrowEl !== null) {
@@ -28,7 +28,32 @@ export const mdEnterAnimation = (baseEl, opts) => {
28
28
  };
29
29
  const results = getPopoverPosition(isRTL, contentWidth, contentHeight, 0, 0, reference, side, align, defaultPosition, trigger, ev);
30
30
  const padding = size === 'cover' ? 0 : POPOVER_MD_BODY_PADDING;
31
- const { originX, originY, top, left, bottom } = calculateWindowAdjustment(side, results.top, results.left, padding, bodyWidth, bodyHeight, contentWidth, contentHeight, 0, results.originX, results.originY, results.referenceCoordinates);
31
+ const { originX, originY, top, left, bottom, checkSafeAreaTop, checkSafeAreaBottom, checkSafeAreaLeft, checkSafeAreaRight, } = calculateWindowAdjustment(side, results.top, results.left, padding, bodyWidth, bodyHeight, contentWidth, contentHeight, 0, results.originX, results.originY, results.referenceCoordinates);
32
+ /**
33
+ * Safe area CSS variable adjustments.
34
+ * When the popover is positioned near an edge, we add the corresponding
35
+ * safe-area inset to ensure the popover doesn't overlap with system UI
36
+ * (status bars, home indicators, navigation bars on Android API 36+, etc.)
37
+ */
38
+ const safeAreaTop = ' + var(--ion-safe-area-top, 0)';
39
+ const safeAreaBottom = ' + var(--ion-safe-area-bottom, 0)';
40
+ const safeAreaLeft = ' + var(--ion-safe-area-left, 0)';
41
+ const safeAreaRight = ' - var(--ion-safe-area-right, 0)';
42
+ let topValue = `${top}px`;
43
+ let bottomValue = bottom !== undefined ? `${bottom}px` : undefined;
44
+ let leftValue = `${left}px`;
45
+ if (checkSafeAreaTop) {
46
+ topValue = `${top}px${safeAreaTop}`;
47
+ }
48
+ if (checkSafeAreaBottom && bottomValue !== undefined) {
49
+ bottomValue = `${bottom}px${safeAreaBottom}`;
50
+ }
51
+ if (checkSafeAreaLeft) {
52
+ leftValue = `${left}px${safeAreaLeft}`;
53
+ }
54
+ if (checkSafeAreaRight) {
55
+ leftValue = `${left}px${safeAreaRight}`;
56
+ }
32
57
  const baseAnimation = createAnimation();
33
58
  const backdropAnimation = createAnimation();
34
59
  const wrapperAnimation = createAnimation();
@@ -45,13 +70,13 @@ export const mdEnterAnimation = (baseEl, opts) => {
45
70
  contentAnimation
46
71
  .addElement(contentEl)
47
72
  .beforeStyles({
48
- top: `calc(${top}px + var(--offset-y, 0px))`,
49
- left: `calc(${left}px + var(--offset-x, 0px))`,
73
+ top: `calc(${topValue} + var(--offset-y, 0px))`,
74
+ left: `calc(${leftValue} + var(--offset-x, 0px))`,
50
75
  'transform-origin': `${originY} ${originX}`,
51
76
  })
52
77
  .beforeAddWrite(() => {
53
- if (bottom !== undefined) {
54
- contentEl.style.setProperty('bottom', `${bottom}px`);
78
+ if (bottomValue !== undefined) {
79
+ contentEl.style.setProperty('bottom', `calc(${bottomValue})`);
55
80
  }
56
81
  })
57
82
  .fromTo('transform', 'scale(0.8)', 'scale(1)');