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