@ionic/core
Version:
Base components for Ionic
1,093 lines (1,092 loc) • 85.5 kB
JavaScript
/*!
* (C) Ionic http://ionicframework.com - MIT License
*/
import { Host, h, writeTask } from "@stencil/core";
import { findIonContent, printIonContentErrorMsg } from "../../utils/content/index";
import { CoreDelegate, attachComponent, detachComponent } from "../../utils/framework-delegate";
import { raf, inheritAttributes, hasLazyBuild, getElementRoot } from "../../utils/helpers";
import { createLockController } from "../../utils/lock-controller";
import { printIonWarning } from "../../utils/logging/index";
import { Style as StatusBarStyle, StatusBar } from "../../utils/native/status-bar";
import { GESTURE, BACKDROP, dismiss, eventMethod, prepareOverlay, present, createTriggerController, setOverlayId, FOCUS_TRAP_DISABLE_CLASS, } from "../../utils/overlays";
import { getClassMap } from "../../utils/theme";
import { deepReady, waitForMount } from "../../utils/transition/index";
import { config } from "../../global/config";
import { getIonMode } from "../../global/ionic-global";
import { KEYBOARD_DID_OPEN } from "../../utils/keyboard/keyboard";
import { iosEnterAnimation } from "./animations/ios.enter";
import { iosLeaveAnimation } from "./animations/ios.leave";
import { portraitToLandscapeTransition, landscapeToPortraitTransition } from "./animations/ios.transition";
import { mdEnterAnimation } from "./animations/md.enter";
import { mdLeaveAnimation } from "./animations/md.leave";
import { createSheetGesture } from "./gestures/sheet";
import { createSwipeToCloseGesture, SwipeToCloseDefaults } from "./gestures/swipe-to-close";
import { setCardStatusBarDark, setCardStatusBarDefault } from "./utils";
// TODO(FW-2832): types
/**
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
*
* @slot - Content is placed inside of the `.modal-content` element.
*
* @part backdrop - The `ion-backdrop` element.
* @part content - The wrapper element for the default slot.
* @part handle - The handle that is displayed at the top of the sheet modal when `handle="true"`.
*/
export class Modal {
constructor() {
this.lockController = createLockController();
this.triggerController = createTriggerController();
this.coreDelegate = CoreDelegate();
this.isSheetModal = false;
this.inheritedAttributes = {};
this.inline = false;
// Whether or not modal is being dismissed via gesture
this.gestureAnimationDismissing = false;
this.presented = false;
/** @internal */
this.hasController = false;
/**
* If `true`, the keyboard will be automatically dismissed when the overlay is presented.
*/
this.keyboardClose = true;
/**
* 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. The modal will
* only expand when dragging the header or handle. The modal will close when
* dragging the header or handle. It can also be closed when dragging the
* content, but only if the content is scrolled to the top.
*/
this.expandToScroll = true;
/**
* A decimal value between 0 and 1 that indicates the
* point after which the backdrop will begin to fade in
* when using a sheet modal. Prior to this point, the
* backdrop will be hidden and the content underneath
* the sheet can be interacted with. This value is exclusive
* meaning the backdrop will become active after the value
* specified.
*/
this.backdropBreakpoint = 0;
/**
* The interaction behavior for the sheet modal when the handle is pressed.
*
* Defaults to `"none"`, which means the modal will not change size or position when the handle is pressed.
* Set to `"cycle"` to let the modal cycle between available breakpoints when pressed.
*
* Handle behavior is unavailable when the `handle` property is set to `false` or
* when the `breakpoints` property is not set (using a fullscreen or card modal).
*/
this.handleBehavior = 'none';
/**
* If `true`, the modal will be dismissed when the backdrop is clicked.
*/
this.backdropDismiss = true;
/**
* If `true`, a backdrop will be displayed behind the modal.
* This property controls whether or not the backdrop
* darkens the screen when the modal is presented.
* It does not control whether or not the backdrop
* is active or present in the DOM.
*/
this.showBackdrop = true;
/**
* If `true`, the modal will animate.
*/
this.animated = true;
/**
* If `true`, the modal will open. If `false`, the modal will close.
* Use this if you need finer grained control over presentation, otherwise
* just use the modalController or the `trigger` property.
* Note: `isOpen` will not automatically be set back to `false` when
* the modal dismisses. You will need to do that in your code.
*/
this.isOpen = false;
/**
* If `true`, the component passed into `ion-modal` will
* automatically be mounted when the modal is created. The
* component will remain mounted even when the modal is dismissed.
* However, the component will be destroyed when the modal is
* destroyed. This property is not reactive and should only be
* used when initially creating a modal.
*
* Note: This feature only applies to inline modals in JavaScript
* frameworks such as Angular, React, and Vue.
*/
this.keepContentsMounted = false;
/**
* 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.
*/
this.focusTrap = true;
/**
* Determines whether or not a modal can dismiss
* when calling the `dismiss` method.
*
* If the value is `true` or the value's function returns `true`, the modal will close when trying to dismiss.
* If the value is `false` or the value's function returns `false`, the modal will not close when trying to dismiss.
*
* See https://ionicframework.com/docs/troubleshooting/runtime#accessing-this
* if you need to access `this` from within the callback.
*/
this.canDismiss = true;
this.onHandleClick = () => {
const { sheetTransition, handleBehavior } = this;
if (handleBehavior !== 'cycle' || sheetTransition !== undefined) {
/**
* The sheet modal should not advance to the next breakpoint
* if the handle behavior is not `cycle` or if the handle
* is clicked while the sheet is moving to a breakpoint.
*/
return;
}
this.moveToNextBreakpoint();
};
this.onBackdropTap = () => {
const { sheetTransition } = this;
if (sheetTransition !== undefined) {
/**
* When the handle is double clicked at the largest breakpoint,
* it will start to move to the first breakpoint. While transitioning,
* the backdrop will often receive the second click. We prevent the
* backdrop from dismissing the modal while moving between breakpoints.
*/
return;
}
this.dismiss(undefined, BACKDROP);
};
this.onLifecycle = (modalEvent) => {
const el = this.usersElement;
const name = LIFECYCLE_MAP[modalEvent.type];
if (el && name) {
const ev = new CustomEvent(name, {
bubbles: false,
cancelable: false,
detail: modalEvent.detail,
});
el.dispatchEvent(ev);
}
};
/**
* When the modal receives focus directly, pass focus to the handle
* if it exists and is focusable, otherwise let the focus trap handle it.
*/
this.onModalFocus = (ev) => {
const { dragHandleEl, el } = this;
// Only handle focus if the modal itself was focused (not a child element)
if (ev.target === el && dragHandleEl && dragHandleEl.tabIndex !== -1) {
dragHandleEl.focus();
}
};
/**
* When the slot changes, we need to find all the modals in the slot
* and set the data-parent-ion-modal attribute on them so we can find them
* and dismiss them when we get dismissed.
* We need to do it this way because when a modal is opened, it's moved to
* the end of the body and is no longer an actual child of the modal.
*/
this.onSlotChange = ({ target }) => {
const slot = target;
slot.assignedElements().forEach((el) => {
el.querySelectorAll('ion-modal').forEach((childModal) => {
// We don't need to write to the DOM if the modal is already tagged
// If this is a deeply nested modal, this effect should cascade so we don't
// need to worry about another modal claiming the same child.
if (childModal.getAttribute('data-parent-ion-modal') === null) {
childModal.setAttribute('data-parent-ion-modal', this.el.id);
}
});
});
};
}
onIsOpenChange(newValue, oldValue) {
if (newValue === true && oldValue === false) {
this.present();
}
else if (newValue === false && oldValue === true) {
this.dismiss();
}
}
triggerChanged() {
const { trigger, el, triggerController } = this;
if (trigger) {
triggerController.addClickListener(el, trigger);
}
}
onWindowResize() {
// Only handle resize for iOS card modals when no custom animations are provided
if (getIonMode(this) !== 'ios' || !this.presentingElement || this.enterAnimation || this.leaveAnimation) {
return;
}
clearTimeout(this.resizeTimeout);
this.resizeTimeout = setTimeout(() => {
this.handleViewTransition();
}, 50); // Debounce to avoid excessive calls during active resizing
}
breakpointsChanged(breakpoints) {
if (breakpoints !== undefined) {
this.sortedBreakpoints = breakpoints.sort((a, b) => a - b);
}
}
connectedCallback() {
const { el } = this;
prepareOverlay(el);
this.triggerChanged();
}
disconnectedCallback() {
this.triggerController.removeClickListener();
this.cleanupViewTransitionListener();
this.cleanupParentRemovalObserver();
}
componentWillLoad() {
var _a;
const { breakpoints, initialBreakpoint, el, htmlAttributes } = this;
const isSheetModal = (this.isSheetModal = breakpoints !== undefined && initialBreakpoint !== undefined);
const attributesToInherit = ['aria-label', 'role'];
this.inheritedAttributes = inheritAttributes(el, attributesToInherit);
// Cache original parent before modal gets moved to body during presentation
if (el.parentNode) {
this.cachedOriginalParent = el.parentNode;
}
/**
* When using a controller modal you can set attributes
* using the htmlAttributes property. Since the above attributes
* need to be inherited inside of the modal, we need to look
* and see if these attributes are being set via htmlAttributes.
*
* We could alternatively move this to componentDidLoad to simplify the work
* here, but we'd then need to make inheritedAttributes a State variable,
* thus causing another render to always happen after the first render.
*/
if (htmlAttributes !== undefined) {
attributesToInherit.forEach((attribute) => {
const attributeValue = htmlAttributes[attribute];
if (attributeValue) {
/**
* If an attribute we need to inherit was
* set using htmlAttributes then add it to
* inheritedAttributes and remove it from htmlAttributes.
* This ensures the attribute is inherited and not
* set on the host.
*
* In this case, if an inherited attribute is set
* on the host element and using htmlAttributes then
* htmlAttributes wins, but that's not a pattern that we recommend.
* The only time you'd need htmlAttributes is when using modalController.
*/
this.inheritedAttributes = Object.assign(Object.assign({}, this.inheritedAttributes), { [attribute]: htmlAttributes[attribute] });
delete htmlAttributes[attribute];
}
});
}
if (isSheetModal) {
this.currentBreakpoint = this.initialBreakpoint;
}
if (breakpoints !== undefined && initialBreakpoint !== undefined && !breakpoints.includes(initialBreakpoint)) {
printIonWarning('[ion-modal] - Your breakpoints array must include the initialBreakpoint value.');
}
if (!((_a = this.htmlAttributes) === null || _a === void 0 ? void 0 : _a.id)) {
setOverlayId(this.el);
}
}
componentDidLoad() {
/**
* If modal was rendered with isOpen="true"
* then we should open modal immediately.
*/
if (this.isOpen === true) {
raf(() => this.present());
}
this.breakpointsChanged(this.breakpoints);
/**
* When binding values in frameworks such as Angular
* it is possible for the value to be set after the Web Component
* initializes but before the value watcher is set up in Stencil.
* As a result, the watcher callback may not be fired.
* We work around this by manually calling the watcher
* callback when the component has loaded and the watcher
* is configured.
*/
this.triggerChanged();
}
/**
* Determines whether or not an overlay
* is being used inline or via a controller/JS
* and returns the correct delegate.
* By default, subsequent calls to getDelegate
* will use a cached version of the delegate.
* This is useful for calling dismiss after
* present so that the correct delegate is given.
*/
getDelegate(force = false) {
if (this.workingDelegate && !force) {
return {
delegate: this.workingDelegate,
inline: this.inline,
};
}
/**
* If using overlay inline
* we potentially need to use the coreDelegate
* so that this works in vanilla JS apps.
* If a developer has presented this component
* via a controller, then we can assume
* the component is already in the
* correct place.
*/
const parentEl = this.el.parentNode;
const inline = (this.inline = parentEl !== null && !this.hasController);
const delegate = (this.workingDelegate = inline ? this.delegate || this.coreDelegate : this.delegate);
return { inline, delegate };
}
/**
* Determines whether or not the
* modal is allowed to dismiss based
* on the state of the canDismiss prop.
*/
async checkCanDismiss(data, role) {
const { canDismiss } = this;
if (typeof canDismiss === 'function') {
return canDismiss(data, role);
}
return canDismiss;
}
/**
* Present the modal overlay after it has been created.
*/
async present() {
const unlock = await this.lockController.lock();
if (this.presented) {
unlock();
return;
}
const { presentingElement, el } = this;
/**
* If the modal is presented multiple times (inline modals), we
* need to reset the current breakpoint to the initial breakpoint.
*/
this.currentBreakpoint = this.initialBreakpoint;
const { inline, delegate } = this.getDelegate(true);
/**
* Emit ionMount so JS Frameworks have an opportunity
* to add the child component to the DOM. The child
* component will be assigned to this.usersElement below.
*/
this.ionMount.emit();
this.usersElement = await attachComponent(delegate, el, this.component, ['ion-page'], this.componentProps, inline);
/**
* When using the lazy loaded build of Stencil, we need to wait
* for every Stencil component instance to be ready before presenting
* otherwise there can be a flash of unstyled content. With the
* custom elements bundle we need to wait for the JS framework
* mount the inner contents of the overlay otherwise WebKit may
* get the transition incorrect.
*/
if (hasLazyBuild(el)) {
await deepReady(this.usersElement);
/**
* If keepContentsMounted="true" then the
* JS Framework has already mounted the inner
* contents so there is no need to wait.
* Otherwise, we need to wait for the JS
* Framework to mount the inner contents
* of this component.
*/
}
else if (!this.keepContentsMounted) {
await waitForMount();
}
writeTask(() => this.el.classList.add('show-modal'));
const hasCardModal = presentingElement !== undefined;
/**
* We need to change the status bar at the
* start of the animation so that it completes
* by the time the card animation is done.
*/
if (hasCardModal && getIonMode(this) === 'ios') {
// Cache the original status bar color before the modal is presented
this.statusBarStyle = await StatusBar.getStyle();
setCardStatusBarDark();
}
await present(this, 'modalEnter', iosEnterAnimation, mdEnterAnimation, {
presentingEl: presentingElement,
currentBreakpoint: this.initialBreakpoint,
backdropBreakpoint: this.backdropBreakpoint,
expandToScroll: this.expandToScroll,
});
/* tslint:disable-next-line */
if (typeof window !== 'undefined') {
/**
* This needs to be setup before any
* non-transition async work so it can be dereferenced
* in the dismiss method. The dismiss method
* only waits for the entering transition
* to finish. It does not wait for all of the `present`
* method to resolve.
*/
this.keyboardOpenCallback = () => {
if (this.gesture) {
/**
* When the native keyboard is opened and the webview
* is resized, the gesture implementation will become unresponsive
* and enter a free-scroll mode.
*
* When the keyboard is opened, we disable the gesture for
* a single frame and re-enable once the contents have repositioned
* from the keyboard placement.
*/
this.gesture.enable(false);
raf(() => {
if (this.gesture) {
this.gesture.enable(true);
}
});
}
};
window.addEventListener(KEYBOARD_DID_OPEN, this.keyboardOpenCallback);
}
/**
* Recalculate isSheetModal because framework bindings (e.g., Angular)
* may not have been applied when componentWillLoad ran.
*/
const isSheetModal = this.breakpoints !== undefined && this.initialBreakpoint !== undefined;
this.isSheetModal = isSheetModal;
if (isSheetModal) {
this.initSheetGesture();
}
else if (hasCardModal) {
this.initSwipeToClose();
}
// Initialize view transition listener for iOS card modals
this.initViewTransitionListener();
// Initialize parent removal observer
this.initParentRemovalObserver();
unlock();
}
initSwipeToClose() {
var _a;
if (getIonMode(this) !== 'ios') {
return;
}
const { el } = this;
// All of the elements needed for the swipe gesture
// 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,
expandToScroll: this.expandToScroll,
}));
const contentEl = findIonContent(el);
if (!contentEl) {
printIonContentErrorMsg(el);
return;
}
const statusBarStyle = (_a = this.statusBarStyle) !== null && _a !== void 0 ? _a : StatusBarStyle.Default;
this.gesture = createSwipeToCloseGesture(el, ani, statusBarStyle, () => {
/**
* While the gesture animation is finishing
* it is possible for a user to tap the backdrop.
* This would result in the dismiss animation
* being played again. Typically this is avoided
* by setting `presented = false` on the overlay
* component; however, we cannot do that here as
* that would prevent the element from being
* removed from the DOM.
*/
this.gestureAnimationDismissing = true;
/**
* Reset the status bar style as the dismiss animation
* starts otherwise the status bar will be the wrong
* color for the duration of the dismiss animation.
* The dismiss method does this as well, but
* in this case it's only called once the animation
* has finished.
*/
setCardStatusBarDefault(this.statusBarStyle);
this.animation.onFinish(async () => {
await this.dismiss(undefined, GESTURE);
this.gestureAnimationDismissing = false;
});
});
this.gesture.enable(true);
}
initSheetGesture() {
const { wrapperEl, initialBreakpoint, backdropBreakpoint } = this;
if (!wrapperEl || initialBreakpoint === undefined) {
return;
}
const animationBuilder = this.enterAnimation || config.get('modalEnter', iosEnterAnimation);
const ani = (this.animation = animationBuilder(this.el, {
presentingEl: this.presentingElement,
currentBreakpoint: initialBreakpoint,
backdropBreakpoint,
expandToScroll: this.expandToScroll,
}));
ani.progressStart(true, 1);
const { gesture, moveSheetToBreakpoint } = createSheetGesture(this.el, this.backdropEl, wrapperEl, initialBreakpoint, backdropBreakpoint, ani, this.sortedBreakpoints, this.expandToScroll, () => { var _a; return (_a = this.currentBreakpoint) !== null && _a !== void 0 ? _a : 0; }, () => this.sheetOnDismiss(), (breakpoint) => {
if (this.currentBreakpoint !== breakpoint) {
this.currentBreakpoint = breakpoint;
this.ionBreakpointDidChange.emit({ breakpoint });
}
});
this.gesture = gesture;
this.moveSheetToBreakpoint = moveSheetToBreakpoint;
this.gesture.enable(true);
/**
* When backdrop interaction is allowed, nested router outlets from child routes
* may block pointer events to parent content. Apply passthrough styles only when
* the modal was the sole content of a child route page.
* See https://github.com/ionic-team/ionic-framework/issues/30700
*/
const backdropNotBlocking = this.showBackdrop === false || this.focusTrap === false || backdropBreakpoint > 0;
if (backdropNotBlocking) {
this.setupChildRoutePassthrough();
}
}
/**
* For sheet modals that allow background interaction, sets up pointer-events
* passthrough on child route page wrappers and nested router outlets.
*/
setupChildRoutePassthrough() {
var _a;
// Cache the page parent for cleanup
this.cachedPageParent = this.getOriginalPageParent();
const pageParent = this.cachedPageParent;
// Skip ion-app (controller modals) and pages with visible sibling content next to the modal
if (!pageParent || pageParent.tagName === 'ION-APP') {
return;
}
const hasVisibleContent = Array.from(pageParent.children).some((child) => {
var _a;
return child !== this.el &&
!(child instanceof HTMLElement && window.getComputedStyle(child).display === 'none') &&
child.tagName !== 'TEMPLATE' &&
child.tagName !== 'SLOT' &&
!(child.nodeType === Node.TEXT_NODE && !((_a = child.textContent) === null || _a === void 0 ? void 0 : _a.trim()));
});
if (hasVisibleContent) {
return;
}
// Child route case: page only contained the modal
pageParent.classList.add('ion-page-overlay-passthrough');
// Also make nested router outlets passthrough
const routerOutlet = pageParent.parentElement;
if ((routerOutlet === null || routerOutlet === void 0 ? void 0 : routerOutlet.tagName) === 'ION-ROUTER-OUTLET' && ((_a = routerOutlet.parentElement) === null || _a === void 0 ? void 0 : _a.tagName) !== 'ION-APP') {
routerOutlet.style.setProperty('pointer-events', 'none');
routerOutlet.setAttribute('data-overlay-passthrough', 'true');
}
}
/**
* Finds the ion-page ancestor of the modal's original parent location.
*/
getOriginalPageParent() {
if (!this.cachedOriginalParent) {
return null;
}
let pageParent = this.cachedOriginalParent;
while (pageParent && !pageParent.classList.contains('ion-page')) {
pageParent = pageParent.parentElement;
}
return pageParent;
}
/**
* Removes passthrough styles added by setupChildRoutePassthrough.
*/
cleanupChildRoutePassthrough() {
const pageParent = this.cachedPageParent;
if (!pageParent) {
return;
}
pageParent.classList.remove('ion-page-overlay-passthrough');
const routerOutlet = pageParent.parentElement;
if (routerOutlet === null || routerOutlet === void 0 ? void 0 : routerOutlet.hasAttribute('data-overlay-passthrough')) {
routerOutlet.style.removeProperty('pointer-events');
routerOutlet.removeAttribute('data-overlay-passthrough');
}
// Clear the cached reference
this.cachedPageParent = undefined;
}
sheetOnDismiss() {
/**
* While the gesture animation is finishing
* it is possible for a user to tap the backdrop.
* This would result in the dismiss animation
* being played again. Typically this is avoided
* by setting `presented = false` on the overlay
* component; however, we cannot do that here as
* that would prevent the element from being
* removed from the DOM.
*/
this.gestureAnimationDismissing = true;
this.animation.onFinish(async () => {
this.currentBreakpoint = 0;
this.ionBreakpointDidChange.emit({ breakpoint: this.currentBreakpoint });
await this.dismiss(undefined, GESTURE);
this.gestureAnimationDismissing = false;
});
}
/**
* Dismiss the modal overlay after it has been presented.
* This is a no-op if the overlay has not been presented yet. If you want
* to remove an overlay from the DOM that was never presented, use the
* [remove](https://developer.mozilla.org/en-US/docs/Web/API/Element/remove) method.
*
* @param data Any data to emit in the dismiss events.
* @param role The role of the element that is dismissing the modal.
* For example, `cancel` or `backdrop`.
*/
async dismiss(data, role) {
var _a;
if (this.gestureAnimationDismissing && role !== GESTURE) {
return false;
}
/**
* Because the canDismiss check below is async,
* we need to claim a lock before the check happens,
* in case the dismiss transition does run.
*/
const unlock = await this.lockController.lock();
/**
* Dismiss all child modals. This is especially important in
* Angular and React because it's possible to lose control of a child
* modal when the parent modal is dismissed.
*/
await this.dismissNestedModals();
/**
* If a canDismiss handler is responsible
* for calling the dismiss method, we should
* not run the canDismiss check again.
*/
if (role !== 'handler' && !(await this.checkCanDismiss(data, role))) {
unlock();
return false;
}
const { presentingElement } = this;
/**
* We need to start the status bar change
* before the animation so that the change
* finishes when the dismiss animation does.
*/
const hasCardModal = presentingElement !== undefined;
if (hasCardModal && getIonMode(this) === 'ios') {
setCardStatusBarDefault(this.statusBarStyle);
}
/* tslint:disable-next-line */
if (typeof window !== 'undefined' && this.keyboardOpenCallback) {
window.removeEventListener(KEYBOARD_DID_OPEN, this.keyboardOpenCallback);
this.keyboardOpenCallback = undefined;
}
const dismissed = await dismiss(this, data, role, 'modalLeave', iosLeaveAnimation, mdLeaveAnimation, {
presentingEl: presentingElement,
currentBreakpoint: (_a = this.currentBreakpoint) !== null && _a !== void 0 ? _a : this.initialBreakpoint,
backdropBreakpoint: this.backdropBreakpoint,
expandToScroll: this.expandToScroll,
});
if (dismissed) {
const { delegate } = this.getDelegate();
await detachComponent(delegate, this.usersElement);
writeTask(() => this.el.classList.remove('show-modal'));
if (this.animation) {
this.animation.destroy();
}
if (this.gesture) {
this.gesture.destroy();
}
this.cleanupViewTransitionListener();
this.cleanupParentRemovalObserver();
this.cleanupChildRoutePassthrough();
}
this.currentBreakpoint = undefined;
this.animation = undefined;
unlock();
return dismissed;
}
/**
* Returns a promise that resolves when the modal did dismiss.
*/
onDidDismiss() {
return eventMethod(this.el, 'ionModalDidDismiss');
}
/**
* Returns a promise that resolves when the modal will dismiss.
*/
onWillDismiss() {
return eventMethod(this.el, 'ionModalWillDismiss');
}
/**
* Move a sheet style modal to a specific breakpoint.
*
* @param breakpoint The breakpoint value to move the sheet modal to.
* Must be a value defined in your `breakpoints` array.
*/
async setCurrentBreakpoint(breakpoint) {
if (!this.isSheetModal) {
printIonWarning('[ion-modal] - setCurrentBreakpoint is only supported on sheet modals.');
return;
}
if (!this.breakpoints.includes(breakpoint)) {
printIonWarning(`[ion-modal] - Attempted to set invalid breakpoint value ${breakpoint}. Please double check that the breakpoint value is part of your defined breakpoints.`);
return;
}
const { currentBreakpoint, moveSheetToBreakpoint, canDismiss, breakpoints, animated } = this;
if (currentBreakpoint === breakpoint) {
return;
}
if (moveSheetToBreakpoint) {
this.sheetTransition = moveSheetToBreakpoint({
breakpoint,
breakpointOffset: 1 - currentBreakpoint,
canDismiss: canDismiss !== undefined && canDismiss !== true && breakpoints[0] === 0,
animated,
});
await this.sheetTransition;
this.sheetTransition = undefined;
}
}
/**
* Returns the current breakpoint of a sheet style modal
*/
async getCurrentBreakpoint() {
return this.currentBreakpoint;
}
async moveToNextBreakpoint() {
const { breakpoints, currentBreakpoint } = this;
if (!breakpoints || currentBreakpoint == null) {
/**
* If the modal does not have breakpoints and/or the current
* breakpoint is not set, we can't move to the next breakpoint.
*/
return false;
}
const allowedBreakpoints = breakpoints.filter((b) => b !== 0);
const currentBreakpointIndex = allowedBreakpoints.indexOf(currentBreakpoint);
const nextBreakpointIndex = (currentBreakpointIndex + 1) % allowedBreakpoints.length;
const nextBreakpoint = allowedBreakpoints[nextBreakpointIndex];
/**
* Sets the current breakpoint to the next available breakpoint.
* If the current breakpoint is the last breakpoint, we set the current
* breakpoint to the first non-zero breakpoint to avoid dismissing the sheet.
*/
await this.setCurrentBreakpoint(nextBreakpoint);
return true;
}
initViewTransitionListener() {
// Only enable for iOS card modals when no custom animations are provided
if (getIonMode(this) !== 'ios' || !this.presentingElement || this.enterAnimation || this.leaveAnimation) {
return;
}
// Set initial view state
this.currentViewIsPortrait = window.innerWidth < 768;
}
handleViewTransition() {
const isPortrait = window.innerWidth < 768;
// Only transition if view state actually changed
if (this.currentViewIsPortrait === isPortrait) {
return;
}
// Cancel any ongoing transition animation
if (this.viewTransitionAnimation) {
this.viewTransitionAnimation.destroy();
this.viewTransitionAnimation = undefined;
}
const { presentingElement } = this;
if (!presentingElement) {
return;
}
// Create transition animation
let transitionAnimation;
if (this.currentViewIsPortrait && !isPortrait) {
// Portrait to landscape transition
transitionAnimation = portraitToLandscapeTransition(this.el, {
presentingEl: presentingElement,
currentBreakpoint: this.currentBreakpoint,
backdropBreakpoint: this.backdropBreakpoint,
expandToScroll: this.expandToScroll,
});
}
else {
// Landscape to portrait transition
transitionAnimation = landscapeToPortraitTransition(this.el, {
presentingEl: presentingElement,
currentBreakpoint: this.currentBreakpoint,
backdropBreakpoint: this.backdropBreakpoint,
expandToScroll: this.expandToScroll,
});
}
// Update state and play animation
this.currentViewIsPortrait = isPortrait;
this.viewTransitionAnimation = transitionAnimation;
transitionAnimation.play().then(() => {
this.viewTransitionAnimation = undefined;
// After orientation transition, recreate the swipe-to-close gesture
// with updated animation that reflects the new presenting element state
this.reinitSwipeToClose();
});
}
cleanupViewTransitionListener() {
// Clear any pending resize timeout
if (this.resizeTimeout) {
clearTimeout(this.resizeTimeout);
this.resizeTimeout = undefined;
}
if (this.viewTransitionAnimation) {
this.viewTransitionAnimation.destroy();
this.viewTransitionAnimation = undefined;
}
}
reinitSwipeToClose() {
// Only reinitialize if we have a presenting element and are on iOS
if (getIonMode(this) !== 'ios' || !this.presentingElement) {
return;
}
// Clean up existing gesture and animation
if (this.gesture) {
this.gesture.destroy();
this.gesture = undefined;
}
if (this.animation) {
// Properly end the progress-based animation at initial state before destroying
// to avoid leaving modal in intermediate swipe position
this.animation.progressEnd(0, 0, 0);
this.animation.destroy();
this.animation = undefined;
}
// Force the modal back to the correct position or it could end up
// in a weird state after destroying the animation
raf(() => {
this.ensureCorrectModalPosition();
this.initSwipeToClose();
});
}
ensureCorrectModalPosition() {
const { el, presentingElement } = this;
const root = getElementRoot(el);
const wrapperEl = root.querySelector('.modal-wrapper');
if (wrapperEl) {
wrapperEl.style.transform = 'translateY(0vh)';
wrapperEl.style.opacity = '1';
}
if ((presentingElement === null || presentingElement === void 0 ? void 0 : presentingElement.tagName) === 'ION-MODAL') {
const isPortrait = window.innerWidth < 768;
if (isPortrait) {
const transformOffset = !CSS.supports('width', 'max(0px, 1px)')
? '30px'
: 'max(30px, var(--ion-safe-area-top))';
const scale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
presentingElement.style.transform = `translateY(${transformOffset}) scale(${scale})`;
}
else {
presentingElement.style.transform = 'translateY(0px) scale(1)';
}
}
}
async dismissNestedModals() {
const nestedModals = document.querySelectorAll(`ion-modal[data-parent-ion-modal="${this.el.id}"]`);
nestedModals === null || nestedModals === void 0 ? void 0 : nestedModals.forEach(async (modal) => {
await modal.dismiss(undefined, 'parent-dismissed');
});
}
initParentRemovalObserver() {
if (typeof MutationObserver === 'undefined') {
return;
}
// Only observe if we have a cached parent and are in browser environment
if (typeof window === 'undefined' || !this.cachedOriginalParent) {
return;
}
// Don't observe document or fragment nodes as they can't be "removed"
if (this.cachedOriginalParent.nodeType === Node.DOCUMENT_NODE ||
this.cachedOriginalParent.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
return;
}
/**
* Don't observe for controller-based modals or when the parent is the
* app root (document.body or ion-app). These parents won't be removed,
* and observing document.body with subtree: true causes performance
* issues with frameworks like Angular during change detection.
*/
if (this.hasController ||
this.cachedOriginalParent === document.body ||
this.cachedOriginalParent.tagName === 'ION-APP') {
return;
}
this.parentRemovalObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList' && mutation.removedNodes.length > 0) {
// Check if our cached original parent was removed
const cachedParentWasRemoved = Array.from(mutation.removedNodes).some((node) => {
var _a, _b;
const isDirectMatch = node === this.cachedOriginalParent;
const isContainedMatch = this.cachedOriginalParent
? (_b = (_a = node).contains) === null || _b === void 0 ? void 0 : _b.call(_a, this.cachedOriginalParent)
: false;
return isDirectMatch || isContainedMatch;
});
// Also check if parent is no longer connected to DOM
const cachedParentDisconnected = this.cachedOriginalParent && !this.cachedOriginalParent.isConnected;
if (cachedParentWasRemoved || cachedParentDisconnected) {
this.dismiss(undefined, 'parent-removed');
// Release the reference to the cached original parent
// so we don't have a memory leak
this.cachedOriginalParent = undefined;
}
}
});
});
// Observe document body with subtree to catch removals at any level
this.parentRemovalObserver.observe(document.body, {
childList: true,
subtree: true,
});
}
cleanupParentRemovalObserver() {
var _a;
(_a = this.parentRemovalObserver) === null || _a === void 0 ? void 0 : _a.disconnect();
this.parentRemovalObserver = undefined;
}
render() {
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';
const isHandleCycle = handleBehavior === 'cycle';
const isSheetModalWithHandle = isSheetModal && showHandle;
return (h(Host, Object.assign({ key: '9a75095a13de0cfc96f1fa69fd92777d25da8daa', "no-router": true,
// Allow the modal to be navigable when the handle is focusable
tabIndex: isHandleCycle && isSheetModalWithHandle ? 0 : -1 }, htmlAttributes, { style: {
zIndex: `${20000 + this.overlayIndex}`,
}, class: Object.assign({ [mode]: true, ['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)), onIonBackdropTap: this.onBackdropTap, onIonModalDidPresent: this.onLifecycle, onIonModalWillPresent: this.onLifecycle, onIonModalWillDismiss: this.onLifecycle, onIonModalDidDismiss: this.onLifecycle, onFocus: this.onModalFocus }), h("ion-backdrop", { key: 'd02612d8063ef20f59f173ff47795f71cdaaf63e', ref: (el) => (this.backdropEl = el), visible: this.showBackdrop, tappable: this.backdropDismiss, part: "backdrop" }), mode === 'ios' && h("div", { key: '708761b70a93e34c08faae079569f444c7416a4c', class: "modal-shadow" }), h("div", Object.assign({ key: 'a72226ff1a98229f9bfd9207b98fc57e02baa430',
/*
role and aria-modal must be used on the
same element. They must also be set inside the
shadow DOM otherwise ion-button will not be highlighted
when using VoiceOver: https://bugs.webkit.org/show_bug.cgi?id=247134
*/
role: "dialog" }, inheritedAttributes, { "aria-modal": "true", class: "modal-wrapper ion-overlay-wrapper", part: "content", ref: (el) => (this.wrapperEl = el) }), showHandle && (h("button", { key: '0547f32323882660221385d84d492929caa77c6b', class: "modal-handle",
// Prevents the handle from receiving keyboard focus when it does not cycle
tabIndex: !isHandleCycle ? -1 : 0, "aria-label": "Activate to adjust the size of the dialog overlaying the screen", onClick: isHandleCycle ? this.onHandleClick : undefined, part: "handle", ref: (el) => (this.dragHandleEl = el) })), h("slot", { key: 'fccbd64518b6fa22f9e874deb6b4ba55d8d89c3b', onSlotchange: this.onSlotChange }))));
}
static get is() { return "ion-modal"; }
static get encapsulation() { return "shadow"; }
static get originalStyleUrls() {
return {
"ios": ["modal.ios.scss"],
"md": ["modal.md.scss"]
};
}
static get styleUrls() {
return {
"ios": ["modal.ios.css"],
"md": ["modal.md.css"]
};
}
static get properties() {
return {
"hasController": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [{
"name": "internal",
"text": undefined
}],
"text": ""
},
"getter": false,
"setter": false,
"reflect": false,
"attribute": "has-controller",
"defaultValue": "false"
},
"overlayIndex": {
"type": "number",
"mutable": false,
"complexType": {
"original": "number",
"resolved": "number",
"references": {}
},
"required": true,
"optional": false,
"docs": {
"tags": [{
"name": "internal",
"text": undefined
}],
"text": ""
},
"getter": false,
"setter": false,
"reflect": false,
"attribute": "overlay-index"
},
"delegate": {
"type": "unknown",
"mutable": false,
"complexType": {
"original": "FrameworkDelegate",
"resolved": "FrameworkDelegate | undefined",
"references": {
"FrameworkDelegate": {
"location": "import",
"path": "../../interface",
"id": "src/interface.d.ts::FrameworkDelegate"
}
}
},
"required": false,
"optional": true,
"docs": {
"tags": [{
"name": "internal",
"text": undefined
}],
"text": ""
},
"getter": false,
"setter": false
},
"keyboardClose": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "If `true`, the keyboard will be automatically dismissed when the overlay is presented."
},
"getter": false,
"setter": false,
"reflect": false,
"attribute": "keyboard-close",