diff --git a/core/api.txt b/core/api.txt index 6679bd89aa1..e373e34003d 100644 --- a/core/api.txt +++ b/core/api.txt @@ -1074,6 +1074,7 @@ ion-modal,prop,backdropDismiss,boolean,true,false,false ion-modal,prop,breakpoints,number[] | undefined,undefined,false,false ion-modal,prop,canDismiss,((data?: any, role?: string | undefined) => Promise) | boolean,true,false,false ion-modal,prop,enterAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false +ion-modal,prop,expandToScroll,boolean,true,false,false ion-modal,prop,focusTrap,boolean,true,false,false ion-modal,prop,handle,boolean | undefined,undefined,false,false ion-modal,prop,handleBehavior,"cycle" | "none" | undefined,'none',false,false diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 1bdfaa88545..5656510c471 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -1731,6 +1731,10 @@ export namespace Components { * Animation to use when the modal is presented. */ "enterAnimation"?: AnimationBuilder; + /** + * 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. + */ + "expandToScroll": boolean; /** * 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. */ @@ -6532,6 +6536,10 @@ declare namespace LocalJSX { * Animation to use when the modal is presented. */ "enterAnimation"?: AnimationBuilder; + /** + * 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. + */ + "expandToScroll"?: boolean; /** * 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. */ diff --git a/core/src/components/modal/animations/ios.enter.ts b/core/src/components/modal/animations/ios.enter.ts index 2001cbdcf43..3c8924f2889 100644 --- a/core/src/components/modal/animations/ios.enter.ts +++ b/core/src/components/modal/animations/ios.enter.ts @@ -17,27 +17,78 @@ const createEnterAnimation = () => { const wrapperAnimation = createAnimation().fromTo('transform', 'translateY(100vh)', 'translateY(0vh)'); - return { backdropAnimation, wrapperAnimation }; + return { backdropAnimation, wrapperAnimation, contentAnimation: undefined }; }; /** * iOS Modal Enter Animation for the Card presentation style */ export const iosEnterAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptions): Animation => { - const { presentingEl, currentBreakpoint } = opts; + const { presentingEl, currentBreakpoint, expandToScroll } = opts; const root = getElementRoot(baseEl); - const { wrapperAnimation, backdropAnimation } = + const { wrapperAnimation, backdropAnimation, contentAnimation } = currentBreakpoint !== undefined ? createSheetEnterAnimation(opts) : createEnterAnimation(); backdropAnimation.addElement(root.querySelector('ion-backdrop')!); wrapperAnimation.addElement(root.querySelectorAll('.modal-wrapper, .modal-shadow')!).beforeStyles({ opacity: 1 }); + // The content animation is only added if scrolling is enabled for + // all the breakpoints. + !expandToScroll && contentAnimation?.addElement(baseEl.querySelector('.ion-page')!); + const baseAnimation = createAnimation('entering-base') .addElement(baseEl) .easing('cubic-bezier(0.32,0.72,0,1)') .duration(500) - .addAnimation(wrapperAnimation); + .addAnimation([wrapperAnimation]) + .beforeAddWrite(() => { + if (expandToScroll) { + // Scroll can only be done when the modal is fully expanded. + return; + } + + /** + * There are some browsers that causes flickering when + * dragging the content when scroll is enabled at every + * breakpoint. This is due to the wrapper element being + * transformed off the screen and having a snap animation. + * + * A workaround is to clone the footer element and append + * it outside of the wrapper element. This way, the footer + * is still visible and the drag can be done without + * flickering. The original footer is hidden until the modal + * is dismissed. This maintains the animation of the footer + * when the modal is dismissed. + * + * The workaround needs to be done before the animation starts + * so there are no flickering issues. + */ + const ionFooter = baseEl.querySelector('ion-footer'); + /** + * This check is needed to prevent more than one footer + * from being appended to the shadow root. + * Otherwise, iOS and MD enter animations would append + * the footer twice. + */ + const ionFooterAlreadyAppended = baseEl.shadowRoot!.querySelector('ion-footer'); + if (ionFooter && !ionFooterAlreadyAppended) { + const footerHeight = ionFooter.clientHeight; + const clonedFooter = ionFooter.cloneNode(true) as HTMLIonFooterElement; + + baseEl.shadowRoot!.appendChild(clonedFooter); + ionFooter.style.setProperty('display', 'none'); + ionFooter.setAttribute('aria-hidden', 'true'); + + // Padding is added to prevent some content from being hidden. + const page = baseEl.querySelector('.ion-page') as HTMLElement; + page.style.setProperty('padding-bottom', `${footerHeight}px`); + } + }); + + if (contentAnimation) { + baseAnimation.addAnimation(contentAnimation); + } if (presentingEl) { const isMobile = window.innerWidth < 768; diff --git a/core/src/components/modal/animations/ios.leave.ts b/core/src/components/modal/animations/ios.leave.ts index 914652878fa..89ba3ce8427 100644 --- a/core/src/components/modal/animations/ios.leave.ts +++ b/core/src/components/modal/animations/ios.leave.ts @@ -19,7 +19,7 @@ const createLeaveAnimation = () => { * iOS Modal Leave Animation */ export const iosLeaveAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptions, duration = 500): Animation => { - const { presentingEl, currentBreakpoint } = opts; + const { presentingEl, currentBreakpoint, expandToScroll } = opts; const root = getElementRoot(baseEl); const { wrapperAnimation, backdropAnimation } = currentBreakpoint !== undefined ? createSheetLeaveAnimation(opts) : createLeaveAnimation(); @@ -32,7 +32,33 @@ export const iosLeaveAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptio .addElement(baseEl) .easing('cubic-bezier(0.32,0.72,0,1)') .duration(duration) - .addAnimation(wrapperAnimation); + .addAnimation(wrapperAnimation) + .beforeAddWrite(() => { + if (expandToScroll) { + // Scroll can only be done when the modal is fully expanded. + return; + } + + /** + * If expandToScroll is disabled, we need to swap + * the visibility to the original, so the footer + * dismisses with the modal and doesn't stay + * until the modal is removed from the DOM. + */ + const ionFooter = baseEl.querySelector('ion-footer'); + if (ionFooter) { + const clonedFooter = baseEl.shadowRoot!.querySelector('ion-footer')!; + + ionFooter.style.removeProperty('display'); + ionFooter.removeAttribute('aria-hidden'); + + clonedFooter.style.setProperty('display', 'none'); + clonedFooter.setAttribute('aria-hidden', 'true'); + + const page = baseEl.querySelector('.ion-page') as HTMLElement; + page.style.removeProperty('padding-bottom'); + } + }); if (presentingEl) { const isMobile = window.innerWidth < 768; diff --git a/core/src/components/modal/animations/md.enter.ts b/core/src/components/modal/animations/md.enter.ts index a04c33e7f9a..fee0efc4f64 100644 --- a/core/src/components/modal/animations/md.enter.ts +++ b/core/src/components/modal/animations/md.enter.ts @@ -19,25 +19,78 @@ const createEnterAnimation = () => { { offset: 1, opacity: 1, transform: `translateY(0px)` }, ]); - return { backdropAnimation, wrapperAnimation }; + return { backdropAnimation, wrapperAnimation, contentAnimation: undefined }; }; /** * Md Modal Enter Animation */ export const mdEnterAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptions): Animation => { - const { currentBreakpoint } = opts; + const { currentBreakpoint, expandToScroll } = opts; const root = getElementRoot(baseEl); - const { wrapperAnimation, backdropAnimation } = + const { wrapperAnimation, backdropAnimation, contentAnimation } = currentBreakpoint !== undefined ? createSheetEnterAnimation(opts) : createEnterAnimation(); backdropAnimation.addElement(root.querySelector('ion-backdrop')!); wrapperAnimation.addElement(root.querySelector('.modal-wrapper')!); - return createAnimation() + // The content animation is only added if scrolling is enabled for + // all the breakpoints. + expandToScroll && contentAnimation?.addElement(baseEl.querySelector('.ion-page')!); + + const baseAnimation = createAnimation() .addElement(baseEl) .easing('cubic-bezier(0.36,0.66,0.04,1)') .duration(280) - .addAnimation([backdropAnimation, wrapperAnimation]); + .addAnimation([backdropAnimation, wrapperAnimation]) + .beforeAddWrite(() => { + if (expandToScroll) { + // Scroll can only be done when the modal is fully expanded. + return; + } + + /** + * There are some browsers that causes flickering when + * dragging the content when scroll is enabled at every + * breakpoint. This is due to the wrapper element being + * transformed off the screen and having a snap animation. + * + * A workaround is to clone the footer element and append + * it outside of the wrapper element. This way, the footer + * is still visible and the drag can be done without + * flickering. The original footer is hidden until the modal + * is dismissed. This maintains the animation of the footer + * when the modal is dismissed. + * + * The workaround needs to be done before the animation starts + * so there are no flickering issues. + */ + const ionFooter = baseEl.querySelector('ion-footer'); + /** + * This check is needed to prevent more than one footer + * from being appended to the shadow root. + * Otherwise, iOS and MD enter animations would append + * the footer twice. + */ + const ionFooterAlreadyAppended = baseEl.shadowRoot!.querySelector('ion-footer'); + if (ionFooter && !ionFooterAlreadyAppended) { + const footerHeight = ionFooter.clientHeight; + const clonedFooter = ionFooter.cloneNode(true) as HTMLIonFooterElement; + + baseEl.shadowRoot!.appendChild(clonedFooter); + ionFooter.style.setProperty('display', 'none'); + ionFooter.setAttribute('aria-hidden', 'true'); + + // Padding is added to prevent some content from being hidden. + const page = baseEl.querySelector('.ion-page') as HTMLElement; + page.style.setProperty('padding-bottom', `${footerHeight}px`); + } + }); + + if (contentAnimation) { + baseAnimation.addAnimation(contentAnimation); + } + + return baseAnimation; }; diff --git a/core/src/components/modal/animations/md.leave.ts b/core/src/components/modal/animations/md.leave.ts index 9977c678d07..e453e9339cd 100644 --- a/core/src/components/modal/animations/md.leave.ts +++ b/core/src/components/modal/animations/md.leave.ts @@ -21,7 +21,7 @@ const createLeaveAnimation = () => { * Md Modal Leave Animation */ export const mdLeaveAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptions): Animation => { - const { currentBreakpoint } = opts; + const { currentBreakpoint, expandToScroll } = opts; const root = getElementRoot(baseEl); const { wrapperAnimation, backdropAnimation } = currentBreakpoint !== undefined ? createSheetLeaveAnimation(opts) : createLeaveAnimation(); @@ -29,8 +29,36 @@ export const mdLeaveAnimation = (baseEl: HTMLElement, opts: ModalAnimationOption backdropAnimation.addElement(root.querySelector('ion-backdrop')!); wrapperAnimation.addElement(root.querySelector('.modal-wrapper')!); - return createAnimation() + const baseAnimation = createAnimation() .easing('cubic-bezier(0.47,0,0.745,0.715)') .duration(200) - .addAnimation([backdropAnimation, wrapperAnimation]); + .addAnimation([backdropAnimation, wrapperAnimation]) + .beforeAddWrite(() => { + if (expandToScroll) { + // Scroll can only be done when the modal is fully expanded. + return; + } + + /** + * If expandToScroll is disabled, we need to swap + * the visibility to the original, so the footer + * dismisses with the modal and doesn't stay + * until the modal is removed from the DOM. + */ + const ionFooter = baseEl.querySelector('ion-footer'); + if (ionFooter) { + const clonedFooter = baseEl.shadowRoot!.querySelector('ion-footer')!; + + ionFooter.style.removeProperty('display'); + ionFooter.removeAttribute('aria-hidden'); + + clonedFooter.style.setProperty('display', 'none'); + clonedFooter.setAttribute('aria-hidden', 'true'); + + const page = baseEl.querySelector('.ion-page') as HTMLElement; + page.style.removeProperty('padding-bottom'); + } + }); + + return baseAnimation; }; diff --git a/core/src/components/modal/animations/sheet.ts b/core/src/components/modal/animations/sheet.ts index 760414f8162..e827846a7be 100644 --- a/core/src/components/modal/animations/sheet.ts +++ b/core/src/components/modal/animations/sheet.ts @@ -4,7 +4,7 @@ import type { ModalAnimationOptions } from '../modal-interface'; import { getBackdropValueForSheet } from '../utils'; export const createSheetEnterAnimation = (opts: ModalAnimationOptions) => { - const { currentBreakpoint, backdropBreakpoint } = opts; + const { currentBreakpoint, backdropBreakpoint, expandToScroll } = opts; /** * If the backdropBreakpoint is undefined, then the backdrop @@ -29,7 +29,17 @@ export const createSheetEnterAnimation = (opts: ModalAnimationOptions) => { { offset: 1, opacity: 1, transform: `translateY(${100 - currentBreakpoint! * 100}%)` }, ]); - return { wrapperAnimation, backdropAnimation }; + /** + * This allows the content to be scrollable at any breakpoint. + */ + const contentAnimation = !expandToScroll + ? createAnimation('contentAnimation').keyframes([ + { offset: 0, opacity: 1, maxHeight: `${(1 - currentBreakpoint!) * 100}%` }, + { offset: 1, opacity: 1, maxHeight: `${currentBreakpoint! * 100}%` }, + ]) + : undefined; + + return { wrapperAnimation, backdropAnimation, contentAnimation }; }; export const createSheetLeaveAnimation = (opts: ModalAnimationOptions) => { diff --git a/core/src/components/modal/gestures/sheet.ts b/core/src/components/modal/gestures/sheet.ts index a90eb5d99ea..5c95481ca0c 100644 --- a/core/src/components/modal/gestures/sheet.ts +++ b/core/src/components/modal/gestures/sheet.ts @@ -49,6 +49,7 @@ export const createSheetGesture = ( backdropBreakpoint: number, animation: Animation, breakpoints: number[] = [], + expandToScroll: boolean, getCurrentBreakpoint: () => number, onDismiss: () => void, onBreakpointChange: (breakpoint: number) => void @@ -71,6 +72,10 @@ export const createSheetGesture = ( { offset: 1, transform: 'translateY(100%)' }, ], BACKDROP_KEYFRAMES: backdropBreakpoint !== 0 ? customBackdrop : defaultBackdrop, + CONTENT_KEYFRAMES: [ + { offset: 0, maxHeight: '100%' }, + { offset: 1, maxHeight: '0%' }, + ], }; const contentEl = baseEl.querySelector('ion-content'); @@ -79,10 +84,11 @@ export const createSheetGesture = ( let offset = 0; let canDismissBlocksGesture = false; const canDismissMaxStep = 0.95; - const wrapperAnimation = animation.childAnimations.find((ani) => ani.id === 'wrapperAnimation'); - const backdropAnimation = animation.childAnimations.find((ani) => ani.id === 'backdropAnimation'); const maxBreakpoint = breakpoints[breakpoints.length - 1]; const minBreakpoint = breakpoints[0]; + const wrapperAnimation = animation.childAnimations.find((ani) => ani.id === 'wrapperAnimation'); + const backdropAnimation = animation.childAnimations.find((ani) => ani.id === 'backdropAnimation'); + const contentAnimation = animation.childAnimations.find((ani) => ani.id === 'contentAnimation'); const enableBackdrop = () => { baseEl.style.setProperty('pointer-events', 'auto'); @@ -110,6 +116,36 @@ export const createSheetGesture = ( baseEl.classList.add(FOCUS_TRAP_DISABLE_CLASS); }; + /** + * Toggles the visible modal footer when `expandToScroll` is disabled. + * @param footer The footer to show. + */ + const swapFooterVisibility = (footer: 'original' | 'cloned') => { + const originalFooter = baseEl.querySelector('ion-footer') as HTMLIonFooterElement | null; + + if (!originalFooter) { + return; + } + + const clonedFooter = wrapperEl.nextElementSibling as HTMLIonFooterElement; + const footerToHide = footer === 'original' ? clonedFooter : originalFooter; + const footerToShow = footer === 'original' ? originalFooter : clonedFooter; + + footerToShow.style.removeProperty('display'); + footerToShow.removeAttribute('aria-hidden'); + + const page = baseEl.querySelector('.ion-page') as HTMLElement; + if (footer === 'original') { + page.style.removeProperty('padding-bottom'); + } else { + const pagePadding = footerToShow.clientHeight; + page.style.setProperty('padding-bottom', `${pagePadding}px`); + } + + footerToHide.style.setProperty('display', 'none'); + footerToHide.setAttribute('aria-hidden', 'true'); + }; + /** * After the entering animation completes, * we need to set the animation to go from @@ -121,6 +157,7 @@ export const createSheetGesture = ( if (wrapperAnimation && backdropAnimation) { wrapperAnimation.keyframes([...SheetDefaults.WRAPPER_KEYFRAMES]); backdropAnimation.keyframes([...SheetDefaults.BACKDROP_KEYFRAMES]); + contentAnimation?.keyframes([...SheetDefaults.CONTENT_KEYFRAMES]); animation.progressStart(true, 1 - currentBreakpoint); /** @@ -138,7 +175,7 @@ export const createSheetGesture = ( } } - if (contentEl && currentBreakpoint !== maxBreakpoint) { + if (contentEl && currentBreakpoint !== maxBreakpoint && expandToScroll) { contentEl.scrollY = false; } @@ -154,6 +191,14 @@ export const createSheetGesture = ( const contentEl = findClosestIonContent(detail.event.target! as HTMLElement); currentBreakpoint = getCurrentBreakpoint(); + /** + * If we have expandToScroll disabled, we should not allow the swipe gesture to start + * if the content is being swiped. + */ + if (!expandToScroll && contentEl) { + return false; + } + if (currentBreakpoint === 1 && contentEl) { /** * The modal should never swipe to close on the content with a refresher. @@ -187,6 +232,16 @@ export const createSheetGesture = ( */ canDismissBlocksGesture = baseEl.canDismiss !== undefined && baseEl.canDismiss !== true && minBreakpoint === 0; + /** + * If expandToScroll is disabled, we need to swap + * the footer visibility to the original, so if the modal + * is dismissed, the footer dismisses with the modal + * and doesn't stay on the screen after the modal is gone. + */ + if (!expandToScroll) { + swapFooterVisibility('original'); + } + /** * If we are pulling down, then it is possible we are pulling on the content. * We do not want scrolling to happen at the same time as the gesture. @@ -323,6 +378,20 @@ export const createSheetGesture = ( }, ]); + if (contentAnimation) { + /** + * The modal content should scroll at any breakpoint when expandToScroll + * is disabled. In order to do this, the content needs to be completely + * viewable so scrolling can access everything. Otherwise, the default + * behavior would show the content off the screen and only allow + * scrolling when the sheet is fully expanded. + */ + contentAnimation.keyframes([ + { offset: 0, maxHeight: `${(1 - breakpointOffset) * 100}%` }, + { offset: 1, maxHeight: `${snapToBreakpoint * 100}%` }, + ]); + } + animation.progressStep(0); } @@ -332,6 +401,15 @@ export const createSheetGesture = ( */ gesture.enable(false); + /** + * If expandToScroll is disabled, we need to swap + * the footer visibility to the cloned one so the footer + * doesn't flicker when the sheet's height is animated. + */ + if (!expandToScroll && shouldRemainOpen) { + swapFooterVisibility('cloned'); + } + if (shouldPreventDismiss) { handleCanDismiss(baseEl, animation); } else if (!shouldRemainOpen) { @@ -339,13 +417,13 @@ export const createSheetGesture = ( } /** - * If the sheet is going to be fully expanded then we should enable - * scrolling immediately. The sheet modal animation takes ~500ms to finish - * so if we wait until then there is a visible delay for when scrolling is - * re-enabled. Native iOS allows for scrolling on the sheet modal as soon - * as the gesture is released, so we align with that. + * Enables scrolling immediately if the sheet is about to fully expand + * or if it allows scrolling at any breakpoint. Without this, there would + * be a ~500ms delay while the modal animation completes, causing a + * noticeable lag. Native iOS allows scrolling as soon as the gesture is + * released, so we align with that behavior. */ - if (contentEl && snapToBreakpoint === breakpoints[breakpoints.length - 1]) { + if (contentEl && (snapToBreakpoint === breakpoints[breakpoints.length - 1] || !expandToScroll)) { contentEl.scrollY = true; } @@ -365,6 +443,7 @@ export const createSheetGesture = ( raf(() => { wrapperAnimation.keyframes([...SheetDefaults.WRAPPER_KEYFRAMES]); backdropAnimation.keyframes([...SheetDefaults.BACKDROP_KEYFRAMES]); + contentAnimation?.keyframes([...SheetDefaults.CONTENT_KEYFRAMES]); animation.progressStart(true, 1 - snapToBreakpoint); currentBreakpoint = snapToBreakpoint; onBreakpointChange(currentBreakpoint); diff --git a/core/src/components/modal/modal-interface.ts b/core/src/components/modal/modal-interface.ts index 733fcc9a55d..0b3ce7f901d 100644 --- a/core/src/components/modal/modal-interface.ts +++ b/core/src/components/modal/modal-interface.ts @@ -31,6 +31,7 @@ export interface ModalAnimationOptions { presentingEl?: HTMLElement; currentBreakpoint?: number; backdropBreakpoint?: number; + expandToScroll: boolean; } export interface ModalBreakpointChangeEventDetail { diff --git a/core/src/components/modal/modal.ios.scss b/core/src/components/modal/modal.ios.scss index fc5e25e3d19..dffb778e020 100644 --- a/core/src/components/modal/modal.ios.scss +++ b/core/src/components/modal/modal.ios.scss @@ -87,3 +87,16 @@ :host(.modal-sheet) .modal-wrapper { @include border-radius(var(--border-radius), var(--border-radius), 0, 0); } + +// iOS Sheet Modal - Scroll at all breakpoints +// -------------------------------------------------- + +/** + * Sheet modals require an additional padding as mentioned in the + * `core.scss` file. However, there's a workaround that requires + * a cloned footer to be added to the modal. This is only necessary + * because the core styles are not being applied to the cloned footer. + */ +:host(.modal-sheet.modal-no-expand-scroll) ion-footer ion-toolbar:first-of-type { + padding-top: $modal-sheet-padding-top; +} diff --git a/core/src/components/modal/modal.scss b/core/src/components/modal/modal.scss index 02b7128c99c..7c5ec7916fe 100644 --- a/core/src/components/modal/modal.scss +++ b/core/src/components/modal/modal.scss @@ -166,3 +166,13 @@ ion-backdrop { position: absolute; bottom: 0; } + +// Sheet Modal - Scroll at all breakpoints +// -------------------------------------------------- + +:host(.modal-sheet.modal-no-expand-scroll) ion-footer { + position: absolute; + bottom: 0; + + width: var(--width); +} diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index bc8f6184f9a..682f602121e 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -130,6 +130,18 @@ export class Modal implements ComponentInterface, OverlayInterface { */ @Prop() breakpoints?: number[]; + /** + * 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. + */ + @Prop() expandToScroll = true; + /** * A decimal value between 0 and 1 that indicates the * initial point the modal will open at when creating a @@ -562,6 +574,7 @@ export class Modal implements ComponentInterface, OverlayInterface { presentingEl: presentingElement, currentBreakpoint: this.initialBreakpoint, backdropBreakpoint: this.backdropBreakpoint, + expandToScroll: this.expandToScroll, }); /* tslint:disable-next-line */ @@ -616,7 +629,10 @@ export class Modal implements ComponentInterface, OverlayInterface { // should be in the DOM and referenced by now, except // for the presenting el const animationBuilder = this.leaveAnimation || config.get('modalLeave', iosLeaveAnimation); - const ani = (this.animation = animationBuilder(el, { presentingEl: this.presentingElement })); + const ani = (this.animation = animationBuilder(el, { + presentingEl: this.presentingElement, + expandToScroll: this.expandToScroll, + })); const contentEl = findIonContent(el); if (!contentEl) { @@ -668,6 +684,7 @@ export class Modal implements ComponentInterface, OverlayInterface { presentingEl: this.presentingElement, currentBreakpoint: initialBreakpoint, backdropBreakpoint, + expandToScroll: this.expandToScroll, })); ani.progressStart(true, 1); @@ -680,6 +697,7 @@ export class Modal implements ComponentInterface, OverlayInterface { backdropBreakpoint, ani, this.sortedBreakpoints, + this.expandToScroll, () => this.currentBreakpoint ?? 0, () => this.sheetOnDismiss(), (breakpoint: number) => { @@ -778,6 +796,7 @@ export class Modal implements ComponentInterface, OverlayInterface { presentingEl: presentingElement, currentBreakpoint: this.currentBreakpoint ?? this.initialBreakpoint, backdropBreakpoint: this.backdropBreakpoint, + expandToScroll: this.expandToScroll, } ); @@ -927,9 +946,16 @@ export class Modal implements ComponentInterface, OverlayInterface { }; render() { - const { handle, isSheetModal, presentingElement, htmlAttributes, handleBehavior, inheritedAttributes, focusTrap } = - this; - + const { + handle, + isSheetModal, + presentingElement, + htmlAttributes, + handleBehavior, + inheritedAttributes, + focusTrap, + expandToScroll, + } = this; const showHandle = handle !== false && isSheetModal; const mode = getIonMode(this); const isCardModal = presentingElement !== undefined && mode === 'ios'; @@ -948,6 +974,7 @@ export class Modal implements ComponentInterface, OverlayInterface { ['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), @@ -1019,6 +1046,12 @@ interface ModalOverlayOptions { * to fade in when using a sheet modal. */ backdropBreakpoint: number; + + /** + * Whether or not the modal should scroll/drag + * the content only when fully expanded. + */ + expandToScroll?: boolean; } type ModalPresentOptions = ModalOverlayOptions; diff --git a/core/src/components/modal/modal.vars.scss b/core/src/components/modal/modal.vars.scss index 724de22a0cf..7565d1c04f1 100644 --- a/core/src/components/modal/modal.vars.scss +++ b/core/src/components/modal/modal.vars.scss @@ -23,3 +23,9 @@ $modal-inset-height-large: 600px; /// @prop - Text color of the modal content $modal-text-color: $text-color; + +/// @prop - Padding top of the sheet modal +$modal-sheet-padding-top: 6px; + +/// @prop - Padding bottom of the sheet modal +$modal-sheet-padding-bottom: 6px; diff --git a/core/src/components/modal/test/sheet/index.html b/core/src/components/modal/test/sheet/index.html index bc4ba338001..8dacb81ffc4 100644 --- a/core/src/components/modal/test/sheet/index.html +++ b/core/src/components/modal/test/sheet/index.html @@ -100,6 +100,12 @@ > Present Sheet Modal (Max breakpoint is not 1) +