diff --git a/core/api.txt b/core/api.txt index df776ec760f..d54e7a6f408 100644 --- a/core/api.txt +++ b/core/api.txt @@ -1445,6 +1445,7 @@ ion-toast,prop,leaveAnimation,((baseEl: any, opts?: any) => Animation) | undefin 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,positionAnchor,HTMLElement | string | undefined,undefined,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 diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 296e1490b81..44ba508e797 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -39,7 +39,7 @@ import { SelectChangeEventDetail, SelectCompareFn, SelectInterface } from "./com import { SelectPopoverOption } from "./components/select-popover/select-popover-interface"; import { TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface"; import { TextareaChangeEventDetail, TextareaInputEventDetail } from "./components/textarea/textarea-interface"; -import { ToastButton, ToastLayout, ToastPosition } from "./components/toast/toast-interface"; +import { ToastButton, ToastDismissOptions, ToastLayout, ToastPosition, ToastPresentOptions } from "./components/toast/toast-interface"; import { ToggleChangeEventDetail } from "./components/toggle/toggle-interface"; export { AccordionGroupChangeEventDetail } from "./components/accordion-group/accordion-group-interface"; export { AnimationBuilder, AutocompleteTypes, Color, ComponentProps, ComponentRef, FrameworkDelegate, StyleEventDetail, TextFieldTypes } from "./interface"; @@ -75,7 +75,7 @@ export { SelectChangeEventDetail, SelectCompareFn, SelectInterface } from "./com export { SelectPopoverOption } from "./components/select-popover/select-popover-interface"; export { TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface"; export { TextareaChangeEventDetail, TextareaInputEventDetail } from "./components/textarea/textarea-interface"; -export { ToastButton, ToastLayout, ToastPosition } from "./components/toast/toast-interface"; +export { ToastButton, ToastDismissOptions, ToastLayout, ToastPosition, ToastPresentOptions } from "./components/toast/toast-interface"; export { ToggleChangeEventDetail } from "./components/toggle/toggle-interface"; export namespace Components { interface IonAccordion { @@ -3157,9 +3157,13 @@ export namespace Components { "onWillDismiss": () => Promise>; "overlayIndex": number; /** - * The position of the toast on the screen. + * The starting position of the toast on the screen. Can be tweaked further using the `positionAnchor` property. */ "position": ToastPosition; + /** + * The element to anchor the toast's position to. Can be set as a direct reference or the ID of the element. With `position="bottom"`, the toast will sit above the chosen element. With `position="top"`, the toast will sit below the chosen element. With `position="middle"`, the value of `positionAnchor` is ignored. + */ + "positionAnchor"?: HTMLElement | string; /** * Present the toast overlay after it has been created. */ @@ -7307,9 +7311,13 @@ declare namespace LocalJSX { "onWillPresent"?: (event: IonToastCustomEvent) => void; "overlayIndex": number; /** - * The position of the toast on the screen. + * The starting position of the toast on the screen. Can be tweaked further using the `positionAnchor` property. */ "position"?: ToastPosition; + /** + * The element to anchor the toast's position to. Can be set as a direct reference or the ID of the element. With `position="bottom"`, the toast will sit above the chosen element. With `position="top"`, the toast will sit below the chosen element. With `position="middle"`, the value of `positionAnchor` is ignored. + */ + "positionAnchor"?: HTMLElement | string; /** * 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). */ diff --git a/core/src/components/toast/animations/ios.enter.ts b/core/src/components/toast/animations/ios.enter.ts index a5159243238..55baa3bdd35 100644 --- a/core/src/components/toast/animations/ios.enter.ts +++ b/core/src/components/toast/animations/ios.enter.ts @@ -2,20 +2,19 @@ import { createAnimation } from '@utils/animation/animation'; import { getElementRoot } from '@utils/helpers'; import type { Animation } from '../../../interface'; +import type { ToastPresentOptions } from '../toast-interface'; /** * iOS Toast Enter Animation */ -export const iosEnterAnimation = (baseEl: HTMLElement, position: string): Animation => { +export const iosEnterAnimation = (baseEl: HTMLElement, opts: ToastPresentOptions): Animation => { const baseAnimation = createAnimation(); const wrapperAnimation = createAnimation(); + const { position, top, bottom } = opts; 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))`; - wrapperAnimation.addElement(wrapperEl); switch (position) { diff --git a/core/src/components/toast/animations/ios.leave.ts b/core/src/components/toast/animations/ios.leave.ts index 8a7a1310450..272003d5f98 100644 --- a/core/src/components/toast/animations/ios.leave.ts +++ b/core/src/components/toast/animations/ios.leave.ts @@ -1,21 +1,19 @@ import { createAnimation } from '@utils/animation/animation'; import { getElementRoot } from '@utils/helpers'; -import type { Animation } from '../../../interface'; +import type { Animation, ToastDismissOptions } from '../../../interface'; /** * iOS Toast Leave Animation */ -export const iosLeaveAnimation = (baseEl: HTMLElement, position: string): Animation => { +export const iosLeaveAnimation = (baseEl: HTMLElement, opts: ToastDismissOptions): Animation => { const baseAnimation = createAnimation(); const wrapperAnimation = createAnimation(); + const { position, top, bottom } = opts; 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))`; - wrapperAnimation.addElement(wrapperEl); switch (position) { diff --git a/core/src/components/toast/animations/md.enter.ts b/core/src/components/toast/animations/md.enter.ts index 3d60caf5bf4..f8f73f383a0 100644 --- a/core/src/components/toast/animations/md.enter.ts +++ b/core/src/components/toast/animations/md.enter.ts @@ -2,20 +2,19 @@ import { createAnimation } from '@utils/animation/animation'; import { getElementRoot } from '@utils/helpers'; import type { Animation } from '../../../interface'; +import type { ToastPresentOptions } from '../toast-interface'; /** * MD Toast Enter Animation */ -export const mdEnterAnimation = (baseEl: HTMLElement, position: string): Animation => { +export const mdEnterAnimation = (baseEl: HTMLElement, opts: ToastPresentOptions): Animation => { const baseAnimation = createAnimation(); const wrapperAnimation = createAnimation(); + const { position, top, bottom } = opts; 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))`; - wrapperAnimation.addElement(wrapperEl); switch (position) { diff --git a/core/src/components/toast/animations/utils.ts b/core/src/components/toast/animations/utils.ts new file mode 100644 index 00000000000..47e883d57e3 --- /dev/null +++ b/core/src/components/toast/animations/utils.ts @@ -0,0 +1,95 @@ +import { win } from '@utils/browser'; +import { printIonWarning } from '@utils/logging'; +import type { Mode } from 'src/interface'; + +import type { ToastAnimationPosition, ToastPosition } from '../toast-interface'; + +/** + * Calculate the CSS top and bottom position of the toast, to be used + * as starting points for the animation keyframes. + * + * Note that MD animates bottom-positioned toasts using style.bottom, + * which calculates from the bottom edge of the screen, while iOS uses + * translateY, which calculates from the top edge of the screen. This + * is why the bottom calculates differ slightly between modes. + * + * @param position The value of the toast's position prop. + * @param positionAnchor The element the toast should be anchored to, + * if applicable. + * @param mode The toast component's mode (md, ios, etc). + * @param toast A reference to the toast element itself. + */ +export function getAnimationPosition( + position: ToastPosition, + positionAnchor: HTMLElement | undefined, + mode: Mode, + toast: HTMLElement +): ToastAnimationPosition { + /** + * Start with a predefined offset from the edge the toast will be + * positioned relative to, whether on the screen or anchor element. + */ + let offset: number; + if (mode === 'md') { + offset = 8; + } else { + offset = position === 'top' ? 10 : -10; + } + + /** + * If positionAnchor is defined, add in the distance from the target + * screen edge to the target anchor edge. For position="top", the + * bottom anchor edge is targeted. For position="bottom", the top + * anchor edge is targeted. + */ + if (positionAnchor && win) { + warnIfAnchorIsHidden(positionAnchor, toast); + + const box = positionAnchor.getBoundingClientRect(); + if (position === 'top') { + offset += box.bottom; + } else if (position === 'bottom') { + /** + * Just box.top is the distance from the top edge of the screen + * to the top edge of the anchor. We want to calculate from the + * bottom edge of the screen instead. + */ + if (mode === 'md') { + offset += win.innerHeight - box.top; + } else { + offset -= win.innerHeight - box.top; + } + } + + /** + * We don't include safe area here because that should already be + * accounted for when checking the position of the anchor. + */ + return { + top: `${offset}px`, + bottom: `${offset}px`, + }; + } else { + return { + top: `calc(${offset}px + var(--ion-safe-area-top, 0px))`, + bottom: + mode === 'md' + ? `calc(${offset}px + var(--ion-safe-area-bottom, 0px))` + : `calc(${offset}px - var(--ion-safe-area-bottom, 0px))`, + }; + } +} + +/** + * If the anchor element is hidden, getBoundingClientRect() + * will return all 0s for it, which can cause unexpected + * results in the position calculation when animating. + */ +function warnIfAnchorIsHidden(positionAnchor: HTMLElement, toast: HTMLElement) { + if (positionAnchor.offsetParent === null) { + printIonWarning( + 'The positionAnchor element for ion-toast was found in the DOM, but appears to be hidden. This may lead to unexpected positioning of the toast.', + toast + ); + } +} diff --git a/core/src/components/toast/test/position-anchor/index.html b/core/src/components/toast/test/position-anchor/index.html new file mode 100644 index 00000000000..b504912bf1a --- /dev/null +++ b/core/src/components/toast/test/position-anchor/index.html @@ -0,0 +1,94 @@ + + + + + Toast - positionAnchor + + + + + + + + + + + + + + Toast - positionAnchor + + + + + Anchor to Header + Anchor to Footer + Anchor to Header (Middle Position) + Anchor to Header (Element Ref) + Anchor to Hidden Element + + + + + + + + + + + + + Footer + + + + + + + diff --git a/core/src/components/toast/test/position-anchor/toast.e2e.ts b/core/src/components/toast/test/position-anchor/toast.e2e.ts new file mode 100644 index 00000000000..cfac7ac6823 --- /dev/null +++ b/core/src/components/toast/test/position-anchor/toast.e2e.ts @@ -0,0 +1,56 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { + test.describe(title('toast: positionAnchor'), () => { + test.beforeEach(async ({ page }) => { + await page.goto('/src/components/toast/test/position-anchor', config); + + /** + * We need to screenshot the whole page to ensure the toasts are positioned + * correctly, but we don't need much extra white space between the header + * and footer. + */ + await page.setViewportSize({ + width: 425, + height: 425, + }); + }); + + test('should place top-position toast underneath anchor', async ({ page }) => { + const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent'); + + await page.click('#headerAnchor'); + await ionToastDidPresent.next(); + + await expect(page).toHaveScreenshot(screenshot(`toast-header-anchor`)); + }); + + test('should place bottom-position toast above anchor', async ({ page }) => { + const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent'); + + await page.click('#footerAnchor'); + await ionToastDidPresent.next(); + + await expect(page).toHaveScreenshot(screenshot(`toast-footer-anchor`)); + }); + + test('should ignore anchor for middle-position toast', async ({ page }) => { + const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent'); + + await page.click('#middleAnchor'); + await ionToastDidPresent.next(); + + await expect(page).toHaveScreenshot(screenshot(`toast-middle-anchor`)); + }); + + test('should correctly anchor toast when using an element reference', async ({ page }) => { + const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent'); + + await page.click('#headerElAnchor'); + await ionToastDidPresent.next(); + + await expect(page).toHaveScreenshot(screenshot(`toast-header-el-anchor`)); + }); + }); +}); diff --git a/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-footer-anchor-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-footer-anchor-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..e6d9e1e350f Binary files /dev/null and b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-footer-anchor-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-footer-anchor-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-footer-anchor-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..4253e51a599 Binary files /dev/null and b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-footer-anchor-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-footer-anchor-ios-ltr-Mobile-Safari-linux.png b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-footer-anchor-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..826e0c0c8fe Binary files /dev/null and b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-footer-anchor-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-footer-anchor-md-ltr-Mobile-Chrome-linux.png b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-footer-anchor-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..6118471866d Binary files /dev/null and b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-footer-anchor-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-footer-anchor-md-ltr-Mobile-Firefox-linux.png b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-footer-anchor-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..1f814424a33 Binary files /dev/null and b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-footer-anchor-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-footer-anchor-md-ltr-Mobile-Safari-linux.png b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-footer-anchor-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..0f032e947f1 Binary files /dev/null and b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-footer-anchor-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-anchor-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-anchor-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..b5b7fe18c54 Binary files /dev/null and b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-anchor-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-anchor-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-anchor-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..4a1063286a3 Binary files /dev/null and b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-anchor-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-anchor-ios-ltr-Mobile-Safari-linux.png b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-anchor-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..133edec6d3b Binary files /dev/null and b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-anchor-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-anchor-md-ltr-Mobile-Chrome-linux.png b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-anchor-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..643d08ba84f Binary files /dev/null and b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-anchor-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-anchor-md-ltr-Mobile-Firefox-linux.png b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-anchor-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..ed34cd5f408 Binary files /dev/null and b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-anchor-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-anchor-md-ltr-Mobile-Safari-linux.png b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-anchor-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..00aaba05201 Binary files /dev/null and b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-anchor-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-el-anchor-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-el-anchor-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..b5b7fe18c54 Binary files /dev/null and b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-el-anchor-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-el-anchor-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-el-anchor-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..4a1063286a3 Binary files /dev/null and b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-el-anchor-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-el-anchor-ios-ltr-Mobile-Safari-linux.png b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-el-anchor-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..133edec6d3b Binary files /dev/null and b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-el-anchor-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-el-anchor-md-ltr-Mobile-Chrome-linux.png b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-el-anchor-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..643d08ba84f Binary files /dev/null and b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-el-anchor-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-el-anchor-md-ltr-Mobile-Firefox-linux.png b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-el-anchor-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..ed34cd5f408 Binary files /dev/null and b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-el-anchor-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-el-anchor-md-ltr-Mobile-Safari-linux.png b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-el-anchor-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..00aaba05201 Binary files /dev/null and b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-header-el-anchor-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-middle-anchor-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-middle-anchor-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..a0afd7b8607 Binary files /dev/null and b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-middle-anchor-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-middle-anchor-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-middle-anchor-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..53521cef0de Binary files /dev/null and b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-middle-anchor-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-middle-anchor-ios-ltr-Mobile-Safari-linux.png b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-middle-anchor-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..a0fbfcd2ca1 Binary files /dev/null and b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-middle-anchor-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-middle-anchor-md-ltr-Mobile-Chrome-linux.png b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-middle-anchor-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..8a081906cfc Binary files /dev/null and b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-middle-anchor-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-middle-anchor-md-ltr-Mobile-Firefox-linux.png b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-middle-anchor-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..9dbe6285725 Binary files /dev/null and b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-middle-anchor-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-middle-anchor-md-ltr-Mobile-Safari-linux.png b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-middle-anchor-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..3dfcc439072 Binary files /dev/null and b/core/src/components/toast/test/position-anchor/toast.e2e.ts-snapshots/toast-middle-anchor-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/toast/toast-interface.ts b/core/src/components/toast/toast-interface.ts index 3bd76eba965..19cb83ae4ae 100644 --- a/core/src/components/toast/toast-interface.ts +++ b/core/src/components/toast/toast-interface.ts @@ -8,6 +8,7 @@ export interface ToastOptions { duration?: number; buttons?: (ToastButton | string)[]; position?: 'top' | 'bottom' | 'middle'; + positionAnchor?: HTMLElement | string; translucent?: boolean; animated?: boolean; icon?: string; @@ -42,3 +43,15 @@ export interface ToastButton { } export type ToastPosition = 'top' | 'bottom' | 'middle'; + +interface ToastPositionAlias { + position: ToastPosition; +} + +export interface ToastAnimationPosition { + top: string; + bottom: string; +} + +export type ToastPresentOptions = ToastPositionAlias & ToastAnimationPosition; +export type ToastDismissOptions = ToastPositionAlias & ToastAnimationPosition; diff --git a/core/src/components/toast/toast.tsx b/core/src/components/toast/toast.tsx index 3137e73d546..9a14b2bfd07 100644 --- a/core/src/components/toast/toast.tsx +++ b/core/src/components/toast/toast.tsx @@ -28,7 +28,15 @@ import { iosEnterAnimation } from './animations/ios.enter'; import { iosLeaveAnimation } from './animations/ios.leave'; import { mdEnterAnimation } from './animations/md.enter'; import { mdLeaveAnimation } from './animations/md.leave'; -import type { ToastButton, ToastPosition, ToastLayout } from './toast-interface'; +import { getAnimationPosition } from './animations/utils'; +import type { + ToastButton, + ToastPosition, + ToastLayout, + ToastPresentOptions, + ToastDismissOptions, + ToastAnimationPosition, +} from './toast-interface'; // TODO(FW-2832): types @@ -57,6 +65,13 @@ export class Toast implements ComponentInterface, OverlayInterface { private customHTMLEnabled = config.get('innerHTMLTemplatesEnabled', ENABLE_HTML_CONTENT_DEFAULT); private durationTimeout?: ReturnType; + /** + * Holds the position of the toast calculated in the present + * animation, to be passed along to the dismiss animation so + * we don't have to calculate the position twice. + */ + private lastPresentedPosition?: ToastAnimationPosition; + presented = false; /** @@ -137,10 +152,19 @@ export class Toast implements ComponentInterface, OverlayInterface { @Prop() keyboardClose = false; /** - * The position of the toast on the screen. + * The starting position of the toast on the screen. Can be tweaked further + * using the `positionAnchor` property. */ @Prop() position: ToastPosition = 'bottom'; + /** + * The element to anchor the toast's position to. Can be set as a direct reference + * or the ID of the element. With `position="bottom"`, the toast will sit above the + * chosen element. With `position="top"`, the toast will sit below the chosen element. + * With `position="middle"`, the value of `positionAnchor` is ignored. + */ + @Prop() positionAnchor?: HTMLElement | string; + /** * An array of buttons for the toast. */ @@ -275,7 +299,21 @@ export class Toast implements ComponentInterface, OverlayInterface { await this.delegateController.attachViewToDom(); - await present(this, 'toastEnter', iosEnterAnimation, mdEnterAnimation, this.position); + const { el, position } = this; + const anchor = this.getAnchorElement(); + const animationPosition = getAnimationPosition(position, anchor, getIonMode(this), el); + + /** + * Cache the calculated position of the toast, so we can re-use it + * in the dismiss animation. + */ + this.lastPresentedPosition = animationPosition; + + await present(this, 'toastEnter', iosEnterAnimation, mdEnterAnimation, { + position, + top: animationPosition.top, + bottom: animationPosition.bottom, + }); /** * Content is revealed to screen readers after @@ -304,8 +342,10 @@ export class Toast implements ComponentInterface, OverlayInterface { async dismiss(data?: any, role?: string): Promise { const unlock = await this.lockController.lock(); - if (this.durationTimeout) { - clearTimeout(this.durationTimeout); + const { durationTimeout, position, lastPresentedPosition } = this; + + if (durationTimeout) { + clearTimeout(durationTimeout); } const dismissed = await dismiss( @@ -315,7 +355,16 @@ export class Toast implements ComponentInterface, OverlayInterface { 'toastLeave', iosLeaveAnimation, mdLeaveAnimation, - this.position + /** + * Fetch the cached position that was calculated back in the present + * animation. We always want to animate the dismiss from the same + * position the present stopped at, so the animation looks continuous. + */ + { + position, + top: lastPresentedPosition?.top ?? '', + bottom: lastPresentedPosition?.bottom ?? '', + } ); if (dismissed) { @@ -323,6 +372,7 @@ export class Toast implements ComponentInterface, OverlayInterface { this.revealContentToScreenReader = false; } + this.lastPresentedPosition = undefined; unlock(); return dismissed; @@ -354,6 +404,43 @@ export class Toast implements ComponentInterface, OverlayInterface { return buttons; } + /** + * Returns the element specified by the positionAnchor prop, + * or undefined if prop's value is an ID string and the element + * is not found in the DOM. + */ + private getAnchorElement(): HTMLElement | undefined { + const { position, positionAnchor, el } = this; + + if (position === 'middle' && positionAnchor !== undefined) { + printIonWarning('The positionAnchor property is ignored when using position="middle".', this.el); + return undefined; + } + + if (typeof positionAnchor === 'string') { + /** + * If the anchor is defined as an ID, find the element. + * We do this on every present so the toast doesn't need + * to account for the surrounding DOM changing since the + * last time it was presented. + */ + const foundEl = document.getElementById(positionAnchor); + if (foundEl === null) { + printIonWarning(`An anchor element with an ID of "${positionAnchor}" was not found in the DOM.`, el); + return undefined; + } + + return foundEl; + } + + if (positionAnchor instanceof HTMLElement) { + return positionAnchor; + } + + printIonWarning('Invalid positionAnchor value:', positionAnchor, el); + return undefined; + } + private async buttonClick(button: ToastButton) { const role = button.role; if (isCancel(role)) { @@ -577,6 +664,3 @@ const buttonClass = (button: ToastButton): CssClassMap => { const buttonPart = (button: ToastButton): string => { return isCancel(button.role) ? 'button cancel' : 'button'; }; - -type ToastPresentOptions = ToastPosition; -type ToastDismissOptions = ToastPosition; diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index 57462d5e850..9915077a8f0 100644 --- a/packages/angular/src/directives/proxies.ts +++ b/packages/angular/src/directives/proxies.ts @@ -2256,7 +2256,7 @@ export declare interface IonTitle extends Components.IonTitle {} @ProxyCmp({ - inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'translucent', 'trigger'], + inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'translucent', 'trigger'], methods: ['present', 'dismiss', 'onDidDismiss', 'onWillDismiss'] }) @Component({ @@ -2264,7 +2264,7 @@ export declare interface IonTitle extends Components.IonTitle {} changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'translucent', 'trigger'], + inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'translucent', 'trigger'], }) export class IonToast { protected el: HTMLElement; diff --git a/packages/vue/src/components/Overlays.ts b/packages/vue/src/components/Overlays.ts index 62df98436bb..d938438c6f8 100644 --- a/packages/vue/src/components/Overlays.ts +++ b/packages/vue/src/components/Overlays.ts @@ -25,7 +25,7 @@ export const IonLoading = /*@__PURE__*/ defineOverlayContainer(' export const IonPicker = /*@__PURE__*/ defineOverlayContainer('ion-picker', defineIonPickerCustomElement, ['animated', 'backdropDismiss', 'buttons', 'columns', 'cssClass', 'duration', 'enterAnimation', 'htmlAttributes', 'isOpen', 'keyboardClose', 'leaveAnimation', 'mode', 'showBackdrop', 'trigger']); -export const IonToast = /*@__PURE__*/ defineOverlayContainer('ion-toast', defineIonToastCustomElement, ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'translucent', 'trigger']); +export const IonToast = /*@__PURE__*/ defineOverlayContainer('ion-toast', defineIonToastCustomElement, ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', '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'], true);