feat(toast): add swipe to dismiss functionality (#28442)

Issue number: resolves #21769

---------

<!-- 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. -->

Toast does not support swipe gestures to dismiss.

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

- Added a `swipeGesture` property that allows users to swipe toasts
closed.
Note: This is a combination of previous PRs
https://github.com/ionic-team/ionic-framework/pull/28380 and
https://github.com/ionic-team/ionic-framework/pull/28402

⚠️ There is a visual glitch on iOS where dragging and having the toast
animate back to its opened position causes a flicker. This is an iOS 17
regression and is being tracked in
https://github.com/ionic-team/ionic-framework/issues/28467. This bug has
been reported to and confirmed by Apple.

## 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. -->

⚠️ Give co-author credit to author in
https://github.com/ionic-team/ionic-framework/pull/23124

---------

Co-authored-by: evgeniy-skakun <evgeniy-skakun@users.noreply.github.com>
This commit is contained in:
Liam DeBeasi
2023-11-13 12:14:29 -05:00
committed by GitHub
parent 0ae327f0e0
commit 30c21aab3e
17 changed files with 748 additions and 36 deletions

View File

@ -1447,6 +1447,7 @@ ion-toast,prop,message,IonicSafeString | string | undefined,undefined,false,fals
ion-toast,prop,mode,"ios" | "md",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,position,"bottom" | "middle" | "top",'bottom',false,false
ion-toast,prop,positionAnchor,HTMLElement | string | undefined,undefined,false,false ion-toast,prop,positionAnchor,HTMLElement | string | undefined,undefined,false,false
ion-toast,prop,swipeGesture,"vertical" | undefined,undefined,false,false
ion-toast,prop,translucent,boolean,false,false,false ion-toast,prop,translucent,boolean,false,false,false
ion-toast,prop,trigger,string | undefined,undefined,false,false ion-toast,prop,trigger,string | undefined,undefined,false,false
ion-toast,method,dismiss,dismiss(data?: any, role?: string) => Promise<boolean> ion-toast,method,dismiss,dismiss(data?: any, role?: string) => Promise<boolean>

View File

@ -39,7 +39,7 @@ import { SelectChangeEventDetail, SelectCompareFn, SelectInterface } from "./com
import { SelectPopoverOption } from "./components/select-popover/select-popover-interface"; import { SelectPopoverOption } from "./components/select-popover/select-popover-interface";
import { TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface"; import { TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface";
import { TextareaChangeEventDetail, TextareaInputEventDetail } from "./components/textarea/textarea-interface"; import { TextareaChangeEventDetail, TextareaInputEventDetail } from "./components/textarea/textarea-interface";
import { ToastButton, ToastDismissOptions, ToastLayout, ToastPosition, ToastPresentOptions } from "./components/toast/toast-interface"; import { ToastButton, ToastDismissOptions, ToastLayout, ToastPosition, ToastPresentOptions, ToastSwipeGestureDirection } from "./components/toast/toast-interface";
import { ToggleChangeEventDetail } from "./components/toggle/toggle-interface"; import { ToggleChangeEventDetail } from "./components/toggle/toggle-interface";
export { AccordionGroupChangeEventDetail } from "./components/accordion-group/accordion-group-interface"; export { AccordionGroupChangeEventDetail } from "./components/accordion-group/accordion-group-interface";
export { AnimationBuilder, AutocompleteTypes, Color, ComponentProps, ComponentRef, FrameworkDelegate, StyleEventDetail, TextFieldTypes } from "./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 { SelectPopoverOption } from "./components/select-popover/select-popover-interface";
export { TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface"; export { TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface";
export { TextareaChangeEventDetail, TextareaInputEventDetail } from "./components/textarea/textarea-interface"; export { TextareaChangeEventDetail, TextareaInputEventDetail } from "./components/textarea/textarea-interface";
export { ToastButton, ToastDismissOptions, ToastLayout, ToastPosition, ToastPresentOptions } from "./components/toast/toast-interface"; export { ToastButton, ToastDismissOptions, ToastLayout, ToastPosition, ToastPresentOptions, ToastSwipeGestureDirection } from "./components/toast/toast-interface";
export { ToggleChangeEventDetail } from "./components/toggle/toggle-interface"; export { ToggleChangeEventDetail } from "./components/toggle/toggle-interface";
export namespace Components { export namespace Components {
interface IonAccordion { interface IonAccordion {
@ -3172,6 +3172,10 @@ export namespace Components {
* Present the toast overlay after it has been created. * Present the toast overlay after it has been created.
*/ */
"present": () => Promise<void>; "present": () => Promise<void>;
/**
* If set to 'vertical', the Toast can be dismissed with a swipe gesture. The swipe direction is determined by the value of the `position` property: `top`: The Toast can be swiped up to dismiss. `bottom`: The Toast can be swiped down to dismiss. `middle`: The Toast can be swiped up or down to dismiss.
*/
"swipeGesture"?: ToastSwipeGestureDirection;
/** /**
* 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). * 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).
*/ */
@ -7970,6 +7974,10 @@ declare namespace LocalJSX {
* 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. * 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; "positionAnchor"?: HTMLElement | string;
/**
* If set to 'vertical', the Toast can be dismissed with a swipe gesture. The swipe direction is determined by the value of the `position` property: `top`: The Toast can be swiped up to dismiss. `bottom`: The Toast can be swiped down to dismiss. `middle`: The Toast can be swiped up or down to dismiss.
*/
"swipeGesture"?: ToastSwipeGestureDirection;
/** /**
* 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). * 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).
*/ */

View File

@ -2,6 +2,7 @@ import { getTimeGivenProgression } from '@utils/animation/cubic-bezier';
import { isIonContent, findClosestIonContent, disableContentScrollY, resetContentScrollY } from '@utils/content'; import { isIonContent, findClosestIonContent, disableContentScrollY, resetContentScrollY } from '@utils/content';
import { createGesture } from '@utils/gesture'; import { createGesture } from '@utils/gesture';
import { clamp, getElementRoot } from '@utils/helpers'; import { clamp, getElementRoot } from '@utils/helpers';
import { OVERLAY_GESTURE_PRIORITY } from '@utils/overlays';
import type { Animation } from '../../../interface'; import type { Animation } from '../../../interface';
import type { GestureDetail } from '../../../utils/gesture'; import type { GestureDetail } from '../../../utils/gesture';
@ -292,7 +293,7 @@ export const createSwipeToCloseGesture = (
const gesture = createGesture({ const gesture = createGesture({
el, el,
gestureName: 'modalSwipeToClose', gestureName: 'modalSwipeToClose',
gesturePriority: 39, gesturePriority: OVERLAY_GESTURE_PRIORITY,
direction: 'y', direction: 'y',
threshold: 10, threshold: 10,
canStart, canStart,

View File

@ -4,6 +4,8 @@ import { getElementRoot } from '@utils/helpers';
import type { Animation } from '../../../interface'; import type { Animation } from '../../../interface';
import type { ToastPresentOptions } from '../toast-interface'; import type { ToastPresentOptions } from '../toast-interface';
import { getOffsetForMiddlePosition } from './utils';
/** /**
* iOS Toast Enter Animation * iOS Toast Enter Animation
*/ */
@ -22,7 +24,7 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts: ToastPresentOptions
wrapperAnimation.fromTo('transform', 'translateY(-100%)', `translateY(${top})`); wrapperAnimation.fromTo('transform', 'translateY(-100%)', `translateY(${top})`);
break; break;
case 'middle': case 'middle':
const topPosition = Math.floor(baseEl.clientHeight / 2 - wrapperEl.clientHeight / 2); const topPosition = getOffsetForMiddlePosition(baseEl.clientHeight, wrapperEl.clientHeight);
wrapperEl.style.top = `${topPosition}px`; wrapperEl.style.top = `${topPosition}px`;
wrapperAnimation.fromTo('opacity', 0.01, 1); wrapperAnimation.fromTo('opacity', 0.01, 1);
break; break;

View File

@ -4,6 +4,8 @@ import { getElementRoot } from '@utils/helpers';
import type { Animation } from '../../../interface'; import type { Animation } from '../../../interface';
import type { ToastPresentOptions } from '../toast-interface'; import type { ToastPresentOptions } from '../toast-interface';
import { getOffsetForMiddlePosition } from './utils';
/** /**
* MD Toast Enter Animation * MD Toast Enter Animation
*/ */
@ -23,7 +25,7 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts: ToastPresentOptions)
wrapperAnimation.fromTo('opacity', 0.01, 1); wrapperAnimation.fromTo('opacity', 0.01, 1);
break; break;
case 'middle': case 'middle':
const topPosition = Math.floor(baseEl.clientHeight / 2 - wrapperEl.clientHeight / 2); const topPosition = getOffsetForMiddlePosition(baseEl.clientHeight, wrapperEl.clientHeight);
wrapperEl.style.top = `${topPosition}px`; wrapperEl.style.top = `${topPosition}px`;
wrapperAnimation.fromTo('opacity', 0.01, 1); wrapperAnimation.fromTo('opacity', 0.01, 1);
break; break;

View File

@ -88,3 +88,15 @@ function warnIfAnchorIsHidden(positionAnchor: HTMLElement, toast: HTMLElement) {
); );
} }
} }
/**
* Returns the top offset required to place
* the toast in the middle of the screen.
* Only needed when position="toast".
* @param toastHeight - The height of the ion-toast element
* @param wrapperHeight - The height of the .toast-wrapper element
* inside the toast's shadow root.
*/
export const getOffsetForMiddlePosition = (toastHeight: number, wrapperHeight: number) => {
return Math.floor(toastHeight / 2 - wrapperHeight / 2);
};

View File

@ -0,0 +1,298 @@
import { Build } from '@stencil/core';
import { createAnimation } from '@utils/animation/animation';
import { createGesture } from '@utils/gesture';
import type { GestureDetail } from '@utils/gesture';
import { getElementRoot } from '@utils/helpers';
import { OVERLAY_GESTURE_PRIORITY } from '@utils/overlays';
import { getOffsetForMiddlePosition } from '../animations/utils';
import type { ToastAnimationPosition } from '../toast-interface';
/**
* Create a gesture that allows the Toast
* to be swiped to dismiss.
* @param el - The Toast element
* @param toastPosition - The last computed position of the Toast. This is computed in the "present" method.
* @param onDismiss - A callback to fire when the Toast was swiped to dismiss.
*/
export const createSwipeToDismissGesture = (
el: HTMLIonToastElement,
toastPosition: ToastAnimationPosition,
onDismiss: () => void
) => {
/**
* Users should swipe on the visible toast wrapper
* rather than on ion-toast which covers the entire screen.
* When testing the class instance the inner wrapper will not
* be defined. As a result, we use a placeholder element in those environments.
*/
const wrapperEl = Build.isTesting
? document.createElement('div')
: getElementRoot(el).querySelector('.toast-wrapper')!;
const hostElHeight = el.clientHeight;
const wrapperElBox = wrapperEl.getBoundingClientRect();
/**
* The maximum amount that
* the toast can be swiped. This should
* account for the wrapper element's height
* too so the toast can be swiped offscreen
* completely.
*/
let MAX_SWIPE_DISTANCE = 0;
/**
* The step value at which a toast
* is eligible for dismissing via gesture.
*/
const DISMISS_THRESHOLD = 0.5;
/**
* The middle position Toast starts 50% of the way
* through the animation, so we need to use this
* as the starting point for our step values.
*/
const STEP_OFFSET = el.position === 'middle' ? 0.5 : 0;
/**
* When the Toast is at the top users will be
* swiping up. As a result, the delta values will be
* negative numbers which will result in negative steps
* and thresholds. As a result, we need to make those numbers
* positive.
*/
const INVERSION_FACTOR = el.position === 'top' ? -1 : 1;
/**
* The top offset that places the
* toast in the middle of the screen.
* Only needed when position="middle".
*/
const topPosition = getOffsetForMiddlePosition(hostElHeight, wrapperElBox.height);
const SWIPE_UP_DOWN_KEYFRAMES = [
{ offset: 0, transform: `translateY(-${topPosition + wrapperElBox.height}px)` },
{ offset: 0.5, transform: `translateY(0px)` },
{ offset: 1, transform: `translateY(${topPosition + wrapperElBox.height}px)` },
];
const swipeAnimation = createAnimation('toast-swipe-to-dismiss-animation')
.addElement(wrapperEl)
/**
* The specific value here does not actually
* matter. We just need this to be a positive
* value so the animation does not jump
* to the end when the user beings to drag.
*/
.duration(100);
switch (el.position) {
case 'middle':
MAX_SWIPE_DISTANCE = hostElHeight + wrapperElBox.height;
swipeAnimation.keyframes(SWIPE_UP_DOWN_KEYFRAMES);
/**
* Toast can be swiped up or down but
* should start in the middle of the screen.
*/
swipeAnimation.progressStart(true, 0.5);
break;
case 'top':
/**
* The bottom edge of the wrapper
* includes the distance between the top
* of the screen and the top of the wrapper
* as well as the wrapper height so the wrapper
* can be dragged fully offscreen.
*/
MAX_SWIPE_DISTANCE = wrapperElBox.bottom;
swipeAnimation.keyframes([
{ offset: 0, transform: `translateY(${toastPosition.top})` },
{ offset: 1, transform: 'translateY(-100%)' },
]);
swipeAnimation.progressStart(true, 0);
break;
case 'bottom':
default:
/**
* This computes the distance between the
* top of the wrapper and the bottom of the
* screen including the height of the wrapper
* element so it can be dragged fully offscreen.
*/
MAX_SWIPE_DISTANCE = hostElHeight - wrapperElBox.top;
swipeAnimation.keyframes([
{ offset: 0, transform: `translateY(${toastPosition.bottom})` },
{ offset: 1, transform: 'translateY(100%)' },
]);
swipeAnimation.progressStart(true, 0);
break;
}
const computeStep = (delta: number) => {
return (delta * INVERSION_FACTOR) / MAX_SWIPE_DISTANCE;
};
const onMove = (detail: GestureDetail) => {
const step = STEP_OFFSET + computeStep(detail.deltaY);
swipeAnimation.progressStep(step);
};
const onEnd = (detail: GestureDetail) => {
const velocity = detail.velocityY;
const threshold = ((detail.deltaY + velocity * 1000) / MAX_SWIPE_DISTANCE) * INVERSION_FACTOR;
/**
* Disable the gesture for the remainder of the animation.
* It will be re-enabled if the toast animates back to
* its initial presented position.
*/
gesture.enable(false);
let shouldDismiss = true;
let playTo: 0 | 1 = 1;
let step: number = 0;
let remainingDistance = 0;
if (el.position === 'middle') {
/**
* A middle positioned Toast appears
* in the middle of the screen (at animation offset 0.5).
* As a result, the threshold will be calculated relative
* to this starting position. In other words at animation offset 0.5
* the threshold will be 0. We want the middle Toast to be eligible
* for dismiss when the user has swiped either half way up or down the
* screen. As a result, we divide DISMISS_THRESHOLD in half. We also
* consider when the threshold is a negative in the event the
* user drags up (since the deltaY will also be negative).
*/
shouldDismiss = threshold >= DISMISS_THRESHOLD / 2 || threshold <= -DISMISS_THRESHOLD / 2;
/**
* Since we are replacing the keyframes
* below the animation always starts from
* the beginning of the new keyframes.
* Similarly, we are always playing to
* the end of the new keyframes.
*/
playTo = 1;
step = 0;
/**
* The Toast should animate from wherever its
* current position is to the desired end state.
*
* To begin, we get the current position of the
* Toast for its starting state.
*/
const wrapperElBox = wrapperEl.getBoundingClientRect();
const startOffset = wrapperElBox.top - topPosition;
const startPosition = `${startOffset}px`;
/**
* If the deltaY is negative then the user is swiping
* up, so the Toast should animate to the top of the screen.
* If the deltaY is positive then the user is swiping
* down, so the Toast should animate to the bottom of the screen.
* We also account for when the deltaY is 0, but realistically
* that should never happen because it means the user did not drag
* the toast.
*/
const offsetFactor = detail.deltaY <= 0 ? -1 : 1;
const endOffset = (topPosition + wrapperElBox.height) * offsetFactor;
/**
* If the Toast should dismiss
* then we need to figure out which edge of
* the screen it should animate towards.
* By default, the Toast will come
* back to its default state in the
* middle of the screen.
*/
const endPosition = shouldDismiss ? `${endOffset}px` : '0px';
const KEYFRAMES = [
{ offset: 0, transform: `translateY(${startPosition})` },
{ offset: 1, transform: `translateY(${endPosition})` },
];
swipeAnimation.keyframes(KEYFRAMES);
/**
* Compute the remaining amount of pixels the
* toast needs to move to be fully dismissed.
*/
remainingDistance = endOffset - startOffset;
} else {
shouldDismiss = threshold >= DISMISS_THRESHOLD;
playTo = shouldDismiss ? 1 : 0;
step = computeStep(detail.deltaY);
/**
* Compute the remaining amount of pixels the
* toast needs to move to be fully dismissed.
*/
const remainingStepAmount = shouldDismiss ? 1 - step : step;
remainingDistance = remainingStepAmount * MAX_SWIPE_DISTANCE;
}
/**
* The animation speed should depend on how quickly
* the user flicks the toast across the screen. However,
* it should be no slower than 200ms.
* We use Math.abs on the remainingDistance because that value
* can be negative when swiping up on a middle position toast.
*/
const duration = Math.min(Math.abs(remainingDistance) / Math.abs(velocity), 200);
swipeAnimation
.onFinish(
() => {
if (shouldDismiss) {
onDismiss();
swipeAnimation.destroy();
} else {
if (el.position === 'middle') {
/**
* If the toast snapped back to
* the middle of the screen we need
* to reset the keyframes
* so the toast can be swiped
* up or down again.
*/
swipeAnimation.keyframes(SWIPE_UP_DOWN_KEYFRAMES).progressStart(true, 0.5);
} else {
swipeAnimation.progressStart(true, 0);
}
/**
* If the toast did not dismiss then
* the user should be able to swipe again.
*/
gesture.enable(true);
}
/**
* This must be a one time callback
* otherwise a new callback will
* be added every time onEnd runs.
*/
},
{ oneTimeCallback: true }
)
.progressEnd(playTo, step, duration);
};
const gesture = createGesture({
el: wrapperEl,
gestureName: 'toast-swipe-to-dismiss',
gesturePriority: OVERLAY_GESTURE_PRIORITY,
/**
* Toast only supports vertical swipes.
* This needs to be updated if we later
* support horizontal swipes.
*/
direction: 'y',
onMove,
onEnd,
});
return gesture;
};

View File

@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Toast - Swipe Gesture</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>
</head>
<body>
<ion-app>
<ion-content class="ion-padding">
<ion-button id="top">Open Top Toast</ion-button>
<ion-button id="middle">Open Middle Toast</ion-button>
<ion-button id="bottom">Open Bottom Toast</ion-button>
<ion-button id="top-anchor">Open Top Toast with Anchor</ion-button>
<ion-button id="bottom-anchor">Open Bottom Toast with Anchor</ion-button>
<ion-fab id="bottom-fab" horizontal="end" vertical="bottom">
<ion-fab-button>
<ion-icon name="add"></ion-icon>
</ion-fab-button>
</ion-fab>
<ion-fab id="top-fab" horizontal="end" vertical="top">
<ion-fab-button>
<ion-icon name="add"></ion-icon>
</ion-fab-button>
</ion-fab>
<ion-toast
id="top-toast"
position="top"
trigger="top"
message="Hello World"
swipe-gesture="vertical"
></ion-toast>
<ion-toast
id="middle-toast"
position="middle"
trigger="middle"
message="Hello World"
swipe-gesture="vertical"
></ion-toast>
<ion-toast
id="bottom-toast"
position="bottom"
trigger="bottom"
message="Hello World"
swipe-gesture="vertical"
></ion-toast>
<ion-toast
id="bottom-anchor-toast"
position="bottom"
trigger="bottom-anchor"
message="Hello World"
swipe-gesture="vertical"
position-anchor="bottom-fab"
></ion-toast>
<ion-toast
id="top-anchor-toast"
position="top"
trigger="top-anchor"
message="Hello World"
swipe-gesture="vertical"
position-anchor="top-fab"
></ion-toast>
</ion-content>
</ion-app>
</body>
</html>

View File

@ -0,0 +1,106 @@
import { configs, test, dragElementBy } from '@utils/test/playwright';
/**
* This behavior does not vary across modes and directions.
*/
configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, config }) => {
test.describe(title('toast: swipe gesture with top position'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('src/components/toast/test/swipe-gesture', config);
});
test('should swipe up to dismiss', async ({ page }) => {
const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent');
const ionToastDidDismiss = await page.spyOnEvent('ionToastDidDismiss');
const presentButton = page.locator('#top');
await presentButton.click();
await ionToastDidPresent.next();
const toastWrapper = page.locator('ion-toast#top-toast .toast-wrapper');
await dragElementBy(toastWrapper, page, 0, -30);
await ionToastDidDismiss.next();
});
test('should swipe up to dismiss with anchor', async ({ page }) => {
const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent');
const ionToastDidDismiss = await page.spyOnEvent('ionToastDidDismiss');
const presentButton = page.locator('#top-anchor');
await presentButton.click();
await ionToastDidPresent.next();
const toastWrapper = page.locator('ion-toast#top-anchor-toast .toast-wrapper');
await dragElementBy(toastWrapper, page, 0, -30);
await ionToastDidDismiss.next();
});
});
test.describe(title('toast: swipe gesture with bottom position'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('src/components/toast/test/swipe-gesture', config);
});
test('should swipe down to dismiss', async ({ page }) => {
const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent');
const ionToastDidDismiss = await page.spyOnEvent('ionToastDidDismiss');
const presentButton = page.locator('#bottom');
await presentButton.click();
await ionToastDidPresent.next();
const toastWrapper = page.locator('ion-toast#bottom-toast .toast-wrapper');
await dragElementBy(toastWrapper, page, 0, 30);
await ionToastDidDismiss.next();
});
test('should swipe down to dismiss with anchor', async ({ page }) => {
const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent');
const ionToastDidDismiss = await page.spyOnEvent('ionToastDidDismiss');
const presentButton = page.locator('#bottom-anchor');
await presentButton.click();
await ionToastDidPresent.next();
const toastWrapper = page.locator('ion-toast#bottom-anchor-toast .toast-wrapper');
await dragElementBy(toastWrapper, page, 0, 30);
await ionToastDidDismiss.next();
});
});
test.describe(title('toast: swipe gesture with middle position'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('src/components/toast/test/swipe-gesture', config);
});
test('should swipe down to dismiss', async ({ page }) => {
const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent');
const ionToastDidDismiss = await page.spyOnEvent('ionToastDidDismiss');
const presentButton = page.locator('#middle');
await presentButton.click();
await ionToastDidPresent.next();
const toastWrapper = page.locator('ion-toast#middle-toast .toast-wrapper');
await dragElementBy(toastWrapper, page, 0, 100);
await ionToastDidDismiss.next();
});
test('should swipe up to dismiss', async ({ page }) => {
const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent');
const ionToastDidDismiss = await page.spyOnEvent('ionToastDidDismiss');
const presentButton = page.locator('#middle');
await presentButton.click();
await ionToastDidPresent.next();
const toastWrapper = page.locator('ion-toast#middle-toast .toast-wrapper');
await dragElementBy(toastWrapper, page, 0, -100);
await ionToastDidDismiss.next();
});
});
});

View File

@ -0,0 +1,29 @@
import { newSpecPage } from '@stencil/core/testing';
import { Toast } from '../toast';
import { config } from '../../../global/config';
describe('toast: duration config', () => {
it('should have duration set to 0', async () => {
const page = await newSpecPage({
components: [Toast],
html: `<ion-toast></ion-toast>`,
});
const toast = page.body.querySelector('ion-toast');
expect(toast.duration).toBe(0);
});
it('should have duration set to 5000', async () => {
config.reset({ toastDuration: 5000 });
const page = await newSpecPage({
components: [Toast],
html: `<ion-toast></ion-toast>`,
});
const toast = page.body.querySelector('ion-toast');
expect(toast.duration).toBe(5000);
});
});

View File

@ -3,6 +3,7 @@ import { newSpecPage } from '@stencil/core/testing';
import { Toast } from '../toast'; import { Toast } from '../toast';
import { config } from '../../../global/config'; import { config } from '../../../global/config';
import { toastController } from '../../../utils/overlays'; import { toastController } from '../../../utils/overlays';
import { createAnimation } from '@utils/animation/animation';
describe('toast: custom html', () => { describe('toast: custom html', () => {
it('should not allow for custom html by default', async () => { it('should not allow for custom html by default', async () => {
@ -91,32 +92,6 @@ describe('toast: a11y smoke test', () => {
}); });
}); });
describe('toast: duration config', () => {
it('should have duration set to 0', async () => {
const page = await newSpecPage({
components: [Toast],
html: `<ion-toast></ion-toast>`,
});
const toast = page.body.querySelector('ion-toast');
expect(toast.duration).toBe(0);
});
it('should have duration set to 5000', async () => {
config.reset({ toastDuration: 5000 });
const page = await newSpecPage({
components: [Toast],
html: `<ion-toast></ion-toast>`,
});
const toast = page.body.querySelector('ion-toast');
expect(toast.duration).toBe(5000);
});
});
describe('toast: htmlAttributes', () => { describe('toast: htmlAttributes', () => {
it('should correctly inherit attributes on host', async () => { it('should correctly inherit attributes on host', async () => {
const page = await newSpecPage({ const page = await newSpecPage({
@ -144,3 +119,99 @@ describe('toast: button cancel', () => {
expect(buttonCancel.getAttribute('part')).toBe('button cancel'); expect(buttonCancel.getAttribute('part')).toBe('button cancel');
}); });
}); });
describe('toast: swipe gesture', () => {
describe('prefersSwipeGesture()', () => {
let toast: Toast;
beforeEach(() => {
toast = new Toast();
});
it('should return true if set to a valid swipe value', () => {
toast.swipeGesture = 'vertical';
expect(toast.prefersSwipeGesture()).toBe(true);
});
it('should return false if set to undefined', () => {
toast.swipeGesture = undefined;
expect(toast.prefersSwipeGesture()).toBe(false);
});
it('should return false if set to null', () => {
toast.swipeGesture = null;
expect(toast.prefersSwipeGesture()).toBe(false);
});
it('should return false if set to invalid string', () => {
toast.swipeGesture = 'limit'; // `'limit'` doesn't exist
expect(toast.prefersSwipeGesture()).toBe(false);
});
});
describe('swipeGesture property', () => {
let toast: Toast;
beforeEach(() => {
toast = new Toast();
// Stub the enter animation so we aren't querying elements in the DOM that may not exist
toast.enterAnimation = () => createAnimation();
});
it('should not create a swipe gesture on present if swipeGesture is undefined', async () => {
expect(toast.gesture).toBe(undefined);
await toast.present();
expect(toast.gesture).toBe(undefined);
});
it('should create a swipe gesture on present', async () => {
toast.swipeGesture = 'vertical';
expect(toast.gesture).toBe(undefined);
await toast.present();
expect(toast.gesture).not.toBe(undefined);
});
it('should destroy a swipe gesture on dismiss', async () => {
toast.swipeGesture = 'vertical';
await toast.present();
expect(toast.gesture).not.toBe(undefined);
await toast.dismiss();
expect(toast.gesture).toBe(undefined);
});
it('should create a swipe gesture if swipeGesture is set after present', async () => {
await toast.present();
expect(toast.gesture).toBe(undefined);
/**
* Manually invoke the watch
* callback synchronously.
*/
toast.swipeGesture = 'vertical';
toast.swipeGestureChanged();
expect(toast.gesture).not.toBe(undefined);
});
it('should destroy a swipe gesture if swipeGesture is cleared after present', async () => {
toast.swipeGesture = 'vertical';
await toast.present();
expect(toast.gesture).not.toBe(undefined);
/**
* Manually invoke the watch
* callback synchronously.
*/
toast.swipeGesture = undefined;
toast.swipeGestureChanged();
expect(toast.gesture).toBe(undefined);
});
it('should not create a swipe gesture if the toast is not presented', async () => {
expect(toast.gesture).toBe(undefined);
toast.swipeGesture = 'vertical';
toast.swipeGestureChanged();
expect(toast.gesture).toBe(undefined);
});
});
});

View File

@ -55,3 +55,4 @@ export interface ToastAnimationPosition {
export type ToastPresentOptions = ToastPositionAlias & ToastAnimationPosition; export type ToastPresentOptions = ToastPositionAlias & ToastAnimationPosition;
export type ToastDismissOptions = ToastPositionAlias & ToastAnimationPosition; export type ToastDismissOptions = ToastPositionAlias & ToastAnimationPosition;
export type ToastSwipeGestureDirection = 'vertical';

View File

@ -1,10 +1,12 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core'; import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { State, Watch, Component, Element, Event, h, Host, Method, Prop } from '@stencil/core'; import { State, Watch, Component, Element, Event, h, Host, Method, Prop } from '@stencil/core';
import { ENABLE_HTML_CONTENT_DEFAULT } from '@utils/config'; import { ENABLE_HTML_CONTENT_DEFAULT } from '@utils/config';
import type { Gesture } from '@utils/gesture';
import { raf } from '@utils/helpers'; import { raf } from '@utils/helpers';
import { createLockController } from '@utils/lock-controller'; import { createLockController } from '@utils/lock-controller';
import { printIonWarning } from '@utils/logging'; import { printIonWarning } from '@utils/logging';
import { import {
GESTURE,
createDelegateController, createDelegateController,
createTriggerController, createTriggerController,
dismiss, dismiss,
@ -29,6 +31,7 @@ import { iosLeaveAnimation } from './animations/ios.leave';
import { mdEnterAnimation } from './animations/md.enter'; import { mdEnterAnimation } from './animations/md.enter';
import { mdLeaveAnimation } from './animations/md.leave'; import { mdLeaveAnimation } from './animations/md.leave';
import { getAnimationPosition } from './animations/utils'; import { getAnimationPosition } from './animations/utils';
import { createSwipeToDismissGesture } from './gestures/swipe-to-dismiss';
import type { import type {
ToastButton, ToastButton,
ToastPosition, ToastPosition,
@ -36,6 +39,7 @@ import type {
ToastPresentOptions, ToastPresentOptions,
ToastDismissOptions, ToastDismissOptions,
ToastAnimationPosition, ToastAnimationPosition,
ToastSwipeGestureDirection,
} from './toast-interface'; } from './toast-interface';
// TODO(FW-2832): types // TODO(FW-2832): types
@ -64,6 +68,7 @@ export class Toast implements ComponentInterface, OverlayInterface {
private readonly triggerController = createTriggerController(); private readonly triggerController = createTriggerController();
private customHTMLEnabled = config.get('innerHTMLTemplatesEnabled', ENABLE_HTML_CONTENT_DEFAULT); private customHTMLEnabled = config.get('innerHTMLTemplatesEnabled', ENABLE_HTML_CONTENT_DEFAULT);
private durationTimeout?: ReturnType<typeof setTimeout>; private durationTimeout?: ReturnType<typeof setTimeout>;
private gesture?: Gesture;
/** /**
* Holds the position of the toast calculated in the present * Holds the position of the toast calculated in the present
@ -193,6 +198,45 @@ export class Toast implements ComponentInterface, OverlayInterface {
*/ */
@Prop() htmlAttributes?: { [key: string]: any }; @Prop() htmlAttributes?: { [key: string]: any };
/**
* If set to 'vertical', the Toast can be dismissed with
* a swipe gesture. The swipe direction is determined by
* the value of the `position` property:
* `top`: The Toast can be swiped up to dismiss.
* `bottom`: The Toast can be swiped down to dismiss.
* `middle`: The Toast can be swiped up or down to dismiss.
*/
@Prop() swipeGesture?: ToastSwipeGestureDirection;
@Watch('swipeGesture')
swipeGestureChanged() {
/**
* If the Toast is presented, then we need to destroy
* any actives gestures before a new gesture is potentially
* created below.
*
* If the Toast is dismissed, then no gesture should be available
* since the Toast is not visible. This case should never
* happen since the "dismiss" method handles destroying
* any active swipe gestures, but we keep this code
* around to handle the first case.
*/
this.destroySwipeGesture();
/**
* A new swipe gesture should only be created
* if the Toast is presented. If the Toast is not
* yet presented then the "present" method will
* handle calling the swipe gesture setup function.
*/
if (this.presented && this.prefersSwipeGesture()) {
/**
* If the Toast is presented then
* lastPresentedPosition is defined.
*/
this.createSwipeGesture(this.lastPresentedPosition!);
}
}
/** /**
* If `true`, the toast will open. If `false`, the toast will close. * If `true`, the toast will open. If `false`, the toast will close.
* Use this if you need finer grained control over presentation, otherwise * Use this if you need finer grained control over presentation, otherwise
@ -326,6 +370,15 @@ export class Toast implements ComponentInterface, OverlayInterface {
this.durationTimeout = setTimeout(() => this.dismiss(undefined, 'timeout'), this.duration); this.durationTimeout = setTimeout(() => this.dismiss(undefined, 'timeout'), this.duration);
} }
/**
* If the Toast has a swipe gesture then we can
* create the gesture so users can swipe the
* presented Toast.
*/
if (this.prefersSwipeGesture()) {
this.createSwipeGesture(animationPosition);
}
unlock(); unlock();
} }
@ -373,6 +426,13 @@ export class Toast implements ComponentInterface, OverlayInterface {
} }
this.lastPresentedPosition = undefined; this.lastPresentedPosition = undefined;
/**
* If the Toast has a swipe gesture then we can
* safely destroy it now that it is dismissed.
*/
this.destroySwipeGesture();
unlock(); unlock();
return dismissed; return dismissed;
@ -486,6 +546,48 @@ export class Toast implements ComponentInterface, OverlayInterface {
} }
}; };
/**
* Create a new swipe gesture so Toast
* can be swiped to dismiss.
*/
private createSwipeGesture = (toastPosition: ToastAnimationPosition) => {
const gesture = (this.gesture = createSwipeToDismissGesture(this.el, toastPosition, () => {
/**
* If the gesture completed then
* we should dismiss the toast.
*/
this.dismiss(undefined, GESTURE);
}));
gesture.enable(true);
};
/**
* Destroy an existing swipe gesture
* so Toast can no longer be swiped to dismiss.
*/
private destroySwipeGesture = () => {
const { gesture } = this;
if (gesture === undefined) {
return;
}
gesture.destroy();
this.gesture = undefined;
};
/**
* Returns `true` if swipeGesture
* is configured to a value that enables the swipe behavior.
* Returns `false` otherwise.
*/
private prefersSwipeGesture = () => {
const { swipeGesture } = this;
return swipeGesture === 'vertical';
};
renderButtons(buttons: ToastButton[], side: 'start' | 'end') { renderButtons(buttons: ToastButton[], side: 'start' | 'end') {
if (buttons.length === 0) { if (buttons.length === 0) {
return; return;

View File

@ -685,6 +685,7 @@ export const safeCall = (handler: any, arg?: any) => {
export const BACKDROP = 'backdrop'; export const BACKDROP = 'backdrop';
export const GESTURE = 'gesture'; export const GESTURE = 'gesture';
export const OVERLAY_GESTURE_PRIORITY = 39;
/** /**
* Creates a delegate controller. * Creates a delegate controller.

View File

@ -2201,7 +2201,7 @@ export declare interface IonTitle extends Components.IonTitle {}
@ProxyCmp({ @ProxyCmp({
inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'translucent', 'trigger'], inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'swipeGesture', 'translucent', 'trigger'],
methods: ['present', 'dismiss', 'onDidDismiss', 'onWillDismiss'] methods: ['present', 'dismiss', 'onDidDismiss', 'onWillDismiss']
}) })
@Component({ @Component({
@ -2209,7 +2209,7 @@ export declare interface IonTitle extends Components.IonTitle {}
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>', template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property // 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', 'positionAnchor', 'translucent', 'trigger'], inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'swipeGesture', 'translucent', 'trigger'],
}) })
export class IonToast { export class IonToast {
protected el: HTMLElement; protected el: HTMLElement;

View File

@ -1915,7 +1915,7 @@ export declare interface IonTitle extends Components.IonTitle {}
@ProxyCmp({ @ProxyCmp({
defineCustomElementFn: defineIonToast, defineCustomElementFn: defineIonToast,
inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'translucent', 'trigger'], inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'swipeGesture', 'translucent', 'trigger'],
methods: ['present', 'dismiss', 'onDidDismiss', 'onWillDismiss'] methods: ['present', 'dismiss', 'onDidDismiss', 'onWillDismiss']
}) })
@Component({ @Component({
@ -1923,7 +1923,7 @@ export declare interface IonTitle extends Components.IonTitle {}
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>', template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property // 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', 'positionAnchor', 'translucent', 'trigger'], inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'swipeGesture', 'translucent', 'trigger'],
standalone: true standalone: true
}) })
export class IonToast { export class IonToast {

View File

@ -25,7 +25,7 @@ export const IonLoading = /*@__PURE__*/ defineOverlayContainer<JSX.IonLoading>('
export const IonPicker = /*@__PURE__*/ defineOverlayContainer<JSX.IonPicker>('ion-picker', defineIonPickerCustomElement, ['animated', 'backdropDismiss', 'buttons', 'columns', 'cssClass', 'duration', 'enterAnimation', 'htmlAttributes', 'isOpen', 'keyboardClose', 'leaveAnimation', 'mode', 'showBackdrop', 'trigger']); export const IonPicker = /*@__PURE__*/ defineOverlayContainer<JSX.IonPicker>('ion-picker', defineIonPickerCustomElement, ['animated', 'backdropDismiss', 'buttons', 'columns', 'cssClass', 'duration', 'enterAnimation', 'htmlAttributes', 'isOpen', 'keyboardClose', 'leaveAnimation', 'mode', 'showBackdrop', 'trigger']);
export const IonToast = /*@__PURE__*/ defineOverlayContainer<JSX.IonToast>('ion-toast', defineIonToastCustomElement, ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'translucent', 'trigger']); export const IonToast = /*@__PURE__*/ defineOverlayContainer<JSX.IonToast>('ion-toast', defineIonToastCustomElement, ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'swipeGesture', 'translucent', 'trigger']);
export const IonModal = /*@__PURE__*/ defineOverlayContainer<JSX.IonModal>('ion-modal', defineIonModalCustomElement, ['animated', 'backdropBreakpoint', 'backdropDismiss', 'breakpoints', 'canDismiss', 'enterAnimation', 'handle', 'handleBehavior', 'htmlAttributes', 'initialBreakpoint', 'isOpen', 'keepContentsMounted', 'keyboardClose', 'leaveAnimation', 'mode', 'presentingElement', 'showBackdrop', 'trigger'], true); export const IonModal = /*@__PURE__*/ defineOverlayContainer<JSX.IonModal>('ion-modal', defineIonModalCustomElement, ['animated', 'backdropBreakpoint', 'backdropDismiss', 'breakpoints', 'canDismiss', 'enterAnimation', 'handle', 'handleBehavior', 'htmlAttributes', 'initialBreakpoint', 'isOpen', 'keepContentsMounted', 'keyboardClose', 'leaveAnimation', 'mode', 'presentingElement', 'showBackdrop', 'trigger'], true);