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', () => {