UNPKG

@ionic/core

Version:
1,167 lines (1,156 loc) • 92.8 kB
/*! * (C) Ionic http://ionicframework.com - MIT License */ import { proxyCustomElement, HTMLElement, createEvent, writeTask, h, Host } from '@stencil/core/internal/client'; import { a as findClosestIonContent, i as isIonContent, d as disableContentScrollY, r as resetContentScrollY, f as findIonContent, p as printIonContentErrorMsg } from './index8.js'; import { C as CoreDelegate, a as attachComponent, d as detachComponent } from './framework-delegate.js'; import { g as getElementRoot, k as clamp, r as raf, d as inheritAttributes, j as hasLazyBuild } from './helpers.js'; import { c as createLockController } from './lock-controller.js'; import { p as printIonWarning } from './index6.js'; import { g as getCapacitor } from './capacitor.js'; import { G as GESTURE, O as OVERLAY_GESTURE_PRIORITY, F as FOCUS_TRAP_DISABLE_CLASS, e as createTriggerController, B as BACKDROP, j as prepareOverlay, k as setOverlayId, f as present, g as dismiss, h as eventMethod } from './overlays.js'; import { g as getClassMap } from './theme.js'; import { e as deepReady, w as waitForMount } from './index2.js'; import { b as getIonMode, c as config } from './ionic-global.js'; import { KEYBOARD_DID_OPEN } from './keyboard2.js'; import { c as createAnimation } from './animation.js'; import { g as getTimeGivenProgression } from './cubic-bezier.js'; import { createGesture } from './index3.js'; import { w as win } from './index5.js'; import { d as defineCustomElement$1 } from './backdrop.js'; var Style; (function (Style) { Style["Dark"] = "DARK"; Style["Light"] = "LIGHT"; Style["Default"] = "DEFAULT"; })(Style || (Style = {})); const StatusBar = { getEngine() { const capacitor = getCapacitor(); if (capacitor === null || capacitor === void 0 ? void 0 : capacitor.isPluginAvailable('StatusBar')) { return capacitor.Plugins.StatusBar; } return undefined; }, setStyle(options) { const engine = this.getEngine(); if (!engine) { return; } engine.setStyle(options); }, getStyle: async function () { const engine = this.getEngine(); if (!engine) { return Style.Default; } const { style } = await engine.getInfo(); return style; }, }; /** * Use y = mx + b to * figure out the backdrop value * at a particular x coordinate. This * is useful when the backdrop does * not begin to fade in until after * the 0 breakpoint. */ const getBackdropValueForSheet = (x, backdropBreakpoint) => { /** * We will use these points: * (backdropBreakpoint, 0) * (maxBreakpoint, 1) * We know that at the beginning breakpoint, * the backdrop will be hidden. We also * know that at the maxBreakpoint, the backdrop * must be fully visible. maxBreakpoint should * always be 1 even if the maximum value * of the breakpoints array is not 1 since * the animation runs from a progress of 0 * to a progress of 1. * m = (y2 - y1) / (x2 - x1) * * This is simplified from: * m = (1 - 0) / (maxBreakpoint - backdropBreakpoint) * * If the backdropBreakpoint is 1, we return 0 as the * backdrop is completely hidden. * */ if (backdropBreakpoint === 1) { return 0; } const slope = 1 / (1 - backdropBreakpoint); /** * From here, compute b which is * the backdrop opacity if the offset * is 0. If the backdrop does not * begin to fade in until after the * 0 breakpoint, this b value will be * negative. This is fine as we never pass * b directly into the animation keyframes. * b = y - mx * Use a known point: (backdropBreakpoint, 0) * This is simplified from: * b = 0 - (backdropBreakpoint * slope) */ const b = -(backdropBreakpoint * slope); /** * Finally, we can now determine the * backdrop offset given an arbitrary * gesture offset. */ return x * slope + b; }; /** * The tablet/desktop card modal activates * when the window width is >= 768. * At that point, the presenting element * is not transformed, so we do not need to * adjust the status bar color. * */ const setCardStatusBarDark = () => { if (!win || win.innerWidth >= 768) { return; } StatusBar.setStyle({ style: Style.Dark }); }; const setCardStatusBarDefault = (defaultStyle = Style.Default) => { if (!win || win.innerWidth >= 768) { return; } StatusBar.setStyle({ style: defaultStyle }); }; const handleCanDismiss = async (el, animation) => { /** * If canDismiss is not a function * then we can return early. If canDismiss is `true`, * then canDismissBlocksGesture is `false` as canDismiss * will never interrupt the gesture. As a result, * this code block is never reached. If canDismiss is `false`, * then we never dismiss. */ if (typeof el.canDismiss !== 'function') { return; } /** * Run the canDismiss callback. * If the function returns `true`, * then we can proceed with dismiss. */ const shouldDismiss = await el.canDismiss(undefined, GESTURE); if (!shouldDismiss) { return; } /** * If canDismiss resolved after the snap * back animation finished, we can * dismiss immediately. * * If canDismiss resolved before the snap * back animation finished, we need to * wait until the snap back animation is * done before dismissing. */ if (animation.isRunning()) { animation.onFinish(() => { el.dismiss(undefined, 'handler'); }, { oneTimeCallback: true }); } else { el.dismiss(undefined, 'handler'); } }; /** * This function lets us simulate a realistic spring-like animation * when swiping down on the modal. * There are two forces that we need to use to compute the spring physics: * * 1. Stiffness, k: This is a measure of resistance applied a spring. * 2. Dampening, c: This value has the effect of reducing or preventing oscillation. * * Using these two values, we can calculate the Spring Force and the Dampening Force * to compute the total force applied to a spring. * * Spring Force: This force pulls a spring back into its equilibrium position. * Hooke's Law tells us that that spring force (FS) = kX. * k is the stiffness of a spring, and X is the displacement of the spring from its * equilibrium position. In this case, it is the amount by which the free end * of a spring was displaced (stretched/pushed) from its "relaxed" position. * * Dampening Force: This force slows down motion. Without it, a spring would oscillate forever. * The dampening force, FD, can be found via this formula: FD = -cv * where c the dampening value and v is velocity. * * Therefore, the resulting force that is exerted on the block is: * F = FS + FD = -kX - cv * * Newton's 2nd Law tells us that F = ma: * ma = -kX - cv. * * For Ionic's purposes, we can assume that m = 1: * a = -kX - cv * * Imagine a block attached to the end of a spring. At equilibrium * the block is at position x = 1. * Pressing on the block moves it to position x = 0; * So, to calculate the displacement, we need to take the * current position and subtract the previous position from it. * X = x - x0 = 0 - 1 = -1. * * For Ionic's purposes, we are only pushing on the spring modal * so we have a max position of 1. * As a result, we can expand displacement to this formula: * X = x - 1 * * a = -k(x - 1) - cv * * We can represent the motion of something as a function of time: f(t) = x. * The derivative of position gives us the velocity: f'(t) * The derivative of the velocity gives us the acceleration: f''(t) * * We can substitute the formula above with these values: * * f"(t) = -k * (f(t) - 1) - c * f'(t) * * This is called a differential equation. * * We know that at t = 0, we are at x = 0 because the modal does not move: f(0) = 0 * This means our velocity is also zero: f'(0) = 0. * * We can cheat a bit and plug the formula into Wolfram Alpha. * However, we need to pick stiffness and dampening values: * k = 0.57 * c = 15 * * I picked these as they are fairly close to native iOS's spring effect * with the modal. * * What we plug in is this: f(0) = 0; f'(0) = 0; f''(t) = -0.57(f(t) - 1) - 15f'(t) * * The result is a formula that lets us calculate the acceleration * for a given time t. * Note: This is the approximate form of the solution. Wolfram Alpha will * give you a complex differential equation too. */ const calculateSpringStep = (t) => { return 0.00255275 * 2.71828 ** (-14.9619 * t) - 1.00255 * 2.71828 ** (-0.0380968 * t) + 1; }; // Defaults for the card swipe animation const SwipeToCloseDefaults = { MIN_PRESENTING_SCALE: 0.915, }; const createSwipeToCloseGesture = (el, animation, statusBarStyle, onDismiss) => { /** * The step value at which a card modal * is eligible for dismissing via gesture. */ const DISMISS_THRESHOLD = 0.5; const height = el.offsetHeight; let isOpen = false; let canDismissBlocksGesture = false; let contentEl = null; let scrollEl = null; const canDismissMaxStep = 0.2; let initialScrollY = true; let lastStep = 0; const getScrollY = () => { if (contentEl && isIonContent(contentEl)) { return contentEl.scrollY; /** * Custom scroll containers are intended to be * used with virtual scrolling, so we assume * there is scrolling in this case. */ } else { return true; } }; const canStart = (detail) => { const target = detail.event.target; if (target === null || !target.closest) { return true; } /** * If we are swiping on the content, * swiping should only be possible if * the content is scrolled all the way * to the top so that we do not interfere * with scrolling. * * We cannot assume that the `ion-content` * target will remain consistent between * swipes. For example, when using * ion-nav within a card modal it is * possible to swipe, push a view, and then * swipe again. The target content will not * be the same between swipes. */ contentEl = findClosestIonContent(target); if (contentEl) { /** * The card should never swipe to close * on the content with a refresher. * Note: We cannot solve this by making the * swipeToClose gesture have a higher priority * than the refresher gesture as the iOS native * refresh gesture uses a scroll listener in * addition to a gesture. * * Note: Do not use getScrollElement here * because we need this to be a synchronous * operation, and getScrollElement is * asynchronous. */ if (isIonContent(contentEl)) { const root = getElementRoot(contentEl); scrollEl = root.querySelector('.inner-scroll'); } else { scrollEl = contentEl; } const hasRefresherInContent = !!contentEl.querySelector('ion-refresher'); return !hasRefresherInContent && scrollEl.scrollTop === 0; } /** * Card should be swipeable on all * parts of the modal except for the footer. */ const footer = target.closest('ion-footer'); if (footer === null) { return true; } return false; }; const onStart = (detail) => { const { deltaY } = detail; /** * Get the initial scrollY value so * that we can correctly reset the scrollY * prop when the gesture ends. */ initialScrollY = getScrollY(); /** * If canDismiss is anything other than `true` * then users should be able to swipe down * until a threshold is hit. At that point, * the card modal should not proceed any further. * TODO (FW-937) * Remove undefined check */ canDismissBlocksGesture = el.canDismiss !== undefined && el.canDismiss !== true; /** * 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. */ if (deltaY > 0 && contentEl) { disableContentScrollY(contentEl); } animation.progressStart(true, isOpen ? 1 : 0); }; const onMove = (detail) => { const { deltaY } = detail; /** * 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. */ if (deltaY > 0 && contentEl) { disableContentScrollY(contentEl); } /** * If we are swiping on the content * then the swipe gesture should only * happen if we are pulling down. * * However, if we pull up and * then down such that the scroll position * returns to 0, we should be able to swipe * the card. */ const step = detail.deltaY / height; /** * Check if user is swiping down and * if we have a canDismiss value that * should block the gesture from * proceeding, */ const isAttemptingDismissWithCanDismiss = step >= 0 && canDismissBlocksGesture; /** * If we are blocking the gesture from dismissing, * set the max step value so that the sheet cannot be * completely hidden. */ const maxStep = isAttemptingDismissWithCanDismiss ? canDismissMaxStep : 0.9999; /** * If we are blocking the gesture from * dismissing, calculate the spring modifier value * this will be added to the starting breakpoint * value to give the gesture a spring-like feeling. * Note that the starting breakpoint is always 0, * so we omit adding 0 to the result. */ const processedStep = isAttemptingDismissWithCanDismiss ? calculateSpringStep(step / maxStep) : step; const clampedStep = clamp(0.0001, processedStep, maxStep); animation.progressStep(clampedStep); /** * When swiping down half way, the status bar style * should be reset to its default value. * * We track lastStep so that we do not fire these * functions on every onMove, only when the user has * crossed a certain threshold. */ if (clampedStep >= DISMISS_THRESHOLD && lastStep < DISMISS_THRESHOLD) { setCardStatusBarDefault(statusBarStyle); /** * However, if we swipe back up, then the * status bar style should be set to have light * text on a dark background. */ } else if (clampedStep < DISMISS_THRESHOLD && lastStep >= DISMISS_THRESHOLD) { setCardStatusBarDark(); } lastStep = clampedStep; }; const onEnd = (detail) => { const velocity = detail.velocityY; const step = detail.deltaY / height; const isAttemptingDismissWithCanDismiss = step >= 0 && canDismissBlocksGesture; const maxStep = isAttemptingDismissWithCanDismiss ? canDismissMaxStep : 0.9999; const processedStep = isAttemptingDismissWithCanDismiss ? calculateSpringStep(step / maxStep) : step; const clampedStep = clamp(0.0001, processedStep, maxStep); const threshold = (detail.deltaY + velocity * 1000) / height; /** * If canDismiss blocks * the swipe gesture, then the * animation can never complete until * canDismiss is checked. */ const shouldComplete = !isAttemptingDismissWithCanDismiss && threshold >= DISMISS_THRESHOLD; let newStepValue = shouldComplete ? -0.001 : 0.001; if (!shouldComplete) { animation.easing('cubic-bezier(1, 0, 0.68, 0.28)'); newStepValue += getTimeGivenProgression([0, 0], [1, 0], [0.68, 0.28], [1, 1], clampedStep)[0]; } else { animation.easing('cubic-bezier(0.32, 0.72, 0, 1)'); newStepValue += getTimeGivenProgression([0, 0], [0.32, 0.72], [0, 1], [1, 1], clampedStep)[0]; } const duration = shouldComplete ? computeDuration(step * height, velocity) : computeDuration((1 - clampedStep) * height, velocity); isOpen = shouldComplete; gesture.enable(false); if (contentEl) { resetContentScrollY(contentEl, initialScrollY); } animation .onFinish(() => { if (!shouldComplete) { gesture.enable(true); } }) .progressEnd(shouldComplete ? 1 : 0, newStepValue, duration); /** * If the canDismiss value blocked the gesture * from proceeding, then we should ignore whatever * shouldComplete is. Whether or not the modal * animation should complete is now determined by * canDismiss. * * If the user swiped >25% of the way * to the max step, then we should * check canDismiss. 25% was chosen * to avoid accidental swipes. */ if (isAttemptingDismissWithCanDismiss && clampedStep > maxStep / 4) { handleCanDismiss(el, animation); } else if (shouldComplete) { onDismiss(); } }; const gesture = createGesture({ el, gestureName: 'modalSwipeToClose', gesturePriority: OVERLAY_GESTURE_PRIORITY, direction: 'y', threshold: 10, canStart, onStart, onMove, onEnd, }); return gesture; }; const computeDuration = (remaining, velocity) => { return clamp(400, remaining / Math.abs(velocity * 1.1), 500); }; const createSheetEnterAnimation = (opts) => { const { currentBreakpoint, backdropBreakpoint, expandToScroll } = opts; /** * If the backdropBreakpoint is undefined, then the backdrop * should always fade in. If the backdropBreakpoint came before the * current breakpoint, then the backdrop should be fading in. */ const shouldShowBackdrop = backdropBreakpoint === undefined || backdropBreakpoint < currentBreakpoint; const initialBackdrop = shouldShowBackdrop ? `calc(var(--backdrop-opacity) * ${currentBreakpoint})` : '0'; const backdropAnimation = createAnimation('backdropAnimation').fromTo('opacity', 0, initialBackdrop); if (shouldShowBackdrop) { backdropAnimation .beforeStyles({ 'pointer-events': 'none', }) .afterClearStyles(['pointer-events']); } const wrapperAnimation = createAnimation('wrapperAnimation').keyframes([ { offset: 0, opacity: 1, transform: 'translateY(100%)' }, { offset: 1, opacity: 1, transform: `translateY(${100 - currentBreakpoint * 100}%)` }, ]); /** * 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 }; }; const createSheetLeaveAnimation = (opts) => { const { currentBreakpoint, backdropBreakpoint } = opts; /** * Backdrop does not always fade in from 0 to 1 if backdropBreakpoint * is defined, so we need to account for that offset by figuring out * what the current backdrop value should be. */ const backdropValue = `calc(var(--backdrop-opacity) * ${getBackdropValueForSheet(currentBreakpoint, backdropBreakpoint)})`; const defaultBackdrop = [ { offset: 0, opacity: backdropValue }, { offset: 1, opacity: 0 }, ]; const customBackdrop = [ { offset: 0, opacity: backdropValue }, { offset: backdropBreakpoint, opacity: 0 }, { offset: 1, opacity: 0 }, ]; const backdropAnimation = createAnimation('backdropAnimation').keyframes(backdropBreakpoint !== 0 ? customBackdrop : defaultBackdrop); const wrapperAnimation = createAnimation('wrapperAnimation').keyframes([ { offset: 0, opacity: 1, transform: `translateY(${100 - currentBreakpoint * 100}%)` }, { offset: 1, opacity: 1, transform: `translateY(100%)` }, ]); return { wrapperAnimation, backdropAnimation }; }; const createEnterAnimation$1 = () => { const backdropAnimation = createAnimation() .fromTo('opacity', 0.01, 'var(--backdrop-opacity)') .beforeStyles({ 'pointer-events': 'none', }) .afterClearStyles(['pointer-events']); const wrapperAnimation = createAnimation().fromTo('transform', 'translateY(100vh)', 'translateY(0vh)'); return { backdropAnimation, wrapperAnimation, contentAnimation: undefined }; }; /** * iOS Modal Enter Animation for the Card presentation style */ const iosEnterAnimation = (baseEl, opts) => { const { presentingEl, currentBreakpoint, expandToScroll } = opts; const root = getElementRoot(baseEl); const { wrapperAnimation, backdropAnimation, contentAnimation } = currentBreakpoint !== undefined ? createSheetEnterAnimation(opts) : createEnterAnimation$1(); 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 === null || contentAnimation === void 0 ? void 0 : 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]) .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); 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'); page.style.setProperty('padding-bottom', `${footerHeight}px`); } }); if (contentAnimation) { baseAnimation.addAnimation(contentAnimation); } if (presentingEl) { const isMobile = window.innerWidth < 768; const hasCardModal = presentingEl.tagName === 'ION-MODAL' && presentingEl.presentingElement !== undefined; const presentingElRoot = getElementRoot(presentingEl); const presentingAnimation = createAnimation().beforeStyles({ transform: 'translateY(0)', 'transform-origin': 'top center', overflow: 'hidden', }); const bodyEl = document.body; if (isMobile) { /** * Fallback for browsers that does not support `max()` (ex: Firefox) * No need to worry about statusbar padding since engines like Gecko * are not used as the engine for standalone Cordova/Capacitor apps */ const transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))'; const modalTransform = hasCardModal ? '-10px' : transformOffset; const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE; const finalTransform = `translateY(${modalTransform}) scale(${toPresentingScale})`; presentingAnimation .afterStyles({ transform: finalTransform, }) .beforeAddWrite(() => bodyEl.style.setProperty('background-color', 'black')) .addElement(presentingEl) .keyframes([ { offset: 0, filter: 'contrast(1)', transform: 'translateY(0px) scale(1)', borderRadius: '0px' }, { offset: 1, filter: 'contrast(0.85)', transform: finalTransform, borderRadius: '10px 10px 0 0' }, ]); baseAnimation.addAnimation(presentingAnimation); } else { baseAnimation.addAnimation(backdropAnimation); if (!hasCardModal) { wrapperAnimation.fromTo('opacity', '0', '1'); } else { const toPresentingScale = hasCardModal ? SwipeToCloseDefaults.MIN_PRESENTING_SCALE : 1; const finalTransform = `translateY(-10px) scale(${toPresentingScale})`; presentingAnimation .afterStyles({ transform: finalTransform, }) .addElement(presentingElRoot.querySelector('.modal-wrapper')) .keyframes([ { offset: 0, filter: 'contrast(1)', transform: 'translateY(0) scale(1)' }, { offset: 1, filter: 'contrast(0.85)', transform: finalTransform }, ]); const shadowAnimation = createAnimation() .afterStyles({ transform: finalTransform, }) .addElement(presentingElRoot.querySelector('.modal-shadow')) .keyframes([ { offset: 0, opacity: '1', transform: 'translateY(0) scale(1)' }, { offset: 1, opacity: '0', transform: finalTransform }, ]); baseAnimation.addAnimation([presentingAnimation, shadowAnimation]); } } } else { baseAnimation.addAnimation(backdropAnimation); } return baseAnimation; }; const createLeaveAnimation$1 = () => { const backdropAnimation = createAnimation().fromTo('opacity', 'var(--backdrop-opacity)', 0); const wrapperAnimation = createAnimation().fromTo('transform', 'translateY(0vh)', 'translateY(100vh)'); return { backdropAnimation, wrapperAnimation }; }; /** * iOS Modal Leave Animation */ const iosLeaveAnimation = (baseEl, opts, duration = 500) => { const { presentingEl, currentBreakpoint, expandToScroll } = opts; const root = getElementRoot(baseEl); const { wrapperAnimation, backdropAnimation } = currentBreakpoint !== undefined ? createSheetLeaveAnimation(opts) : createLeaveAnimation$1(); backdropAnimation.addElement(root.querySelector('ion-backdrop')); wrapperAnimation.addElement(root.querySelectorAll('.modal-wrapper, .modal-shadow')).beforeStyles({ opacity: 1 }); const baseAnimation = createAnimation('leaving-base') .addElement(baseEl) .easing('cubic-bezier(0.32,0.72,0,1)') .duration(duration) .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'); page.style.removeProperty('padding-bottom'); } }); if (presentingEl) { const isMobile = window.innerWidth < 768; const hasCardModal = presentingEl.tagName === 'ION-MODAL' && presentingEl.presentingElement !== undefined; const presentingElRoot = getElementRoot(presentingEl); const presentingAnimation = createAnimation() .beforeClearStyles(['transform']) .afterClearStyles(['transform']) .onFinish((currentStep) => { // only reset background color if this is the last card-style modal if (currentStep !== 1) { return; } presentingEl.style.setProperty('overflow', ''); const numModals = Array.from(bodyEl.querySelectorAll('ion-modal:not(.overlay-hidden)')).filter((m) => m.presentingElement !== undefined).length; if (numModals <= 1) { bodyEl.style.setProperty('background-color', ''); } }); const bodyEl = document.body; if (isMobile) { const transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))'; const modalTransform = hasCardModal ? '-10px' : transformOffset; const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE; const finalTransform = `translateY(${modalTransform}) scale(${toPresentingScale})`; presentingAnimation.addElement(presentingEl).keyframes([ { offset: 0, filter: 'contrast(0.85)', transform: finalTransform, borderRadius: '10px 10px 0 0' }, { offset: 1, filter: 'contrast(1)', transform: 'translateY(0px) scale(1)', borderRadius: '0px' }, ]); baseAnimation.addAnimation(presentingAnimation); } else { baseAnimation.addAnimation(backdropAnimation); if (!hasCardModal) { wrapperAnimation.fromTo('opacity', '1', '0'); } else { const toPresentingScale = hasCardModal ? SwipeToCloseDefaults.MIN_PRESENTING_SCALE : 1; const finalTransform = `translateY(-10px) scale(${toPresentingScale})`; presentingAnimation .addElement(presentingElRoot.querySelector('.modal-wrapper')) .afterStyles({ transform: 'translate3d(0, 0, 0)', }) .keyframes([ { offset: 0, filter: 'contrast(0.85)', transform: finalTransform }, { offset: 1, filter: 'contrast(1)', transform: 'translateY(0) scale(1)' }, ]); const shadowAnimation = createAnimation() .addElement(presentingElRoot.querySelector('.modal-shadow')) .afterStyles({ transform: 'translateY(0) scale(1)', }) .keyframes([ { offset: 0, opacity: '0', transform: finalTransform }, { offset: 1, opacity: '1', transform: 'translateY(0) scale(1)' }, ]); baseAnimation.addAnimation([presentingAnimation, shadowAnimation]); } } } else { baseAnimation.addAnimation(backdropAnimation); } return baseAnimation; }; const createEnterAnimation = () => { const backdropAnimation = createAnimation() .fromTo('opacity', 0.01, 'var(--backdrop-opacity)') .beforeStyles({ 'pointer-events': 'none', }) .afterClearStyles(['pointer-events']); const wrapperAnimation = createAnimation().keyframes([ { offset: 0, opacity: 0.01, transform: 'translateY(40px)' }, { offset: 1, opacity: 1, transform: `translateY(0px)` }, ]); return { backdropAnimation, wrapperAnimation, contentAnimation: undefined }; }; /** * Md Modal Enter Animation */ const mdEnterAnimation = (baseEl, opts) => { const { currentBreakpoint, expandToScroll } = opts; const root = getElementRoot(baseEl); const { wrapperAnimation, backdropAnimation, contentAnimation } = currentBreakpoint !== undefined ? createSheetEnterAnimation(opts) : createEnterAnimation(); backdropAnimation.addElement(root.querySelector('ion-backdrop')); wrapperAnimation.addElement(root.querySelector('.modal-wrapper')); // The content animation is only added if scrolling is enabled for // all the breakpoints. expandToScroll && (contentAnimation === null || contentAnimation === void 0 ? void 0 : 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]) .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); 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'); page.style.setProperty('padding-bottom', `${footerHeight}px`); } }); if (contentAnimation) { baseAnimation.addAnimation(contentAnimation); } return baseAnimation; }; const createLeaveAnimation = () => { const backdropAnimation = createAnimation().fromTo('opacity', 'var(--backdrop-opacity)', 0); const wrapperAnimation = createAnimation().keyframes([ { offset: 0, opacity: 0.99, transform: `translateY(0px)` }, { offset: 1, opacity: 0, transform: 'translateY(40px)' }, ]); return { backdropAnimation, wrapperAnimation }; }; /** * Md Modal Leave Animation */ const mdLeaveAnimation = (baseEl, opts) => { const { currentBreakpoint, expandToScroll } = opts; const root = getElementRoot(baseEl); const { wrapperAnimation, backdropAnimation } = currentBreakpoint !== undefined ? createSheetLeaveAnimation(opts) : createLeaveAnimation(); backdropAnimation.addElement(root.querySelector('ion-backdrop')); wrapperAnimation.addElement(root.querySelector('.modal-wrapper')); const baseAnimation = createAnimation() .easing('cubic-bezier(0.47,0,0.745,0.715)') .duration(200) .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'); page.style.removeProperty('padding-bottom'); } }); return baseAnimation; }; const createSheetGesture = (baseEl, backdropEl, wrapperEl, initialBreakpoint, backdropBreakpoint, animation, breakpoints = [], expandToScroll, getCurrentBreakpoint, onDismiss, onBreakpointChange) => { // Defaults for the sheet swipe animation const defaultBackdrop = [ { offset: 0, opacity: 'var(--backdrop-opacity)' }, { offset: 1, opacity: 0.01 }, ]; const customBackdrop = [ { offset: 0, opacity: 'var(--backdrop-opacity)' }, { offset: 1 - backdropBreakpoint, opacity: 0 }, { offset: 1, opacity: 0 }, ]; const SheetDefaults = { WRAPPER_KEYFRAMES: [ { offset: 0, transform: 'translateY(0%)' }, { 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'); const height = wrapperEl.clientHeight; let currentBreakpoint = initialBreakpoint; let offset = 0; let canDismissBlocksGesture = false; let cachedScrollEl = null; const canDismissMaxStep = 0.95; 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'); backdropEl.style.setProperty('pointer-events', 'auto'); /** * When the backdrop is enabled, elements such * as inputs should not be focusable outside * the sheet. */ baseEl.classList.remove(FOCUS_TRAP_DISABLE_CLASS); }; const disableBackdrop = () => { baseEl.style.setProperty('pointer-events', 'none'); backdropEl.style.setProperty('pointer-events', 'none'); /** * When the backdrop is enabled, elements such * as inputs should not be focusable outside * the sheet. * Adding this class disables focus trapping * for the sheet temporarily. */ 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) => { const originalFooter = baseEl.querySelector('ion-footer'); if (!originalFooter) { return; } const clonedFooter = wrapperEl.nextElementSibling; 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'); 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 * offset 0 to offset 1 so that users can * swipe in any direction. We then set the * animation offset to the current breakpoint * so there is no flickering. */ if (wrapperAnimation && backdropAnimation) { wrapperAnimation.keyframes([...SheetDefaults.WRAPPER_KEYFRAMES]); backdropAnimation.keyframes([...SheetDefaults.BACKDROP_KEYFRAMES]); contentAnimation === null || contentAnimation === void 0 ? void 0 : contentAnimation.keyframes([...SheetDefaults.CONTENT_KEYFRAMES]); animation.progressStart(true, 1 - currentBreakpoint); /** * If backdrop is not enabled, then content * behind modal should be clickable. To do this, we need * to remove pointer-events from ion-modal as a whole. * ion-backdrop and .modal-wrapper always have pointer-events: auto * applied, so the modal content can still be interacted with. */ const shouldEnableBackdrop = currentBreakpoint > backdropBreakpoint; if (shouldEnableBackdrop) { enableBackdrop(); } else { disableBackdrop(); } } if (contentEl && currentBreakpoint !== maxBreakpoint && expandToScroll) { contentEl.scrollY = false; } const canStart = (detail) => { /** * If we are swiping on the content, swiping should only be possible if the content * is scrolled all the way to the top so that we do not interfere with scrolling. * * We cannot assume that the `ion-content` target will remain consistent between swipes. * For example, when using ion-nav within a modal it is possible to swipe, push a view, * and then swipe again. The target content will not be the same between swipes. */ const contentEl = findClosestIonContent(detail.event.target); currentBreakpoint = getCurrentBreakpoint(); /** * If `expandToScroll` is disabled, we should not allow the swipe gesture * to start if the content is not scrolled to the top. */ if (!expandToScroll && contentEl) { const scrollEl = isIonContent(contentEl) ? getElementRoot(contentEl).querySelector('.inner-scroll') : contentEl; return scrollEl.scrollTop === 0; } if (currentBreakpoint === 1 && contentEl) { /** * The modal should never swipe to close on the content with a refresher. * Note 1: We cannot solve this by making this gesture have a higher priority than * the refresher gesture as the iOS native refresh gesture uses a scroll listener in * addition to a gesture. * * Note 2: Do not use getScrollElement here because we need this to be a synchronous * operation, and getScrollElement is asynchronous. */ const scrollEl = isIonContent(contentEl) ? getElementRoot(contentEl).querySelector('.inner-scroll') : contentEl; const hasRefresherInContent = !!contentEl.querySelector('ion-refresher'); return !hasRefresherInContent && scrollEl.scrollTop === 0; } return true; }; const onStart = (detail) => { /** * If canDismiss is anything other than `true` * then users should be able to swipe down * until a threshold is hit. At that point, * the card modal should not proceed any further. * * canDismiss is never fired via gesture if there is * no 0 breakpoint. However, it can be fired if the user * presses Esc or the hardware back button. * TODO (FW-937) * Remove undefined check */ canDismissBlocksGesture = baseEl.canDismiss !== undefined && baseEl.canDismiss !== true && minBreakpoint === 0; /** * Cache the scroll element reference when the gesture starts, * this allows us to avoid querying the DOM for the target in onMove, * which would impact performance significantly. */ if (!expandToScroll) { const targetEl = findClosestIonContent(detail.event.target); cachedScrollEl = targetEl && isIonContent(targetEl) ? getElementRoot(targetEl).querySelector('.inner-scroll') : targetEl; } /** * 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. */ if (detail.deltaY > 0 && contentEl) { contentEl.scrollY = false; } raf(() => { /** * Dismisses the open keyboard when the sheet drag gesture is started. * Sets the focus onto the modal element. */ baseEl.focus(); }); animation.progressStart(true, 1 - currentBreakpoint); }; const onMove = (detail) => { /** * If `expandToScroll` is disabled, and an upwards swipe gesture is done within * the scrollable content, we should not allow the swipe gesture to continue. */ if (!expandToScroll && detail.deltaY <= 0 && cachedScrollEl) { return; } /** * 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. * This accounts for when the user scrolls down, scrolls all the way up, and then * pulls down again such that the modal should start to move. */ if (detail.deltaY > 0 && contentEl) { contentEl.scrollY = false; } /** * Given the change in gesture position on the Y axis, * compute where the offset of the animation should be * relative to where the user dragged. */ const initialStep = 1 - currentBreakpoint; const secondToLastBreakpoint = breakpoints.length > 1 ? 1 - breakpoints[1] : undefined; const step = initialStep + detail.deltaY / height; const isAttemptingDismissWithCanDismiss = secondToLastBreakpoint !== undefined && step >= secondToLastBreakpoint && canDismissBlocksGesture; /** * If we are blocking the gesture from dismissing, * set the max step value so that the sheet cannot be * completely hidden. */ const maxStep = isAttemptingDismissWithCanDismiss ? canDismissMaxStep : 0.9999; /** * If we are blocking the gesture from * dismissing, calculate the spring modifier value * this will be added to the starting breakpoint * value to give the gesture a spring-like feeling. * Note that when isAttemptingDismissWithCanDismiss is true, * the modifier is always added to the breakpoint that * appears right after the 0 breakpoint. * * Note that this modifier is essentially the progression * between secondToLastBreakpoint and maxStep which is * why we subtract secondToLastBreakpoint. This lets us get * the result as a value from 0 to 1.