diff --git a/core/api.txt b/core/api.txt index b7020e0ca4..3714b490a8 100644 --- a/core/api.txt +++ b/core/api.txt @@ -1353,20 +1353,26 @@ ion-toast,prop,enterAnimation,((baseEl: any, opts?: any) => Animation) | undefin ion-toast,prop,header,string | undefined,undefined,false,false ion-toast,prop,htmlAttributes,undefined | { [key: string]: any; },undefined,false,false ion-toast,prop,icon,string | undefined,undefined,false,false +ion-toast,prop,isOpen,boolean,false,false,false ion-toast,prop,keyboardClose,boolean,false,false,false ion-toast,prop,leaveAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false ion-toast,prop,message,IonicSafeString | string | undefined,undefined,false,false ion-toast,prop,mode,"ios" | "md",undefined,false,false ion-toast,prop,position,"bottom" | "middle" | "top",'bottom',false,false ion-toast,prop,translucent,boolean,false,false,false +ion-toast,prop,trigger,string | undefined,undefined,false,false ion-toast,method,dismiss,dismiss(data?: any, role?: string) => Promise ion-toast,method,onDidDismiss,onDidDismiss() => Promise> ion-toast,method,onWillDismiss,onWillDismiss() => Promise> ion-toast,method,present,present() => Promise +ion-toast,event,didDismiss,OverlayEventDetail,true +ion-toast,event,didPresent,void,true ion-toast,event,ionToastDidDismiss,OverlayEventDetail,true ion-toast,event,ionToastDidPresent,void,true ion-toast,event,ionToastWillDismiss,OverlayEventDetail,true ion-toast,event,ionToastWillPresent,void,true +ion-toast,event,willDismiss,OverlayEventDetail,true +ion-toast,event,willPresent,void,true ion-toast,css-prop,--background ion-toast,css-prop,--border-color ion-toast,css-prop,--border-radius diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 39d2117994..57b4ab7c41 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -2849,6 +2849,7 @@ export namespace Components { * Additional classes to apply for custom CSS. If multiple classes are provided they should be separated by spaces. */ "cssClass"?: string | string[]; + "delegate"?: FrameworkDelegate; /** * Dismiss the toast overlay after it has been presented. * @param data Any data to emit in the dismiss events. @@ -2863,6 +2864,7 @@ export namespace Components { * Animation to use when the toast is presented. */ "enterAnimation"?: AnimationBuilder; + "hasController": boolean; /** * Header to be shown in the toast. */ @@ -2875,6 +2877,10 @@ export namespace Components { * The name of the icon to display, or the path to a valid SVG file. See `ion-icon`. https://ionic.io/ionicons */ "icon"?: string; + /** + * If `true`, the toast will open. If `false`, the toast will close. Use this if you need finer grained control over presentation, otherwise just use the toastController or the `trigger` property. Note: `isOpen` will not automatically be set back to `false` when the toast 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. */ @@ -2912,6 +2918,10 @@ export namespace Components { * If `true`, the toast 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). */ "translucent": boolean; + /** + * An ID corresponding to the trigger element that causes the toast to open when clicked. + */ + "trigger": string | undefined; } interface IonToggle { /** @@ -6727,6 +6737,7 @@ declare namespace LocalJSX { * Additional classes to apply for custom CSS. If multiple classes are provided they should be separated by spaces. */ "cssClass"?: string | string[]; + "delegate"?: FrameworkDelegate; /** * How many milliseconds to wait before hiding the toast. By default, it will show until `dismiss()` is called. */ @@ -6735,6 +6746,7 @@ declare namespace LocalJSX { * Animation to use when the toast is presented. */ "enterAnimation"?: AnimationBuilder; + "hasController"?: boolean; /** * Header to be shown in the toast. */ @@ -6747,6 +6759,10 @@ declare namespace LocalJSX { * The name of the icon to display, or the path to a valid SVG file. See `ion-icon`. https://ionic.io/ionicons */ "icon"?: string; + /** + * If `true`, the toast will open. If `false`, the toast will close. Use this if you need finer grained control over presentation, otherwise just use the toastController or the `trigger` property. Note: `isOpen` will not automatically be set back to `false` when the toast 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. */ @@ -6763,6 +6779,14 @@ declare namespace LocalJSX { * The mode determines which platform styles to use. */ "mode"?: "ios" | "md"; + /** + * Emitted after the toast has dismissed. Shorthand for ionToastDidDismiss. + */ + "onDidDismiss"?: (event: IonToastCustomEvent) => void; + /** + * Emitted after the toast has presented. Shorthand for ionToastWillDismiss. + */ + "onDidPresent"?: (event: IonToastCustomEvent) => void; /** * Emitted after the toast has dismissed. */ @@ -6779,6 +6803,14 @@ declare namespace LocalJSX { * Emitted before the toast has presented. */ "onIonToastWillPresent"?: (event: IonToastCustomEvent) => void; + /** + * Emitted before the toast has dismissed. Shorthand for ionToastWillDismiss. + */ + "onWillDismiss"?: (event: IonToastCustomEvent) => void; + /** + * Emitted before the toast has presented. Shorthand for ionToastWillPresent. + */ + "onWillPresent"?: (event: IonToastCustomEvent) => void; "overlayIndex": number; /** * The position of the toast on the screen. @@ -6788,6 +6820,10 @@ declare namespace LocalJSX { * If `true`, the toast 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). */ "translucent"?: boolean; + /** + * An ID corresponding to the trigger element that causes the toast to open when clicked. + */ + "trigger"?: string | undefined; } interface IonToggle { /** diff --git a/core/src/components/toast/test/isOpen/index.html b/core/src/components/toast/test/isOpen/index.html new file mode 100644 index 0000000000..291bbdb815 --- /dev/null +++ b/core/src/components/toast/test/isOpen/index.html @@ -0,0 +1,50 @@ + + + + + Toast - isOpen + + + + + + + + + + + + Toast - isOpen + + + + + Open Toast, 2s Duration + Open Toast, Close Manually After 500ms + + + + + + + + diff --git a/core/src/components/toast/test/isOpen/toast.e2e.ts b/core/src/components/toast/test/isOpen/toast.e2e.ts new file mode 100644 index 0000000000..d40b14e4c5 --- /dev/null +++ b/core/src/components/toast/test/isOpen/toast.e2e.ts @@ -0,0 +1,30 @@ +import { test } from '@utils/test/playwright'; + +test.describe('toast: isOpen', () => { + test.beforeEach(async ({ page, skip }) => { + skip.rtl('isOpen does not behave differently in RTL'); + skip.mode('md', 'isOpen does not behave differently in MD'); + await page.goto('/src/components/toast/test/isOpen'); + }); + + test('should open the toast', async ({ page }) => { + const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent'); + await page.click('#default'); + + await ionToastDidPresent.next(); + await page.waitForSelector('ion-toast', { state: 'visible' }); + }); + + test('should open the toast then close after a timeout', async ({ page }) => { + const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent'); + const ionToastDidDismiss = await page.spyOnEvent('ionToastDidDismiss'); + await page.click('#timeout'); + + await ionToastDidPresent.next(); + await page.waitForSelector('ion-toast', { state: 'visible' }); + + await ionToastDidDismiss.next(); + + await page.waitForSelector('ion-toast', { state: 'hidden' }); + }); +}); diff --git a/core/src/components/toast/test/trigger/index.html b/core/src/components/toast/test/trigger/index.html new file mode 100644 index 0000000000..8cd2d12a72 --- /dev/null +++ b/core/src/components/toast/test/trigger/index.html @@ -0,0 +1,43 @@ + + + + + Toast - Trigger + + + + + + + + + + + + Toast - Trigger + + + + + Open Toast, 2s Duration + Open Toast, Close Manually After 500ms + + + + + + + + + diff --git a/core/src/components/toast/test/trigger/toast.e2e.ts b/core/src/components/toast/test/trigger/toast.e2e.ts new file mode 100644 index 0000000000..d8fd5914a2 --- /dev/null +++ b/core/src/components/toast/test/trigger/toast.e2e.ts @@ -0,0 +1,31 @@ +import { test } from '@utils/test/playwright'; + +test.describe('toast: trigger', () => { + test.beforeEach(async ({ page, skip }) => { + skip.rtl('trigger does not behave differently in RTL'); + skip.mode('md'); + await page.goto('/src/components/toast/test/trigger'); + }); + + test('should open the toast', async ({ page }) => { + const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent'); + await page.click('#default'); + + await ionToastDidPresent.next(); + await page.waitForSelector('#default-toast', { state: 'visible' }); + }); + + test('should present a previously presented toast', async ({ page }) => { + const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent'); + const ionToastDidDismiss = await page.spyOnEvent('ionToastDidDismiss'); + + await page.click('#timeout'); + + await ionToastDidDismiss.next(); + + await page.click('#timeout'); + + await ionToastDidPresent.next(); + await page.waitForSelector('#timeout-toast', { state: 'visible' }); + }); +}); diff --git a/core/src/components/toast/toast.tsx b/core/src/components/toast/toast.tsx index 157d7b7814..47ddb285cb 100644 --- a/core/src/components/toast/toast.tsx +++ b/core/src/components/toast/toast.tsx @@ -1,5 +1,5 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; -import { Component, Element, Event, Host, Method, Prop, h } from '@stencil/core'; +import { Watch, Component, Element, Event, Host, Method, Prop, h } from '@stencil/core'; import { getIonMode } from '../../global/ionic-global'; import type { @@ -9,8 +9,18 @@ import type { OverlayEventDetail, OverlayInterface, ToastButton, + FrameworkDelegate, } from '../../interface'; -import { dismiss, eventMethod, isCancel, prepareOverlay, present, safeCall } from '../../utils/overlays'; +import { + createDelegateController, + createTriggerController, + dismiss, + eventMethod, + isCancel, + prepareOverlay, + present, + safeCall, +} from '../../utils/overlays'; import type { IonicSafeString } from '../../utils/sanitization'; import { sanitizeDOMString } from '../../utils/sanitization'; import { createColorClasses, getClassMap } from '../../utils/theme'; @@ -38,6 +48,9 @@ import { mdLeaveAnimation } from './animations/md.leave'; shadow: true, }) export class Toast implements ComponentInterface, OverlayInterface { + private readonly delegateController = createDelegateController(this); + private readonly triggerController = createTriggerController(); + private currentTransition?: Promise; private durationTimeout: any; presented = false; @@ -49,6 +62,12 @@ export class Toast implements ComponentInterface, OverlayInterface { */ @Prop() overlayIndex!: number; + /** @internal */ + @Prop() delegate?: FrameworkDelegate; + + /** @internal */ + @Prop() hasController = false; + /** * The color to use from your application's color palette. * Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. @@ -126,6 +145,36 @@ export class Toast implements ComponentInterface, OverlayInterface { */ @Prop() htmlAttributes?: { [key: string]: any }; + /** + * If `true`, the toast will open. If `false`, the toast will close. + * Use this if you need finer grained control over presentation, otherwise + * just use the toastController or the `trigger` property. + * Note: `isOpen` will not automatically be set back to `false` when + * the toast dismisses. You will need to do that in your code. + */ + @Prop() isOpen = false; + @Watch('isOpen') + onIsOpenChange(newValue: boolean, oldValue: boolean) { + if (newValue === true && oldValue === false) { + this.present(); + } else if (newValue === false && oldValue === true) { + this.dismiss(); + } + } + + /** + * An ID corresponding to the trigger element that + * causes the toast to open when clicked. + */ + @Prop() trigger: string | undefined; + @Watch('trigger') + triggerChanged() { + const { trigger, el, triggerController } = this; + if (trigger) { + triggerController.addClickListener(el, trigger); + } + } + /** * Emitted after the toast has presented. */ @@ -146,8 +195,37 @@ export class Toast implements ComponentInterface, OverlayInterface { */ @Event({ eventName: 'ionToastDidDismiss' }) didDismiss!: EventEmitter; + /** + * Emitted after the toast has presented. + * Shorthand for ionToastWillDismiss. + */ + @Event({ eventName: 'didPresent' }) didPresentShorthand!: EventEmitter; + + /** + * Emitted before the toast has presented. + * Shorthand for ionToastWillPresent. + */ + @Event({ eventName: 'willPresent' }) willPresentShorthand!: EventEmitter; + + /** + * Emitted before the toast has dismissed. + * Shorthand for ionToastWillDismiss. + */ + @Event({ eventName: 'willDismiss' }) willDismissShorthand!: EventEmitter; + + /** + * Emitted after the toast has dismissed. + * Shorthand for ionToastDidDismiss. + */ + @Event({ eventName: 'didDismiss' }) didDismissShorthand!: EventEmitter; + connectedCallback() { prepareOverlay(this.el); + this.triggerChanged(); + } + + disconnectedCallback() { + this.triggerController.removeClickListener(); } /** @@ -155,7 +233,23 @@ export class Toast implements ComponentInterface, OverlayInterface { */ @Method() async present(): Promise { - await present(this, 'toastEnter', iosEnterAnimation, mdEnterAnimation, this.position); + /** + * When using an inline toast + * and dismissing a toast it is possible to + * quickly present the toast 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; + } + + await this.delegateController.attachViewToDom(); + + this.currentTransition = present(this, 'toastEnter', iosEnterAnimation, mdEnterAnimation, this.position); + await this.currentTransition; + this.currentTransition = undefined; if (this.duration > 0) { this.durationTimeout = setTimeout(() => this.dismiss(undefined, 'timeout'), this.duration); @@ -172,11 +266,27 @@ export class Toast implements ComponentInterface, OverlayInterface { * Some examples include: ``"cancel"`, `"destructive"`, "selected"`, and `"backdrop"`. */ @Method() - dismiss(data?: any, role?: string): Promise { + async dismiss(data?: any, role?: string): Promise { if (this.durationTimeout) { clearTimeout(this.durationTimeout); } - return dismiss(this, data, role, 'toastLeave', iosLeaveAnimation, mdLeaveAnimation, this.position); + + this.currentTransition = dismiss( + this, + data, + role, + 'toastLeave', + iosLeaveAnimation, + mdLeaveAnimation, + this.position + ); + const dismissed = await this.currentTransition; + + if (dismissed) { + this.delegateController.removeViewFromDom(); + } + + return dismissed; } /** diff --git a/packages/react/src/components/IonToast.tsx b/packages/react/src/components/IonToast.tsx index 7c5f96eb84..6fc43746dc 100644 --- a/packages/react/src/components/IonToast.tsx +++ b/packages/react/src/components/IonToast.tsx @@ -1,11 +1,11 @@ import { + JSX, ToastButton as ToastButtonCore, - ToastOptions as ToastOptionsCore, - toastController as toastControllerCore, + ToastOptions as ToastOptionsCore } from '@ionic/core/components'; import { defineCustomElement } from '@ionic/core/components/ion-toast.js'; -import { createControllerComponent } from './createControllerComponent'; +import { createInlineOverlayComponent } from './createInlineOverlayComponent'; export interface ToastButton extends Omit { icon?: @@ -20,15 +20,7 @@ export interface ToastOptions extends Omit { buttons?: (ToastButton | string)[]; } -const toastController = { - create: (options: ToastOptions) => toastControllerCore.create(options as any), - dismiss: (data?: any, role?: string | undefined, id?: string | undefined) => - toastControllerCore.dismiss(data, role, id), - getTop: () => toastControllerCore.getTop(), -}; - -export const IonToast = /*@__PURE__*/ createControllerComponent( +export const IonToast = /*@__PURE__*/ createInlineOverlayComponent( 'ion-toast', - toastController, defineCustomElement ); diff --git a/packages/react/test-app/tests/e2e/specs/overlay-components/IonToast.cy.ts b/packages/react/test-app/tests/e2e/specs/overlay-components/IonToast.cy.ts index 0612ddf55b..8e59222fb6 100644 --- a/packages/react/test-app/tests/e2e/specs/overlay-components/IonToast.cy.ts +++ b/packages/react/test-app/tests/e2e/specs/overlay-components/IonToast.cy.ts @@ -9,7 +9,7 @@ describe('IonToast', () => { cy.get('ion-toast'); cy.get('ion-toast').shadow().contains('Hello from a toast!'); cy.get('ion-toast').shadow().find('button').contains('hide').click(); - cy.get('ion-toast').should('not.exist'); + cy.get('ion-toast').should('not.be.visible'); }); it('display toast and call dismiss to close it', () => { @@ -17,6 +17,6 @@ describe('IonToast', () => { cy.get('ion-button').contains('Show Toast, call dismiss in 250 ms').click(); cy.get('ion-toast'); cy.get('ion-toast').shadow().contains('Hello from a toast!'); - cy.get('ion-toast').should('not.exist'); + cy.get('ion-toast').should('not.be.visible'); }); }); diff --git a/packages/vue/scripts/copy-overlays.js b/packages/vue/scripts/copy-overlays.js index 4e73ba78e4..48c3eb277b 100644 --- a/packages/vue/scripts/copy-overlays.js +++ b/packages/vue/scripts/copy-overlays.js @@ -23,7 +23,6 @@ function generateOverlays() { }, { tag: 'ion-toast', - controller: 'toastController', name: 'IonToast' }, { diff --git a/packages/vue/src/components/Overlays.ts b/packages/vue/src/components/Overlays.ts index c1de3cc88b..6743064cbc 100644 --- a/packages/vue/src/components/Overlays.ts +++ b/packages/vue/src/components/Overlays.ts @@ -7,7 +7,6 @@ import { JSX, alertController, pickerController, - toastController, } from '@ionic/core/components'; import { defineCustomElement as defineIonActionSheetCustomElement } from '@ionic/core/components/ion-action-sheet.js' @@ -28,7 +27,7 @@ export const IonLoading = /*@__PURE__*/ defineOverlayContainer(' export const IonPicker = /*@__PURE__*/ defineOverlayContainer('ion-picker', defineIonPickerCustomElement, ['animated', 'backdropDismiss', 'buttons', 'columns', 'cssClass', 'duration', 'enterAnimation', 'htmlAttributes', 'keyboardClose', 'leaveAnimation', 'mode', 'showBackdrop'], pickerController); -export const IonToast = /*@__PURE__*/ defineOverlayContainer('ion-toast', defineIonToastCustomElement, ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'keyboardClose', 'leaveAnimation', 'message', 'mode', 'position', 'translucent'], toastController); +export const IonToast = /*@__PURE__*/ defineOverlayContainer('ion-toast', defineIonToastCustomElement, ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'leaveAnimation', 'message', 'mode', 'position', 'translucent', 'trigger']); export const IonModal = /*@__PURE__*/ defineOverlayContainer('ion-modal', defineIonModalCustomElement, ['animated', 'backdropBreakpoint', 'backdropDismiss', 'breakpoints', 'canDismiss', 'enterAnimation', 'handle', 'handleBehavior', 'htmlAttributes', 'initialBreakpoint', 'isOpen', 'keepContentsMounted', 'keyboardClose', 'leaveAnimation', 'mode', 'presentingElement', 'showBackdrop', 'trigger']); diff --git a/packages/vue/test/base/tests/e2e/specs/overlays.cy.js b/packages/vue/test/base/tests/e2e/specs/overlays.cy.js index 30d3c700cb..376023d81f 100644 --- a/packages/vue/test/base/tests/e2e/specs/overlays.cy.js +++ b/packages/vue/test/base/tests/e2e/specs/overlays.cy.js @@ -86,11 +86,11 @@ describe('Overlays', () => { cy.get('ion-radio#controller').click(); cy.get('ion-button#present-overlay').click(); - cy.get('ion-toast').should('exist'); + cy.get('ion-toast.ion-toast-controller').should('exist'); - cy.get('ion-toast').shadow().find('button').click(); + cy.get('ion-toast.ion-toast-controller').shadow().find('button').click(); - cy.get('ion-toast').should('not.exist'); + cy.get('ion-toast.ion-toast-controller').should('not.exist'); }); it(`should open and close ion-alert via component`, () => { @@ -122,7 +122,7 @@ describe('Overlays', () => { cy.get('ion-toast').shadow().find('button').click(); - cy.get('ion-toast').should('not.exist'); + cy.get('ion-toast').should('not.be.visible'); }); it('should pass props to modal via controller', () => {