@ionic/core 8.7.18-dev.11770316772.1e7b64e5 → 8.7.18-dev.11770674094.18396f54

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 (36) hide show
  1. package/components/ion-refresher.js +3 -32
  2. package/components/modal.js +248 -13
  3. package/components/popover.js +152 -30
  4. package/dist/cjs/ion-modal.cjs.entry.js +248 -13
  5. package/dist/cjs/ion-popover.cjs.entry.js +152 -30
  6. package/dist/cjs/ion-refresher_2.cjs.entry.js +3 -32
  7. package/dist/collection/components/modal/modal.ios.css +0 -4
  8. package/dist/collection/components/modal/modal.js +127 -11
  9. package/dist/collection/components/modal/modal.md.css +0 -4
  10. package/dist/collection/components/modal/safe-area-utils.js +123 -0
  11. package/dist/collection/components/popover/animations/ios.enter.js +33 -8
  12. package/dist/collection/components/popover/animations/md.enter.js +26 -6
  13. package/dist/collection/components/popover/utils.js +96 -19
  14. package/dist/collection/components/refresher/refresher.js +4 -70
  15. package/dist/docs.json +4 -49
  16. package/dist/esm/ion-modal.entry.js +248 -13
  17. package/dist/esm/ion-popover.entry.js +152 -30
  18. package/dist/esm/ion-refresher_2.entry.js +3 -32
  19. package/dist/html.html-data.json +1 -1
  20. package/dist/ionic/ionic.esm.js +1 -1
  21. package/dist/ionic/p-36d78f36.entry.js +4 -0
  22. package/dist/ionic/p-639dd543.entry.js +4 -0
  23. package/dist/ionic/p-cc3446d5.entry.js +4 -0
  24. package/dist/types/components/modal/modal.d.ts +23 -0
  25. package/dist/types/components/modal/safe-area-utils.d.ts +55 -0
  26. package/dist/types/components/popover/utils.d.ts +23 -1
  27. package/dist/types/components/refresher/refresher-interface.d.ts +0 -7
  28. package/dist/types/components/refresher/refresher.d.ts +3 -16
  29. package/dist/types/components.d.ts +5 -16
  30. package/dist/types/interface.d.ts +1 -1
  31. package/hydrate/index.js +403 -75
  32. package/hydrate/index.mjs +403 -75
  33. package/package.json +1 -1
  34. package/dist/ionic/p-90f2d3ff.entry.js +0 -4
  35. package/dist/ionic/p-91840a80.entry.js +0 -4
  36. package/dist/ionic/p-f9061316.entry.js +0 -4
@@ -16,6 +16,58 @@ require('./index-DkNv4J_i.js');
16
16
  require('./hardware-back-button-VCK4V3mG.js');
17
17
  require('./gesture-controller-dtqlP_q4.js');
18
18
 
19
+ /**
20
+ * Shared per-frame cache for safe-area insets. Avoids creating a temporary
21
+ * DOM element and forcing a synchronous reflow on every call within the same
22
+ * frame (e.g., multiple popovers presenting simultaneously). Invalidated
23
+ * after the next animation frame so values stay fresh across orientation
24
+ * changes and viewport resizes.
25
+ */
26
+ let cachedInsets = null;
27
+ let cacheInvalidationScheduled = false;
28
+ /**
29
+ * Resolves the current --ion-safe-area-* CSS custom property values
30
+ * to actual pixel numbers. A temporary element is needed because
31
+ * getComputedStyle on :root returns the declared value of custom
32
+ * properties (e.g. "env(safe-area-inset-top)") rather than a
33
+ * resolved number. By assigning them to standard padding properties,
34
+ * the browser resolves the value.
35
+ *
36
+ * Results are cached for the current frame to avoid repeated reflows.
37
+ */
38
+ const getSafeAreaInsets = (doc) => {
39
+ if (cachedInsets !== null) {
40
+ return cachedInsets;
41
+ }
42
+ if (doc.body === null) {
43
+ return { top: 0, bottom: 0, left: 0, right: 0 };
44
+ }
45
+ const el = doc.createElement('div');
46
+ el.style.cssText =
47
+ 'position:fixed;visibility:hidden;pointer-events:none;top:0;left:0;' +
48
+ 'padding-top:var(--ion-safe-area-top,0px);' +
49
+ 'padding-bottom:var(--ion-safe-area-bottom,0px);' +
50
+ 'padding-left:var(--ion-safe-area-left,0px);' +
51
+ 'padding-right:var(--ion-safe-area-right,0px);';
52
+ doc.body.appendChild(el);
53
+ const style = getComputedStyle(el);
54
+ const insets = {
55
+ top: parseFloat(style.paddingTop) || 0,
56
+ bottom: parseFloat(style.paddingBottom) || 0,
57
+ left: parseFloat(style.paddingLeft) || 0,
58
+ right: parseFloat(style.paddingRight) || 0,
59
+ };
60
+ el.remove();
61
+ cachedInsets = insets;
62
+ if (!cacheInvalidationScheduled) {
63
+ cacheInvalidationScheduled = true;
64
+ helpers.raf(() => {
65
+ cachedInsets = null;
66
+ cacheInvalidationScheduled = false;
67
+ });
68
+ }
69
+ return insets;
70
+ };
19
71
  /**
20
72
  * Returns the dimensions of the popover
21
73
  * arrow on `ios` mode. If arrow is disabled
@@ -651,8 +703,10 @@ const calculatePopoverCenterAlign = (side, triggerBoundingBox, contentWidth, con
651
703
  * Adjusts popover positioning coordinates
652
704
  * such that popover does not appear offscreen
653
705
  * or overlapping safe area bounds.
706
+ *
707
+ * @internal - This is not part of the public API.
654
708
  */
655
- const calculateWindowAdjustment = (side, coordTop, coordLeft, bodyPadding, bodyWidth, bodyHeight, contentWidth, contentHeight, safeAreaMargin, contentOriginX, contentOriginY, triggerCoordinates, coordArrowTop = 0, coordArrowLeft = 0, arrowHeight = 0) => {
709
+ const calculateWindowAdjustment = (side, coordTop, coordLeft, bodyPadding, bodyWidth, bodyHeight, contentWidth, contentHeight, safeArea, contentOriginX, contentOriginY, triggerCoordinates, coordArrowTop = 0, coordArrowLeft = 0, arrowHeight = 0) => {
656
710
  let arrowTop = coordArrowTop;
657
711
  const arrowLeft = coordArrowLeft;
658
712
  let left = coordLeft;
@@ -662,16 +716,19 @@ const calculateWindowAdjustment = (side, coordTop, coordLeft, bodyPadding, bodyW
662
716
  let originY = contentOriginY;
663
717
  let checkSafeAreaLeft = false;
664
718
  let checkSafeAreaRight = false;
719
+ let checkSafeAreaTop = false;
720
+ let checkSafeAreaBottom = false;
665
721
  const triggerTop = triggerCoordinates
666
722
  ? triggerCoordinates.top + triggerCoordinates.height
667
723
  : bodyHeight / 2 - contentHeight / 2;
668
724
  const triggerHeight = triggerCoordinates ? triggerCoordinates.height : 0;
669
725
  let addPopoverBottomClass = false;
726
+ const hideArrow = false;
670
727
  /**
671
728
  * Adjust popover so it does not
672
729
  * go off the left of the screen.
673
730
  */
674
- if (left < bodyPadding + safeAreaMargin) {
731
+ if (left < bodyPadding + safeArea.left) {
675
732
  left = bodyPadding;
676
733
  checkSafeAreaLeft = true;
677
734
  originX = 'left';
@@ -680,41 +737,58 @@ const calculateWindowAdjustment = (side, coordTop, coordLeft, bodyPadding, bodyW
680
737
  * go off the right of the screen.
681
738
  */
682
739
  }
683
- else if (contentWidth + bodyPadding + left + safeAreaMargin > bodyWidth) {
740
+ else if (contentWidth + bodyPadding + left + safeArea.right > bodyWidth) {
684
741
  checkSafeAreaRight = true;
685
742
  left = bodyWidth - contentWidth - bodyPadding;
686
743
  originX = 'right';
687
744
  }
688
745
  /**
689
746
  * Adjust popover so it does not
690
- * go off the top of the screen.
747
+ * go off the bottom of the screen
748
+ * or overlap the bottom safe area.
691
749
  * If popover is on the left or the right of
692
750
  * the trigger, then we should not adjust top
693
751
  * margins.
694
752
  */
695
- if (triggerTop + triggerHeight + contentHeight > bodyHeight && (side === 'top' || side === 'bottom')) {
696
- if (triggerTop - contentHeight > 0) {
753
+ if (triggerTop + triggerHeight + contentHeight > bodyHeight - safeArea.bottom &&
754
+ (side === 'top' || side === 'bottom')) {
755
+ /**
756
+ * Calculate where the popover top would be if flipped
757
+ * above the trigger. Check whether that position clears
758
+ * the top safe area with room for bodyPadding.
759
+ */
760
+ const idealFlipTop = triggerTop - contentHeight - triggerHeight - (arrowHeight - 1);
761
+ if (idealFlipTop >= safeArea.top + bodyPadding) {
697
762
  /**
698
- * While we strive to align the popover with the trigger
699
- * on smaller screens this is not always possible. As a result,
700
- * we adjust the popover up so that it does not hang
701
- * off the bottom of the screen. However, we do not want to move
702
- * the popover up so much that it goes off the top of the screen.
703
- *
704
- * We chose 12 here so that the popover position looks a bit nicer as
705
- * it is not right up against the edge of the screen.
763
+ * Popover fits above the trigger without overlapping
764
+ * the top safe area. Use the ideal position directly —
765
+ * no safe-area CSS vars needed since it clears both edges.
706
766
  */
707
- top = Math.max(12, triggerTop - contentHeight - triggerHeight - (arrowHeight - 1));
767
+ top = idealFlipTop;
708
768
  arrowTop = top + contentHeight;
709
769
  originY = 'bottom';
710
770
  addPopoverBottomClass = true;
711
- /**
712
- * If not enough room for popover to appear
713
- * above trigger, then cut it off.
714
- */
715
771
  }
716
772
  else {
773
+ /**
774
+ * Can't flip above the trigger. Constrain the bottom
775
+ * edge while keeping the top near the trigger. This
776
+ * creates a scrollable area anchored to the trigger.
777
+ */
717
778
  bottom = bodyPadding;
779
+ checkSafeAreaBottom = true;
780
+ /**
781
+ * When the trigger is near the bottom of the screen,
782
+ * the calculated top may be at or past the bottom
783
+ * constraint, leaving zero visible height. In that
784
+ * case, pull top up to the top safe area so the
785
+ * popover fills the available space between safe areas.
786
+ */
787
+ const bottomEdge = bodyHeight - safeArea.bottom - bodyPadding;
788
+ if (top >= bottomEdge) {
789
+ top = safeArea.top + bodyPadding;
790
+ checkSafeAreaTop = true;
791
+ }
718
792
  }
719
793
  }
720
794
  return {
@@ -725,9 +799,12 @@ const calculateWindowAdjustment = (side, coordTop, coordLeft, bodyPadding, bodyW
725
799
  originY,
726
800
  checkSafeAreaLeft,
727
801
  checkSafeAreaRight,
802
+ checkSafeAreaTop,
803
+ checkSafeAreaBottom,
728
804
  arrowTop,
729
805
  arrowLeft,
730
806
  addPopoverBottomClass,
807
+ hideArrow,
731
808
  };
732
809
  };
733
810
  const shouldShowArrow = (side, didAdjustBounds = false, ev, trigger) => {
@@ -757,6 +834,15 @@ const shouldShowArrow = (side, didAdjustBounds = false, ev, trigger) => {
757
834
  };
758
835
 
759
836
  const POPOVER_IOS_BODY_PADDING = 5;
837
+ /**
838
+ * Minimum edge margin for iOS popovers ensures visual spacing from screen
839
+ * edges on devices without safe areas (e.g., older iPhones without notches).
840
+ * Previously this was a hardcoded `safeAreaMargin = 25` that served dual
841
+ * purpose: safe-area avoidance AND visual spacing. Now that actual safe-area
842
+ * insets are read dynamically, this floor preserves the visual spacing when
843
+ * safe-area values are 0.
844
+ */
845
+ const POPOVER_IOS_MIN_EDGE_MARGIN = 25;
760
846
  /**
761
847
  * iOS Popover Enter Animation
762
848
  */
@@ -782,8 +868,16 @@ const iosEnterAnimation = (baseEl, opts) => {
782
868
  };
783
869
  const results = getPopoverPosition(isRTL, contentWidth, contentHeight, arrowWidth, arrowHeight, reference, side, align, defaultPosition, trigger, ev);
784
870
  const padding = size === 'cover' ? 0 : POPOVER_IOS_BODY_PADDING;
785
- const margin = size === 'cover' ? 0 : 25;
786
- 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);
871
+ const rawSafeArea = getSafeAreaInsets(doc);
872
+ const safeArea = size === 'cover'
873
+ ? { top: 0, bottom: 0, left: 0, right: 0 }
874
+ : {
875
+ top: Math.max(rawSafeArea.top, POPOVER_IOS_MIN_EDGE_MARGIN),
876
+ bottom: Math.max(rawSafeArea.bottom, POPOVER_IOS_MIN_EDGE_MARGIN),
877
+ left: Math.max(rawSafeArea.left, POPOVER_IOS_MIN_EDGE_MARGIN),
878
+ right: Math.max(rawSafeArea.right, POPOVER_IOS_MIN_EDGE_MARGIN),
879
+ };
880
+ const { originX, originY, top, left, bottom, checkSafeAreaLeft, checkSafeAreaRight, checkSafeAreaTop, checkSafeAreaBottom, arrowTop, arrowLeft, addPopoverBottomClass} = calculateWindowAdjustment(side, results.top, results.left, padding, bodyWidth, bodyHeight, contentWidth, contentHeight, safeArea, results.originX, results.originY, results.referenceCoordinates, results.arrowTop, results.arrowLeft, arrowHeight);
787
881
  const baseAnimation = animation.createAnimation();
788
882
  const backdropAnimation = animation.createAnimation();
789
883
  const contentAnimation = animation.createAnimation();
@@ -814,10 +908,14 @@ const iosEnterAnimation = (baseEl, opts) => {
814
908
  baseEl.classList.add('popover-bottom');
815
909
  }
816
910
  if (bottom !== undefined) {
817
- contentEl.style.setProperty('bottom', `${bottom}px`);
911
+ let bottomValue = `${bottom}px`;
912
+ if (checkSafeAreaBottom) {
913
+ bottomValue = `${bottom}px + var(--ion-safe-area-bottom, 0px)`;
914
+ }
915
+ contentEl.style.setProperty('bottom', `calc(${bottomValue})`);
818
916
  }
819
- const safeAreaLeft = ' + var(--ion-safe-area-left, 0)';
820
- const safeAreaRight = ' - var(--ion-safe-area-right, 0)';
917
+ const safeAreaLeft = ' + var(--ion-safe-area-left, 0px)';
918
+ const safeAreaRight = ' - var(--ion-safe-area-right, 0px)';
821
919
  let leftValue = `${left}px`;
822
920
  if (checkSafeAreaLeft) {
823
921
  leftValue = `${left}px${safeAreaLeft}`;
@@ -825,7 +923,11 @@ const iosEnterAnimation = (baseEl, opts) => {
825
923
  if (checkSafeAreaRight) {
826
924
  leftValue = `${left}px${safeAreaRight}`;
827
925
  }
828
- contentEl.style.setProperty('top', `calc(${top}px + var(--offset-y, 0))`);
926
+ let topValue = `${top}px`;
927
+ if (checkSafeAreaTop) {
928
+ topValue = `${top}px + var(--ion-safe-area-top, 0px)`;
929
+ }
930
+ contentEl.style.setProperty('top', `calc(${topValue} + var(--offset-y, 0))`);
829
931
  contentEl.style.setProperty('left', `calc(${leftValue} + var(--offset-x, 0))`);
830
932
  contentEl.style.setProperty('transform-origin', `${originY} ${originX}`);
831
933
  if (arrowEl !== null) {
@@ -901,7 +1003,23 @@ const mdEnterAnimation = (baseEl, opts) => {
901
1003
  };
902
1004
  const results = getPopoverPosition(isRTL, contentWidth, contentHeight, 0, 0, reference, side, align, defaultPosition, trigger, ev);
903
1005
  const padding = size === 'cover' ? 0 : POPOVER_MD_BODY_PADDING;
904
- const { originX, originY, top, left, bottom } = calculateWindowAdjustment(side, results.top, results.left, padding, bodyWidth, bodyHeight, contentWidth, contentHeight, 0, results.originX, results.originY, results.referenceCoordinates);
1006
+ // MD mode now applies safe-area insets (previously passed 0, ignoring all safe areas).
1007
+ // This is needed for Android edge-to-edge (API 36+) where system bars overlap content.
1008
+ const safeArea = size === 'cover' ? { top: 0, bottom: 0, left: 0, right: 0 } : getSafeAreaInsets(doc);
1009
+ const { originX, originY, top, left, bottom, checkSafeAreaLeft, checkSafeAreaRight, checkSafeAreaTop, checkSafeAreaBottom, addPopoverBottomClass, } = calculateWindowAdjustment(side, results.top, results.left, padding, bodyWidth, bodyHeight, contentWidth, contentHeight, safeArea, results.originX, results.originY, results.referenceCoordinates);
1010
+ const safeAreaLeftCalc = ' + var(--ion-safe-area-left, 0px)';
1011
+ const safeAreaRightCalc = ' - var(--ion-safe-area-right, 0px)';
1012
+ let leftValue = `${left}px`;
1013
+ if (checkSafeAreaLeft) {
1014
+ leftValue = `${left}px${safeAreaLeftCalc}`;
1015
+ }
1016
+ if (checkSafeAreaRight) {
1017
+ leftValue = `${left}px${safeAreaRightCalc}`;
1018
+ }
1019
+ let topValue = `${top}px`;
1020
+ if (checkSafeAreaTop) {
1021
+ topValue = `${top}px + var(--ion-safe-area-top, 0px)`;
1022
+ }
905
1023
  const baseAnimation = animation.createAnimation();
906
1024
  const backdropAnimation = animation.createAnimation();
907
1025
  const wrapperAnimation = animation.createAnimation();
@@ -918,13 +1036,17 @@ const mdEnterAnimation = (baseEl, opts) => {
918
1036
  contentAnimation
919
1037
  .addElement(contentEl)
920
1038
  .beforeStyles({
921
- top: `calc(${top}px + var(--offset-y, 0px))`,
922
- left: `calc(${left}px + var(--offset-x, 0px))`,
1039
+ top: `calc(${topValue} + var(--offset-y, 0px))`,
1040
+ left: `calc(${leftValue} + var(--offset-x, 0px))`,
923
1041
  'transform-origin': `${originY} ${originX}`,
924
1042
  })
925
1043
  .beforeAddWrite(() => {
926
1044
  if (bottom !== undefined) {
927
- contentEl.style.setProperty('bottom', `${bottom}px`);
1045
+ let bottomValue = `${bottom}px`;
1046
+ if (checkSafeAreaBottom) {
1047
+ bottomValue = `${bottom}px + var(--ion-safe-area-bottom, 0px)`;
1048
+ }
1049
+ contentEl.style.setProperty('bottom', `calc(${bottomValue})`);
928
1050
  }
929
1051
  })
930
1052
  .fromTo('transform', 'scale(0.8)', 'scale(1)');
@@ -936,7 +1058,7 @@ const mdEnterAnimation = (baseEl, opts) => {
936
1058
  if (size === 'cover') {
937
1059
  baseEl.style.setProperty('--width', `${contentWidth}px`);
938
1060
  }
939
- if (originY === 'bottom') {
1061
+ if (addPopoverBottomClass) {
940
1062
  baseEl.classList.add('popover-bottom');
941
1063
  }
942
1064
  })
@@ -214,8 +214,6 @@ const Refresher = class {
214
214
  this.ionRefresh = index.createEvent(this, "ionRefresh", 7);
215
215
  this.ionPull = index.createEvent(this, "ionPull", 7);
216
216
  this.ionStart = index.createEvent(this, "ionStart", 7);
217
- this.ionPullStart = index.createEvent(this, "ionPullStart", 7);
218
- this.ionPullEnd = index.createEvent(this, "ionPullEnd", 7);
219
217
  this.appliedStyles = false;
220
218
  this.didStart = false;
221
219
  this.progress = 0;
@@ -270,8 +268,8 @@ const Refresher = class {
270
268
  * than `1`. The default value is `1` which is equal to the speed of the cursor.
271
269
  * If a negative value is passed in, the factor will be `1` instead.
272
270
  *
273
- * For example, If the value passed is `1.2` and the content is dragged by
274
- * `10` pixels, instead of `10` pixels, the content will be pulled by `12` pixels
271
+ * For example: If the value passed is `1.2` and the content is dragged by
272
+ * `10` pixels, instead of `10` pixels the content will be pulled by `12` pixels
275
273
  * (an increase of 20 percent). If the value passed is `0.8`, the dragged amount
276
274
  * will be `8` pixels, less than the amount the cursor has moved.
277
275
  *
@@ -321,9 +319,6 @@ const Refresher = class {
321
319
  this.animations = [];
322
320
  this.progress = 0;
323
321
  this.state = 1 /* RefresherState.Inactive */;
324
- this.ionPullEnd.emit({
325
- reason: state === 32 /* RefresherState.Completing */ ? 'complete' : 'cancel',
326
- });
327
322
  }
328
323
  async setupiOSNativeRefresher(pullingSpinner, refreshingSpinner) {
329
324
  this.elementToTransform = this.scrollEl;
@@ -356,7 +351,6 @@ const Refresher = class {
356
351
  if (!this.didStart) {
357
352
  this.didStart = true;
358
353
  this.ionStart.emit();
359
- this.ionPullStart.emit();
360
354
  }
361
355
  // emit "pulling" on every move
362
356
  if (this.pointerDown) {
@@ -432,7 +426,6 @@ const Refresher = class {
432
426
  this.lastVelocityY = ev.velocityY;
433
427
  },
434
428
  onEnd: () => {
435
- const hadStarted = this.didStart;
436
429
  this.pointerDown = false;
437
430
  this.didStart = false;
438
431
  if (this.needsCompletion) {
@@ -442,13 +435,6 @@ const Refresher = class {
442
435
  else if (this.didRefresh) {
443
436
  index.readTask(() => translateElement(this.elementToTransform, `${this.el.clientHeight}px`));
444
437
  }
445
- else if (hadStarted) {
446
- /**
447
- * User started pulling but released before reaching the refresh threshold.
448
- * Emit ionPullEnd to complete the event pair.
449
- */
450
- this.ionPullEnd.emit({ reason: 'cancel' });
451
- }
452
438
  },
453
439
  });
454
440
  this.disabledChanged();
@@ -495,7 +481,6 @@ const Refresher = class {
495
481
  ev.data.animation = animation;
496
482
  animation.progressStart(false, 0);
497
483
  this.ionStart.emit();
498
- this.ionPullStart.emit();
499
484
  this.animations.push(animation);
500
485
  return;
501
486
  }
@@ -518,7 +503,6 @@ const Refresher = class {
518
503
  this.animations = [];
519
504
  this.gesture.enable(true);
520
505
  this.state = 1 /* RefresherState.Inactive */;
521
- this.ionPullEnd.emit({ reason: 'cancel' });
522
506
  });
523
507
  return;
524
508
  }
@@ -763,7 +747,6 @@ const Refresher = class {
763
747
  if (!this.didStart) {
764
748
  this.didStart = true;
765
749
  this.ionStart.emit();
766
- this.ionPullStart.emit();
767
750
  }
768
751
  // emit "pulling" on every move
769
752
  this.ionPull.emit();
@@ -807,15 +790,6 @@ const Refresher = class {
807
790
  * available right away.
808
791
  */
809
792
  this.restoreOverflowStyle();
810
- /**
811
- * If ionPullStart was emitted, we need to emit ionPullEnd
812
- * even though the gesture was aborted before reaching the
813
- * pulling threshold.
814
- */
815
- if (this.didStart) {
816
- this.didStart = false;
817
- this.ionPullEnd.emit({ reason: 'cancel' });
818
- }
819
793
  }
820
794
  }
821
795
  beginRefresh() {
@@ -862,9 +836,6 @@ const Refresher = class {
862
836
  if (this.contentFullscreen && this.backgroundContentEl) {
863
837
  (_a = this.backgroundContentEl) === null || _a === void 0 ? void 0 : _a.style.removeProperty('--offset-top');
864
838
  }
865
- this.ionPullEnd.emit({
866
- reason: state === 32 /* RefresherState.Completing */ ? 'complete' : 'cancel',
867
- });
868
839
  }, 600);
869
840
  // reset the styles on the scroll element
870
841
  // set that the refresh is actively cancelling/completing
@@ -918,7 +889,7 @@ const Refresher = class {
918
889
  }
919
890
  render() {
920
891
  const mode = ionicGlobal.getIonMode(this);
921
- return (index.h(index.Host, { key: 'bed0c2f44c32ecac24b7d8326ac7c7ef1d12c44f', slot: "fixed", class: {
892
+ return (index.h(index.Host, { key: '2d1bd880877b698604542ab2d602d38b9504d975', slot: "fixed", class: {
922
893
  [mode]: true,
923
894
  // Used internally for styling
924
895
  [`refresher-${mode}`]: true,
@@ -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) {
@@ -21,6 +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
25
  import { setCardStatusBarDark, setCardStatusBarDefault } from "./utils";
25
26
  // TODO(FW-2832): types
26
27
  /**
@@ -255,6 +256,9 @@ export class Modal {
255
256
  this.triggerController.removeClickListener();
256
257
  this.cleanupViewTransitionListener();
257
258
  this.cleanupParentRemovalObserver();
259
+ // Also called in dismiss() — intentional dual cleanup covers both
260
+ // dismiss-then-remove and direct DOM removal without dismiss.
261
+ this.cleanupSafeAreaOverrides();
258
262
  }
259
263
  componentWillLoad() {
260
264
  var _a;
@@ -415,6 +419,11 @@ export class Modal {
415
419
  await waitForMount();
416
420
  }
417
421
  writeTask(() => this.el.classList.add('show-modal'));
422
+ // Recalculate isSheetModal before safe-area setup because framework
423
+ // bindings (e.g., Angular) may not have been applied when componentWillLoad ran.
424
+ this.isSheetModal = this.breakpoints !== undefined && this.initialBreakpoint !== undefined;
425
+ // Set initial safe-area overrides before animation
426
+ this.setInitialSafeAreaOverrides();
418
427
  const hasCardModal = presentingElement !== undefined;
419
428
  /**
420
429
  * We need to change the status bar at the
@@ -432,6 +441,10 @@ export class Modal {
432
441
  backdropBreakpoint: this.backdropBreakpoint,
433
442
  expandToScroll: this.expandToScroll,
434
443
  });
444
+ // Update safe-area based on actual position after animation
445
+ this.updateSafeAreaOverrides();
446
+ // Apply fullscreen safe-area padding if needed
447
+ this.applyFullscreenSafeArea();
435
448
  /* tslint:disable-next-line */
436
449
  if (typeof window !== 'undefined') {
437
450
  /**
@@ -463,13 +476,7 @@ export class Modal {
463
476
  };
464
477
  window.addEventListener(KEYBOARD_DID_OPEN, this.keyboardOpenCallback);
465
478
  }
466
- /**
467
- * Recalculate isSheetModal because framework bindings (e.g., Angular)
468
- * may not have been applied when componentWillLoad ran.
469
- */
470
- const isSheetModal = this.breakpoints !== undefined && this.initialBreakpoint !== undefined;
471
- this.isSheetModal = isSheetModal;
472
- if (isSheetModal) {
479
+ if (this.isSheetModal) {
473
480
  this.initSheetGesture();
474
481
  }
475
482
  else if (hasCardModal) {
@@ -546,6 +553,8 @@ export class Modal {
546
553
  if (this.currentBreakpoint !== breakpoint) {
547
554
  this.currentBreakpoint = breakpoint;
548
555
  this.ionBreakpointDidChange.emit({ breakpoint });
556
+ // Update safe-area overrides based on new position
557
+ this.updateSafeAreaOverrides();
549
558
  }
550
559
  });
551
560
  this.gesture = gesture;
@@ -713,6 +722,7 @@ export class Modal {
713
722
  }
714
723
  this.cleanupViewTransitionListener();
715
724
  this.cleanupParentRemovalObserver();
725
+ this.cleanupSafeAreaOverrides();
716
726
  this.cleanupChildRoutePassthrough();
717
727
  }
718
728
  this.currentBreakpoint = undefined;
@@ -841,6 +851,9 @@ export class Modal {
841
851
  this.viewTransitionAnimation = transitionAnimation;
842
852
  transitionAnimation.play().then(() => {
843
853
  this.viewTransitionAnimation = undefined;
854
+ // Wait for a layout pass after the transition so getBoundingClientRect()
855
+ // in getPositionBasedSafeAreaConfig() reflects the new dimensions.
856
+ raf(() => this.updateSafeAreaOverrides());
844
857
  // After orientation transition, recreate the swipe-to-close gesture
845
858
  // with updated animation that reflects the new presenting element state
846
859
  this.reinitSwipeToClose();
@@ -967,6 +980,109 @@ export class Modal {
967
980
  (_a = this.parentRemovalObserver) === null || _a === void 0 ? void 0 : _a.disconnect();
968
981
  this.parentRemovalObserver = undefined;
969
982
  }
983
+ /**
984
+ * Creates the context object for safe-area utilities.
985
+ */
986
+ getSafeAreaContext() {
987
+ return {
988
+ isSheetModal: this.isSheetModal,
989
+ isCardModal: this.presentingElement !== undefined && getIonMode(this) === 'ios',
990
+ presentingElement: this.presentingElement,
991
+ breakpoints: this.breakpoints,
992
+ currentBreakpoint: this.currentBreakpoint,
993
+ };
994
+ }
995
+ /**
996
+ * Sets initial safe-area overrides before modal animation.
997
+ * Called in present() before animation starts.
998
+ */
999
+ setInitialSafeAreaOverrides() {
1000
+ const context = this.getSafeAreaContext();
1001
+ const safeAreaConfig = getInitialSafeAreaConfig(context);
1002
+ applySafeAreaOverrides(this.el, safeAreaConfig);
1003
+ }
1004
+ /**
1005
+ * Updates safe-area overrides during dynamic state changes.
1006
+ * Called after animations, during gestures, and on orientation changes.
1007
+ */
1008
+ updateSafeAreaOverrides() {
1009
+ const { wrapperEl, el } = this;
1010
+ 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
+ });
1022
+ return;
1023
+ }
1024
+ // Card modals have fixed safe-area requirements set by initial prediction.
1025
+ if (context.isCardModal)
1026
+ return;
1027
+ // wrapperEl is required for position-based detection below
1028
+ if (!wrapperEl)
1029
+ return;
1030
+ // Regular modals: use position-based detection to correctly handle both
1031
+ // fullscreen modals and centered dialogs with custom dimensions.
1032
+ const safeAreaConfig = getPositionBasedSafeAreaConfig(wrapperEl);
1033
+ applySafeAreaOverrides(el, safeAreaConfig);
1034
+ }
1035
+ /**
1036
+ * Applies padding-bottom to fullscreen modal wrapper to prevent
1037
+ * content from overlapping system navigation bar.
1038
+ */
1039
+ applyFullscreenSafeArea() {
1040
+ const { wrapperEl, el } = this;
1041
+ if (!wrapperEl)
1042
+ return;
1043
+ const context = this.getSafeAreaContext();
1044
+ if (context.isSheetModal || context.isCardModal)
1045
+ return;
1046
+ // Check for standard Ionic layout children (ion-content, ion-footer),
1047
+ // searching one level deep for wrapped components (e.g.,
1048
+ // <app-footer><ion-footer>...</ion-footer></app-footer>).
1049
+ let hasContent = false;
1050
+ let hasFooter = false;
1051
+ for (const child of Array.from(el.children)) {
1052
+ if (child.tagName === 'ION-CONTENT')
1053
+ hasContent = true;
1054
+ if (child.tagName === 'ION-FOOTER')
1055
+ hasFooter = true;
1056
+ for (const grandchild of Array.from(child.children)) {
1057
+ if (grandchild.tagName === 'ION-CONTENT')
1058
+ hasContent = true;
1059
+ if (grandchild.tagName === 'ION-FOOTER')
1060
+ hasFooter = true;
1061
+ }
1062
+ }
1063
+ // Only apply wrapper padding for standard Ionic layouts (has ion-content
1064
+ // but no ion-footer). Custom modals with raw HTML are fully
1065
+ // developer-controlled and should not be modified.
1066
+ if (!hasContent || hasFooter)
1067
+ return;
1068
+ // Reduce wrapper height by safe-area and add equivalent padding so the
1069
+ // total visual size stays the same but the flex content area shrinks.
1070
+ // Using height + padding instead of box-sizing: border-box avoids
1071
+ // breaking custom modals that set --border-width (border-box would
1072
+ // include the border inside the height, changing the layout).
1073
+ wrapperEl.style.setProperty('height', 'calc(var(--height) - var(--ion-safe-area-bottom, 0px))');
1074
+ wrapperEl.style.setProperty('padding-bottom', 'var(--ion-safe-area-bottom, 0px)');
1075
+ }
1076
+ /**
1077
+ * Clears all safe-area overrides and padding from wrapper.
1078
+ */
1079
+ cleanupSafeAreaOverrides() {
1080
+ clearSafeAreaOverrides(this.el);
1081
+ if (this.wrapperEl) {
1082
+ this.wrapperEl.style.removeProperty('height');
1083
+ this.wrapperEl.style.removeProperty('padding-bottom');
1084
+ }
1085
+ }
970
1086
  render() {
971
1087
  const { handle, isSheetModal, presentingElement, htmlAttributes, handleBehavior, inheritedAttributes, focusTrap, expandToScroll, } = this;
972
1088
  const showHandle = handle !== false && isSheetModal;
@@ -974,20 +1090,20 @@ export class Modal {
974
1090
  const isCardModal = presentingElement !== undefined && mode === 'ios';
975
1091
  const isHandleCycle = handleBehavior === 'cycle';
976
1092
  const isSheetModalWithHandle = isSheetModal && showHandle;
977
- return (h(Host, Object.assign({ key: '87328006ea6c75ebc518ace300438492a567223e', "no-router": true,
1093
+ return (h(Host, Object.assign({ key: 'f6c1245b5458693f19a040b362a98140c571297f', "no-router": true,
978
1094
  // Allow the modal to be navigable when the handle is focusable
979
1095
  tabIndex: isHandleCycle && isSheetModalWithHandle ? 0 : -1 }, htmlAttributes, { style: {
980
1096
  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',
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',
982
1098
  /*
983
1099
  role and aria-modal must be used on the
984
1100
  same element. They must also be set inside the
985
1101
  shadow DOM otherwise ion-button will not be highlighted
986
1102
  when using VoiceOver: https://bugs.webkit.org/show_bug.cgi?id=247134
987
1103
  */
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",
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",
989
1105
  // 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 }))));
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 }))));
991
1107
  }
992
1108
  static get is() { return "ion-modal"; }
993
1109
  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) {