feat(modal): modals can now be used inline (#23341)

resolves #20117, resolves #20263
This commit is contained in:
Liam DeBeasi
2021-06-01 11:09:40 -04:00
committed by GitHub
parent 8c6163c5b6
commit 3be1c3dcd7
41 changed files with 860 additions and 205 deletions

View File

@ -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

View File

@ -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: `<ng-container [ngTemplateOutlet]="template" *ngIf="isCmpOpen"></ng-container>`, inputs: ["animated", "backdropDismiss", "component", "componentProps", "cssClass", "enterAnimation", "event", "isOpen", "keyboardClose", "leaveAnimation", "mode", "showBackdrop", "translucent"] })
export class IonModal {
@ContentChild(TemplateRef, { static: false }) template: TemplateRef<any>;
ionModalDidPresent!: EventEmitter<CustomEvent>;
ionModalWillPresent!: EventEmitter<CustomEvent>;
ionModalWillDismiss!: EventEmitter<CustomEvent>;
ionModalDidDismiss!: EventEmitter<CustomEvent>;
didPresent!: EventEmitter<CustomEvent>;
willPresent!: EventEmitter<CustomEvent>;
willDismiss!: EventEmitter<CustomEvent>;
didDismiss!: EventEmitter<CustomEvent>;
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"]);
}
}

View File

@ -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,

View File

@ -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<boolean>
ion-modal,method,onDidDismiss,onDidDismiss<T = any>() => Promise<OverlayEventDetail<T>>
ion-modal,method,onWillDismiss,onWillDismiss<T = any>() => Promise<OverlayEventDetail<T>>
ion-modal,method,present,present() => Promise<void>
ion-modal,event,didDismiss,OverlayEventDetail<any>,true
ion-modal,event,didPresent,void,true
ion-modal,event,ionModalDidDismiss,OverlayEventDetail<any>,true
ion-modal,event,ionModalDidPresent,void,true
ion-modal,event,ionModalWillDismiss,OverlayEventDetail<any>,true
ion-modal,event,ionModalWillPresent,void,true
ion-modal,event,willDismiss,OverlayEventDetail<any>,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

View File

@ -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<OverlayEventDetail>) => void;
/**
* Emitted after the modal has presented. Shorthand for ionModalWillDismiss.
*/
"onDidPresent"?: (event: CustomEvent<void>) => void;
/**
* Emitted after the modal has dismissed.
*/
@ -4873,6 +4895,14 @@ declare namespace LocalJSX {
* Emitted before the modal has presented.
*/
"onIonModalWillPresent"?: (event: CustomEvent<void>) => void;
/**
* Emitted before the modal has dismissed. Shorthand for ionModalWillDismiss.
*/
"onWillDismiss"?: (event: CustomEvent<OverlayEventDetail>) => void;
/**
* Emitted before the modal has presented. Shorthand for ionModalWillPresent.
*/
"onWillPresent"?: (event: CustomEvent<void>) => 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 {
/**

View File

@ -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 }

View File

@ -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)'
})

View File

@ -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)' }

View File

@ -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

View File

@ -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);
}
}

View File

@ -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};
}
}

View File

@ -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) {

View File

@ -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<any>;
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<OverlayEventDetail>;
/**
* Emitted after the modal has presented.
* Shorthand for ionModalWillDismiss.
*/
@Event({ eventName: 'didPresent' }) didPresentShorthand!: EventEmitter<void>;
/**
* Emitted before the modal has presented.
* Shorthand for ionModalWillPresent.
*/
@Event({ eventName: 'willPresent' }) willPresentShorthand!: EventEmitter<void>;
/**
* Emitted before the modal has dismissed.
* Shorthand for ionModalWillDismiss.
*/
@Event({ eventName: 'willDismiss' }) willDismissShorthand!: EventEmitter<OverlayEventDetail>;
/**
* Emitted after the modal has dismissed.
* Shorthand for ionModalDidDismiss.
*/
@Event({ eventName: 'didDismiss' }) didDismissShorthand!: EventEmitter<OverlayEventDetail>;
@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 (
<Host
@ -275,8 +429,11 @@ export class Modal implements ComponentInterface, OverlayInterface {
class={{
[mode]: true,
[`modal-card`]: this.presentingElement !== undefined && mode === 'ios',
'overlay-hidden': true,
'modal-interactive': presented,
...getClassMap(this.cssClass)
}}
id={modalId}
style={{
zIndex: `${20000 + this.overlayIndex}`,
}}
@ -287,19 +444,18 @@ export class Modal implements ComponentInterface, OverlayInterface {
onIonModalWillDismiss={this.onLifecycle}
onIonModalDidDismiss={this.onLifecycle}
>
<ion-backdrop visible={this.showBackdrop} tappable={this.backdropDismiss}/>
<ion-backdrop visible={this.showBackdrop} tappable={this.backdropDismiss} part="backdrop" />
{mode === 'ios' && <div class="modal-shadow"></div>}
<div tabindex="0"></div>
<div
role="dialog"
class="modal-wrapper ion-overlay-wrapper"
part="content"
>
<slot></slot>
</div>
<div tabindex="0"></div>
</Host>
);
}
@ -311,3 +467,5 @@ const LIFECYCLE_MAP: any = {
'ionModalWillDismiss': 'ionViewWillLeave',
'ionModalDidDismiss': 'ionViewDidLeave',
};
let modalIds = 0;

View File

@ -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 `<ng-content>` internally. Instead, we use `<ng-container>` which expects an `<ng-template>` to be passed in. As a result, when passing in your component you will need to wrap it in an `<ng-template>`:
```html
<ion-modal [isOpen]="isModalOpen">
<ng-template>
<app-modal-content></app-modal-content>
</ng-template>
</ion-modal>
```
### 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.
@ -756,29 +816,32 @@ 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` |
| `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 |
| --------------------- | --------------------------------------- | -------------------------------------- |
| --------------------- | -------------------------------------------------------------------------- | -------------------------------------- |
| `didDismiss` | Emitted after the modal has dismissed. Shorthand for ionModalDidDismiss. | `CustomEvent<OverlayEventDetail<any>>` |
| `didPresent` | Emitted after the modal has presented. Shorthand for ionModalWillDismiss. | `CustomEvent<void>` |
| `ionModalDidDismiss` | Emitted after the modal has dismissed. | `CustomEvent<OverlayEventDetail<any>>` |
| `ionModalDidPresent` | Emitted after the modal has presented. | `CustomEvent<void>` |
| `ionModalWillDismiss` | Emitted before the modal has dismissed. | `CustomEvent<OverlayEventDetail<any>>` |
| `ionModalWillPresent` | Emitted before the modal has presented. | `CustomEvent<void>` |
| `willDismiss` | Emitted before the modal has dismissed. Shorthand for ionModalWillDismiss. | `CustomEvent<OverlayEventDetail<any>>` |
| `willPresent` | Emitted before the modal has presented. Shorthand for ionModalWillPresent. | `CustomEvent<void>` |
## Methods
@ -824,6 +887,21 @@ Type: `Promise<void>`
## 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 |

View File

@ -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);
});

View File

@ -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);
});

View File

@ -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();
}
});

View File

@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8">
<title>Modal - Inline</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet">
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet">
<script src="../../../../../scripts/testing/scripts.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
</head>
<body>
<ion-app>
<div class="ion-page">
<ion-header>
<ion-toolbar>
<ion-title>Modal - Inline</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-button onclick="openModal(event)">Open Modal</ion-button>
<ion-modal swipe-to-close="true">
<ion-header>
<ion-toolbar>
<ion-title>
Modal
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
This is my inline modal content!
</ion-content>
</ion-modal>
</ion-content>
</div>
</ion-app>
<script>
const modal = document.querySelector('ion-modal');
modal.presentingElement = document.querySelector('.ion-page');
const openModal = () => {
modal.isOpen = true;
}
modal.addEventListener('didDismiss', () => {
modal.isOpen = false;
});
</script>
</body>
</html>

View File

@ -0,0 +1,47 @@
import { newE2EPage } from '@stencil/core/testing';
test('should open the modal', async () => {
const page = await newE2EPage({ url: '/src/components/modal/test/isOpen?ionic:_testing=true' });
const screenshotCompares = [];
const trigger = await page.find('#default');
trigger.click();
await page.waitForSelector('ion-modal');
const modal = await page.find('ion-modal');
await modal.waitForVisible();
screenshotCompares.push(await page.compareScreenshot());
for (const screenshotCompare of screenshotCompares) {
expect(screenshotCompare).toMatchScreenshot();
}
});
test('should open the modal then close after a timeout', async () => {
const page = await newE2EPage({ url: '/src/components/modal/test/isOpen?ionic:_testing=true' });
const screenshotCompares = [];
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
const trigger = await page.find('#timeout');
trigger.click();
await page.waitForSelector('ion-modal');
const modal = await page.find('ion-modal');
await modal.waitForVisible();
screenshotCompares.push(await page.compareScreenshot());
await ionModalDidDismiss.next();
await modal.waitForNotVisible();
screenshotCompares.push(await page.compareScreenshot());
for (const screenshotCompare of screenshotCompares) {
expect(screenshotCompare).toMatchScreenshot();
}
});

View File

@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8">
<title>Modal - isOpen</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet">
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet">
<script src="../../../../../scripts/testing/scripts.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<style>
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-row-gap: 20px;
grid-column-gap: 20px;
padding: 200px;
}
.grid-item {
margin: 0 auto;
}
h2 {
font-size: 12px;
font-weight: normal;
color: #6f7378;
margin-top: 10px;
margin-left: 5px;
}
</style>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Modal - isOpen</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="grid">
<div class="grid-item">
<h2>Default</h2>
<ion-button id="default" onclick="openModal()">Open Modal</ion-button>
</div>
<div class="grid-item">
<h2>Open, then close after 500ms</h2>
<ion-button id="timeout" onclick="openModal(500)">Open Modal</ion-button>
</div>
</div>
<ion-modal>
<ion-content class="ion-padding">
Hello World
</ion-content>
</ion-modal>
</ion-content>
</ion-app>
<script>
const modal = document.querySelector('ion-modal');
const openModal = (timeout) => {
modal.isOpen = true;
if (timeout) {
setTimeout(() => {
modal.isOpen = false;
}, timeout);
}
}
modal.addEventListener('ionModalDidDismiss', () => {
modal.isOpen = false;
});
</script>
</body>
</html>

View File

@ -3,9 +3,9 @@ import { testModal } from '../test.utils';
const DIRECTORY = 'spec';
test('modal: card', async () => {
await testModal(DIRECTORY, '#card-modal');
await testModal(DIRECTORY, '#card-modal', true);
});
test('modal:rtl: card', async () => {
await testModal(DIRECTORY, '#card-modal', true);
await testModal(DIRECTORY, '#card-modal', true, true);
});

View File

@ -5,6 +5,7 @@ import { generateE2EUrl } from '../../../utils/test/utils';
export const testModal = async (
type: string,
selector: string,
expectUnmount = true,
rtl = false
) => {
const pageUrl = generateE2EUrl('modal', type, rtl);
@ -41,8 +42,10 @@ export const testModal = async (
screenshotCompares.push(await page.compareScreenshot('dismiss'));
if (expectUnmount) {
modal = await page.find('ion-modal');
expect(modal).toBeNull();
}
for (const screenshotCompare of screenshotCompares) {
expect(screenshotCompare).toMatchScreenshot();

View File

@ -0,0 +1,19 @@
import { newE2EPage } from '@stencil/core/testing';
test('should open modal by left clicking on trigger', async () => {
const page = await newE2EPage({ url: '/src/components/modal/test/trigger?ionic:_testing=true' });
const screenshotCompares = [];
await page.click('#left-click-trigger');
await page.waitForSelector('.left-click-modal');
let modal = await page.find('.left-click-modal');
await modal.waitForVisible();
screenshotCompares.push(await page.compareScreenshot());
for (const screenshotCompare of screenshotCompares) {
expect(screenshotCompare).toMatchScreenshot();
}
});

View File

@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8">
<title>Modal - Triggers</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet">
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet">
<script src="../../../../../scripts/testing/scripts.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<style>
.grid {
display: grid;
grid-template-columns: 1fr;
grid-row-gap: 20px;
grid-column-gap: 20px;
padding: 200px;
}
.grid-item {
margin: 0 auto;
}
h2 {
font-size: 12px;
font-weight: normal;
color: #6f7378;
margin-top: 10px;
margin-left: 5px;
}
</style>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Modal - Triggers</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="grid">
<div class="grid-item">
<h2>Click</h2>
<ion-button id="left-click-trigger">Trigger</ion-button>
<ion-modal
class="left-click-modal"
trigger="left-click-trigger"
>
<ion-content class="ion-padding">
Modal Content
</ion-content>
</ion-modal>
</div>
</div>
</ion-content>
</ion-app>
</body>
</html>

View File

@ -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.
*

View File

@ -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 `<ng-content>` internally. Instead, we use `<ng-container>` which expects an `<ng-template>` to be passed in. As a result, when passing in your component you will need to wrap it in an `<ng-template>`:
```html
<ion-popover [isOpen]="isPopoverOpen">
<ng-template>
<app-popover-content></app-popover-content>
</ng-template>
</ion-popover>
```
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.

View File

@ -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);
});

View File

@ -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);
});

View File

@ -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');

View File

@ -63,7 +63,6 @@
const popover = document.querySelector('ion-popover');
const openPopover = (ev, timeout) => {
console.log(ev, timeout)
popover.event = ev;
popover.isOpen = true;

View File

@ -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'));
if (expectUnmount) {
popover = await page.find('ion-popover');
expect(popover).toBeNull();
}
for (const screenshotCompare of screenshotCompares) {
expect(screenshotCompare).toMatchScreenshot();

View File

@ -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

View File

@ -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 }
}

View File

@ -445,6 +445,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);
}

View File

@ -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<ModalOptions, 'component' | 'componentProps'> & {
children: React.ReactNode;
};
export const IonModal = /*@__PURE__*/ createOverlayComponent<
ReactModalOptions,
export const IonModal = /*@__PURE__*/ createInlineOverlayComponent<
JSX.IonModal,
HTMLIonModalElement
>('IonModal', modalController);
>('ion-modal');

View File

@ -18,11 +18,6 @@ function generateOverlays() {
controller: 'loadingController',
name: 'IonLoading'
},
{
tag: 'ion-modal',
controller: 'modalController',
name: 'IonModal'
},
{
tag: 'ion-picker',
controller: 'pickerController',

View File

@ -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
)
}
}
});

View File

@ -5,7 +5,6 @@ import {
actionSheetController,
alertController,
loadingController,
modalController,
pickerController,
toastController
} from '@ionic/core';
@ -18,8 +17,6 @@ export const IonAlert = /*@__PURE__*/defineOverlayContainer<JSX.IonAlert>('ion-a
export const IonLoading = /*@__PURE__*/defineOverlayContainer<JSX.IonLoading>('ion-loading', ['animated', 'backdropDismiss', 'cssClass', 'duration', 'enterAnimation', 'keyboardClose', 'leaveAnimation', 'message', 'mode', 'showBackdrop', 'spinner', 'translucent'], loadingController);
export const IonModal = /*@__PURE__*/defineOverlayContainer<JSX.IonModal>('ion-modal', ['animated', 'backdropDismiss', 'component', 'componentProps', 'cssClass', 'enterAnimation', 'keyboardClose', 'leaveAnimation', 'mode', 'presentingElement', 'showBackdrop', 'swipeToClose'], modalController);
export const IonPicker = /*@__PURE__*/defineOverlayContainer<JSX.IonPicker>('ion-picker', ['animated', 'backdropDismiss', 'buttons', 'columns', 'cssClass', 'duration', 'enterAnimation', 'keyboardClose', 'leaveAnimation', 'mode', 'showBackdrop'], pickerController);
export const IonToast = /*@__PURE__*/defineOverlayContainer<JSX.IonToast>('ion-toast', ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'keyboardClose', 'leaveAnimation', 'message', 'mode', 'position', 'translucent'], toastController);

View File

@ -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';

View File

@ -99,13 +99,12 @@
<ion-modal
:is-open="isModalOpen"
:componentProps="overlayProps"
@willPresent="onModalWillPresent"
@didPresent="onModalDidPresent"
@willDismiss="onModalWillDismiss"
@didDismiss="onModalDidDismiss"
>
<ModalContent></ModalContent>
<ModalContent :title="overlayProps.title"></ModalContent>
</ion-modal>
<ion-popover

View File

@ -26,10 +26,16 @@ const testComponent = (overlay, shadow = false) => {
cy.get(overlay).shadow().find('ion-backdrop').click({ force: true });
} else {
cy.get(`${overlay} ion-backdrop`).click({ force: true });
}
/**
* 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', () => {
beforeEach(() => {
@ -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`, () => {