diff --git a/BREAKING.md b/BREAKING.md
index ab70dd22e6..f33644f315 100644
--- a/BREAKING.md
+++ b/BREAKING.md
@@ -14,6 +14,7 @@ This is a comprehensive list of the breaking changes introduced in the major ver
- [Components](#components)
* [Header](#header)
+ * [Modal](#modal)
* [Popover](#popover)
* [Tab Bar](#tab-bar)
* [Toast](#toast)
@@ -47,7 +48,13 @@ ion-header.header-collapse-condense ion-toolbar:last-of-type {
Converted `ion-popover` to use [Shadow DOM](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM).
-If you were targeting the internals of `ion-popover` in your CSS, you will need to target the `backdrop`, `arrow`, or `content` [Shadow Parts](https://ionicframework.com/docs/theming/css-shadow-parts) instead.
+If you were targeting the internals of `ion-popover` in your CSS, you will need to target the `backdrop`, `arrow`, or `content` [Shadow Parts](https://ionicframework.com/docs/theming/css-shadow-parts) instead, or use the provided CSS Variables.
+
+#### Modal
+
+Converted `ion-modal` to use [Shadow DOM](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM).
+
+If you were targeting the internals of `ion-modal` in your CSS, you will need to target the `backdrop` or `content` [Shadow Parts](https://ionicframework.com/docs/theming/css-shadow-parts) instead, or use the provided CSS Variables.
#### Tab Bar
diff --git a/angular/src/directives/overlays/ion-modal.ts b/angular/src/directives/overlays/ion-modal.ts
new file mode 100644
index 0000000000..83f6a31606
--- /dev/null
+++ b/angular/src/directives/overlays/ion-modal.ts
@@ -0,0 +1,39 @@
+/* eslint-disable */
+/* tslint:disable */
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, ElementRef, EventEmitter, NgZone, TemplateRef } from "@angular/core";
+import { ProxyCmp, proxyOutputs } from "../proxies-utils";
+import { Components } from "@ionic/core";
+export declare interface IonModal extends Components.IonModal {
+}
+@ProxyCmp({ inputs: ["animated", "backdropDismiss", "cssClass", "enterAnimation", "event", "isOpen", "keyboardClose", "leaveAnimation", "mode", "showBackdrop", "translucent"], "methods": ["present", "dismiss", "onDidDismiss", "onWillDismiss"] })
+@Component({ selector: "ion-modal", changeDetection: ChangeDetectionStrategy.OnPush, template: ``, inputs: ["animated", "backdropDismiss", "component", "componentProps", "cssClass", "enterAnimation", "event", "isOpen", "keyboardClose", "leaveAnimation", "mode", "showBackdrop", "translucent"] })
+export class IonModal {
+ @ContentChild(TemplateRef, { static: false }) template: TemplateRef;
+
+ ionModalDidPresent!: EventEmitter;
+ ionModalWillPresent!: EventEmitter;
+ ionModalWillDismiss!: EventEmitter;
+ ionModalDidDismiss!: EventEmitter;
+ didPresent!: EventEmitter;
+ willPresent!: EventEmitter;
+ willDismiss!: EventEmitter;
+ didDismiss!: EventEmitter;
+ isCmpOpen: boolean = false;
+
+ protected el: HTMLElement;
+ constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) {
+ c.detach();
+ this.el = r.nativeElement;
+
+ this.el.addEventListener('willPresent', () => {
+ this.isCmpOpen = true;
+ c.detectChanges();
+ });
+ this.el.addEventListener('didDismiss', () => {
+ this.isCmpOpen = false;
+ c.detectChanges();
+ });
+
+ proxyOutputs(this, this.el, ["ionModalDidPresent", "ionModalWillPresent", "ionModalWillDismiss", "ionModalDidDismiss", "didPresent", "willPresent", "willDismiss", "didDismiss"]);
+ }
+}
diff --git a/angular/src/ionic-module.ts b/angular/src/ionic-module.ts
index 1ac600a3bf..3681f89058 100644
--- a/angular/src/ionic-module.ts
+++ b/angular/src/ionic-module.ts
@@ -13,6 +13,7 @@ import { IonRouterOutlet } from './directives/navigation/ion-router-outlet';
import { IonTabs } from './directives/navigation/ion-tabs';
import { NavDelegate } from './directives/navigation/nav-delegate';
import { RouterLinkDelegate } from './directives/navigation/router-link-delegate';
+import { IonModal } from './directives/overlays/ion-modal';
import { IonPopover } from './directives/overlays/ion-popover';
import { IonAccordion, IonAccordionGroup, IonApp, IonAvatar, IonBackButton, IonBackdrop, IonBadge, IonButton, IonButtons, IonCard, IonCardContent, IonCardHeader, IonCardSubtitle, IonCardTitle, IonCheckbox, IonChip, IonCol, IonContent, IonDatetime, IonFab, IonFabButton, IonFabList, IonFooter, IonGrid, IonHeader, IonIcon, IonImg, IonInfiniteScroll, IonInfiniteScrollContent, IonInput, IonItem, IonItemDivider, IonItemGroup, IonItemOption, IonItemOptions, IonItemSliding, IonLabel, IonList, IonListHeader, IonMenu, IonMenuButton, IonMenuToggle, IonNav, IonNavLink, IonNote, IonProgressBar, IonRadio, IonRadioGroup, IonRange, IonRefresher, IonRefresherContent, IonReorder, IonReorderGroup, IonRippleEffect, IonRow, IonSearchbar, IonSegment, IonSegmentButton, IonSelect, IonSelectOption, IonSkeletonText, IonSlide, IonSlides, IonSpinner, IonSplitPane, IonTabBar, IonTabButton, IonText, IonTextarea, IonThumbnail, IonTitle, IonToggle, IonToolbar } from './directives/proxies';
import { VirtualFooter } from './directives/virtual-scroll/virtual-footer';
@@ -68,6 +69,7 @@ const DECLARATIONS = [
IonMenu,
IonMenuButton,
IonMenuToggle,
+ IonModal,
IonNav,
IonNavLink,
IonNote,
diff --git a/core/api.txt b/core/api.txt
index 8846a74906..5612dbc32e 100644
--- a/core/api.txt
+++ b/core/api.txt
@@ -721,27 +721,30 @@ ion-menu-toggle,shadow
ion-menu-toggle,prop,autoHide,boolean,true,false,false
ion-menu-toggle,prop,menu,string | undefined,undefined,false,false
-ion-modal,scoped
+ion-modal,shadow
ion-modal,prop,animated,boolean,true,false,false
ion-modal,prop,backdropDismiss,boolean,true,false,false
-ion-modal,prop,component,Function | HTMLElement | null | string,undefined,true,false
-ion-modal,prop,componentProps,undefined | { [key: string]: any; },undefined,false,false
-ion-modal,prop,cssClass,string | string[] | undefined,undefined,false,false
ion-modal,prop,enterAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false
+ion-modal,prop,isOpen,boolean,false,false,false
ion-modal,prop,keyboardClose,boolean,true,false,false
ion-modal,prop,leaveAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false
ion-modal,prop,mode,"ios" | "md",undefined,false,false
ion-modal,prop,presentingElement,HTMLElement | undefined,undefined,false,false
ion-modal,prop,showBackdrop,boolean,true,false,false
ion-modal,prop,swipeToClose,boolean,false,false,false
+ion-modal,prop,trigger,string | undefined,undefined,false,false
ion-modal,method,dismiss,dismiss(data?: any, role?: string | undefined) => Promise
ion-modal,method,onDidDismiss,onDidDismiss() => Promise>
ion-modal,method,onWillDismiss,onWillDismiss() => Promise>
ion-modal,method,present,present() => Promise
+ion-modal,event,didDismiss,OverlayEventDetail,true
+ion-modal,event,didPresent,void,true
ion-modal,event,ionModalDidDismiss,OverlayEventDetail,true
ion-modal,event,ionModalDidPresent,void,true
ion-modal,event,ionModalWillDismiss,OverlayEventDetail,true
ion-modal,event,ionModalWillPresent,void,true
+ion-modal,event,willDismiss,OverlayEventDetail,true
+ion-modal,event,willPresent,void,true
ion-modal,css-prop,--backdrop-opacity
ion-modal,css-prop,--background
ion-modal,css-prop,--border-color
@@ -754,6 +757,8 @@ ion-modal,css-prop,--max-width
ion-modal,css-prop,--min-height
ion-modal,css-prop,--min-width
ion-modal,css-prop,--width
+ion-modal,part,backdrop
+ion-modal,part,content
ion-nav,shadow
ion-nav,prop,animated,boolean,true,false,false
diff --git a/core/src/components.d.ts b/core/src/components.d.ts
index b0c360f784..fe1eded51c 100644
--- a/core/src/components.d.ts
+++ b/core/src/components.d.ts
@@ -1375,7 +1375,7 @@ export namespace Components {
/**
* The component to display inside of the modal.
*/
- "component": ComponentRef;
+ "component"?: ComponentRef;
/**
* The data to pass to the modal component.
*/
@@ -1395,6 +1395,11 @@ export namespace Components {
* Animation to use when the modal is presented.
*/
"enterAnimation"?: AnimationBuilder;
+ "inline": boolean;
+ /**
+ * 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.
+ */
+ "isOpen": boolean;
/**
* If `true`, the keyboard will be automatically dismissed when the overlay is presented.
*/
@@ -1432,6 +1437,10 @@ export namespace Components {
* If `true`, the modal can be swiped to dismiss. Only applies in iOS mode.
*/
"swipeToClose": boolean;
+ /**
+ * An ID corresponding to the trigger element that causes the modal to open when clicked.
+ */
+ "trigger": string | undefined;
}
interface IonNav {
/**
@@ -4831,7 +4840,7 @@ declare namespace LocalJSX {
/**
* The component to display inside of the modal.
*/
- "component": ComponentRef;
+ "component"?: ComponentRef;
/**
* The data to pass to the modal component.
*/
@@ -4845,6 +4854,11 @@ declare namespace LocalJSX {
* Animation to use when the modal is presented.
*/
"enterAnimation"?: AnimationBuilder;
+ "inline"?: boolean;
+ /**
+ * 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.
+ */
+ "isOpen"?: boolean;
/**
* If `true`, the keyboard will be automatically dismissed when the overlay is presented.
*/
@@ -4857,6 +4871,14 @@ declare namespace LocalJSX {
* The mode determines which platform styles to use.
*/
"mode"?: "ios" | "md";
+ /**
+ * Emitted after the modal has dismissed. Shorthand for ionModalDidDismiss.
+ */
+ "onDidDismiss"?: (event: CustomEvent) => void;
+ /**
+ * Emitted after the modal has presented. Shorthand for ionModalWillDismiss.
+ */
+ "onDidPresent"?: (event: CustomEvent) => void;
/**
* Emitted after the modal has dismissed.
*/
@@ -4873,6 +4895,14 @@ declare namespace LocalJSX {
* Emitted before the modal has presented.
*/
"onIonModalWillPresent"?: (event: CustomEvent) => void;
+ /**
+ * Emitted before the modal has dismissed. Shorthand for ionModalWillDismiss.
+ */
+ "onWillDismiss"?: (event: CustomEvent) => void;
+ /**
+ * Emitted before the modal has presented. Shorthand for ionModalWillPresent.
+ */
+ "onWillPresent"?: (event: CustomEvent) => void;
"overlayIndex": number;
/**
* The element that presented the modal. This is used for card presentation effects and for stacking multiple modals on top of each other. Only applies in iOS mode.
@@ -4886,6 +4916,10 @@ declare namespace LocalJSX {
* If `true`, the modal can be swiped to dismiss. Only applies in iOS mode.
*/
"swipeToClose"?: boolean;
+ /**
+ * An ID corresponding to the trigger element that causes the modal to open when clicked.
+ */
+ "trigger"?: string | undefined;
}
interface IonNav {
/**
diff --git a/core/src/components/modal/animations/ios.enter.ts b/core/src/components/modal/animations/ios.enter.ts
index 4902da4dfc..30955ad429 100644
--- a/core/src/components/modal/animations/ios.enter.ts
+++ b/core/src/components/modal/animations/ios.enter.ts
@@ -1,5 +1,6 @@
import { Animation } from '../../../interface';
import { createAnimation } from '../../../utils/animation/animation';
+import { getElementRoot } from '../../../utils/helpers';
import { SwipeToCloseDefaults } from '../gestures/swipe-to-close';
/**
@@ -9,8 +10,9 @@ export const iosEnterAnimation = (
baseEl: HTMLElement,
presentingEl?: HTMLElement,
): Animation => {
+ const root = getElementRoot(baseEl);
const backdropAnimation = createAnimation()
- .addElement(baseEl.querySelector('ion-backdrop')!)
+ .addElement(root.querySelector('ion-backdrop')!)
.fromTo('opacity', 0.01, 'var(--backdrop-opacity)')
.beforeStyles({
'pointer-events': 'none'
@@ -18,7 +20,7 @@ export const iosEnterAnimation = (
.afterClearStyles(['pointer-events']);
const wrapperAnimation = createAnimation()
- .addElement(baseEl.querySelectorAll('.modal-wrapper, .modal-shadow')!)
+ .addElement(root.querySelectorAll('.modal-wrapper, .modal-shadow')!)
.beforeStyles({ 'opacity': 1 })
.fromTo('transform', 'translateY(100vh)', 'translateY(0vh)');
@@ -31,6 +33,7 @@ export const iosEnterAnimation = (
if (presentingEl) {
const isMobile = window.innerWidth < 768;
const hasCardModal = (presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined);
+ const presentingElRoot = getElementRoot(presentingEl);
const presentingAnimation = createAnimation()
.beforeStyles({
@@ -77,7 +80,7 @@ export const iosEnterAnimation = (
.afterStyles({
'transform': finalTransform
})
- .addElement(presentingEl.querySelector('.modal-wrapper')!)
+ .addElement(presentingElRoot.querySelector('.modal-wrapper')!)
.keyframes([
{ offset: 0, filter: 'contrast(1)', transform: 'translateY(0) scale(1)' },
{ offset: 1, filter: 'contrast(0.85)', transform: finalTransform }
@@ -87,7 +90,7 @@ export const iosEnterAnimation = (
.afterStyles({
'transform': finalTransform
})
- .addElement(presentingEl.querySelector('.modal-shadow')!)
+ .addElement(presentingElRoot.querySelector('.modal-shadow')!)
.keyframes([
{ offset: 0, opacity: '1', transform: 'translateY(0) scale(1)' },
{ offset: 1, opacity: '0', transform: finalTransform }
diff --git a/core/src/components/modal/animations/ios.leave.ts b/core/src/components/modal/animations/ios.leave.ts
index 5d05c0f33c..54606dd0c6 100644
--- a/core/src/components/modal/animations/ios.leave.ts
+++ b/core/src/components/modal/animations/ios.leave.ts
@@ -1,5 +1,6 @@
import { Animation } from '../../../interface';
import { createAnimation } from '../../../utils/animation/animation';
+import { getElementRoot } from '../../../utils/helpers';
import { SwipeToCloseDefaults } from '../gestures/swipe-to-close';
/**
@@ -10,12 +11,13 @@ export const iosLeaveAnimation = (
presentingEl?: HTMLElement,
duration = 500
): Animation => {
+ const root = getElementRoot(baseEl);
const backdropAnimation = createAnimation()
- .addElement(baseEl.querySelector('ion-backdrop')!)
+ .addElement(root.querySelector('ion-backdrop')!)
.fromTo('opacity', 'var(--backdrop-opacity)', 0.0);
const wrapperAnimation = createAnimation()
- .addElement(baseEl.querySelectorAll('.modal-wrapper, .modal-shadow')!)
+ .addElement(root.querySelectorAll('.modal-wrapper, .modal-shadow')!)
.beforeStyles({ 'opacity': 1 })
.fromTo('transform', 'translateY(0vh)', 'translateY(100vh)');
@@ -28,6 +30,7 @@ export const iosLeaveAnimation = (
if (presentingEl) {
const isMobile = window.innerWidth < 768;
const hasCardModal = (presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined);
+ const presentingElRoot = getElementRoot(presentingEl);
const presentingAnimation = createAnimation()
.beforeClearStyles(['transform'])
@@ -70,7 +73,7 @@ export const iosLeaveAnimation = (
const finalTransform = `translateY(-10px) scale(${toPresentingScale})`;
presentingAnimation
- .addElement(presentingEl.querySelector('.modal-wrapper')!)
+ .addElement(presentingElRoot.querySelector('.modal-wrapper')!)
.afterStyles({
'transform': 'translate3d(0, 0, 0)'
})
@@ -80,7 +83,7 @@ export const iosLeaveAnimation = (
]);
const shadowAnimation = createAnimation()
- .addElement(presentingEl.querySelector('.modal-shadow')!)
+ .addElement(presentingElRoot.querySelector('.modal-shadow')!)
.afterStyles({
'transform': 'translateY(0) scale(1)'
})
diff --git a/core/src/components/modal/animations/md.enter.ts b/core/src/components/modal/animations/md.enter.ts
index c37d1a5765..fa0d87cbf1 100644
--- a/core/src/components/modal/animations/md.enter.ts
+++ b/core/src/components/modal/animations/md.enter.ts
@@ -1,16 +1,18 @@
import { Animation } from '../../../interface';
import { createAnimation } from '../../../utils/animation/animation';
+import { getElementRoot } from '../../../utils/helpers';
/**
* Md Modal Enter Animation
*/
export const mdEnterAnimation = (baseEl: HTMLElement): Animation => {
+ const root = getElementRoot(baseEl);
const baseAnimation = createAnimation();
const backdropAnimation = createAnimation();
const wrapperAnimation = createAnimation();
backdropAnimation
- .addElement(baseEl.querySelector('ion-backdrop')!)
+ .addElement(root.querySelector('ion-backdrop')!)
.fromTo('opacity', 0.01, 'var(--backdrop-opacity)')
.beforeStyles({
'pointer-events': 'none'
@@ -18,7 +20,7 @@ export const mdEnterAnimation = (baseEl: HTMLElement): Animation => {
.afterClearStyles(['pointer-events']);
wrapperAnimation
- .addElement(baseEl.querySelector('.modal-wrapper')!)
+ .addElement(root.querySelector('.modal-wrapper')!)
.keyframes([
{ offset: 0, opacity: 0.01, transform: 'translateY(40px)' },
{ offset: 1, opacity: 1, transform: 'translateY(0px)' }
diff --git a/core/src/components/modal/animations/md.leave.ts b/core/src/components/modal/animations/md.leave.ts
index 8827912e81..e1ffe22671 100644
--- a/core/src/components/modal/animations/md.leave.ts
+++ b/core/src/components/modal/animations/md.leave.ts
@@ -1,17 +1,19 @@
import { Animation } from '../../../interface';
import { createAnimation } from '../../../utils/animation/animation';
+import { getElementRoot } from '../../../utils/helpers';
/**
* Md Modal Leave Animation
*/
export const mdLeaveAnimation = (baseEl: HTMLElement): Animation => {
+ const root = getElementRoot(baseEl);
const baseAnimation = createAnimation();
const backdropAnimation = createAnimation();
const wrapperAnimation = createAnimation();
- const wrapperEl = baseEl.querySelector('.modal-wrapper')!;
+ const wrapperEl = root.querySelector('.modal-wrapper')!;
backdropAnimation
- .addElement(baseEl.querySelector('ion-backdrop')!)
+ .addElement(root.querySelector('ion-backdrop')!)
.fromTo('opacity', 'var(--backdrop-opacity)', 0.0);
wrapperAnimation
diff --git a/core/src/components/modal/modal.ios.scss b/core/src/components/modal/modal.ios.scss
index 0a4dc10c21..85f5a46a41 100644
--- a/core/src/components/modal/modal.ios.scss
+++ b/core/src/components/modal/modal.ios.scss
@@ -4,7 +4,7 @@
// iOS Modals
// --------------------------------------------------
-:host:first-of-type {
+:host(:first-of-type) {
--backdrop-opacity: var(--ion-backdrop-opacity, 0.4);
}
@@ -58,19 +58,13 @@
--height: calc(100% - (120px + var(--ion-safe-area-top) + var(--ion-safe-area-bottom)));
--max-width: 720px;
--max-height: 1000px;
- }
-
- :host(.modal-card) {
--backdrop-opacity: 0;
+ --box-shadow: 0px 0px 30px 10px rgba(0, 0, 0, 0.1);
transition: all 0.5s ease-in-out;
-
- &:first-of-type {
- --backdrop-opacity: 0.18;
- }
}
:host(.modal-card) .modal-shadow {
- box-shadow: 0px 0px 30px 10px rgba(0, 0, 0, 0.1);
+ box-shadow: var(--box-shadow);
}
}
diff --git a/core/src/components/modal/modal.md.scss b/core/src/components/modal/modal.md.scss
index d294ae4244..3833e0fcf0 100644
--- a/core/src/components/modal/modal.md.scss
+++ b/core/src/components/modal/modal.md.scss
@@ -5,7 +5,7 @@
// Material Design Modals
// --------------------------------------------------
-:host:first-of-type {
+:host(:first-of-type) {
--backdrop-opacity: var(--ion-backdrop-opacity, 0.32);
}
@@ -14,7 +14,7 @@
--border-radius: 2px;
}
- :host:first-of-type {
+ :host(:first-of-type) {
--box-shadow: #{$modal-inset-box-shadow};
}
}
@@ -23,4 +23,4 @@
@include transform(translate3d(0, 40px, 0));
opacity: .01;
-}
\ No newline at end of file
+}
diff --git a/core/src/components/modal/modal.scss b/core/src/components/modal/modal.scss
index c0b84be815..8730b7c024 100644
--- a/core/src/components/modal/modal.scss
+++ b/core/src/components/modal/modal.scss
@@ -48,6 +48,13 @@
outline: none;
contain: strict;
+
+ pointer-events: none;
+}
+
+:host(.modal-interactive) .modal-wrapper,
+:host(.modal-interactive) ion-backdrop {
+ pointer-events: auto;
}
:host(.overlay-hidden) {
diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx
index f09d06a83c..dcdfef9e39 100644
--- a/core/src/components/modal/modal.tsx
+++ b/core/src/components/modal/modal.tsx
@@ -1,9 +1,10 @@
-import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, Watch, h, writeTask } from '@stencil/core';
+import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, State, Watch, h, writeTask } from '@stencil/core';
import { config } from '../../global/config';
import { getIonMode } from '../../global/ionic-global';
import { Animation, AnimationBuilder, ComponentProps, ComponentRef, FrameworkDelegate, Gesture, OverlayEventDetail, OverlayInterface } from '../../interface';
-import { attachComponent, detachComponent } from '../../utils/framework-delegate';
+import { CoreDelegate, attachComponent, detachComponent } from '../../utils/framework-delegate';
+import { raf } from '../../utils/helpers';
import { BACKDROP, activeAnimations, dismiss, eventMethod, prepareOverlay, present } from '../../utils/overlays';
import { getClassMap } from '../../utils/theme';
import { deepReady } from '../../utils/transition';
@@ -16,6 +17,11 @@ import { createSwipeToCloseGesture } from './gestures/swipe-to-close';
/**
* @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.
*/
@Component({
tag: 'ion-modal',
@@ -23,22 +29,31 @@ import { createSwipeToCloseGesture } from './gestures/swipe-to-close';
ios: 'modal.ios.scss',
md: 'modal.md.scss'
},
- scoped: true
+ shadow: true
})
export class Modal implements ComponentInterface, OverlayInterface {
private gesture?: Gesture;
+ private modalIndex = modalIds++;
+ private modalId?: string;
+ private coreDelegate: FrameworkDelegate = CoreDelegate();
+ private currentTransition?: Promise;
+ private destroyTriggerInteraction?: () => void;
// Reference to the user's provided modal content
private usersElement?: HTMLElement;
// Whether or not modal is being dismissed via gesture
private gestureAnimationDismissing = false;
- presented = false;
lastFocus?: HTMLElement;
animation?: Animation;
+ @State() presented = false;
+
@Element() el!: HTMLIonModalElement;
+ /** @internal */
+ @Prop() inline = true;
+
/** @internal */
@Prop() overlayIndex!: number;
@@ -62,17 +77,20 @@ export class Modal implements ComponentInterface, OverlayInterface {
/**
* The component to display inside of the modal.
+ * @internal
*/
- @Prop() component!: ComponentRef;
+ @Prop() component?: ComponentRef;
/**
* The data to pass to the modal component.
+ * @internal
*/
@Prop() componentProps?: ComponentProps;
/**
* Additional classes to apply for custom CSS. If multiple classes are
* provided they should be separated by spaces.
+ * @internal
*/
@Prop() cssClass?: string | string[];
@@ -102,6 +120,34 @@ export class Modal implements ComponentInterface, OverlayInterface {
*/
@Prop() presentingElement?: HTMLElement;
+ /**
+ * 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.
+ */
+ @Prop() isOpen = false;
+
+ @Watch('isOpen')
+ onIsOpenChange(newValue: boolean, oldValue: boolean) {
+ if (newValue === true && oldValue === false) {
+ this.present();
+ } else if (newValue === false && oldValue === true) {
+ this.dismiss();
+ }
+ }
+
+ /**
+ * An ID corresponding to the trigger element that
+ * causes the modal to open when clicked.
+ */
+ @Prop() trigger: string | undefined;
+ @Watch('trigger')
+ onTriggerChange() {
+ this.configureTriggerInteraction();
+ }
+
/**
* Emitted after the modal has presented.
*/
@@ -122,6 +168,30 @@ export class Modal implements ComponentInterface, OverlayInterface {
*/
@Event({ eventName: 'ionModalDidDismiss' }) didDismiss!: EventEmitter;
+ /**
+ * Emitted after the modal has presented.
+ * Shorthand for ionModalWillDismiss.
+ */
+ @Event({ eventName: 'didPresent' }) didPresentShorthand!: EventEmitter;
+
+ /**
+ * Emitted before the modal has presented.
+ * Shorthand for ionModalWillPresent.
+ */
+ @Event({ eventName: 'willPresent' }) willPresentShorthand!: EventEmitter;
+
+ /**
+ * Emitted before the modal has dismissed.
+ * Shorthand for ionModalWillDismiss.
+ */
+ @Event({ eventName: 'willDismiss' }) willDismissShorthand!: EventEmitter;
+
+ /**
+ * Emitted after the modal has dismissed.
+ * Shorthand for ionModalDidDismiss.
+ */
+ @Event({ eventName: 'didDismiss' }) didDismissShorthand!: EventEmitter;
+
@Watch('swipeToClose')
swipeToCloseChanged(enable: boolean) {
if (this.gesture) {
@@ -135,6 +205,50 @@ export class Modal implements ComponentInterface, OverlayInterface {
prepareOverlay(this.el);
}
+ componentWillLoad() {
+ /**
+ * If user has custom ID set then we should
+ * not assign the default incrementing ID.
+ */
+ this.modalId = (this.el.hasAttribute('id')) ? this.el.getAttribute('id')! : `ion-modal-${this.modalIndex}`;
+ }
+
+ componentDidLoad() {
+ /**
+ * If modal was rendered with isOpen="true"
+ * then we should open modal immediately.
+ */
+ if (this.isOpen === true) {
+ raf(() => this.present());
+ }
+
+ this.configureTriggerInteraction();
+ }
+
+ private configureTriggerInteraction = () => {
+ const { trigger, el, destroyTriggerInteraction } = this;
+
+ if (destroyTriggerInteraction) {
+ destroyTriggerInteraction();
+ }
+
+ const triggerEl = (trigger !== undefined) ? document.getElementById(trigger) : null;
+ if (!triggerEl) { return; }
+
+ const configureTriggerInteraction = (triggerEl: HTMLElement, modalEl: HTMLIonModalElement) => {
+ const openModal = () => {
+ modalEl.present();
+ }
+ triggerEl.addEventListener('click', openModal);
+
+ return () => {
+ triggerEl.removeEventListener('click', openModal);
+ }
+ }
+
+ this.destroyTriggerInteraction = configureTriggerInteraction(triggerEl, el);
+ }
+
/**
* Present the modal overlay after it has been created.
*/
@@ -143,20 +257,42 @@ export class Modal implements ComponentInterface, OverlayInterface {
if (this.presented) {
return;
}
- const container = this.el.querySelector(`.modal-wrapper`);
- if (!container) {
- throw new Error('container is undefined');
+
+ /**
+ * When using an inline modal
+ * and dismissing a modal it is possible to
+ * quickly present the modal while it is
+ * dismissing. We need to await any current
+ * transition to allow the dismiss to finish
+ * before presenting again.
+ */
+ if (this.currentTransition !== undefined) {
+ await this.currentTransition;
}
- const componentProps = {
+
+ const data = {
...this.componentProps,
modal: this.el
};
- this.usersElement = await attachComponent(this.delegate, container, this.component, ['ion-page'], componentProps);
+
+ /**
+ * If using modal inline
+ * we potentially need to use the coreDelegate
+ * so that this works in vanilla JS apps
+ */
+ const delegate = (this.inline) ? this.delegate || this.coreDelegate : this.delegate;
+
+ this.usersElement = await attachComponent(delegate, this.el, this.component, ['ion-page'], data, this.inline);
+
await deepReady(this.usersElement);
writeTask(() => this.el.classList.add('show-modal'));
- await present(this, 'modalEnter', iosEnterAnimation, mdEnterAnimation, this.presentingElement);
+ this.currentTransition = present(this, 'modalEnter', iosEnterAnimation, mdEnterAnimation, this.presentingElement);
+
+ await this.currentTransition;
+
+ this.currentTransition = undefined;
if (this.swipeToClose) {
this.initSwipeToClose();
@@ -207,11 +343,27 @@ export class Modal implements ComponentInterface, OverlayInterface {
return false;
}
+ /**
+ * When using an inline modal
+ * and presenting a modal it is possible to
+ * quickly dismiss the modal while it is
+ * presenting. We need to await any current
+ * transition to allow the present to finish
+ * before dismissing again.
+ */
+ if (this.currentTransition !== undefined) {
+ await this.currentTransition;
+ }
+
const enteringAnimation = activeAnimations.get(this) || [];
- const dismissed = await dismiss(this, data, role, 'modalLeave', iosLeaveAnimation, mdLeaveAnimation, this.presentingElement);
+
+ this.currentTransition = dismiss(this, data, role, 'modalLeave', iosLeaveAnimation, mdLeaveAnimation, this.presentingElement);
+
+ const dismissed = await this.currentTransition;
if (dismissed) {
- await detachComponent(this.delegate, this.usersElement);
+ const delegate = (this.inline) ? this.delegate || this.coreDelegate : this.delegate;
+ await detachComponent(delegate, this.usersElement);
if (this.animation) {
this.animation.destroy();
}
@@ -220,6 +372,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
}
this.animation = undefined;
+ this.currentTransition = undefined;
return dismissed;
}
@@ -266,6 +419,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
render() {
const mode = getIonMode(this);
+ const { presented, modalId } = this;
return (
-
+
{mode === 'ios' && }
-
-
+
-
);
}
@@ -311,3 +467,5 @@ const LIFECYCLE_MAP: any = {
'ionModalWillDismiss': 'ionViewWillLeave',
'ionModalDidDismiss': 'ionViewDidLeave',
};
+
+let modalIds = 0;
diff --git a/core/src/components/modal/readme.md b/core/src/components/modal/readme.md
index d74c7a7d8e..072ce951f8 100644
--- a/core/src/components/modal/readme.md
+++ b/core/src/components/modal/readme.md
@@ -2,6 +2,66 @@
A Modal is a dialog that appears on top of the app's content, and must be dismissed by the app before interaction can resume. It is useful as a select component when there are a lot of options to choose from, or when filtering items in a list, as well as many other use cases.
+## Presenting
+
+There are two ways to use `ion-modal`: inline or via the `modalController`. Each method comes with different considerations, so be sure to use the approach that best fits your use case.
+
+## Inline Modals
+
+`ion-modal` can be used by writing the component directly in your template. This reduces the number of handlers you need to wire up in order to present the modal. See [Usage](#usage) for an example of how to write a modal inline.
+
+When using `ion-modal` with Angular, React, or Vue, the component you pass in will be destroyed when the modal is dismissed. As this functionality is provided by the JavaScript framework, using `ion-modal` without a JavaScript framework will not destroy the component you passed in. If this is a needed functionality, we recommend using the `modalController` instead.
+
+### Angular
+
+Since the component you passed in needs to be created when the modal is presented and destroyed when the modal is dismissed, we are unable to project the content using `` internally. Instead, we use `` which expects an `` to be passed in. As a result, when passing in your component you will need to wrap it in an ``:
+
+```html
+
+
+
+
+
+```
+
+### When to use
+
+Using a modal inline is useful when you do not want to explicitly wire up click events to open the modal. For example, you can use the `is-open` property to easily present or dismiss a modal based on some state in your application.
+
+If you need fine grained control over when the modal is presented and dismissed, we recommend you use the `modalController`.
+
+## Controller Modals
+
+`ion-modal` can also be presented programmatically by using the `modalController` imported from Ionic Framework. This allows you to have complete control over when a modal is presented above and beyond the customization that inline modals give you. See [Usage](#usage) for an example of how to use the `modalController`.
+
+### When to use
+
+We typically recommend that you write your modals inline as it streamlines the amount of code in your application. You should only use the `modalController` for complex use cases where writing a modal inline is impractical.
+
+## Interfaces
+
+Below you will find all of the options available to you when using the `modalController`. These options should be supplied when calling `modalController.create()`.
+
+```typescript
+interface ModalOptions {
+ component: any;
+ componentProps?: { [key: string]: any };
+ presentingElement?: HTMLElement;
+ showBackdrop?: boolean;
+ backdropDismiss?: boolean;
+ cssClass?: string | string[];
+ animated?: boolean;
+ swipeToClose?: boolean;
+
+ mode?: 'ios' | 'md';
+ keyboardClose?: boolean;
+ id?: string;
+
+ enterAnimation?: AnimationBuilder;
+ leaveAnimation?: AnimationBuilder;
+}
+```
+
## Dismissing
The modal can be dismissed after creation by calling the `dismiss()` method on the modal controller. The `onDidDismiss` function can be called to perform an action after the modal is dismissed.
@@ -755,30 +815,33 @@ export default defineComponent({
## Properties
-| Property | Attribute | Description | Type | Default |
-| ------------------------ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ----------- |
-| `animated` | `animated` | If `true`, the modal will animate. | `boolean` | `true` |
-| `backdropDismiss` | `backdrop-dismiss` | If `true`, the modal will be dismissed when the backdrop is clicked. | `boolean` | `true` |
-| `component` _(required)_ | `component` | The component to display inside of the modal. | `Function \| HTMLElement \| null \| string` | `undefined` |
-| `componentProps` | -- | The data to pass to the modal component. | `undefined \| { [key: string]: any; }` | `undefined` |
-| `cssClass` | `css-class` | Additional classes to apply for custom CSS. If multiple classes are provided they should be separated by spaces. | `string \| string[] \| undefined` | `undefined` |
-| `enterAnimation` | -- | Animation to use when the modal is presented. | `((baseEl: any, opts?: any) => Animation) \| undefined` | `undefined` |
-| `keyboardClose` | `keyboard-close` | If `true`, the keyboard will be automatically dismissed when the overlay is presented. | `boolean` | `true` |
-| `leaveAnimation` | -- | Animation to use when the modal is dismissed. | `((baseEl: any, opts?: any) => Animation) \| undefined` | `undefined` |
-| `mode` | `mode` | The mode determines which platform styles to use. | `"ios" \| "md"` | `undefined` |
-| `presentingElement` | -- | The element that presented the modal. This is used for card presentation effects and for stacking multiple modals on top of each other. Only applies in iOS mode. | `HTMLElement \| undefined` | `undefined` |
-| `showBackdrop` | `show-backdrop` | If `true`, a backdrop will be displayed behind the modal. | `boolean` | `true` |
-| `swipeToClose` | `swipe-to-close` | If `true`, the modal can be swiped to dismiss. Only applies in iOS mode. | `boolean` | `false` |
+| Property | Attribute | Description | Type | Default |
+| ------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ----------- |
+| `animated` | `animated` | If `true`, the modal will animate. | `boolean` | `true` |
+| `backdropDismiss` | `backdrop-dismiss` | If `true`, the modal will be dismissed when the backdrop is clicked. | `boolean` | `true` |
+| `enterAnimation` | -- | Animation to use when the modal is presented. | `((baseEl: any, opts?: any) => Animation) \| undefined` | `undefined` |
+| `isOpen` | `is-open` | 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. | `boolean` | `false` |
+| `keyboardClose` | `keyboard-close` | If `true`, the keyboard will be automatically dismissed when the overlay is presented. | `boolean` | `true` |
+| `leaveAnimation` | -- | Animation to use when the modal is dismissed. | `((baseEl: any, opts?: any) => Animation) \| undefined` | `undefined` |
+| `mode` | `mode` | The mode determines which platform styles to use. | `"ios" \| "md"` | `undefined` |
+| `presentingElement` | -- | The element that presented the modal. This is used for card presentation effects and for stacking multiple modals on top of each other. Only applies in iOS mode. | `HTMLElement \| undefined` | `undefined` |
+| `showBackdrop` | `show-backdrop` | If `true`, a backdrop will be displayed behind the modal. | `boolean` | `true` |
+| `swipeToClose` | `swipe-to-close` | If `true`, the modal can be swiped to dismiss. Only applies in iOS mode. | `boolean` | `false` |
+| `trigger` | `trigger` | An ID corresponding to the trigger element that causes the modal to open when clicked. | `string \| undefined` | `undefined` |
## Events
-| Event | Description | Type |
-| --------------------- | --------------------------------------- | -------------------------------------- |
-| `ionModalDidDismiss` | Emitted after the modal has dismissed. | `CustomEvent>` |
-| `ionModalDidPresent` | Emitted after the modal has presented. | `CustomEvent` |
-| `ionModalWillDismiss` | Emitted before the modal has dismissed. | `CustomEvent>` |
-| `ionModalWillPresent` | Emitted before the modal has presented. | `CustomEvent` |
+| Event | Description | Type |
+| --------------------- | -------------------------------------------------------------------------- | -------------------------------------- |
+| `didDismiss` | Emitted after the modal has dismissed. Shorthand for ionModalDidDismiss. | `CustomEvent>` |
+| `didPresent` | Emitted after the modal has presented. Shorthand for ionModalWillDismiss. | `CustomEvent` |
+| `ionModalDidDismiss` | Emitted after the modal has dismissed. | `CustomEvent>` |
+| `ionModalDidPresent` | Emitted after the modal has presented. | `CustomEvent` |
+| `ionModalWillDismiss` | Emitted before the modal has dismissed. | `CustomEvent>` |
+| `ionModalWillPresent` | Emitted before the modal has presented. | `CustomEvent` |
+| `willDismiss` | Emitted before the modal has dismissed. Shorthand for ionModalWillDismiss. | `CustomEvent>` |
+| `willPresent` | Emitted before the modal has presented. Shorthand for ionModalWillPresent. | `CustomEvent` |
## Methods
@@ -824,6 +887,21 @@ Type: `Promise`
+## Slots
+
+| Slot | Description |
+| --------------------------------------------------------------- | ----------- |
+| `"= Content is placed inside of the `.modal-content` element."` | |
+
+
+## Shadow Parts
+
+| Part | Description |
+| ------------ | ----------------------------------------- |
+| `"backdrop"` | The `ion-backdrop` element. |
+| `"content"` | The wrapper element for the default slot. |
+
+
## CSS Custom Properties
| Name | Description |
diff --git a/core/src/components/modal/test/basic/e2e.ts b/core/src/components/modal/test/basic/e2e.ts
index 06ce04f06b..ae455199eb 100644
--- a/core/src/components/modal/test/basic/e2e.ts
+++ b/core/src/components/modal/test/basic/e2e.ts
@@ -56,18 +56,15 @@ test('modal: return focus', async () => {
await modal.waitForNotVisible(),
]);
- modal = await page.find('ion-modal');
- expect(modal).toBeNull();
-
const activeElement = await page.evaluateHandle(() => document.activeElement);
const id = await activeElement.evaluate((node) => node.id);
expect(id).toEqual('basic-modal');
});
test('modal: basic', async () => {
- await testModal(DIRECTORY, '#basic-modal');
+ await testModal(DIRECTORY, '#basic-modal', false);
});
test('modal:rtl: basic', async () => {
- await testModal(DIRECTORY, '#basic-modal', true);
+ await testModal(DIRECTORY, '#basic-modal', false, true);
});
diff --git a/core/src/components/modal/test/custom/e2e.ts b/core/src/components/modal/test/custom/e2e.ts
index 775145e429..9099134909 100644
--- a/core/src/components/modal/test/custom/e2e.ts
+++ b/core/src/components/modal/test/custom/e2e.ts
@@ -3,9 +3,9 @@ import { testModal } from '../test.utils';
const DIRECTORY = 'custom';
test('modal: custom', async () => {
- await testModal(DIRECTORY, '#custom-modal');
+ await testModal(DIRECTORY, '#custom-modal', false);
});
test('modal:rtl: custom', async () => {
- await testModal(DIRECTORY, '#custom-modal', true);
+ await testModal(DIRECTORY, '#custom-modal', false, true);
});
diff --git a/core/src/components/modal/test/inline/e2e.ts b/core/src/components/modal/test/inline/e2e.ts
new file mode 100644
index 0000000000..4eeef862e1
--- /dev/null
+++ b/core/src/components/modal/test/inline/e2e.ts
@@ -0,0 +1,38 @@
+import { newE2EPage } from '@stencil/core/testing';
+
+test('modal: inline', async () => {
+ const page = await newE2EPage({ url: '/src/components/modal/test/inline?ionic:_testing=true' });
+ const screenshotCompares = [];
+
+ await page.click('ion-button');
+ await page.waitForSelector('ion-modal');
+
+ let modal = await page.find('ion-modal');
+
+ expect(modal).not.toBe(null);
+ await modal.waitForVisible();
+
+ screenshotCompares.push(await page.compareScreenshot());
+
+ await modal.callMethod('dismiss');
+ await modal.waitForNotVisible();
+
+ screenshotCompares.push(await page.compareScreenshot('dismiss'));
+
+ modal = await page.find('ion-modal');
+ expect(modal).not.toBe(null);
+
+ await page.click('ion-button');
+ await page.waitForSelector('ion-modal');
+
+ let modalAgain = await page.find('ion-modal');
+
+ expect(modalAgain).not.toBe(null);
+ await modalAgain.waitForVisible();
+
+ screenshotCompares.push(await page.compareScreenshot());
+
+ for (const screenshotCompare of screenshotCompares) {
+ expect(screenshotCompare).toMatchScreenshot();
+ }
+});
diff --git a/core/src/components/modal/test/inline/index.html b/core/src/components/modal/test/inline/index.html
new file mode 100644
index 0000000000..dc52f0d35f
--- /dev/null
+++ b/core/src/components/modal/test/inline/index.html
@@ -0,0 +1,54 @@
+
+
+
+
+ Modal - Inline
+
+
+
+
+
+
+
+
+
+
+
+ Modal - Inline
+
+
+
+
+ Open Modal
+
+
+
+
+
+ Modal
+
+
+
+
+ This is my inline modal content!
+
+
+
+