@ionic/core
Version:
Base components for Ionic
1,167 lines (1,156 loc) • 92.8 kB
JavaScript
/*!
* (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.