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

Default

+ Open Modal +
+
+

Open, then close after 500ms

+ Open Modal +
+
+ + + + Hello World + + +
+
+ + + + diff --git a/core/src/components/modal/test/spec/e2e.ts b/core/src/components/modal/test/spec/e2e.ts index 3962b3759b..3f40349321 100644 --- a/core/src/components/modal/test/spec/e2e.ts +++ b/core/src/components/modal/test/spec/e2e.ts @@ -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); }); diff --git a/core/src/components/modal/test/test.utils.ts b/core/src/components/modal/test/test.utils.ts index 7cd5eab427..ae62364b0b 100644 --- a/core/src/components/modal/test/test.utils.ts +++ b/core/src/components/modal/test/test.utils.ts @@ -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')); - modal = await page.find('ion-modal'); - expect(modal).toBeNull(); + if (expectUnmount) { + modal = await page.find('ion-modal'); + expect(modal).toBeNull(); + } for (const screenshotCompare of screenshotCompares) { expect(screenshotCompare).toMatchScreenshot(); diff --git a/core/src/components/modal/test/trigger/e2e.ts b/core/src/components/modal/test/trigger/e2e.ts new file mode 100644 index 0000000000..6d48ef8df3 --- /dev/null +++ b/core/src/components/modal/test/trigger/e2e.ts @@ -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(); + } +}); diff --git a/core/src/components/modal/test/trigger/index.html b/core/src/components/modal/test/trigger/index.html new file mode 100644 index 0000000000..fb31dd9c2c --- /dev/null +++ b/core/src/components/modal/test/trigger/index.html @@ -0,0 +1,60 @@ + + + + + Modal - Triggers + + + + + + + + + + + + Modal - Triggers + + + + +
+
+

Click

+ Trigger + + + Modal Content + + +
+
+
+
+ + diff --git a/core/src/components/popover/popover.tsx b/core/src/components/popover/popover.tsx index b286814a1c..cf383cab31 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 27b0dd37b7..6b14500fc7 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 2203c3c331..3d5c7fdbad 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 f586ea2010..701a1d21a6 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 3fb7631790..0ba38679e3 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 61e0ed21a4..5f11d58b8f 100644 --- a/core/src/components/popover/test/isOpen/index.html +++ b/core/src/components/popover/test/isOpen/index.html @@ -63,7 +63,6 @@ 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 341f30d299..44f17d96c1 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 0dbe43c1ae..f6f74def49 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 f93efab723..997d87e208 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 f97c739f09..65b5681996 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -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); } diff --git a/packages/react/src/components/IonModal.tsx b/packages/react/src/components/IonModal.tsx index 5d3b6b3870..0a4aa82240 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 37e8d949f5..3b2acf075b 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 0000000000..69e4c8e7aa --- /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 63cab9007c..38becd6e2a 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 dae0296500..8ecda3a651 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 fbdea4b5dc..ef3ce1fbb1 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`, () => {