Skip to content

Commit 897ff6f

Browse files
averyjohnstonIonitronliamdebeasi
authored
feat(toast): allow custom positioning relative to specific element (#28248)
Issue number: resolves #17499 --------- <!-- Please do not submit updates to dependencies unless it fixes an issue. --> <!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. --> ## What is the current behavior? <!-- Please describe the current behavior that you are modifying. --> Currently, there isn't a way to position toasts such that they don't overlap navigation elements such as headers, footers, and FABs. ## What is the new behavior? <!-- Please describe the behavior or changes that are being added by this PR. --> Added the new `positionAnchor` property, which specifies an element that the toast's position should be anchored to. While the name can be tweaked, we should take care to keep the relation between it and the `position` property clear. The `position` acts as a sort of "origin" point, and the toast is moved from there to sit near the chosen anchor element. This is important because it helps clarify why the toast sits above the anchor for `position="bottom"` and vice versa. I chose not to rename the `position` prop itself to avoid breaking changes. Docs PR: ionic-team/ionic-docs#3158 ## Does this introduce a breaking change? - [ ] Yes - [x] No <!-- If this introduces a breaking change, please describe the impact and migration path for existing applications below. --> ## Other information <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. --> --------- Co-authored-by: ionitron <[email protected]> Co-authored-by: Liam DeBeasi <[email protected]>
1 parent 01167fc commit 897ff6f

File tree

36 files changed

+376
-29
lines changed

36 files changed

+376
-29
lines changed

core/api.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1445,6 +1445,7 @@ ion-toast,prop,leaveAnimation,((baseEl: any, opts?: any) => Animation) | undefin
14451445
ion-toast,prop,message,IonicSafeString | string | undefined,undefined,false,false
14461446
ion-toast,prop,mode,"ios" | "md",undefined,false,false
14471447
ion-toast,prop,position,"bottom" | "middle" | "top",'bottom',false,false
1448+
ion-toast,prop,positionAnchor,HTMLElement | string | undefined,undefined,false,false
14481449
ion-toast,prop,translucent,boolean,false,false,false
14491450
ion-toast,prop,trigger,string | undefined,undefined,false,false
14501451
ion-toast,method,dismiss,dismiss(data?: any, role?: string) => Promise<boolean>

core/src/components.d.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import { SelectChangeEventDetail, SelectCompareFn, SelectInterface } from "./com
3939
import { SelectPopoverOption } from "./components/select-popover/select-popover-interface";
4040
import { TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface";
4141
import { TextareaChangeEventDetail, TextareaInputEventDetail } from "./components/textarea/textarea-interface";
42-
import { ToastButton, ToastLayout, ToastPosition } from "./components/toast/toast-interface";
42+
import { ToastButton, ToastDismissOptions, ToastLayout, ToastPosition, ToastPresentOptions } from "./components/toast/toast-interface";
4343
import { ToggleChangeEventDetail } from "./components/toggle/toggle-interface";
4444
export { AccordionGroupChangeEventDetail } from "./components/accordion-group/accordion-group-interface";
4545
export { AnimationBuilder, AutocompleteTypes, Color, ComponentProps, ComponentRef, FrameworkDelegate, StyleEventDetail, TextFieldTypes } from "./interface";
@@ -75,7 +75,7 @@ export { SelectChangeEventDetail, SelectCompareFn, SelectInterface } from "./com
7575
export { SelectPopoverOption } from "./components/select-popover/select-popover-interface";
7676
export { TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface";
7777
export { TextareaChangeEventDetail, TextareaInputEventDetail } from "./components/textarea/textarea-interface";
78-
export { ToastButton, ToastLayout, ToastPosition } from "./components/toast/toast-interface";
78+
export { ToastButton, ToastDismissOptions, ToastLayout, ToastPosition, ToastPresentOptions } from "./components/toast/toast-interface";
7979
export { ToggleChangeEventDetail } from "./components/toggle/toggle-interface";
8080
export namespace Components {
8181
interface IonAccordion {
@@ -3157,9 +3157,13 @@ export namespace Components {
31573157
"onWillDismiss": <T = any>() => Promise<OverlayEventDetail<T>>;
31583158
"overlayIndex": number;
31593159
/**
3160-
* The position of the toast on the screen.
3160+
* The starting position of the toast on the screen. Can be tweaked further using the `positionAnchor` property.
31613161
*/
31623162
"position": ToastPosition;
3163+
/**
3164+
* 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.
3165+
*/
3166+
"positionAnchor"?: HTMLElement | string;
31633167
/**
31643168
* Present the toast overlay after it has been created.
31653169
*/
@@ -7307,9 +7311,13 @@ declare namespace LocalJSX {
73077311
"onWillPresent"?: (event: IonToastCustomEvent<void>) => void;
73087312
"overlayIndex": number;
73097313
/**
7310-
* The position of the toast on the screen.
7314+
* The starting position of the toast on the screen. Can be tweaked further using the `positionAnchor` property.
73117315
*/
73127316
"position"?: ToastPosition;
7317+
/**
7318+
* 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.
7319+
*/
7320+
"positionAnchor"?: HTMLElement | string;
73137321
/**
73147322
* 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).
73157323
*/

core/src/components/toast/animations/ios.enter.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,19 @@ import { createAnimation } from '@utils/animation/animation';
22
import { getElementRoot } from '@utils/helpers';
33

44
import type { Animation } from '../../../interface';
5+
import type { ToastPresentOptions } from '../toast-interface';
56

67
/**
78
* iOS Toast Enter Animation
89
*/
9-
export const iosEnterAnimation = (baseEl: HTMLElement, position: string): Animation => {
10+
export const iosEnterAnimation = (baseEl: HTMLElement, opts: ToastPresentOptions): Animation => {
1011
const baseAnimation = createAnimation();
1112
const wrapperAnimation = createAnimation();
13+
const { position, top, bottom } = opts;
1214

1315
const root = getElementRoot(baseEl);
1416
const wrapperEl = root.querySelector('.toast-wrapper') as HTMLElement;
1517

16-
const bottom = `calc(-10px - var(--ion-safe-area-bottom, 0px))`;
17-
const top = `calc(10px + var(--ion-safe-area-top, 0px))`;
18-
1918
wrapperAnimation.addElement(wrapperEl);
2019

2120
switch (position) {

core/src/components/toast/animations/ios.leave.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,19 @@
11
import { createAnimation } from '@utils/animation/animation';
22
import { getElementRoot } from '@utils/helpers';
33

4-
import type { Animation } from '../../../interface';
4+
import type { Animation, ToastDismissOptions } from '../../../interface';
55

66
/**
77
* iOS Toast Leave Animation
88
*/
9-
export const iosLeaveAnimation = (baseEl: HTMLElement, position: string): Animation => {
9+
export const iosLeaveAnimation = (baseEl: HTMLElement, opts: ToastDismissOptions): Animation => {
1010
const baseAnimation = createAnimation();
1111
const wrapperAnimation = createAnimation();
12+
const { position, top, bottom } = opts;
1213

1314
const root = getElementRoot(baseEl);
1415
const wrapperEl = root.querySelector('.toast-wrapper') as HTMLElement;
1516

16-
const bottom = `calc(-10px - var(--ion-safe-area-bottom, 0px))`;
17-
const top = `calc(10px + var(--ion-safe-area-top, 0px))`;
18-
1917
wrapperAnimation.addElement(wrapperEl);
2018

2119
switch (position) {

core/src/components/toast/animations/md.enter.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,19 @@ import { createAnimation } from '@utils/animation/animation';
22
import { getElementRoot } from '@utils/helpers';
33

44
import type { Animation } from '../../../interface';
5+
import type { ToastPresentOptions } from '../toast-interface';
56

67
/**
78
* MD Toast Enter Animation
89
*/
9-
export const mdEnterAnimation = (baseEl: HTMLElement, position: string): Animation => {
10+
export const mdEnterAnimation = (baseEl: HTMLElement, opts: ToastPresentOptions): Animation => {
1011
const baseAnimation = createAnimation();
1112
const wrapperAnimation = createAnimation();
13+
const { position, top, bottom } = opts;
1214

1315
const root = getElementRoot(baseEl);
1416
const wrapperEl = root.querySelector('.toast-wrapper') as HTMLElement;
1517

16-
const bottom = `calc(8px + var(--ion-safe-area-bottom, 0px))`;
17-
const top = `calc(8px + var(--ion-safe-area-top, 0px))`;
18-
1918
wrapperAnimation.addElement(wrapperEl);
2019

2120
switch (position) {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { win } from '@utils/browser';
2+
import { printIonWarning } from '@utils/logging';
3+
import type { Mode } from 'src/interface';
4+
5+
import type { ToastAnimationPosition, ToastPosition } from '../toast-interface';
6+
7+
/**
8+
* Calculate the CSS top and bottom position of the toast, to be used
9+
* as starting points for the animation keyframes.
10+
*
11+
* Note that MD animates bottom-positioned toasts using style.bottom,
12+
* which calculates from the bottom edge of the screen, while iOS uses
13+
* translateY, which calculates from the top edge of the screen. This
14+
* is why the bottom calculates differ slightly between modes.
15+
*
16+
* @param position The value of the toast's position prop.
17+
* @param positionAnchor The element the toast should be anchored to,
18+
* if applicable.
19+
* @param mode The toast component's mode (md, ios, etc).
20+
* @param toast A reference to the toast element itself.
21+
*/
22+
export function getAnimationPosition(
23+
position: ToastPosition,
24+
positionAnchor: HTMLElement | undefined,
25+
mode: Mode,
26+
toast: HTMLElement
27+
): ToastAnimationPosition {
28+
/**
29+
* Start with a predefined offset from the edge the toast will be
30+
* positioned relative to, whether on the screen or anchor element.
31+
*/
32+
let offset: number;
33+
if (mode === 'md') {
34+
offset = 8;
35+
} else {
36+
offset = position === 'top' ? 10 : -10;
37+
}
38+
39+
/**
40+
* If positionAnchor is defined, add in the distance from the target
41+
* screen edge to the target anchor edge. For position="top", the
42+
* bottom anchor edge is targeted. For position="bottom", the top
43+
* anchor edge is targeted.
44+
*/
45+
if (positionAnchor && win) {
46+
warnIfAnchorIsHidden(positionAnchor, toast);
47+
48+
const box = positionAnchor.getBoundingClientRect();
49+
if (position === 'top') {
50+
offset += box.bottom;
51+
} else if (position === 'bottom') {
52+
/**
53+
* Just box.top is the distance from the top edge of the screen
54+
* to the top edge of the anchor. We want to calculate from the
55+
* bottom edge of the screen instead.
56+
*/
57+
if (mode === 'md') {
58+
offset += win.innerHeight - box.top;
59+
} else {
60+
offset -= win.innerHeight - box.top;
61+
}
62+
}
63+
64+
/**
65+
* We don't include safe area here because that should already be
66+
* accounted for when checking the position of the anchor.
67+
*/
68+
return {
69+
top: `${offset}px`,
70+
bottom: `${offset}px`,
71+
};
72+
} else {
73+
return {
74+
top: `calc(${offset}px + var(--ion-safe-area-top, 0px))`,
75+
bottom:
76+
mode === 'md'
77+
? `calc(${offset}px + var(--ion-safe-area-bottom, 0px))`
78+
: `calc(${offset}px - var(--ion-safe-area-bottom, 0px))`,
79+
};
80+
}
81+
}
82+
83+
/**
84+
* If the anchor element is hidden, getBoundingClientRect()
85+
* will return all 0s for it, which can cause unexpected
86+
* results in the position calculation when animating.
87+
*/
88+
function warnIfAnchorIsHidden(positionAnchor: HTMLElement, toast: HTMLElement) {
89+
if (positionAnchor.offsetParent === null) {
90+
printIonWarning(
91+
'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.',
92+
toast
93+
);
94+
}
95+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<!DOCTYPE html>
2+
<html lang="en" dir="ltr">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>Toast - positionAnchor</title>
6+
<meta
7+
name="viewport"
8+
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
9+
/>
10+
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
11+
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
12+
<script src="../../../../../scripts/testing/scripts.js"></script>
13+
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
14+
15+
<style>
16+
html {
17+
--ion-safe-area-top: 30px;
18+
--ion-safe-area-bottom: 30px;
19+
}
20+
</style>
21+
</head>
22+
23+
<body>
24+
<ion-app>
25+
<ion-header id="header">
26+
<ion-toolbar>
27+
<ion-title>Toast - positionAnchor</ion-title>
28+
</ion-toolbar>
29+
</ion-header>
30+
31+
<ion-content class="ion-padding">
32+
<ion-button id="headerAnchor">Anchor to Header</ion-button>
33+
<ion-button id="footerAnchor">Anchor to Footer</ion-button>
34+
<ion-button id="middleAnchor">Anchor to Header (Middle Position)</ion-button>
35+
<ion-button id="headerElAnchor">Anchor to Header (Element Ref)</ion-button>
36+
<ion-button id="hiddenElAnchor">Anchor to Hidden Element</ion-button>
37+
38+
<ion-toast
39+
id="headerToast"
40+
trigger="headerAnchor"
41+
position="top"
42+
position-anchor="header"
43+
message="Hello World"
44+
duration="2000"
45+
></ion-toast>
46+
<ion-toast
47+
id="footerToast"
48+
trigger="footerAnchor"
49+
position="bottom"
50+
position-anchor="footer"
51+
message="Hello World"
52+
duration="2000"
53+
></ion-toast>
54+
<ion-toast
55+
id="middleToast"
56+
trigger="middleAnchor"
57+
position="middle"
58+
position-anchor="header"
59+
message="Hello World"
60+
duration="2000"
61+
></ion-toast>
62+
<ion-toast
63+
id="headerElToast"
64+
trigger="headerElAnchor"
65+
position="top"
66+
message="Hello World"
67+
duration="2000"
68+
></ion-toast>
69+
<ion-toast
70+
id="hiddenElToast"
71+
trigger="hiddenElAnchor"
72+
position="bottom"
73+
position-anchor="hiddenEl"
74+
message="Hello World"
75+
duration="2000"
76+
></ion-toast>
77+
78+
<div id="hiddenEl" style="display: none">Shh I'm hiding</div>
79+
</ion-content>
80+
81+
<ion-footer id="footer">
82+
<ion-toolbar>
83+
<ion-title>Footer</ion-title>
84+
</ion-toolbar>
85+
</ion-footer>
86+
</ion-app>
87+
88+
<script>
89+
const headerElToast = document.querySelector('#headerElToast');
90+
const header = document.querySelector('ion-header');
91+
headerElToast.positionAnchor = header;
92+
</script>
93+
</body>
94+
</html>
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { expect } from '@playwright/test';
2+
import { configs, test } from '@utils/test/playwright';
3+
4+
configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
5+
test.describe(title('toast: positionAnchor'), () => {
6+
test.beforeEach(async ({ page }) => {
7+
await page.goto('/src/components/toast/test/position-anchor', config);
8+
9+
/**
10+
* We need to screenshot the whole page to ensure the toasts are positioned
11+
* correctly, but we don't need much extra white space between the header
12+
* and footer.
13+
*/
14+
await page.setViewportSize({
15+
width: 425,
16+
height: 425,
17+
});
18+
});
19+
20+
test('should place top-position toast underneath anchor', async ({ page }) => {
21+
const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent');
22+
23+
await page.click('#headerAnchor');
24+
await ionToastDidPresent.next();
25+
26+
await expect(page).toHaveScreenshot(screenshot(`toast-header-anchor`));
27+
});
28+
29+
test('should place bottom-position toast above anchor', async ({ page }) => {
30+
const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent');
31+
32+
await page.click('#footerAnchor');
33+
await ionToastDidPresent.next();
34+
35+
await expect(page).toHaveScreenshot(screenshot(`toast-footer-anchor`));
36+
});
37+
38+
test('should ignore anchor for middle-position toast', async ({ page }) => {
39+
const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent');
40+
41+
await page.click('#middleAnchor');
42+
await ionToastDidPresent.next();
43+
44+
await expect(page).toHaveScreenshot(screenshot(`toast-middle-anchor`));
45+
});
46+
47+
test('should correctly anchor toast when using an element reference', async ({ page }) => {
48+
const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent');
49+
50+
await page.click('#headerElAnchor');
51+
await ionToastDidPresent.next();
52+
53+
await expect(page).toHaveScreenshot(screenshot(`toast-header-el-anchor`));
54+
});
55+
});
56+
});

0 commit comments

Comments
 (0)