Skip to content

feat(toast): allow custom positioning relative to specific element #28248

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 31 commits into from
Oct 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a9bb198
initial setup
Sep 26, 2023
036b584
log a warning when used with position="middle"
Sep 26, 2023
81a3c64
calculate animation positions based on anchor
Sep 27, 2023
428563c
add tests
Sep 27, 2023
d5a1093
chore(): add updated snapshots
Ionitron Sep 27, 2023
42b182d
add new prop to ToastOptions interface
Sep 27, 2023
0c25cda
add screenshot for el ref anchor
Sep 27, 2023
07743ec
lint
Sep 27, 2023
10449e5
chore(): add updated snapshots
Ionitron Sep 27, 2023
92db953
run Vue build
Sep 28, 2023
56b0124
revert flaky screenshot
Sep 28, 2023
42a343f
move beforeEach inside describe
Sep 28, 2023
f5cd473
chore(): add updated snapshots
Ionitron Sep 28, 2023
9381de9
lint
Sep 28, 2023
b4159b7
revert flaky screenshot
Sep 28, 2023
323de22
Merge branch 'feature-7.5' into FW-4520
averyjohnston Sep 28, 2023
c14cd73
pull anchor el grab into separate function
Sep 29, 2023
4f6bdd5
make double sure positionAnchor is the right type
Oct 2, 2023
1e2a8b7
warn if anchor appears to be hidden on present
Oct 2, 2023
9bfa69c
pull animation position logic into separate util
Oct 2, 2023
95d1bfb
calculate animation position in toast component and cache result for …
Oct 2, 2023
10fb4c2
lint
Oct 2, 2023
a92a4a9
chore(): add updated snapshots
Ionitron Oct 2, 2023
5ded707
revert flaky screenshot
liamdebeasi Oct 3, 2023
82ac509
add comment to warnIfAnchorIsHidden
Oct 3, 2023
120937a
add comment to lastPresentedPosition
Oct 3, 2023
a5d85d7
clear lastPresentedPosition on dismiss
Oct 3, 2023
ba77bd9
use getIonMode instead of config getMode
Oct 3, 2023
b064f3a
add param comments to getAnimationPosition
Oct 3, 2023
8125555
lint
Oct 3, 2023
412f88a
Merge branch 'feature-7.5' into FW-4520
averyjohnston Oct 4, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>
Expand Down
16 changes: 12 additions & 4 deletions core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -3157,9 +3157,13 @@ export namespace Components {
"onWillDismiss": <T = any>() => Promise<OverlayEventDetail<T>>;
"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.
*/
Expand Down Expand Up @@ -7307,9 +7311,13 @@ declare namespace LocalJSX {
"onWillPresent"?: (event: IonToastCustomEvent<void>) => 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).
*/
Expand Down
7 changes: 3 additions & 4 deletions core/src/components/toast/animations/ios.enter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 3 additions & 5 deletions core/src/components/toast/animations/ios.leave.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
7 changes: 3 additions & 4 deletions core/src/components/toast/animations/md.enter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
95 changes: 95 additions & 0 deletions core/src/components/toast/animations/utils.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
}
94 changes: 94 additions & 0 deletions core/src/components/toast/test/position-anchor/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Toast - positionAnchor</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>

<style>
html {
--ion-safe-area-top: 30px;
--ion-safe-area-bottom: 30px;
}
</style>
</head>

<body>
<ion-app>
<ion-header id="header">
<ion-toolbar>
<ion-title>Toast - positionAnchor</ion-title>
</ion-toolbar>
</ion-header>

<ion-content class="ion-padding">
<ion-button id="headerAnchor">Anchor to Header</ion-button>
<ion-button id="footerAnchor">Anchor to Footer</ion-button>
<ion-button id="middleAnchor">Anchor to Header (Middle Position)</ion-button>
<ion-button id="headerElAnchor">Anchor to Header (Element Ref)</ion-button>
<ion-button id="hiddenElAnchor">Anchor to Hidden Element</ion-button>

<ion-toast
id="headerToast"
trigger="headerAnchor"
position="top"
position-anchor="header"
message="Hello World"
duration="2000"
></ion-toast>
<ion-toast
id="footerToast"
trigger="footerAnchor"
position="bottom"
position-anchor="footer"
message="Hello World"
duration="2000"
></ion-toast>
<ion-toast
id="middleToast"
trigger="middleAnchor"
position="middle"
position-anchor="header"
message="Hello World"
duration="2000"
></ion-toast>
<ion-toast
id="headerElToast"
trigger="headerElAnchor"
position="top"
message="Hello World"
duration="2000"
></ion-toast>
<ion-toast
id="hiddenElToast"
trigger="hiddenElAnchor"
position="bottom"
position-anchor="hiddenEl"
message="Hello World"
duration="2000"
></ion-toast>

<div id="hiddenEl" style="display: none">Shh I'm hiding</div>
</ion-content>

<ion-footer id="footer">
<ion-toolbar>
<ion-title>Footer</ion-title>
</ion-toolbar>
</ion-footer>
</ion-app>

<script>
const headerElToast = document.querySelector('#headerElToast');
const header = document.querySelector('ion-header');
headerElToast.positionAnchor = header;
</script>
</body>
</html>
56 changes: 56 additions & 0 deletions core/src/components/toast/test/position-anchor/toast.e2e.ts
Original file line number Diff line number Diff line change
@@ -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`));
});
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading