diff --git a/BREAKING.md b/BREAKING.md
index ab70dd22e6a..f33644f3152 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 00000000000..83f6a31606e
--- /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 1ac600a3bf8..3681f89058d 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 a10fdade340..0ce7ab928e2 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 c0c2e51281e..6f264a9a22b 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 4902da4dfc0..30955ad4294 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 5d05c0f33c4..54606dd0c6b 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 c37d1a5765a..fa0d87cbf1d 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 8827912e81e..e1ffe22671a 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 0a4dc10c217..85f5a46a415 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 d294ae42447..3833e0fcf04 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 c0b84be8157..8730b7c024f 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 f09d06a83c2..dcdfef9e395 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 77d5f17d2fb..56ca866d964 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.
@@ -746,30 +806,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
@@ -815,6 +878,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 06ce04f06bd..ae455199eb9 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 775145e4298..90991349091 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 00000000000..4eeef862e1b
--- /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 00000000000..dc52f0d35f8
--- /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!
+
+
+
+
+
+
+
+
diff --git a/core/src/components/popover/popover.tsx b/core/src/components/popover/popover.tsx
index b286814a1ca..cf383cab31b 100644
--- a/core/src/components/popover/popover.tsx
+++ b/core/src/components/popover/popover.tsx
@@ -2,7 +2,7 @@ import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Meth
import { getIonMode } from '../../global/ionic-global';
import { AnimationBuilder, ComponentProps, ComponentRef, FrameworkDelegate, OverlayEventDetail, OverlayInterface, PopoverSize, PositionAlign, PositionReference, PositionSide, TriggerAction } from '../../interface';
-import { attachComponent, detachComponent } from '../../utils/framework-delegate';
+import { CoreDelegate, attachComponent, detachComponent } from '../../utils/framework-delegate';
import { addEventListener, raf } from '../../utils/helpers';
import { BACKDROP, dismiss, eventMethod, focusFirstDescendant, prepareOverlay, present } from '../../utils/overlays';
import { isPlatform } from '../../utils/platform';
@@ -15,28 +15,6 @@ import { mdEnterAnimation } from './animations/md.enter';
import { mdLeaveAnimation } from './animations/md.leave';
import { configureDismissInteraction, configureKeyboardInteraction, configureTriggerInteraction } from './utils';
-const CoreDelegate = () => {
- let Cmp: any;
- const attachViewToDom = (parentElement: HTMLElement) => {
- Cmp = parentElement;
- const app = document.querySelector('ion-app') || document.body;
- if (app && Cmp) {
- app.appendChild(Cmp);
- }
-
- return Cmp;
- }
-
- const removeViewFromDom = () => {
- if (Cmp) {
- Cmp.remove();
- }
- return Promise.resolve();
- }
-
- return { attachViewToDom, removeViewFromDom }
-}
-
/**
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
*
diff --git a/core/src/components/popover/readme.md b/core/src/components/popover/readme.md
index 27b0dd37b77..6b14500fc74 100644
--- a/core/src/components/popover/readme.md
+++ b/core/src/components/popover/readme.md
@@ -3,7 +3,6 @@
A Popover is a dialog that appears on top of the current page. It can be used for anything, but generally it is used for overflow actions that don't fit in the navigation bar.
There are two ways to use `ion-popover`: inline or via the `popoverController`. Each method comes with different considerations, so be sure to use the approach that best fits your use case.
-<<<<<<< HEAD
## Inline Popovers
@@ -79,68 +78,6 @@ type PositionSide = 'top' | 'right' | 'bottom' | 'left' | 'start' | 'end';
type PositionAlign = 'start' | 'center' | 'end';
```
-=======
-
-## Inline Popovers
-
-`ion-popover` 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 popover. See [Usage](#usage) for an example of how to write a popover inline.
-
-When using `ion-popover` with Angular, React, or Vue, the component you pass in will be destroyed when the popover is dismissed. If you are not using a JavaScript Framework, you should use the `component` property to pass in the name of a Web Component. This Web Component will be destroyed when the popover is dismissed, and a new instance will be created if the popover is presented again.
-
-### Angular
-
-Since the component you passed in needs to be created when the popover is presented and destroyed when the popover 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
-
-
-
-
-
-```
-
-Liam: Usage will be filled out via desktop popover PR.
-
-### When to use
-
-Liam: Will be filled out via desktop popover PR.
-
-## Controller Popovers
-
-`ion-popover` can also be presented programmatically by using the `popoverController` imported from Ionic Framework. This allows you to have complete control over when a popover is presented above and beyond the customization that inline popovers give you. See [Usage](#usage) for an example of how to use the `popoverController`.
-
-Liam: Usage will be filled out via desktop popover PR.
-
-
-### When to use
-
-Liam: Will be filled out via desktop popover PR.
-
-## Interfaces
-
-Below you will find all of the options available to you when using the `popoverController`. These options should be supplied when calling `popoverController.create()`.
-
-```typescript
-interface PopoverOptions {
- component: any;
- componentProps?: { [key: string]: any };
- showBackdrop?: boolean;
- backdropDismiss?: boolean;
- translucent?: boolean;
- cssClass?: string | string[];
- event?: Event;
- animated?: boolean;
-
- mode?: 'ios' | 'md';
- keyboardClose?: boolean;
- id?: string;
-
- enterAnimation?: AnimationBuilder;
- leaveAnimation?: AnimationBuilder;
-}
-```
->>>>>>> origin/next
-
## Customization
Popover uses scoped encapsulation, which means it will automatically scope its CSS by appending each of the styles with an additional class at runtime. Overriding scoped selectors in CSS requires a [higher specificity](https://developer.mozilla.org/en-US/docs/Web/CSS/Specificity) selector.
diff --git a/core/src/components/popover/test/arrow/e2e.ts b/core/src/components/popover/test/arrow/e2e.ts
index 2203c3c3312..3d5c7fdbad4 100644
--- a/core/src/components/popover/test/arrow/e2e.ts
+++ b/core/src/components/popover/test/arrow/e2e.ts
@@ -1,35 +1,35 @@
import { newE2EPage } from '@stencil/core/testing';
test('popover - arrow side: top', async () => {
- await testPopover('top');
+ await testPopover('top', false);
});
test('popover - arrow side: right', async () => {
- await testPopover('right');
+ await testPopover('right', false);
});
test('popover - arrow side: bottom', async () => {
- await testPopover('bottom');
+ await testPopover('bottom', false);
});
test('popover - arrow side: left', async () => {
- await testPopover('left');
+ await testPopover('left', false);
});
test('popover - arrow side: start', async () => {
- await testPopover('start');
+ await testPopover('start'), false;
});
test('popover - arrow side: end', async () => {
- await testPopover('end');
+ await testPopover('end', false);
});
test('popover - arrow side: start, rtl', async () => {
- await testPopover('start', true);
+ await testPopover('start', false, true);
});
test('popover - arrow side: end, rtl', async () => {
- await testPopover('end', true);
+ await testPopover('end', false, true);
});
diff --git a/core/src/components/popover/test/basic/e2e.ts b/core/src/components/popover/test/basic/e2e.ts
index f586ea20108..701a1d21a6f 100644
--- a/core/src/components/popover/test/basic/e2e.ts
+++ b/core/src/components/popover/test/basic/e2e.ts
@@ -62,21 +62,21 @@ test('popover: custom class', async () => {
*/
test('popover:rtl: basic', async () => {
- await testPopover(DIRECTORY, '#basic-popover', true);
+ await testPopover(DIRECTORY, '#basic-popover', true, true);
});
test('popover:rtl: translucent', async () => {
- await testPopover(DIRECTORY, '#translucent-popover', true);
+ await testPopover(DIRECTORY, '#translucent-popover', true, true);
});
test('popover:rtl: long list', async () => {
- await testPopover(DIRECTORY, '#long-list-popover', true);
+ await testPopover(DIRECTORY, '#long-list-popover', true, true);
});
test('popover:rtl: no event', async () => {
- await testPopover(DIRECTORY, '#no-event-popover', true);
+ await testPopover(DIRECTORY, '#no-event-popover', true, true);
});
test('popover:rtl: custom class', async () => {
- await testPopover(DIRECTORY, '#custom-class-popover', true);
+ await testPopover(DIRECTORY, '#custom-class-popover', true, true);
});
diff --git a/core/src/components/popover/test/inline/e2e.ts b/core/src/components/popover/test/inline/e2e.ts
index 3fb76317905..0ba38679e30 100644
--- a/core/src/components/popover/test/inline/e2e.ts
+++ b/core/src/components/popover/test/inline/e2e.ts
@@ -20,7 +20,6 @@ test('popover: inline', async () => {
screenshotCompares.push(await page.compareScreenshot('dismiss'));
popover = await page.find('ion-popover');
- expect(popover).toBeNull();
await page.click('ion-button');
await page.waitForSelector('ion-popover');
diff --git a/core/src/components/popover/test/isOpen/index.html b/core/src/components/popover/test/isOpen/index.html
index 61e0ed21a4f..5f11d58b8f3 100644
--- a/core/src/components/popover/test/isOpen/index.html
+++ b/core/src/components/popover/test/isOpen/index.html
@@ -63,7 +63,6 @@
Open, then close after 500ms
const popover = document.querySelector('ion-popover');
const openPopover = (ev, timeout) => {
- console.log(ev, timeout)
popover.event = ev;
popover.isOpen = true;
diff --git a/core/src/components/popover/test/test.utils.ts b/core/src/components/popover/test/test.utils.ts
index 341f30d2999..44f17d96c1f 100644
--- a/core/src/components/popover/test/test.utils.ts
+++ b/core/src/components/popover/test/test.utils.ts
@@ -5,6 +5,7 @@ import { generateE2EUrl } from '../../../utils/test/utils';
export const testPopover = async (
type: string,
selector: string,
+ expectUnmount = true,
rtl = false
) => {
try {
@@ -29,8 +30,10 @@ export const testPopover = async (
screenshotCompares.push(await page.compareScreenshot('dismiss'));
- popover = await page.find('ion-popover');
- expect(popover).toBeNull();
+ if (expectUnmount) {
+ popover = await page.find('ion-popover');
+ expect(popover).toBeNull();
+ }
for (const screenshotCompare of screenshotCompares) {
expect(screenshotCompare).toMatchScreenshot();
diff --git a/core/src/css/core.scss b/core/src/css/core.scss
index 0dbe43c1ae7..f6f74def499 100644
--- a/core/src/css/core.scss
+++ b/core/src/css/core.scss
@@ -70,6 +70,16 @@ html.ios ion-modal .ion-page {
border-radius: inherit;
}
+/**
+ * Card style modal on iPadOS
+ * should only have backdrop on first instance.
+ */
+@media screen and (min-width: 768px) {
+ html.ios ion-modal.modal-card:first-of-type {
+ --backdrop-opacity: 0.18;
+ }
+}
+
// Ionic Colors
// --------------------------------------------------
// Generates the color classes and variables based on the
diff --git a/core/src/utils/framework-delegate.ts b/core/src/utils/framework-delegate.ts
index f93efab7234..997d87e2089 100644
--- a/core/src/utils/framework-delegate.ts
+++ b/core/src/utils/framework-delegate.ts
@@ -45,3 +45,81 @@ export const detachComponent = (delegate: FrameworkDelegate | undefined, element
}
return Promise.resolve();
};
+
+export const CoreDelegate = () => {
+ let BaseComponent: any;
+ let Reference: any;
+ const attachViewToDom = async (
+ parentElement: HTMLElement,
+ userComponent: any,
+ userComponentProps: any = {},
+ cssClasses: string[] = []
+ ) => {
+ BaseComponent = parentElement;
+ /**
+ * If passing in a component via the `component` props
+ * we need to append it inside of our overlay component.
+ */
+ if (userComponent) {
+ /**
+ * If passing in the tag name, create
+ * the element otherwise just get a reference
+ * to the component.
+ */
+ const el: any = (typeof userComponent === 'string')
+ ? BaseComponent.ownerDocument && BaseComponent.ownerDocument.createElement(userComponent)
+ : userComponent;
+
+ /**
+ * Add any css classes passed in
+ * via the cssClasses prop on the overlay.
+ */
+ cssClasses.forEach(c => el.classList.add(c));
+
+ /**
+ * Add any props passed in
+ * via the componentProps prop on the overlay.
+ */
+ Object.assign(el, userComponentProps);
+
+ /**
+ * Finally, append the component
+ * inside of the overlay component.
+ */
+ BaseComponent.appendChild(el);
+
+ await new Promise(resolve => componentOnReady(el, resolve));
+ }
+
+ /**
+ * Get the root of the app and
+ * add the overlay there.
+ */
+ const app = document.querySelector('ion-app') || document.body;
+
+ /**
+ * Create a placeholder comment so that
+ * we can return this component to where
+ * it was previously.
+ */
+ Reference = document.createComment('ionic teleport');
+ BaseComponent.parentNode.insertBefore(Reference, BaseComponent);
+
+ app.appendChild(BaseComponent);
+
+ return BaseComponent;
+ }
+
+ const removeViewFromDom = () => {
+ /**
+ * Return component to where it was previously in the DOM.
+ */
+ if (BaseComponent && Reference) {
+ Reference.parentNode.insertBefore(BaseComponent, Reference);
+ Reference.remove();
+ }
+ return Promise.resolve();
+ }
+
+ return { attachViewToDom, removeViewFromDom }
+}
diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts
index bf8cadd41b8..c9ca78eb274 100644
--- a/core/src/utils/overlays.ts
+++ b/core/src/utils/overlays.ts
@@ -404,6 +404,9 @@ export const dismiss = async (
activeAnimations.delete(overlay);
+ // Make overlay hidden again in case it is being reused
+ overlay.el.classList.add('overlay-hidden');
+
} catch (err) {
console.error(err);
}
diff --git a/packages/react/src/components/IonModal.tsx b/packages/react/src/components/IonModal.tsx
index 5d3b6b38707..0a4aa82240c 100644
--- a/packages/react/src/components/IonModal.tsx
+++ b/packages/react/src/components/IonModal.tsx
@@ -1,12 +1,8 @@
-import { ModalOptions, modalController } from '@ionic/core';
+import { JSX } from '@ionic/core';
-import { createOverlayComponent } from './createOverlayComponent';
+import { createInlineOverlayComponent } from './createInlineOverlayComponent'
-export type ReactModalOptions = Omit & {
- children: React.ReactNode;
-};
-
-export const IonModal = /*@__PURE__*/ createOverlayComponent<
- ReactModalOptions,
+export const IonModal = /*@__PURE__*/ createInlineOverlayComponent<
+ JSX.IonModal,
HTMLIonModalElement
->('IonModal', modalController);
+>('ion-modal');
diff --git a/packages/vue/scripts/copy-overlays.js b/packages/vue/scripts/copy-overlays.js
index 37e8d949f5c..3b2acf075b2 100644
--- a/packages/vue/scripts/copy-overlays.js
+++ b/packages/vue/scripts/copy-overlays.js
@@ -18,11 +18,6 @@ function generateOverlays() {
controller: 'loadingController',
name: 'IonLoading'
},
- {
- tag: 'ion-modal',
- controller: 'modalController',
- name: 'IonModal'
- },
{
tag: 'ion-picker',
controller: 'pickerController',
diff --git a/packages/vue/src/components/IonModal.ts b/packages/vue/src/components/IonModal.ts
new file mode 100644
index 00000000000..69e4c8e7aaf
--- /dev/null
+++ b/packages/vue/src/components/IonModal.ts
@@ -0,0 +1,22 @@
+import { defineComponent, h, ref, onMounted } from 'vue';
+
+export const IonModal = defineComponent({
+ name: 'IonModal',
+ setup(_, { attrs, slots }) {
+ const isOpen = ref(false);
+ const elementRef = ref();
+
+ onMounted(() => {
+ elementRef.value.addEventListener('will-present', () => isOpen.value = true);
+ elementRef.value.addEventListener('did-dismiss', () => isOpen.value = false);
+ });
+
+ return () => {
+ return h(
+ 'ion-modal',
+ { ...attrs, ref: elementRef },
+ (isOpen.value) ? slots : undefined
+ )
+ }
+ }
+});
diff --git a/packages/vue/src/components/Overlays.ts b/packages/vue/src/components/Overlays.ts
index 63cab9007c0..38becd6e2a2 100644
--- a/packages/vue/src/components/Overlays.ts
+++ b/packages/vue/src/components/Overlays.ts
@@ -5,7 +5,6 @@ import {
actionSheetController,
alertController,
loadingController,
- modalController,
pickerController,
toastController
} from '@ionic/core';
@@ -18,8 +17,6 @@ export const IonAlert = /*@__PURE__*/defineOverlayContainer('ion-a
export const IonLoading = /*@__PURE__*/defineOverlayContainer('ion-loading', ['animated', 'backdropDismiss', 'cssClass', 'duration', 'enterAnimation', 'keyboardClose', 'leaveAnimation', 'message', 'mode', 'showBackdrop', 'spinner', 'translucent'], loadingController);
-export const IonModal = /*@__PURE__*/defineOverlayContainer('ion-modal', ['animated', 'backdropDismiss', 'component', 'componentProps', 'cssClass', 'enterAnimation', 'keyboardClose', 'leaveAnimation', 'mode', 'presentingElement', 'showBackdrop', 'swipeToClose'], modalController);
-
export const IonPicker = /*@__PURE__*/defineOverlayContainer('ion-picker', ['animated', 'backdropDismiss', 'buttons', 'columns', 'cssClass', 'duration', 'enterAnimation', 'keyboardClose', 'leaveAnimation', 'mode', 'showBackdrop'], pickerController);
export const IonToast = /*@__PURE__*/defineOverlayContainer('ion-toast', ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'keyboardClose', 'leaveAnimation', 'message', 'mode', 'position', 'translucent'], toastController);
diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts
index dae0296500c..8ecda3a6515 100644
--- a/packages/vue/src/index.ts
+++ b/packages/vue/src/index.ts
@@ -14,6 +14,7 @@ export { IonNav } from './components/IonNav';
export { IonIcon } from './components/IonIcon';
export { IonApp } from './components/IonApp';
export { IonPopover } from './components/IonPopover';
+export { IonModal } from './components/IonModal';
export * from './components/Overlays';
diff --git a/packages/vue/test-app/src/views/Overlays.vue b/packages/vue/test-app/src/views/Overlays.vue
index fbdea4b5dcf..ef3ce1fbb1d 100644
--- a/packages/vue/test-app/src/views/Overlays.vue
+++ b/packages/vue/test-app/src/views/Overlays.vue
@@ -99,13 +99,12 @@
-
+ {
cy.get(overlay).shadow().find('ion-backdrop').click({ force: true });
} else {
cy.get(`${overlay} ion-backdrop`).click({ force: true });
- }
- cy.get(overlay).should('not.exist');
+ /**
+ * Overlay components that are shadow can be used inline
+ * so they should not be removed from the DOM. This test
+ * might need to be revisited if other overlay components
+ * are converted to shadow as well.
+ */
+ cy.get(overlay).should('not.exist');
+ }
}
describe('Overlays', () => {
@@ -50,7 +56,7 @@ describe('Overlays', () => {
});
it(`should open and close ion-modal via controller`, () => {
- testController('ion-modal');
+ testController('ion-modal', true);
});
it(`should open and close ion-popover via controller`, () => {
@@ -82,7 +88,7 @@ describe('Overlays', () => {
});
it(`should open and close ion-modal via component`, () => {
- testComponent('ion-modal');
+ testComponent('ion-modal', true);
});
it(`should open and close ion-popover via component`, () => {