feat(popover): popover can now be used inline (#23231)

BREAKING CHANGE: Converted `ion-popover` to use the Shadow DOM.
This commit is contained in:
Liam DeBeasi
2021-05-03 12:02:22 -04:00
committed by GitHub
parent 6fcb3a62b1
commit 308fa1c0dd
29 changed files with 826 additions and 170 deletions

View File

@@ -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<OverlayEventDetail>) => void;
/**
* Emitted after the popover has presented. Shorthand for ionPopoverWillDismiss.
*/
"onDidPresent"?: (event: CustomEvent<void>) => void;
/**
* Emitted after the popover has dismissed.
*/
@@ -5038,6 +5056,14 @@ declare namespace LocalJSX {
* Emitted before the popover has presented.
*/
"onIonPopoverWillPresent"?: (event: CustomEvent<void>) => void;
/**
* Emitted before the popover has dismissed. Shorthand for ionPopoverWillDismiss.
*/
"onWillDismiss"?: (event: CustomEvent<OverlayEventDetail>) => void;
/**
* Emitted before the popover has presented. Shorthand for ionPopoverWillPresent.
*/
"onWillPresent"?: (event: CustomEvent<void>) => void;
"overlayIndex": number;
/**
* If `true`, a backdrop will be displayed behind the popover.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<any>;
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<OverlayEventDetail>;
/**
* Emitted after the popover has presented.
* Shorthand for ionPopoverWillDismiss.
*/
@Event({ eventName: 'didPresent' }) didPresentShorthand!: EventEmitter<void>;
/**
* Emitted before the popover has presented.
* Shorthand for ionPopoverWillPresent.
*/
@Event({ eventName: 'willPresent' }) willPresentShorthand!: EventEmitter<void>;
/**
* Emitted before the popover has dismissed.
* Shorthand for ionPopoverWillDismiss.
*/
@Event({ eventName: 'willDismiss' }) willDismissShorthand!: EventEmitter<OverlayEventDetail>;
/**
* Emitted after the popover has dismissed.
* Shorthand for ionPopoverDidDismiss.
*/
@Event({ eventName: 'didDismiss' }) didDismissShorthand!: EventEmitter<OverlayEventDetail>;
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<boolean> {
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 (
<Host
aria-modal="true"
@@ -207,10 +349,13 @@ export class Popover implements ComponentInterface, OverlayInterface {
style={{
zIndex: `${20000 + this.overlayIndex}`,
}}
id={popoverId}
class={{
...getClassMap(this.cssClass),
[mode]: true,
'popover-translucent': this.translucent
'popover-translucent': this.translucent,
'overlay-hidden': true,
'popover-interactive': presented,
}}
onIonPopoverDidPresent={onLifecycle}
onIonPopoverWillPresent={onLifecycle}
@@ -219,16 +364,14 @@ export class Popover implements ComponentInterface, OverlayInterface {
onIonDismiss={this.onDismiss}
onIonBackdropTap={this.onBackdropTap}
>
<ion-backdrop tappable={this.backdropDismiss} visible={this.showBackdrop}/>
<div tabindex="0"></div>
<ion-backdrop part="backdrop" tappable={this.backdropDismiss} visible={this.showBackdrop}/>
<div class="popover-wrapper ion-overlay-wrapper">
<div class="popover-arrow"></div>
<div class="popover-content"></div>
<div class="popover-arrow" part="arrow"></div>
<div class="popover-content" part="content">
<slot></slot>
</div>
</div>
<div tabindex="0"></div>
</Host>
);
}
@@ -240,3 +383,5 @@ const LIFECYCLE_MAP: any = {
'ionPopoverWillDismiss': 'ionViewWillLeave',
'ionPopoverDidDismiss': 'ionViewDidLeave',
};
let popoverIds = 0;

View File

@@ -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 `<ng-content>` internally. Instead, we use `<ng-container>` which expects an `<ng-template>` to be passed in. As a result, when passing in your component you will need to wrap it in an `<ng-template>`:
```html
<ion-popover [isOpen]="isPopoverOpen">
<ng-template>
<app-popover-content></app-popover-content>
</ng-template>
</ion-popover>
```
Liam: Usage will be filled out via desktop popover PR.
### When to use
Liam: Will be filled out via desktop popover PR.
## Controller Popovers
`ion-popover` can also be presented programmatically by using the `popoverController` imported from Ionic Framework. This allows you to have complete control over when a popover is presented above and beyond the customization that inline popovers give you. See [Usage](#usage) for an example of how to use the `popoverController`.
Liam: Usage will be filled out via desktop popover PR.
### When to use
Liam: Will be filled out via desktop popover PR.
## Interfaces
Below you will find all of the options available to you when using the `popoverController`. These options should be supplied when calling `popoverController.create()`.
```typescript
interface PopoverOptions {
component: any;
componentProps?: { [key: string]: any };
showBackdrop?: boolean;
backdropDismiss?: boolean;
translucent?: boolean;
cssClass?: string | string[];
event?: Event;
animated?: boolean;
mode?: 'ios' | 'md';
keyboardClose?: boolean;
id?: string;
enterAnimation?: AnimationBuilder;
leaveAnimation?: AnimationBuilder;
}
```
## 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<OverlayEventDetail<any>>` |
| `ionPopoverDidPresent` | Emitted after the popover has presented. | `CustomEvent<void>` |
| `ionPopoverWillDismiss` | Emitted before the popover has dismissed. | `CustomEvent<OverlayEventDetail<any>>` |
| `ionPopoverWillPresent` | Emitted before the popover has presented. | `CustomEvent<void>` |
| Event | Description | Type |
| ----------------------- | ------------------------------------------------------------------------------ | -------------------------------------- |
| `didDismiss` | Emitted after the popover has dismissed. Shorthand for ionPopoverDidDismiss. | `CustomEvent<OverlayEventDetail<any>>` |
| `didPresent` | Emitted after the popover has presented. Shorthand for ionPopoverWillDismiss. | `CustomEvent<void>` |
| `ionPopoverDidDismiss` | Emitted after the popover has dismissed. | `CustomEvent<OverlayEventDetail<any>>` |
| `ionPopoverDidPresent` | Emitted after the popover has presented. | `CustomEvent<void>` |
| `ionPopoverWillDismiss` | Emitted before the popover has dismissed. | `CustomEvent<OverlayEventDetail<any>>` |
| `ionPopoverWillPresent` | Emitted before the popover has presented. | `CustomEvent<void>` |
| `willDismiss` | Emitted before the popover has dismissed. Shorthand for ionPopoverWillDismiss. | `CustomEvent<OverlayEventDetail<any>>` |
| `willPresent` | Emitted before the popover has presented. Shorthand for ionPopoverWillPresent. | `CustomEvent<void>` |
## Methods
@@ -429,6 +490,22 @@ Type: `Promise<void>`
## 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 |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<HTMLElement> => {
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;

View File

@@ -22,6 +22,11 @@ export interface OverlayInterface {
willDismiss: EventEmitter<OverlayEventDetail>;
didDismiss: EventEmitter<OverlayEventDetail>;
didPresentShorthand?: EventEmitter<void>;
willPresentShorthand?: EventEmitter<void>;
willDismissShorthand?: EventEmitter<OverlayEventDetail>;
didDismissShorthand?: EventEmitter<OverlayEventDetail>;
present(): Promise<void>;
dismiss(data?: any, role?: string): Promise<boolean>;
}

View File

@@ -50,9 +50,14 @@ export const createOverlay = <T extends HTMLIonOverlayElement>(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.
* If we are focusing the overlay, clear
* the last focused element so that hitting
* tab activates the first focusable element
* in the overlay wrapper.
*/
} else {
/**
* We do not want to focus the traps, so get the overlay
* wrapper element as the traps live outside of the wrapper.
*/
const overlayRoot = getElementRoot(lastOverlay);
if (!overlayRoot.contains(target)) { return; }
if (lastOverlay === target) {
lastOverlay.lastFocus = undefined;
const overlayWrapper = overlayRoot.querySelector('.ion-overlay-wrapper');
/**
* 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 (!overlayWrapper) { return; }
const overlayRoot = getElementRoot(lastOverlay);
if (!overlayRoot.contains(target)) { 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();
}
});