Skip to content

Commit 166e435

Browse files
kumibrrthetaPCbrandyscarney
authored
feat(modal): add expandToScroll property to allow scrolling at all breakpoints (#30097)
Issue number: resolves #24631 Co-authored-by: Maria Hutt <13530427+thetaPC@users.noreply.github.com> Co-authored-by: Brandy Smith <brandyscarney@users.noreply.github.com>
1 parent 621333d commit 166e435

23 files changed

+365
-32
lines changed

core/api.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1074,6 +1074,7 @@ ion-modal,prop,backdropDismiss,boolean,true,false,false
10741074
ion-modal,prop,breakpoints,number[] | undefined,undefined,false,false
10751075
ion-modal,prop,canDismiss,((data?: any, role?: string | undefined) => Promise<boolean>) | boolean,true,false,false
10761076
ion-modal,prop,enterAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false
1077+
ion-modal,prop,expandToScroll,boolean,true,false,false
10771078
ion-modal,prop,focusTrap,boolean,true,false,false
10781079
ion-modal,prop,handle,boolean | undefined,undefined,false,false
10791080
ion-modal,prop,handleBehavior,"cycle" | "none" | undefined,'none',false,false

core/src/components.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1731,6 +1731,10 @@ export namespace Components {
17311731
* Animation to use when the modal is presented.
17321732
*/
17331733
"enterAnimation"?: AnimationBuilder;
1734+
/**
1735+
* Controls whether scrolling or dragging within the sheet modal expands it to a larger breakpoint. This only takes effect when `breakpoints` and `initialBreakpoint` are set. If `true`, scrolling or dragging anywhere in the modal will first expand it to the next breakpoint. Once fully expanded, scrolling will affect the content. If `false`, scrolling will always affect the content, and the modal will only expand when dragging the header or handle.
1736+
*/
1737+
"expandToScroll": boolean;
17341738
/**
17351739
* If `true`, focus will not be allowed to move outside of this overlay. If `false`, focus will be allowed to move outside of the overlay. In most scenarios this property should remain set to `true`. Setting this property to `false` can cause severe accessibility issues as users relying on assistive technologies may be able to move focus into a confusing state. We recommend only setting this to `false` when absolutely necessary. Developers may want to consider disabling focus trapping if this overlay presents a non-Ionic overlay from a 3rd party library. Developers would disable focus trapping on the Ionic overlay when presenting the 3rd party overlay and then re-enable focus trapping when dismissing the 3rd party overlay and moving focus back to the Ionic overlay.
17361740
*/
@@ -6532,6 +6536,10 @@ declare namespace LocalJSX {
65326536
* Animation to use when the modal is presented.
65336537
*/
65346538
"enterAnimation"?: AnimationBuilder;
6539+
/**
6540+
* Controls whether scrolling or dragging within the sheet modal expands it to a larger breakpoint. This only takes effect when `breakpoints` and `initialBreakpoint` are set. If `true`, scrolling or dragging anywhere in the modal will first expand it to the next breakpoint. Once fully expanded, scrolling will affect the content. If `false`, scrolling will always affect the content, and the modal will only expand when dragging the header or handle.
6541+
*/
6542+
"expandToScroll"?: boolean;
65356543
/**
65366544
* If `true`, focus will not be allowed to move outside of this overlay. If `false`, focus will be allowed to move outside of the overlay. In most scenarios this property should remain set to `true`. Setting this property to `false` can cause severe accessibility issues as users relying on assistive technologies may be able to move focus into a confusing state. We recommend only setting this to `false` when absolutely necessary. Developers may want to consider disabling focus trapping if this overlay presents a non-Ionic overlay from a 3rd party library. Developers would disable focus trapping on the Ionic overlay when presenting the 3rd party overlay and then re-enable focus trapping when dismissing the 3rd party overlay and moving focus back to the Ionic overlay.
65376545
*/

core/src/components/modal/animations/ios.enter.ts

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,27 +17,78 @@ const createEnterAnimation = () => {
1717

1818
const wrapperAnimation = createAnimation().fromTo('transform', 'translateY(100vh)', 'translateY(0vh)');
1919

20-
return { backdropAnimation, wrapperAnimation };
20+
return { backdropAnimation, wrapperAnimation, contentAnimation: undefined };
2121
};
2222

2323
/**
2424
* iOS Modal Enter Animation for the Card presentation style
2525
*/
2626
export const iosEnterAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptions): Animation => {
27-
const { presentingEl, currentBreakpoint } = opts;
27+
const { presentingEl, currentBreakpoint, expandToScroll } = opts;
2828
const root = getElementRoot(baseEl);
29-
const { wrapperAnimation, backdropAnimation } =
29+
const { wrapperAnimation, backdropAnimation, contentAnimation } =
3030
currentBreakpoint !== undefined ? createSheetEnterAnimation(opts) : createEnterAnimation();
3131

3232
backdropAnimation.addElement(root.querySelector('ion-backdrop')!);
3333

3434
wrapperAnimation.addElement(root.querySelectorAll('.modal-wrapper, .modal-shadow')!).beforeStyles({ opacity: 1 });
3535

36+
// The content animation is only added if scrolling is enabled for
37+
// all the breakpoints.
38+
!expandToScroll && contentAnimation?.addElement(baseEl.querySelector('.ion-page')!);
39+
3640
const baseAnimation = createAnimation('entering-base')
3741
.addElement(baseEl)
3842
.easing('cubic-bezier(0.32,0.72,0,1)')
3943
.duration(500)
40-
.addAnimation(wrapperAnimation);
44+
.addAnimation([wrapperAnimation])
45+
.beforeAddWrite(() => {
46+
if (expandToScroll) {
47+
// Scroll can only be done when the modal is fully expanded.
48+
return;
49+
}
50+
51+
/**
52+
* There are some browsers that causes flickering when
53+
* dragging the content when scroll is enabled at every
54+
* breakpoint. This is due to the wrapper element being
55+
* transformed off the screen and having a snap animation.
56+
*
57+
* A workaround is to clone the footer element and append
58+
* it outside of the wrapper element. This way, the footer
59+
* is still visible and the drag can be done without
60+
* flickering. The original footer is hidden until the modal
61+
* is dismissed. This maintains the animation of the footer
62+
* when the modal is dismissed.
63+
*
64+
* The workaround needs to be done before the animation starts
65+
* so there are no flickering issues.
66+
*/
67+
const ionFooter = baseEl.querySelector('ion-footer');
68+
/**
69+
* This check is needed to prevent more than one footer
70+
* from being appended to the shadow root.
71+
* Otherwise, iOS and MD enter animations would append
72+
* the footer twice.
73+
*/
74+
const ionFooterAlreadyAppended = baseEl.shadowRoot!.querySelector('ion-footer');
75+
if (ionFooter && !ionFooterAlreadyAppended) {
76+
const footerHeight = ionFooter.clientHeight;
77+
const clonedFooter = ionFooter.cloneNode(true) as HTMLIonFooterElement;
78+
79+
baseEl.shadowRoot!.appendChild(clonedFooter);
80+
ionFooter.style.setProperty('display', 'none');
81+
ionFooter.setAttribute('aria-hidden', 'true');
82+
83+
// Padding is added to prevent some content from being hidden.
84+
const page = baseEl.querySelector('.ion-page') as HTMLElement;
85+
page.style.setProperty('padding-bottom', `${footerHeight}px`);
86+
}
87+
});
88+
89+
if (contentAnimation) {
90+
baseAnimation.addAnimation(contentAnimation);
91+
}
4192

4293
if (presentingEl) {
4394
const isMobile = window.innerWidth < 768;

core/src/components/modal/animations/ios.leave.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const createLeaveAnimation = () => {
1919
* iOS Modal Leave Animation
2020
*/
2121
export const iosLeaveAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptions, duration = 500): Animation => {
22-
const { presentingEl, currentBreakpoint } = opts;
22+
const { presentingEl, currentBreakpoint, expandToScroll } = opts;
2323
const root = getElementRoot(baseEl);
2424
const { wrapperAnimation, backdropAnimation } =
2525
currentBreakpoint !== undefined ? createSheetLeaveAnimation(opts) : createLeaveAnimation();
@@ -32,7 +32,33 @@ export const iosLeaveAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptio
3232
.addElement(baseEl)
3333
.easing('cubic-bezier(0.32,0.72,0,1)')
3434
.duration(duration)
35-
.addAnimation(wrapperAnimation);
35+
.addAnimation(wrapperAnimation)
36+
.beforeAddWrite(() => {
37+
if (expandToScroll) {
38+
// Scroll can only be done when the modal is fully expanded.
39+
return;
40+
}
41+
42+
/**
43+
* If expandToScroll is disabled, we need to swap
44+
* the visibility to the original, so the footer
45+
* dismisses with the modal and doesn't stay
46+
* until the modal is removed from the DOM.
47+
*/
48+
const ionFooter = baseEl.querySelector('ion-footer');
49+
if (ionFooter) {
50+
const clonedFooter = baseEl.shadowRoot!.querySelector('ion-footer')!;
51+
52+
ionFooter.style.removeProperty('display');
53+
ionFooter.removeAttribute('aria-hidden');
54+
55+
clonedFooter.style.setProperty('display', 'none');
56+
clonedFooter.setAttribute('aria-hidden', 'true');
57+
58+
const page = baseEl.querySelector('.ion-page') as HTMLElement;
59+
page.style.removeProperty('padding-bottom');
60+
}
61+
});
3662

3763
if (presentingEl) {
3864
const isMobile = window.innerWidth < 768;

core/src/components/modal/animations/md.enter.ts

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,25 +19,78 @@ const createEnterAnimation = () => {
1919
{ offset: 1, opacity: 1, transform: `translateY(0px)` },
2020
]);
2121

22-
return { backdropAnimation, wrapperAnimation };
22+
return { backdropAnimation, wrapperAnimation, contentAnimation: undefined };
2323
};
2424

2525
/**
2626
* Md Modal Enter Animation
2727
*/
2828
export const mdEnterAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptions): Animation => {
29-
const { currentBreakpoint } = opts;
29+
const { currentBreakpoint, expandToScroll } = opts;
3030
const root = getElementRoot(baseEl);
31-
const { wrapperAnimation, backdropAnimation } =
31+
const { wrapperAnimation, backdropAnimation, contentAnimation } =
3232
currentBreakpoint !== undefined ? createSheetEnterAnimation(opts) : createEnterAnimation();
3333

3434
backdropAnimation.addElement(root.querySelector('ion-backdrop')!);
3535

3636
wrapperAnimation.addElement(root.querySelector('.modal-wrapper')!);
3737

38-
return createAnimation()
38+
// The content animation is only added if scrolling is enabled for
39+
// all the breakpoints.
40+
expandToScroll && contentAnimation?.addElement(baseEl.querySelector('.ion-page')!);
41+
42+
const baseAnimation = createAnimation()
3943
.addElement(baseEl)
4044
.easing('cubic-bezier(0.36,0.66,0.04,1)')
4145
.duration(280)
42-
.addAnimation([backdropAnimation, wrapperAnimation]);
46+
.addAnimation([backdropAnimation, wrapperAnimation])
47+
.beforeAddWrite(() => {
48+
if (expandToScroll) {
49+
// Scroll can only be done when the modal is fully expanded.
50+
return;
51+
}
52+
53+
/**
54+
* There are some browsers that causes flickering when
55+
* dragging the content when scroll is enabled at every
56+
* breakpoint. This is due to the wrapper element being
57+
* transformed off the screen and having a snap animation.
58+
*
59+
* A workaround is to clone the footer element and append
60+
* it outside of the wrapper element. This way, the footer
61+
* is still visible and the drag can be done without
62+
* flickering. The original footer is hidden until the modal
63+
* is dismissed. This maintains the animation of the footer
64+
* when the modal is dismissed.
65+
*
66+
* The workaround needs to be done before the animation starts
67+
* so there are no flickering issues.
68+
*/
69+
const ionFooter = baseEl.querySelector('ion-footer');
70+
/**
71+
* This check is needed to prevent more than one footer
72+
* from being appended to the shadow root.
73+
* Otherwise, iOS and MD enter animations would append
74+
* the footer twice.
75+
*/
76+
const ionFooterAlreadyAppended = baseEl.shadowRoot!.querySelector('ion-footer');
77+
if (ionFooter && !ionFooterAlreadyAppended) {
78+
const footerHeight = ionFooter.clientHeight;
79+
const clonedFooter = ionFooter.cloneNode(true) as HTMLIonFooterElement;
80+
81+
baseEl.shadowRoot!.appendChild(clonedFooter);
82+
ionFooter.style.setProperty('display', 'none');
83+
ionFooter.setAttribute('aria-hidden', 'true');
84+
85+
// Padding is added to prevent some content from being hidden.
86+
const page = baseEl.querySelector('.ion-page') as HTMLElement;
87+
page.style.setProperty('padding-bottom', `${footerHeight}px`);
88+
}
89+
});
90+
91+
if (contentAnimation) {
92+
baseAnimation.addAnimation(contentAnimation);
93+
}
94+
95+
return baseAnimation;
4396
};

core/src/components/modal/animations/md.leave.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,44 @@ const createLeaveAnimation = () => {
2121
* Md Modal Leave Animation
2222
*/
2323
export const mdLeaveAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptions): Animation => {
24-
const { currentBreakpoint } = opts;
24+
const { currentBreakpoint, expandToScroll } = opts;
2525
const root = getElementRoot(baseEl);
2626
const { wrapperAnimation, backdropAnimation } =
2727
currentBreakpoint !== undefined ? createSheetLeaveAnimation(opts) : createLeaveAnimation();
2828

2929
backdropAnimation.addElement(root.querySelector('ion-backdrop')!);
3030
wrapperAnimation.addElement(root.querySelector('.modal-wrapper')!);
3131

32-
return createAnimation()
32+
const baseAnimation = createAnimation()
3333
.easing('cubic-bezier(0.47,0,0.745,0.715)')
3434
.duration(200)
35-
.addAnimation([backdropAnimation, wrapperAnimation]);
35+
.addAnimation([backdropAnimation, wrapperAnimation])
36+
.beforeAddWrite(() => {
37+
if (expandToScroll) {
38+
// Scroll can only be done when the modal is fully expanded.
39+
return;
40+
}
41+
42+
/**
43+
* If expandToScroll is disabled, we need to swap
44+
* the visibility to the original, so the footer
45+
* dismisses with the modal and doesn't stay
46+
* until the modal is removed from the DOM.
47+
*/
48+
const ionFooter = baseEl.querySelector('ion-footer');
49+
if (ionFooter) {
50+
const clonedFooter = baseEl.shadowRoot!.querySelector('ion-footer')!;
51+
52+
ionFooter.style.removeProperty('display');
53+
ionFooter.removeAttribute('aria-hidden');
54+
55+
clonedFooter.style.setProperty('display', 'none');
56+
clonedFooter.setAttribute('aria-hidden', 'true');
57+
58+
const page = baseEl.querySelector('.ion-page') as HTMLElement;
59+
page.style.removeProperty('padding-bottom');
60+
}
61+
});
62+
63+
return baseAnimation;
3664
};

core/src/components/modal/animations/sheet.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { ModalAnimationOptions } from '../modal-interface';
44
import { getBackdropValueForSheet } from '../utils';
55

66
export const createSheetEnterAnimation = (opts: ModalAnimationOptions) => {
7-
const { currentBreakpoint, backdropBreakpoint } = opts;
7+
const { currentBreakpoint, backdropBreakpoint, expandToScroll } = opts;
88

99
/**
1010
* If the backdropBreakpoint is undefined, then the backdrop
@@ -29,7 +29,17 @@ export const createSheetEnterAnimation = (opts: ModalAnimationOptions) => {
2929
{ offset: 1, opacity: 1, transform: `translateY(${100 - currentBreakpoint! * 100}%)` },
3030
]);
3131

32-
return { wrapperAnimation, backdropAnimation };
32+
/**
33+
* This allows the content to be scrollable at any breakpoint.
34+
*/
35+
const contentAnimation = !expandToScroll
36+
? createAnimation('contentAnimation').keyframes([
37+
{ offset: 0, opacity: 1, maxHeight: `${(1 - currentBreakpoint!) * 100}%` },
38+
{ offset: 1, opacity: 1, maxHeight: `${currentBreakpoint! * 100}%` },
39+
])
40+
: undefined;
41+
42+
return { wrapperAnimation, backdropAnimation, contentAnimation };
3343
};
3444

3545
export const createSheetLeaveAnimation = (opts: ModalAnimationOptions) => {

0 commit comments

Comments
 (0)