UNPKG

@ionic/core

Version:
1,093 lines (1,092 loc) • 85.5 kB
/*! * (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",