fix(modal): reset footer positioning after content drag and multi-footer support (#30470)

Issue number: resolves #30468

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->

Currently, if you use pointer events to drag the content of a sheet
modal with `expandToScroll` disabled and have you have a footer and a
dismiss button, then you use the dismiss button to close the modal, the
footer will be stuck in its pinned position at the bottom of the screen.

Additionally, if you have multiple footers, only one of them properly
gets pinned and unpinned.

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

- We now move footers back to their stationary position when we finish
our drag event on modal content
- We support pinning and unpinning multiple footers at the same time now

## Does this introduce a breaking change?

- [ ] Yes
- [X] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

Dev build: `8.6.1-dev.11749575087.1b86eb67`
This commit is contained in:
Shane
2025-06-10 11:09:37 -07:00
committed by GitHub
parent 5ca8fc85aa
commit 071b414a00

View File

@ -84,7 +84,7 @@ export const createSheetGesture = (
let offset = 0;
let canDismissBlocksGesture = false;
let cachedScrollEl: HTMLElement | null = null;
let cachedFooterEl: HTMLIonFooterElement | null = null;
let cachedFooterEls: HTMLIonFooterElement[] | null = null;
let cachedFooterYPosition: number | null = null;
let currentFooterState: 'moving' | 'stationary' | null = null;
const canDismissMaxStep = 0.95;
@ -126,9 +126,9 @@ export const createSheetGesture = (
* @param newPosition Whether the footer is in a moving or stationary position.
*/
const swapFooterPosition = (newPosition: 'moving' | 'stationary') => {
if (!cachedFooterEl) {
cachedFooterEl = baseEl.querySelector('ion-footer') as HTMLIonFooterElement | null;
if (!cachedFooterEl) {
if (!cachedFooterEls) {
cachedFooterEls = Array.from(baseEl.querySelectorAll('ion-footer'));
if (!cachedFooterEls.length) {
return;
}
}
@ -137,57 +137,80 @@ export const createSheetGesture = (
currentFooterState = newPosition;
if (newPosition === 'stationary') {
// Reset positioning styles to allow normal document flow
cachedFooterEl.classList.remove('modal-footer-moving');
cachedFooterEl.style.removeProperty('position');
cachedFooterEl.style.removeProperty('width');
cachedFooterEl.style.removeProperty('height');
cachedFooterEl.style.removeProperty('top');
cachedFooterEl.style.removeProperty('left');
page?.style.removeProperty('padding-bottom');
cachedFooterEls.forEach((cachedFooterEl) => {
// Reset positioning styles to allow normal document flow
cachedFooterEl.classList.remove('modal-footer-moving');
cachedFooterEl.style.removeProperty('position');
cachedFooterEl.style.removeProperty('width');
cachedFooterEl.style.removeProperty('height');
cachedFooterEl.style.removeProperty('top');
cachedFooterEl.style.removeProperty('left');
page?.style.removeProperty('padding-bottom');
// Move to page
page?.appendChild(cachedFooterEl);
// Move to page
page?.appendChild(cachedFooterEl);
});
} else {
// Get both the footer and document body positions
const cachedFooterElRect = cachedFooterEl.getBoundingClientRect();
const bodyRect = document.body.getBoundingClientRect();
let footerHeights = 0;
cachedFooterEls.forEach((cachedFooterEl, index) => {
// Get both the footer and document body positions
const cachedFooterElRect = cachedFooterEl.getBoundingClientRect();
const bodyRect = document.body.getBoundingClientRect();
// Add padding to the parent element to prevent content from being hidden
// when the footer is positioned absolutely. This has to be done before we
// make the footer absolutely positioned or we may accidentally cause the
// sheet to scroll.
const footerHeight = cachedFooterEl.clientHeight;
page?.style.setProperty('padding-bottom', `${footerHeight}px`);
// Calculate the total height of all footers
// so we can add padding to the page element
footerHeights += cachedFooterEl.clientHeight;
// Apply positioning styles to keep footer at bottom
cachedFooterEl.classList.add('modal-footer-moving');
// Calculate absolute position relative to body
// We need to subtract the body's offsetTop to get true position within document.body
const absoluteTop = cachedFooterElRect.top - bodyRect.top;
const absoluteLeft = cachedFooterElRect.left - bodyRect.left;
// Calculate absolute position relative to body
// We need to subtract the body's offsetTop to get true position within document.body
const absoluteTop = cachedFooterElRect.top - bodyRect.top;
const absoluteLeft = cachedFooterElRect.left - bodyRect.left;
// Capture the footer's current dimensions and store them in CSS variables for
// later use when applying absolute positioning.
cachedFooterEl.style.setProperty('--pinned-width', `${cachedFooterEl.clientWidth}px`);
cachedFooterEl.style.setProperty('--pinned-height', `${cachedFooterEl.clientHeight}px`);
cachedFooterEl.style.setProperty('--pinned-top', `${absoluteTop}px`);
cachedFooterEl.style.setProperty('--pinned-left', `${absoluteLeft}px`);
// Capture the footer's current dimensions and hard code them during the drag
cachedFooterEl.style.setProperty('position', 'absolute');
cachedFooterEl.style.setProperty('width', `${cachedFooterEl.clientWidth}px`);
cachedFooterEl.style.setProperty('height', `${cachedFooterEl.clientHeight}px`);
cachedFooterEl.style.setProperty('top', `${absoluteTop}px`);
cachedFooterEl.style.setProperty('left', `${absoluteLeft}px`);
// Only cache the first footer's Y position
// This is used to determine if the sheet has been moved below the footer
// and needs to be swapped back to stationary so it collapses correctly.
if (index === 0) {
cachedFooterYPosition = absoluteTop;
// If there's a header, we need to combine the header height with the footer position
// because the header moves with the drag handle, so when it starts overlapping the footer,
// we need to account for that.
const header = baseEl.querySelector('ion-header') as HTMLIonHeaderElement | null;
if (header) {
cachedFooterYPosition -= header.clientHeight;
}
}
});
// Also cache the footer Y position, which we use to determine if the
// sheet has been moved below the footer. When that happens, we need to swap
// the position back so it will collapse correctly.
cachedFooterYPosition = absoluteTop;
// If there's a toolbar, we need to combine the toolbar height with the footer position
// because the toolbar moves with the drag handle, so when it starts overlapping the footer,
// we need to account for that.
const toolbar = baseEl.querySelector('ion-toolbar') as HTMLIonToolbarElement | null;
if (toolbar) {
cachedFooterYPosition -= toolbar.clientHeight;
}
// Apply the pinning of styles after we've calculated everything
// so that we don't cause layouts to shift while calculating the footer positions.
// Otherwise, with multiple footers we'll end up capturing the wrong positions.
cachedFooterEls.forEach((cachedFooterEl) => {
// Add padding to the parent element to prevent content from being hidden
// when the footer is positioned absolutely. This has to be done before we
// make the footer absolutely positioned or we may accidentally cause the
// sheet to scroll.
page?.style.setProperty('padding-bottom', `${footerHeights}px`);
document.body.appendChild(cachedFooterEl);
// Apply positioning styles to keep footer at bottom
cachedFooterEl.classList.add('modal-footer-moving');
// Apply our preserved styles to pin the footer
cachedFooterEl.style.setProperty('position', 'absolute');
cachedFooterEl.style.setProperty('width', 'var(--pinned-width)');
cachedFooterEl.style.setProperty('height', 'var(--pinned-height)');
cachedFooterEl.style.setProperty('top', 'var(--pinned-top)');
cachedFooterEl.style.setProperty('left', 'var(--pinned-left)');
// Move the element to the body when everything else is done
document.body.appendChild(cachedFooterEl);
});
}
};
@ -400,6 +423,14 @@ export const createSheetGesture = (
* is not scrolled to the top.
*/
if (!expandToScroll && detail.deltaY <= 0 && cachedScrollEl && cachedScrollEl.scrollTop > 0) {
/**
* If expand to scroll is disabled, we need to make sure we swap the footer position
* back to stationary so that it will collapse correctly if the modal is dismissed without
* dragging (e.g. through a dismiss button).
* This can cause issues if the user has a modal with content that can be dragged, as we'll
* swap to moving on drag and if we don't swap back here then the footer will get stuck.
*/
swapFooterPosition('stationary');
return;
}