diff --git a/BREAKING.md b/BREAKING.md
index f52b0a8b66f..ab70dd22e6a 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)
+ * [Popover](#popover)
* [Tab Bar](#tab-bar)
* [Toast](#toast)
* [Toolbar](#toolbar)
@@ -42,6 +43,12 @@ ion-header.header-collapse-condense ion-toolbar:last-of-type {
}
```
+#### Popover
+
+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.
+
#### Tab Bar
The default iOS tab bar background color has been updated to better reflect the latest iOS styles. The new default value is:
diff --git a/angular/src/directives/overlays/ion-popover.ts b/angular/src/directives/overlays/ion-popover.ts
new file mode 100644
index 00000000000..c5add56ec73
--- /dev/null
+++ b/angular/src/directives/overlays/ion-popover.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 IonPopover extends Components.IonPopover {
+}
+@ProxyCmp({ inputs: ["animated", "backdropDismiss", "cssClass", "enterAnimation", "event", "isOpen", "keyboardClose", "leaveAnimation", "mode", "showBackdrop", "translucent"], "methods": ["present", "dismiss", "onDidDismiss", "onWillDismiss"] })
+@Component({ selector: "ion-popover", changeDetection: ChangeDetectionStrategy.OnPush, template: ``, inputs: ["animated", "backdropDismiss", "component", "componentProps", "cssClass", "enterAnimation", "event", "isOpen", "keyboardClose", "leaveAnimation", "mode", "showBackdrop", "translucent"] })
+export class IonPopover {
+ @ContentChild(TemplateRef, { static: false }) template: TemplateRef;
+
+ ionPopoverDidPresent!: EventEmitter;
+ ionPopoverWillPresent!: EventEmitter;
+ ionPopoverWillDismiss!: EventEmitter;
+ ionPopoverDidDismiss!: 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, ["ionPopoverDidPresent", "ionPopoverWillPresent", "ionPopoverWillDismiss", "ionPopoverDidDismiss", "didPresent", "willPresent", "willDismiss", "didDismiss"]);
+ }
+}
diff --git a/angular/src/ionic-module.ts b/angular/src/ionic-module.ts
index 8adfbab859c..1ac600a3bf8 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 { 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';
import { VirtualHeader } from './directives/virtual-scroll/virtual-header';
@@ -70,6 +71,7 @@ const DECLARATIONS = [
IonNav,
IonNavLink,
IonNote,
+ IonPopover,
IonProgressBar,
IonRadio,
IonRadioGroup,
diff --git a/core/api.txt b/core/api.txt
index 326a803aa7b..de0aab9d541 100644
--- a/core/api.txt
+++ b/core/api.txt
@@ -823,14 +823,14 @@ ion-picker,css-prop,--min-height
ion-picker,css-prop,--min-width
ion-picker,css-prop,--width
-ion-popover,scoped
+ion-popover,shadow
ion-popover,prop,animated,boolean,true,false,false
ion-popover,prop,backdropDismiss,boolean,true,false,false
-ion-popover,prop,component,Function | HTMLElement | null | string,undefined,true,false
+ion-popover,prop,component,Function | HTMLElement | null | string | undefined,undefined,false,false
ion-popover,prop,componentProps,undefined | { [key: string]: any; },undefined,false,false
-ion-popover,prop,cssClass,string | string[] | undefined,undefined,false,false
ion-popover,prop,enterAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false
ion-popover,prop,event,any,undefined,false,false
+ion-popover,prop,isOpen,boolean,false,false,false
ion-popover,prop,keyboardClose,boolean,true,false,false
ion-popover,prop,leaveAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false
ion-popover,prop,mode,"ios" | "md",undefined,false,false
@@ -840,10 +840,14 @@ ion-popover,method,dismiss,dismiss(data?: any, role?: string | undefined) => Pro
ion-popover,method,onDidDismiss,onDidDismiss() => Promise>
ion-popover,method,onWillDismiss,onWillDismiss() => Promise>
ion-popover,method,present,present() => Promise
+ion-popover,event,didDismiss,OverlayEventDetail,true
+ion-popover,event,didPresent,void,true
ion-popover,event,ionPopoverDidDismiss,OverlayEventDetail,true
ion-popover,event,ionPopoverDidPresent,void,true
ion-popover,event,ionPopoverWillDismiss,OverlayEventDetail,true
ion-popover,event,ionPopoverWillPresent,void,true
+ion-popover,event,willDismiss,OverlayEventDetail,true
+ion-popover,event,willPresent,void,true
ion-popover,css-prop,--backdrop-opacity
ion-popover,css-prop,--background
ion-popover,css-prop,--box-shadow
@@ -853,6 +857,9 @@ ion-popover,css-prop,--max-width
ion-popover,css-prop,--min-height
ion-popover,css-prop,--min-width
ion-popover,css-prop,--width
+ion-popover,part,arrow
+ion-popover,part,backdrop
+ion-popover,part,content
ion-progress-bar,shadow
ion-progress-bar,prop,buffer,number,1,false,false
diff --git a/core/src/components.d.ts b/core/src/components.d.ts
index c352e052809..5e3d35e3045 100644
--- a/core/src/components.d.ts
+++ b/core/src/components.d.ts
@@ -1658,11 +1658,11 @@ export namespace Components {
*/
"backdropDismiss": boolean;
/**
- * The component to display inside of the popover.
+ * The component to display inside of the popover. You only need to use this if you are not using a JavaScript framework. Otherwise, you can just slot your component inside of `ion-popover`.
*/
- "component": ComponentRef;
+ "component"?: ComponentRef;
/**
- * The data to pass to the popover component.
+ * The data to pass to the popover component. You only need to use this if you are not using a JavaScript framework. Otherwise, you can just set the props directly on your component.
*/
"componentProps"?: ComponentProps;
/**
@@ -1684,6 +1684,11 @@ export namespace Components {
* The event to pass to the popover animation.
*/
"event": any;
+ "inline": boolean;
+ /**
+ * If `true`, the popover will open. If `false`, the popover will close. Use this if you need finer grained control over presentation, otherwise just use the popoverController or the `trigger` property. Note: `isOpen` will not automatically be set back to `false` when the popover 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.
*/
@@ -4990,11 +4995,11 @@ declare namespace LocalJSX {
*/
"backdropDismiss"?: boolean;
/**
- * The component to display inside of the popover.
+ * The component to display inside of the popover. You only need to use this if you are not using a JavaScript framework. Otherwise, you can just slot your component inside of `ion-popover`.
*/
- "component": ComponentRef;
+ "component"?: ComponentRef;
/**
- * The data to pass to the popover component.
+ * The data to pass to the popover component. You only need to use this if you are not using a JavaScript framework. Otherwise, you can just set the props directly on your component.
*/
"componentProps"?: ComponentProps;
/**
@@ -5010,6 +5015,11 @@ declare namespace LocalJSX {
* The event to pass to the popover animation.
*/
"event"?: any;
+ "inline"?: boolean;
+ /**
+ * If `true`, the popover will open. If `false`, the popover will close. Use this if you need finer grained control over presentation, otherwise just use the popoverController or the `trigger` property. Note: `isOpen` will not automatically be set back to `false` when the popover 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.
*/
@@ -5022,6 +5032,14 @@ declare namespace LocalJSX {
* The mode determines which platform styles to use.
*/
"mode"?: "ios" | "md";
+ /**
+ * Emitted after the popover has dismissed. Shorthand for ionPopoverDidDismiss.
+ */
+ "onDidDismiss"?: (event: CustomEvent) => void;
+ /**
+ * Emitted after the popover has presented. Shorthand for ionPopoverWillDismiss.
+ */
+ "onDidPresent"?: (event: CustomEvent) => void;
/**
* Emitted after the popover has dismissed.
*/
@@ -5038,6 +5056,14 @@ declare namespace LocalJSX {
* Emitted before the popover has presented.
*/
"onIonPopoverWillPresent"?: (event: CustomEvent) => void;
+ /**
+ * Emitted before the popover has dismissed. Shorthand for ionPopoverWillDismiss.
+ */
+ "onWillDismiss"?: (event: CustomEvent) => void;
+ /**
+ * Emitted before the popover has presented. Shorthand for ionPopoverWillPresent.
+ */
+ "onWillPresent"?: (event: CustomEvent) => void;
"overlayIndex": number;
/**
* If `true`, a backdrop will be displayed behind the popover.
diff --git a/core/src/components/popover/animations/ios.enter.ts b/core/src/components/popover/animations/ios.enter.ts
index 0c3655ef32c..17dc922659c 100644
--- a/core/src/components/popover/animations/ios.enter.ts
+++ b/core/src/components/popover/animations/ios.enter.ts
@@ -1,5 +1,6 @@
import { Animation } from '../../../interface';
import { createAnimation } from '../../../utils/animation/animation';
+import { getElementRoot } from '../../../utils/helpers';
/**
* iOS Popover Enter Animation
@@ -8,7 +9,8 @@ export const iosEnterAnimation = (baseEl: HTMLElement, ev?: Event): Animation =>
let originY = 'top';
let originX = 'left';
- const contentEl = baseEl.querySelector('.popover-content') as HTMLElement;
+ const root = getElementRoot(baseEl);
+ const contentEl = root.querySelector('.popover-content') as HTMLElement;
const contentDimentions = contentEl.getBoundingClientRect();
const contentWidth = contentDimentions.width;
const contentHeight = contentDimentions.height;
@@ -24,7 +26,7 @@ export const iosEnterAnimation = (baseEl: HTMLElement, ev?: Event): Animation =>
const targetWidth = (targetDim && targetDim.width) || 0;
const targetHeight = (targetDim && targetDim.height) || 0;
- const arrowEl = baseEl.querySelector('.popover-arrow') as HTMLElement;
+ const arrowEl = root.querySelector('.popover-arrow') as HTMLElement;
const arrowDim = arrowEl.getBoundingClientRect();
const arrowWidth = arrowDim.width;
@@ -103,7 +105,7 @@ export const iosEnterAnimation = (baseEl: HTMLElement, ev?: Event): Animation =>
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'
@@ -111,11 +113,10 @@ export const iosEnterAnimation = (baseEl: HTMLElement, ev?: Event): Animation =>
.afterClearStyles(['pointer-events']);
wrapperAnimation
- .addElement(baseEl.querySelector('.popover-wrapper')!)
+ .addElement(root.querySelector('.popover-wrapper')!)
.fromTo('opacity', 0.01, 1);
return baseAnimation
- .addElement(baseEl)
.easing('ease')
.duration(100)
.addAnimation([backdropAnimation, wrapperAnimation]);
diff --git a/core/src/components/popover/animations/ios.leave.ts b/core/src/components/popover/animations/ios.leave.ts
index a83bfddff1d..2d1553f3155 100644
--- a/core/src/components/popover/animations/ios.leave.ts
+++ b/core/src/components/popover/animations/ios.leave.ts
@@ -1,24 +1,25 @@
import { Animation } from '../../../interface';
import { createAnimation } from '../../../utils/animation/animation';
+import { getElementRoot } from '../../../utils/helpers';
/**
* iOS Popover Leave Animation
*/
export const iosLeaveAnimation = (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', 'var(--backdrop-opacity)', 0);
wrapperAnimation
- .addElement(baseEl.querySelector('.popover-wrapper')!)
+ .addElement(root.querySelector('.popover-wrapper')!)
.fromTo('opacity', 0.99, 0);
return baseAnimation
- .addElement(baseEl)
.easing('ease')
.duration(500)
.addAnimation([backdropAnimation, wrapperAnimation]);
diff --git a/core/src/components/popover/animations/md.enter.ts b/core/src/components/popover/animations/md.enter.ts
index 30cc2dbdce8..63d8624b82a 100644
--- a/core/src/components/popover/animations/md.enter.ts
+++ b/core/src/components/popover/animations/md.enter.ts
@@ -1,5 +1,6 @@
import { Animation } from '../../../interface';
import { createAnimation } from '../../../utils/animation/animation';
+import { getElementRoot } from '../../../utils/helpers';
/**
* Md Popover Enter Animation
@@ -12,7 +13,8 @@ export const mdEnterAnimation = (baseEl: HTMLElement, ev?: Event): Animation =>
let originY = 'top';
let originX = isRTL ? 'right' : 'left';
- const contentEl = baseEl.querySelector('.popover-content') as HTMLElement;
+ const root = getElementRoot(baseEl);
+ const contentEl = root.querySelector('.popover-content') as HTMLElement;
const contentDimentions = contentEl.getBoundingClientRect();
const contentWidth = contentDimentions.width;
const contentHeight = contentDimentions.height;
@@ -85,7 +87,7 @@ export const mdEnterAnimation = (baseEl: HTMLElement, ev?: Event): Animation =>
const viewportAnimation = createAnimation();
backdropAnimation
- .addElement(baseEl.querySelector('ion-backdrop')!)
+ .addElement(root.querySelector('ion-backdrop')!)
.fromTo('opacity', 0.01, 'var(--backdrop-opacity)')
.beforeStyles({
'pointer-events': 'none'
@@ -93,7 +95,7 @@ export const mdEnterAnimation = (baseEl: HTMLElement, ev?: Event): Animation =>
.afterClearStyles(['pointer-events']);
wrapperAnimation
- .addElement(baseEl.querySelector('.popover-wrapper')!)
+ .addElement(root.querySelector('.popover-wrapper')!)
.fromTo('opacity', 0.01, 1);
contentAnimation
@@ -106,11 +108,10 @@ export const mdEnterAnimation = (baseEl: HTMLElement, ev?: Event): Animation =>
.fromTo('transform', 'scale(0.001)', 'scale(1)');
viewportAnimation
- .addElement(baseEl.querySelector('.popover-viewport')!)
+ .addElement(root.querySelector('.popover-viewport')!)
.fromTo('opacity', 0.01, 1);
return baseAnimation
- .addElement(baseEl)
.easing('cubic-bezier(0.36,0.66,0.04,1)')
.duration(300)
.addAnimation([backdropAnimation, wrapperAnimation, contentAnimation, viewportAnimation]);
diff --git a/core/src/components/popover/animations/md.leave.ts b/core/src/components/popover/animations/md.leave.ts
index 8200b68a302..350940081e6 100644
--- a/core/src/components/popover/animations/md.leave.ts
+++ b/core/src/components/popover/animations/md.leave.ts
@@ -1,24 +1,25 @@
import { Animation } from '../../../interface';
import { createAnimation } from '../../../utils/animation/animation';
+import { getElementRoot } from '../../../utils/helpers';
/**
* Md Popover Leave Animation
*/
export const mdLeaveAnimation = (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', 'var(--backdrop-opacity)', 0);
wrapperAnimation
- .addElement(baseEl.querySelector('.popover-wrapper')!)
+ .addElement(root.querySelector('.popover-wrapper')!)
.fromTo('opacity', 0.99, 0);
return baseAnimation
- .addElement(baseEl)
.easing('ease')
.duration(500)
.addAnimation([backdropAnimation, wrapperAnimation]);
diff --git a/core/src/components/popover/popover.scss b/core/src/components/popover/popover.scss
index f538505f357..512de38dbb2 100644
--- a/core/src/components/popover/popover.scss
+++ b/core/src/components/popover/popover.scss
@@ -37,6 +37,13 @@
color: $popover-text-color;
z-index: $z-index-overlay;
+
+ pointer-events: none;
+}
+
+:host(.popover-interactive) .popover-content,
+:host(.popover-interactive) ion-backdrop {
+ pointer-events: auto;
}
:host(.overlay-hidden) {
diff --git a/core/src/components/popover/popover.tsx b/core/src/components/popover/popover.tsx
index 3584883e1af..c645a71d394 100644
--- a/core/src/components/popover/popover.tsx
+++ b/core/src/components/popover/popover.tsx
@@ -1,8 +1,9 @@
-import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, h } from '@stencil/core';
+import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, State, Watch, h } from '@stencil/core';
import { getIonMode } from '../../global/ionic-global';
import { AnimationBuilder, ComponentProps, ComponentRef, FrameworkDelegate, OverlayEventDetail, OverlayInterface } from '../../interface';
import { attachComponent, detachComponent } from '../../utils/framework-delegate';
+import { raf } from '../../utils/helpers';
import { BACKDROP, dismiss, eventMethod, prepareOverlay, present } from '../../utils/overlays';
import { getClassMap } from '../../utils/theme';
import { deepReady } from '../../utils/transition';
@@ -12,8 +13,36 @@ import { iosLeaveAnimation } from './animations/ios.leave';
import { mdEnterAnimation } from './animations/md.enter';
import { mdLeaveAnimation } from './animations/md.leave';
+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.
+ *
+ * @slot - Content is placed inside of the `.popover-content` element.
+ *
+ * @part backdrop - The `ion-backdrop` element.
+ * @part arrow - The arrow that points to the reference element. Only applies on `ios` mode.
+ * @part content - The wrapper element for the default slot.
*/
@Component({
tag: 'ion-popover',
@@ -21,17 +50,25 @@ import { mdLeaveAnimation } from './animations/md.leave';
ios: 'popover.ios.scss',
md: 'popover.md.scss'
},
- scoped: true
+ shadow: true
})
export class Popover implements ComponentInterface, OverlayInterface {
private usersElement?: HTMLElement;
+ private popoverIndex = popoverIds++;
+ private popoverId?: string;
+ private coreDelegate: FrameworkDelegate = CoreDelegate();
+ private currentTransition?: Promise;
- presented = false;
lastFocus?: HTMLElement;
+ @State() presented = false;
+
@Element() el!: HTMLIonPopoverElement;
+ /** @internal */
+ @Prop() inline = true;
+
/** @internal */
@Prop() delegate?: FrameworkDelegate;
@@ -50,11 +87,17 @@ export class Popover implements ComponentInterface, OverlayInterface {
/**
* The component to display inside of the popover.
+ * You only need to use this if you are not using
+ * a JavaScript framework. Otherwise, you can just
+ * slot your component inside of `ion-popover`.
*/
- @Prop() component!: ComponentRef;
+ @Prop() component?: ComponentRef;
/**
* The data to pass to the popover component.
+ * You only need to use this if you are not using
+ * a JavaScript framework. Otherwise, you can just
+ * set the props directly on your component.
*/
@Prop() componentProps?: ComponentProps;
@@ -66,6 +109,7 @@ export class Popover implements ComponentInterface, OverlayInterface {
/**
* Additional classes to apply for custom CSS. If multiple classes are
* provided they should be separated by spaces.
+ * @internal
*/
@Prop() cssClass?: string | string[];
@@ -96,6 +140,24 @@ export class Popover implements ComponentInterface, OverlayInterface {
*/
@Prop() animated = true;
+ /**
+ * If `true`, the popover will open. If `false`, the popover will close.
+ * Use this if you need finer grained control over presentation, otherwise
+ * just use the popoverController or the `trigger` property.
+ * Note: `isOpen` will not automatically be set back to `false` when
+ * the popover 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();
+ }
+ }
+
/**
* Emitted after the popover has presented.
*/
@@ -116,10 +178,52 @@ export class Popover implements ComponentInterface, OverlayInterface {
*/
@Event({ eventName: 'ionPopoverDidDismiss' }) didDismiss!: EventEmitter;
+ /**
+ * Emitted after the popover has presented.
+ * Shorthand for ionPopoverWillDismiss.
+ */
+ @Event({ eventName: 'didPresent' }) didPresentShorthand!: EventEmitter;
+
+ /**
+ * Emitted before the popover has presented.
+ * Shorthand for ionPopoverWillPresent.
+ */
+ @Event({ eventName: 'willPresent' }) willPresentShorthand!: EventEmitter;
+
+ /**
+ * Emitted before the popover has dismissed.
+ * Shorthand for ionPopoverWillDismiss.
+ */
+ @Event({ eventName: 'willDismiss' }) willDismissShorthand!: EventEmitter;
+
+ /**
+ * Emitted after the popover has dismissed.
+ * Shorthand for ionPopoverDidDismiss.
+ */
+ @Event({ eventName: 'didDismiss' }) didDismissShorthand!: EventEmitter;
+
connectedCallback() {
prepareOverlay(this.el);
}
+ componentWillLoad() {
+ /**
+ * If user has custom ID set then we should
+ * not assign the default incrementing ID.
+ */
+ this.popoverId = (this.el.hasAttribute('id')) ? this.el.getAttribute('id')! : `ion-popover-${this.popoverIndex}`;
+ }
+
+ componentDidLoad() {
+ /**
+ * If popover was rendered with isOpen="true"
+ * then we should open popover immediately.
+ */
+ if (this.isOpen === true) {
+ raf(() => this.present());
+ }
+ }
+
/**
* Present the popover overlay after it has been created.
*/
@@ -128,17 +232,39 @@ export class Popover implements ComponentInterface, OverlayInterface {
if (this.presented) {
return;
}
- const container = this.el.querySelector('.popover-content');
- if (!container) {
- throw new Error('container is undefined');
+
+ /**
+ * When using an inline popover
+ * and dismissing a popover it is possible to
+ * quickly present the popover 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 data = {
...this.componentProps,
popover: this.el
};
- this.usersElement = await attachComponent(this.delegate, container, this.component, ['popover-viewport', (this.el as any)['s-sc']], data);
+
+ /**
+ * If using popover 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, ['popover-viewport'], data, this.inline);
await deepReady(this.usersElement);
- return present(this, 'popoverEnter', iosEnterAnimation, mdEnterAnimation, this.event);
+
+ this.currentTransition = present(this, 'popoverEnter', iosEnterAnimation, mdEnterAnimation, this.event);
+
+ await this.currentTransition;
+
+ this.currentTransition = undefined;
}
/**
@@ -149,10 +275,26 @@ export class Popover implements ComponentInterface, OverlayInterface {
*/
@Method()
async dismiss(data?: any, role?: string): Promise {
- const shouldDismiss = await dismiss(this, data, role, 'popoverLeave', iosLeaveAnimation, mdLeaveAnimation, this.event);
+ /**
+ * When using an inline popover
+ * and presenting a popover it is possible to
+ * quickly dismiss the popover 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;
+ }
+
+ this.currentTransition = dismiss(this, data, role, 'popoverLeave', iosLeaveAnimation, mdLeaveAnimation, this.event);
+ const shouldDismiss = await this.currentTransition;
if (shouldDismiss) {
await detachComponent(this.delegate, this.usersElement);
}
+
+ this.currentTransition = undefined;
+
return shouldDismiss;
}
@@ -198,7 +340,7 @@ export class Popover implements ComponentInterface, OverlayInterface {
render() {
const mode = getIonMode(this);
- const { onLifecycle } = this;
+ const { onLifecycle, presented, popoverId } = this;
return (
-
-
-
+
-
-
+
+
+
+
-
-
);
}
@@ -240,3 +383,5 @@ const LIFECYCLE_MAP: any = {
'ionPopoverWillDismiss': 'ionViewWillLeave',
'ionPopoverDidDismiss': 'ionViewDidLeave',
};
+
+let popoverIds = 0;
diff --git a/core/src/components/popover/readme.md b/core/src/components/popover/readme.md
index 910a21851c6..45acd59e5ed 100644
--- a/core/src/components/popover/readme.md
+++ b/core/src/components/popover/readme.md
@@ -2,9 +2,66 @@
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.
-## Presenting
+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.
-To present a popover, call the `present` method on a popover instance. In order to position the popover relative to the element clicked, a click event needs to be passed into the options of the the `present` method. If the event is not passed, the popover will be positioned in the center of the viewport.
+## 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;
+}
+```
## Customization
@@ -360,30 +417,34 @@ export default defineComponent({
## Properties
-| Property | Attribute | Description | Type | Default |
-| ------------------------ | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ----------- |
-| `animated` | `animated` | If `true`, the popover will animate. | `boolean` | `true` |
-| `backdropDismiss` | `backdrop-dismiss` | If `true`, the popover will be dismissed when the backdrop is clicked. | `boolean` | `true` |
-| `component` _(required)_ | `component` | The component to display inside of the popover. | `Function \| HTMLElement \| null \| string` | `undefined` |
-| `componentProps` | -- | The data to pass to the popover 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 popover is presented. | `((baseEl: any, opts?: any) => Animation) \| undefined` | `undefined` |
-| `event` | `event` | The event to pass to the popover animation. | `any` | `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 popover is dismissed. | `((baseEl: any, opts?: any) => Animation) \| undefined` | `undefined` |
-| `mode` | `mode` | The mode determines which platform styles to use. | `"ios" \| "md"` | `undefined` |
-| `showBackdrop` | `show-backdrop` | If `true`, a backdrop will be displayed behind the popover. | `boolean` | `true` |
-| `translucent` | `translucent` | If `true`, the popover will be translucent. Only applies when the mode is `"ios"` and the device supports [`backdrop-filter`](https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter#Browser_compatibility). | `boolean` | `false` |
+| Property | Attribute | Description | Type | Default |
+| ----------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | ----------- |
+| `animated` | `animated` | If `true`, the popover will animate. | `boolean` | `true` |
+| `backdropDismiss` | `backdrop-dismiss` | If `true`, the popover will be dismissed when the backdrop is clicked. | `boolean` | `true` |
+| `component` | `component` | The component to display inside of the popover. You only need to use this if you are not using a JavaScript framework. Otherwise, you can just slot your component inside of `ion-popover`. | `Function \| HTMLElement \| null \| string \| undefined` | `undefined` |
+| `componentProps` | -- | The data to pass to the popover component. You only need to use this if you are not using a JavaScript framework. Otherwise, you can just set the props directly on your component. | `undefined \| { [key: string]: any; }` | `undefined` |
+| `enterAnimation` | -- | Animation to use when the popover is presented. | `((baseEl: any, opts?: any) => Animation) \| undefined` | `undefined` |
+| `event` | `event` | The event to pass to the popover animation. | `any` | `undefined` |
+| `isOpen` | `is-open` | If `true`, the popover will open. If `false`, the popover will close. Use this if you need finer grained control over presentation, otherwise just use the popoverController or the `trigger` property. Note: `isOpen` will not automatically be set back to `false` when the popover 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 popover is dismissed. | `((baseEl: any, opts?: any) => Animation) \| undefined` | `undefined` |
+| `mode` | `mode` | The mode determines which platform styles to use. | `"ios" \| "md"` | `undefined` |
+| `showBackdrop` | `show-backdrop` | If `true`, a backdrop will be displayed behind the popover. | `boolean` | `true` |
+| `translucent` | `translucent` | If `true`, the popover will be translucent. Only applies when the mode is `"ios"` and the device supports [`backdrop-filter`](https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter#Browser_compatibility). | `boolean` | `false` |
## Events
-| Event | Description | Type |
-| ----------------------- | ----------------------------------------- | -------------------------------------- |
-| `ionPopoverDidDismiss` | Emitted after the popover has dismissed. | `CustomEvent>` |
-| `ionPopoverDidPresent` | Emitted after the popover has presented. | `CustomEvent` |
-| `ionPopoverWillDismiss` | Emitted before the popover has dismissed. | `CustomEvent>` |
-| `ionPopoverWillPresent` | Emitted before the popover has presented. | `CustomEvent` |
+| Event | Description | Type |
+| ----------------------- | ------------------------------------------------------------------------------ | -------------------------------------- |
+| `didDismiss` | Emitted after the popover has dismissed. Shorthand for ionPopoverDidDismiss. | `CustomEvent>` |
+| `didPresent` | Emitted after the popover has presented. Shorthand for ionPopoverWillDismiss. | `CustomEvent` |
+| `ionPopoverDidDismiss` | Emitted after the popover has dismissed. | `CustomEvent>` |
+| `ionPopoverDidPresent` | Emitted after the popover has presented. | `CustomEvent` |
+| `ionPopoverWillDismiss` | Emitted before the popover has dismissed. | `CustomEvent>` |
+| `ionPopoverWillPresent` | Emitted before the popover has presented. | `CustomEvent` |
+| `willDismiss` | Emitted before the popover has dismissed. Shorthand for ionPopoverWillDismiss. | `CustomEvent>` |
+| `willPresent` | Emitted before the popover has presented. Shorthand for ionPopoverWillPresent. | `CustomEvent` |
## Methods
@@ -429,6 +490,22 @@ Type: `Promise`
+## Slots
+
+| Slot | Description |
+| ---- | ----------------------------------------------------------- |
+| | Content is placed inside of the `.popover-content` element. |
+
+
+## Shadow Parts
+
+| Part | Description |
+| ------------ | --------------------------------------------------------------------------- |
+| `"arrow"` | The arrow that points to the reference element. Only applies on `ios` mode. |
+| `"backdrop"` | The `ion-backdrop` element. |
+| `"content"` | The wrapper element for the default slot. |
+
+
## CSS Custom Properties
| Name | Description |
diff --git a/core/src/components/popover/test/inline/e2e.ts b/core/src/components/popover/test/inline/e2e.ts
new file mode 100644
index 00000000000..3fb76317905
--- /dev/null
+++ b/core/src/components/popover/test/inline/e2e.ts
@@ -0,0 +1,38 @@
+import { newE2EPage } from '@stencil/core/testing';
+
+test('popover: inline', async () => {
+ const page = await newE2EPage({ url: '/src/components/popover/test/inline?ionic:_testing=true' });
+ const screenshotCompares = [];
+
+ await page.click('ion-button');
+ await page.waitForSelector('ion-popover');
+
+ let popover = await page.find('ion-popover');
+
+ expect(popover).not.toBe(null);
+ await popover.waitForVisible();
+
+ screenshotCompares.push(await page.compareScreenshot());
+
+ await popover.callMethod('dismiss');
+ await popover.waitForNotVisible();
+
+ 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');
+
+ let popoverAgain = await page.find('ion-popover');
+
+ expect(popoverAgain).not.toBe(null);
+ await popoverAgain.waitForVisible();
+
+ screenshotCompares.push(await page.compareScreenshot());
+
+ for (const screenshotCompare of screenshotCompares) {
+ expect(screenshotCompare).toMatchScreenshot();
+ }
+});
diff --git a/core/src/components/popover/test/inline/index.html b/core/src/components/popover/test/inline/index.html
new file mode 100644
index 00000000000..29b28290ce0
--- /dev/null
+++ b/core/src/components/popover/test/inline/index.html
@@ -0,0 +1,45 @@
+
+
+
+
+ Popover - Inline
+
+
+
+
+
+
+
+
+
+
+ Popover - Inline
+
+
+
+
+ Open Popover
+
+
+
+ This is my inline popover content!
+
+
+
+
+
+
+
+
+
diff --git a/core/src/components/toast/animations/ios.enter.ts b/core/src/components/toast/animations/ios.enter.ts
index 5af7d850f9a..7ee45b65120 100644
--- a/core/src/components/toast/animations/ios.enter.ts
+++ b/core/src/components/toast/animations/ios.enter.ts
@@ -1,15 +1,16 @@
import { Animation } from '../../../interface';
import { createAnimation } from '../../../utils/animation/animation';
+import { getElementRoot } from '../../../utils/helpers';
/**
* iOS Toast Enter Animation
*/
-export const iosEnterAnimation = (baseEl: ShadowRoot, position: string): Animation => {
+export const iosEnterAnimation = (baseEl: HTMLElement, position: string): Animation => {
const baseAnimation = createAnimation();
const wrapperAnimation = createAnimation();
- const hostEl = baseEl.host || baseEl;
- const wrapperEl = baseEl.querySelector('.toast-wrapper') as HTMLElement;
+ const root = getElementRoot(baseEl);
+ const wrapperEl = root.querySelector('.toast-wrapper') as HTMLElement;
const bottom = `calc(-10px - var(--ion-safe-area-bottom, 0px))`;
const top = `calc(10px + var(--ion-safe-area-top, 0px))`;
@@ -22,7 +23,7 @@ export const iosEnterAnimation = (baseEl: ShadowRoot, position: string): Animati
break;
case 'middle':
const topPosition = Math.floor(
- hostEl.clientHeight / 2 - wrapperEl.clientHeight / 2
+ baseEl.clientHeight / 2 - wrapperEl.clientHeight / 2
);
wrapperEl.style.top = `${topPosition}px`;
wrapperAnimation.fromTo('opacity', 0.01, 1);
@@ -32,7 +33,6 @@ export const iosEnterAnimation = (baseEl: ShadowRoot, position: string): Animati
break;
}
return baseAnimation
- .addElement(hostEl)
.easing('cubic-bezier(.155,1.105,.295,1.12)')
.duration(400)
.addAnimation(wrapperAnimation);
diff --git a/core/src/components/toast/animations/ios.leave.ts b/core/src/components/toast/animations/ios.leave.ts
index 38a4d0a1992..8694bd83456 100644
--- a/core/src/components/toast/animations/ios.leave.ts
+++ b/core/src/components/toast/animations/ios.leave.ts
@@ -1,15 +1,16 @@
import { Animation } from '../../../interface';
import { createAnimation } from '../../../utils/animation/animation';
+import { getElementRoot } from '../../../utils/helpers';
/**
* iOS Toast Leave Animation
*/
-export const iosLeaveAnimation = (baseEl: ShadowRoot, position: string): Animation => {
+export const iosLeaveAnimation = (baseEl: HTMLElement, position: string): Animation => {
const baseAnimation = createAnimation();
const wrapperAnimation = createAnimation();
- const hostEl = baseEl.host || baseEl;
- const wrapperEl = baseEl.querySelector('.toast-wrapper') as HTMLElement;
+ const root = getElementRoot(baseEl);
+ const wrapperEl = root.querySelector('.toast-wrapper') as HTMLElement;
const bottom = `calc(-10px - var(--ion-safe-area-bottom, 0px))`;
const top = `calc(10px + var(--ion-safe-area-top, 0px))`;
@@ -28,7 +29,6 @@ export const iosLeaveAnimation = (baseEl: ShadowRoot, position: string): Animati
break;
}
return baseAnimation
- .addElement(hostEl)
.easing('cubic-bezier(.36,.66,.04,1)')
.duration(300)
.addAnimation(wrapperAnimation);
diff --git a/core/src/components/toast/animations/md.enter.ts b/core/src/components/toast/animations/md.enter.ts
index d2b696045d4..8e7e00a3ea7 100644
--- a/core/src/components/toast/animations/md.enter.ts
+++ b/core/src/components/toast/animations/md.enter.ts
@@ -1,15 +1,16 @@
import { Animation } from '../../../interface';
import { createAnimation } from '../../../utils/animation/animation';
+import { getElementRoot } from '../../../utils/helpers';
/**
* MD Toast Enter Animation
*/
-export const mdEnterAnimation = (baseEl: ShadowRoot, position: string): Animation => {
+export const mdEnterAnimation = (baseEl: HTMLElement, position: string): Animation => {
const baseAnimation = createAnimation();
const wrapperAnimation = createAnimation();
- const hostEl = baseEl.host || baseEl;
- const wrapperEl = baseEl.querySelector('.toast-wrapper') as HTMLElement;
+ const root = getElementRoot(baseEl);
+ const wrapperEl = root.querySelector('.toast-wrapper') as HTMLElement;
const bottom = `calc(8px + var(--ion-safe-area-bottom, 0px))`;
const top = `calc(8px + var(--ion-safe-area-top, 0px))`;
@@ -23,7 +24,7 @@ export const mdEnterAnimation = (baseEl: ShadowRoot, position: string): Animatio
break;
case 'middle':
const topPosition = Math.floor(
- hostEl.clientHeight / 2 - wrapperEl.clientHeight / 2
+ baseEl.clientHeight / 2 - wrapperEl.clientHeight / 2
);
wrapperEl.style.top = `${topPosition}px`;
wrapperAnimation.fromTo('opacity', 0.01, 1);
@@ -34,7 +35,6 @@ export const mdEnterAnimation = (baseEl: ShadowRoot, position: string): Animatio
break;
}
return baseAnimation
- .addElement(hostEl)
.easing('cubic-bezier(.36,.66,.04,1)')
.duration(400)
.addAnimation(wrapperAnimation);
diff --git a/core/src/components/toast/animations/md.leave.ts b/core/src/components/toast/animations/md.leave.ts
index c3a1558f652..8e261fc5eec 100644
--- a/core/src/components/toast/animations/md.leave.ts
+++ b/core/src/components/toast/animations/md.leave.ts
@@ -1,22 +1,22 @@
import { Animation } from '../../../interface';
import { createAnimation } from '../../../utils/animation/animation';
+import { getElementRoot } from '../../../utils/helpers';
/**
* md Toast Leave Animation
*/
-export const mdLeaveAnimation = (baseEl: ShadowRoot): Animation => {
+export const mdLeaveAnimation = (baseEl: HTMLElement): Animation => {
const baseAnimation = createAnimation();
const wrapperAnimation = createAnimation();
- const hostEl = baseEl.host || baseEl;
- const wrapperEl = baseEl.querySelector('.toast-wrapper') as HTMLElement;
+ const root = getElementRoot(baseEl);
+ const wrapperEl = root.querySelector('.toast-wrapper') as HTMLElement;
wrapperAnimation
.addElement(wrapperEl)
.fromTo('opacity', 0.99, 0);
return baseAnimation
- .addElement(hostEl)
.easing('cubic-bezier(.36,.66,.04,1)')
.duration(300)
.addAnimation(wrapperAnimation);
diff --git a/core/src/utils/framework-delegate.ts b/core/src/utils/framework-delegate.ts
index ce0e6f1621f..f93efab7234 100644
--- a/core/src/utils/framework-delegate.ts
+++ b/core/src/utils/framework-delegate.ts
@@ -5,14 +5,15 @@ import { componentOnReady } from './helpers';
export const attachComponent = async (
delegate: FrameworkDelegate | undefined,
container: Element,
- component: ComponentRef,
+ component?: ComponentRef,
cssClasses?: string[],
- componentProps?: { [key: string]: any }
+ componentProps?: { [key: string]: any },
+ inline?: boolean
): Promise => {
if (delegate) {
return delegate.attachViewToDom(container, component, componentProps, cssClasses);
}
- if (typeof component !== 'string' && !(component instanceof HTMLElement)) {
+ if (!inline && typeof component !== 'string' && !(component instanceof HTMLElement)) {
throw new Error('framework delegate is missing');
}
@@ -28,6 +29,7 @@ export const attachComponent = async (
}
container.appendChild(el);
+
await new Promise(resolve => componentOnReady(el, resolve));
return el;
diff --git a/core/src/utils/overlays-interface.ts b/core/src/utils/overlays-interface.ts
index ace690ca521..48129871b7e 100644
--- a/core/src/utils/overlays-interface.ts
+++ b/core/src/utils/overlays-interface.ts
@@ -22,6 +22,11 @@ export interface OverlayInterface {
willDismiss: EventEmitter;
didDismiss: EventEmitter;
+ didPresentShorthand?: EventEmitter;
+ willPresentShorthand?: EventEmitter;
+ willDismissShorthand?: EventEmitter;
+ didDismissShorthand?: EventEmitter;
+
present(): Promise;
dismiss(data?: any, role?: string): Promise;
}
diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts
index 7e2395d341d..54c4ea0e47f 100644
--- a/core/src/utils/overlays.ts
+++ b/core/src/utils/overlays.ts
@@ -50,9 +50,14 @@ export const createOverlay = (tagName: string,
const element = document.createElement(tagName) as HTMLIonOverlayElement;
element.classList.add('overlay-hidden');
- // convert the passed in overlay options into props
- // that get passed down into the new overlay
- Object.assign(element, opts);
+ /**
+ * Convert the passed in overlay options into props
+ * that get passed down into the new overlay.
+ * Inline is needed for ion-popover as it can
+ * be presented via a controller or written
+ * inline in a template.
+ */
+ Object.assign(element, { ...opts, inline: false });
// append the overlay element to the document body
getAppRoot(document).appendChild(element);
@@ -112,48 +117,103 @@ const trapKeyboardFocus = (ev: Event, doc: Document) => {
const lastOverlay = getOverlay(doc);
const target = ev.target as HTMLElement | null;
- // If no active overlay, ignore this event
- if (!lastOverlay || !target) { return; }
-
/**
- * If we are focusing the overlay, clear
- * the last focused element so that hitting
- * tab activates the first focusable element
- * in the overlay wrapper.
+ * If no active overlay, ignore this event.
+ *
+ * If this component uses the shadow dom,
+ * this global listener is pointless
+ * since it will not catch the focus
+ * traps as they are inside the shadow root.
+ * We need to add a listener to the shadow root
+ * itself to ensure the focus trap works.
*/
- if (lastOverlay === target) {
- lastOverlay.lastFocus = undefined;
+ if (!lastOverlay || !target
+ ) { return; }
+ const trapScopedFocus = () => {
/**
- * Otherwise, we must be focusing an element
- * inside of the overlay. The two possible options
- * here are an input/button/etc or the ion-focus-trap
- * element. The focus trap element is used to prevent
- * the keyboard focus from leaving the overlay when
- * using Tab or screen assistants.
- */
- } else {
- /**
- * We do not want to focus the traps, so get the overlay
- * wrapper element as the traps live outside of the wrapper.
+ * If we are focusing the overlay, clear
+ * the last focused element so that hitting
+ * tab activates the first focusable element
+ * in the overlay wrapper.
*/
- const overlayRoot = getElementRoot(lastOverlay);
- if (!overlayRoot.contains(target)) { return; }
+ if (lastOverlay === target) {
+ lastOverlay.lastFocus = undefined;
+
+ /**
+ * Otherwise, we must be focusing an element
+ * inside of the overlay. The two possible options
+ * here are an input/button/etc or the ion-focus-trap
+ * element. The focus trap element is used to prevent
+ * the keyboard focus from leaving the overlay when
+ * using Tab or screen assistants.
+ */
+ } else {
+ /**
+ * We do not want to focus the traps, so get the overlay
+ * wrapper element as the traps live outside of the wrapper.
+ */
- const overlayWrapper = overlayRoot.querySelector('.ion-overlay-wrapper');
+ const overlayRoot = getElementRoot(lastOverlay);
+ if (!overlayRoot.contains(target)) { return; }
- if (!overlayWrapper) { return; }
+ const overlayWrapper = overlayRoot.querySelector('.ion-overlay-wrapper');
+ if (!overlayWrapper) { return; }
+
+ /**
+ * If the target is inside the wrapper, let the browser
+ * focus as normal and keep a log of the last focused element.
+ */
+ if (overlayWrapper.contains(target)) {
+ lastOverlay.lastFocus = target;
+ } else {
+ /**
+ * Otherwise, we must have focused one of the focus traps.
+ * We need to wrap the focus to either the first element
+ * or the last element.
+ */
+
+ /**
+ * Once we call `focusFirstDescendant` and focus the first
+ * descendant, another focus event will fire which will
+ * cause `lastOverlay.lastFocus` to be updated before
+ * we can run the code after that. We will cache the value
+ * here to avoid that.
+ */
+ const lastFocus = lastOverlay.lastFocus;
+
+ // Focus the first element in the overlay wrapper
+ focusFirstDescendant(overlayWrapper, lastOverlay);
+
+ /**
+ * If the cached last focused element is the
+ * same as the active element, then we need
+ * to wrap focus to the last descendant. This happens
+ * when the first descendant is focused, and the user
+ * presses Shift + Tab. The previous line will focus
+ * the same descendant again (the first one), causing
+ * last focus to equal the active element.
+ */
+ if (lastFocus === doc.activeElement) {
+ focusLastDescendant(overlayWrapper, lastOverlay);
+ }
+ lastOverlay.lastFocus = doc.activeElement as HTMLElement;
+ }
+ }
+ }
+ const trapShadowFocus = () => {
/**
* If the target is inside the wrapper, let the browser
* focus as normal and keep a log of the last focused element.
*/
- if (overlayWrapper.contains(target)) {
+ if (lastOverlay.contains(target)) {
lastOverlay.lastFocus = target;
} else {
/**
- * Otherwise, we must have focused one of the focus traps.
- * We need to wrap the focus to either the first element
+ * Otherwise, we are about to have focus
+ * go out of the overlay. We need to wrap
+ * the focus to either the first element
* or the last element.
*/
@@ -167,7 +227,7 @@ const trapKeyboardFocus = (ev: Event, doc: Document) => {
const lastFocus = lastOverlay.lastFocus;
// Focus the first element in the overlay wrapper
- focusFirstDescendant(overlayWrapper, lastOverlay);
+ focusFirstDescendant(lastOverlay, lastOverlay);
/**
* If the cached last focused element is the
@@ -179,11 +239,17 @@ const trapKeyboardFocus = (ev: Event, doc: Document) => {
* last focus to equal the active element.
*/
if (lastFocus === doc.activeElement) {
- focusLastDescendant(overlayWrapper, lastOverlay);
+ focusLastDescendant(lastOverlay, lastOverlay);
}
lastOverlay.lastFocus = doc.activeElement as HTMLElement;
}
}
+
+ if (lastOverlay.shadowRoot) {
+ trapShadowFocus();
+ } else {
+ trapScopedFocus();
+ }
};
export const connectListeners = (doc: Document) => {
@@ -248,6 +314,7 @@ export const present = async (
}
overlay.presented = true;
overlay.willPresent.emit();
+ overlay.willPresentShorthand?.emit();
const mode = getIonMode(overlay);
// get the user's animation fn if one was provided
@@ -258,6 +325,8 @@ export const present = async (
const completed = await overlayAnimation(overlay, animationBuilder, overlay.el, opts);
if (completed) {
overlay.didPresent.emit();
+ overlay.didPresentShorthand?.emit();
+
}
/**
@@ -319,6 +388,8 @@ export const dismiss = async (
// Overlay contents should not be clickable during dismiss
overlay.el.style.setProperty('pointer-events', 'none');
overlay.willDismiss.emit({ data, role });
+ overlay.willDismissShorthand?.emit({ data, role });
+
const mode = getIonMode(overlay);
const animationBuilder = (overlay.leaveAnimation)
? overlay.leaveAnimation
@@ -329,6 +400,7 @@ export const dismiss = async (
await overlayAnimation(overlay, animationBuilder, overlay.el, opts);
}
overlay.didDismiss.emit({ data, role });
+ overlay.didDismissShorthand?.emit({ data, role });
activeAnimations.delete(overlay);
@@ -353,7 +425,7 @@ const overlayAnimation = async (
// Make overlay visible in case it's hidden
baseEl.classList.remove('overlay-hidden');
- const aniRoot = baseEl.shadowRoot || overlay.el;
+ const aniRoot = overlay.el;
const animation = animationBuilder(aniRoot, opts);
if (!overlay.animated || !config.getBoolean('animated', true)) {
@@ -363,7 +435,7 @@ const overlayAnimation = async (
if (overlay.keyboardClose) {
animation.beforeAddWrite(() => {
const activeElement = baseEl.ownerDocument!.activeElement as HTMLElement;
- if (activeElement && activeElement.matches('input, ion-input, ion-textarea')) {
+ if (activeElement && activeElement.matches('input,ion-input, ion-textarea')) {
activeElement.blur();
}
});
diff --git a/packages/react/src/components/IonPopover.tsx b/packages/react/src/components/IonPopover.tsx
index 4684a1a2ea3..91af321921f 100644
--- a/packages/react/src/components/IonPopover.tsx
+++ b/packages/react/src/components/IonPopover.tsx
@@ -1,12 +1,8 @@
-import { PopoverOptions, popoverController } from '@ionic/core';
+import { JSX } from '@ionic/core';
-import { createOverlayComponent } from './createOverlayComponent';
+import { createInlineOverlayComponent } from './createInlineOverlayComponent'
-export type ReactPopoverOptions = Omit & {
- children: React.ReactNode;
-};
-
-export const IonPopover = /*@__PURE__*/ createOverlayComponent<
- ReactPopoverOptions,
+export const IonPopover = /*@__PURE__*/ createInlineOverlayComponent<
+ JSX.IonPopover,
HTMLIonPopoverElement
->('IonPopover', popoverController);
+>('ion-popover');
diff --git a/packages/react/src/components/createInlineOverlayComponent.tsx b/packages/react/src/components/createInlineOverlayComponent.tsx
new file mode 100644
index 00000000000..ef39a6c697f
--- /dev/null
+++ b/packages/react/src/components/createInlineOverlayComponent.tsx
@@ -0,0 +1,126 @@
+import { OverlayEventDetail } from '@ionic/core'
+import React from 'react';
+
+import {
+ attachProps,
+ camelToDashCase,
+ createForwardRef,
+ dashToPascalCase,
+ isCoveredByReact,
+ mergeRefs,
+} from './utils';
+
+type InlineOverlayState = {
+ isOpen: boolean;
+}
+
+interface IonicReactInternalProps extends React.HTMLAttributes {
+ forwardedRef?: React.ForwardedRef;
+ ref?: React.Ref;
+ onDidDismiss?: (event: CustomEvent) => void;
+ onDidPresent?: (event: CustomEvent) => void;
+ onWillDismiss?: (event: CustomEvent) => void;
+ onWillPresent?: (event: CustomEvent) => void;
+}
+
+export const createInlineOverlayComponent = (
+ tagName: string
+) => {
+ const displayName = dashToPascalCase(tagName);
+ const ReactComponent = class extends React.Component, InlineOverlayState> {
+ ref: React.RefObject;
+ wrapperRef: React.RefObject;
+ stableMergedRefs: React.RefCallback
+
+ constructor(props: IonicReactInternalProps) {
+ super(props);
+ // Create a local ref to to attach props to the wrapped element.
+ this.ref = React.createRef();
+ // React refs must be stable (not created inline).
+ this.stableMergedRefs = mergeRefs(this.ref, this.props.forwardedRef)
+ // Component is hidden by default
+ this.state = { isOpen: false };
+ // Create a local ref to the inner child element.
+ this.wrapperRef = React.createRef();
+ }
+
+ componentDidMount() {
+ this.componentDidUpdate(this.props);
+
+ /**
+ * Mount the inner component
+ * when overlay is about to open.
+ * Also manually call the onWillPresent
+ * handler if present as setState will
+ * cause the event handlers to be
+ * destroyed and re-created.
+ */
+ this.ref.current?.addEventListener('willPresent', (evt: any) => {
+ this.setState({ isOpen: true });
+
+ this.props.onWillPresent && this.props.onWillPresent(evt);
+ });
+
+ /**
+ * Unmount the inner component.
+ * React will call Node.removeChild
+ * which expects the child to be
+ * a direct descendent of the parent
+ * but due to the presence of
+ * Web Component slots, this is not
+ * always the case. To work around this
+ * we move the inner component to the root
+ * of the Web Component so React can
+ * cleanup properly.
+ */
+ this.ref.current?.addEventListener('didDismiss', (evt: any) => {
+ const wrapper = this.wrapperRef.current!;
+ this.ref.current!.append(wrapper);
+
+ this.setState({ isOpen: false });
+
+ this.props.onDidDismiss && this.props.onDidDismiss(evt);
+ });
+ }
+
+ componentDidUpdate(prevProps: IonicReactInternalProps) {
+ const node = this.ref.current! as HTMLElement;
+ attachProps(node, this.props, prevProps);
+ }
+
+ render() {
+ const { children, forwardedRef, style, className, ref, ...cProps } = this.props;
+
+ const propsToPass = Object.keys(cProps).reduce((acc, name) => {
+ if (name.indexOf('on') === 0 && name[2] === name[2].toUpperCase()) {
+ const eventName = name.substring(2).toLowerCase();
+ if (isCoveredByReact(eventName)) {
+ (acc as any)[name] = (cProps as any)[name];
+ }
+ } else if (['string', 'boolean', 'number'].includes(typeof (cProps as any)[name])) {
+ (acc as any)[camelToDashCase(name)] = (cProps as any)[name];
+ }
+ return acc;
+ }, {});
+
+ const newProps: IonicReactInternalProps = {
+ ...propsToPass,
+ ref: this.stableMergedRefs,
+ style,
+ };
+
+ /**
+ * We only want the inner component
+ * to be mounted if the overlay is open,
+ * so conditionally render the component
+ * based on the isOpen state.
+ */
+ return React.createElement(tagName, newProps, (this.state.isOpen) ? React.createElement('div', { id: 'ion-react-wrapper', ref: this.wrapperRef }, children) : null);
+ }
+
+ static get displayName() {
+ return displayName;
+ }
+ };
+ return createForwardRef(ReactComponent, displayName);
+};
diff --git a/packages/vue/scripts/copy-overlays.js b/packages/vue/scripts/copy-overlays.js
index 60c81de521b..37e8d949f5c 100644
--- a/packages/vue/scripts/copy-overlays.js
+++ b/packages/vue/scripts/copy-overlays.js
@@ -28,11 +28,6 @@ function generateOverlays() {
controller: 'pickerController',
name: 'IonPicker'
},
- {
- tag: 'ion-popover',
- controller: 'popoverController',
- name: 'IonPopover'
- },
{
tag: 'ion-toast',
controller: 'toastController',
diff --git a/packages/vue/src/components/IonPopover.ts b/packages/vue/src/components/IonPopover.ts
new file mode 100644
index 00000000000..b5ce6b59603
--- /dev/null
+++ b/packages/vue/src/components/IonPopover.ts
@@ -0,0 +1,22 @@
+import { defineComponent, h, ref, onMounted } from 'vue';
+
+export const IonPopover = defineComponent({
+ name: 'IonPopover',
+ 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-popover',
+ { ...attrs, ref: elementRef },
+ (isOpen.value) ? slots : undefined
+ )
+ }
+ }
+});
diff --git a/packages/vue/src/components/Overlays.ts b/packages/vue/src/components/Overlays.ts
index d41c2586577..63cab9007c0 100644
--- a/packages/vue/src/components/Overlays.ts
+++ b/packages/vue/src/components/Overlays.ts
@@ -7,7 +7,6 @@ import {
loadingController,
modalController,
pickerController,
- popoverController,
toastController
} from '@ionic/core';
@@ -23,7 +22,5 @@ export const IonModal = /*@__PURE__*/defineOverlayContainer('ion-m
export const IonPicker = /*@__PURE__*/defineOverlayContainer('ion-picker', ['animated', 'backdropDismiss', 'buttons', 'columns', 'cssClass', 'duration', 'enterAnimation', 'keyboardClose', 'leaveAnimation', 'mode', 'showBackdrop'], pickerController);
-export const IonPopover = /*@__PURE__*/defineOverlayContainer('ion-popover', ['animated', 'backdropDismiss', 'component', 'componentProps', 'cssClass', 'enterAnimation', 'event', 'keyboardClose', 'leaveAnimation', 'mode', 'showBackdrop', 'translucent'], popoverController);
-
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 f121e581ce3..dae0296500c 100644
--- a/packages/vue/src/index.ts
+++ b/packages/vue/src/index.ts
@@ -13,6 +13,7 @@ export { IonTabBar } from './components/IonTabBar';
export { IonNav } from './components/IonNav';
export { IonIcon } from './components/IonIcon';
export { IonApp } from './components/IonApp';
+export { IonPopover } from './components/IonPopover';
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 90502e966a0..fbdea4b5dcf 100644
--- a/packages/vue/test-app/src/views/Overlays.vue
+++ b/packages/vue/test-app/src/views/Overlays.vue
@@ -109,12 +109,12 @@
-
+ {
- const actionSheet = await actionSheetController.create({ buttons: actionSheetButtons });
+ const actionSheet = await actionSheetController.create({ cssClass: "ion-action-sheet-controller", buttons: actionSheetButtons });
await actionSheet.present();
}
const openAlert = async () => {
- const alert = await alertController.create({ buttons: alertButtons, header: 'Alert!' });
+ const alert = await alertController.create({ cssClass: "ion-alert-controller", buttons: alertButtons, header: 'Alert!' });
await alert.present();
}
const openLoading = async () => {
- const loading = await loadingController.create({ message: "Loading", duration: 2000, backdropDismiss: true });
+ const loading = await loadingController.create({ cssClass: "ion-loading-controller", message: "Loading", duration: 2000, backdropDismiss: true });
await loading.present();
}
const openToast = async () => {
- const toast = await toastController.create({ header: "Toast!", buttons: toastButtons });
+ const toast = await toastController.create({ cssClass: "ion-toast-controller", header: "Toast!", buttons: toastButtons });
await toast.present();
}
const openModal = async () => {
- const modal = await modalController.create({ component: ModalContent, componentProps: overlayProps });
+ const modal = await modalController.create({ cssClass: "ion-modal-controller", component: ModalContent, componentProps: overlayProps });
await modal.present();
}
const openPopover = async (event: Event) => {
- const popover = await popoverController.create({ component: PopoverContent, event, componentProps: overlayProps });
+ const popover = await popoverController.create({ cssClass: "ion-popover-controller", component: PopoverContent, event, componentProps: overlayProps });
await popover.present();
}
diff --git a/packages/vue/test-app/tests/e2e/specs/overlays.js b/packages/vue/test-app/tests/e2e/specs/overlays.js
index d6eb3dfcc7c..d60a4f10cc0 100644
--- a/packages/vue/test-app/tests/e2e/specs/overlays.js
+++ b/packages/vue/test-app/tests/e2e/specs/overlays.js
@@ -1,24 +1,61 @@
+const testController = (overlay, shadow = false) => {
+ const selector = `.${overlay}-controller`;
+ cy.get(`ion-radio#${overlay}`).click();
+ cy.get('ion-radio#controller').click();
+
+ cy.get('ion-button#present-overlay').click();
+ cy.get(selector).should('exist').should('be.visible');
+
+ if (shadow) {
+ cy.get(selector).shadow().find('ion-backdrop').click({ force: true });
+ } else {
+ cy.get(`${selector} ion-backdrop`).click({ force: true });
+ }
+
+ cy.get(selector).should('not.exist');
+}
+
+const testComponent = (overlay, shadow = false) => {
+ cy.get(`ion-radio#${overlay}`).click();
+ cy.get('ion-radio#component').click();
+
+ cy.get('ion-button#present-overlay').click();
+ cy.get(overlay).should('exist').should('be.visible');
+
+ if (shadow) {
+ 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');
+}
+
describe('Overlays', () => {
beforeEach(() => {
cy.viewport(1000, 900);
cy.visit('http://localhost:8080/overlays')
})
- const overlays = ['ion-alert', 'ion-action-sheet', 'ion-loading', 'ion-modal', 'ion-popover'];
+ it(`should open and close ion-alert via controller`, () => {
+ testController('ion-alert');
+ });
- for (let overlay of overlays) {
- it(`should open and close ${overlay} via controller`, () => {
- cy.get(`ion-radio#${overlay}`).click();
- cy.get('ion-radio#controller').click();
+ it(`should open and close ion-action-sheet via controller`, () => {
+ testController('ion-action-sheet');
+ });
- cy.get('ion-button#present-overlay').click();
- cy.get(overlay).should('exist').should('be.visible');
+ it(`should open and close ion-loading via controller`, () => {
+ testController('ion-loading');
+ });
- cy.get(`${overlay} ion-backdrop`).click({ force: true });
+ it(`should open and close ion-modal via controller`, () => {
+ testController('ion-modal');
+ });
- cy.get(overlay).should('not.exist');
- });
- }
+ it(`should open and close ion-popover via controller`, () => {
+ testController('ion-popover', true);
+ });
it(`should open and close ion-toast via controller`, () => {
cy.get(`ion-radio#ion-toast`).click();
@@ -32,19 +69,25 @@ describe('Overlays', () => {
cy.get('ion-toast').should('not.exist');
});
- for (let overlay of overlays) {
- it(`should open and close ${overlay} via component`, () => {
- cy.get(`ion-radio#${overlay}`).click();
- cy.get('ion-radio#component').click();
+ it(`should open and close ion-alert via component`, () => {
+ testComponent('ion-alert');
+ });
- cy.get('ion-button#present-overlay').click();
- cy.get(overlay).should('exist').should('be.visible');
+ it(`should open and close ion-action-sheet via component`, () => {
+ testComponent('ion-action-sheet');
+ });
- cy.get(`${overlay} ion-backdrop`).click({ force: true });
+ it(`should open and close ion-loading via component`, () => {
+ testComponent('ion-loading');
+ });
- cy.get(overlay).should('not.exist');
- });
- }
+ it(`should open and close ion-modal via component`, () => {
+ testComponent('ion-modal');
+ });
+
+ it(`should open and close ion-popover via component`, () => {
+ testComponent('ion-popover', true);
+ });
it(`should open and close ion-toast via component`, () => {
cy.get(`ion-radio#ion-toast`).click();
@@ -83,9 +126,9 @@ describe('Overlays', () => {
cy.get('ion-radio#controller').click();
cy.get('ion-button#present-overlay').click();
- cy.get('ion-popover').should('exist');
+ cy.get('ion-popover.ion-popover-controller').should('exist');
- cy.get('ion-popover ion-content').should('have.text', 'Custom Title');
+ cy.get('ion-popover.ion-popover-controller ion-content').should('have.text', 'Custom Title');
});
it('should pass props to popover via component', () => {
@@ -95,7 +138,7 @@ describe('Overlays', () => {
cy.get('ion-button#present-overlay').click();
cy.get('ion-popover').should('exist');
- cy.get('ion-popover ion-content').should('have.text', 'Custom Title');
+ cy.get('ion-popover.popover-inline ion-content').should('have.text', 'Custom Title');
});
it('should only open one instance at a time when props change quickly on component', () => {