mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-11-08 15:51:16 +08:00
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:
@ -4,6 +4,8 @@ import { getElementRoot } from '@utils/helpers';
|
||||
import type { Animation } from '../../../interface';
|
||||
import type { ToastPresentOptions } from '../toast-interface';
|
||||
|
||||
import { getOffsetForMiddlePosition } from './utils';
|
||||
|
||||
/**
|
||||
* iOS Toast Enter Animation
|
||||
*/
|
||||
@ -22,7 +24,7 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts: ToastPresentOptions
|
||||
wrapperAnimation.fromTo('transform', 'translateY(-100%)', `translateY(${top})`);
|
||||
break;
|
||||
case 'middle':
|
||||
const topPosition = Math.floor(baseEl.clientHeight / 2 - wrapperEl.clientHeight / 2);
|
||||
const topPosition = getOffsetForMiddlePosition(baseEl.clientHeight, wrapperEl.clientHeight);
|
||||
wrapperEl.style.top = `${topPosition}px`;
|
||||
wrapperAnimation.fromTo('opacity', 0.01, 1);
|
||||
break;
|
||||
|
||||
@ -4,6 +4,8 @@ import { getElementRoot } from '@utils/helpers';
|
||||
import type { Animation } from '../../../interface';
|
||||
import type { ToastPresentOptions } from '../toast-interface';
|
||||
|
||||
import { getOffsetForMiddlePosition } from './utils';
|
||||
|
||||
/**
|
||||
* MD Toast Enter Animation
|
||||
*/
|
||||
@ -23,7 +25,7 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts: ToastPresentOptions)
|
||||
wrapperAnimation.fromTo('opacity', 0.01, 1);
|
||||
break;
|
||||
case 'middle':
|
||||
const topPosition = Math.floor(baseEl.clientHeight / 2 - wrapperEl.clientHeight / 2);
|
||||
const topPosition = getOffsetForMiddlePosition(baseEl.clientHeight, wrapperEl.clientHeight);
|
||||
wrapperEl.style.top = `${topPosition}px`;
|
||||
wrapperAnimation.fromTo('opacity', 0.01, 1);
|
||||
break;
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
298
core/src/components/toast/gestures/swipe-to-dismiss.ts
Normal file
298
core/src/components/toast/gestures/swipe-to-dismiss.ts
Normal 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;
|
||||
};
|
||||
78
core/src/components/toast/test/swipe-gesture/index.html
Normal file
78
core/src/components/toast/test/swipe-gesture/index.html
Normal 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>
|
||||
106
core/src/components/toast/test/swipe-gesture/toast.e2e.ts
Normal file
106
core/src/components/toast/test/swipe-gesture/toast.e2e.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
29
core/src/components/toast/test/toast-config.spec.ts
Normal file
29
core/src/components/toast/test/toast-config.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -3,6 +3,7 @@ import { newSpecPage } from '@stencil/core/testing';
|
||||
import { Toast } from '../toast';
|
||||
import { config } from '../../../global/config';
|
||||
import { toastController } from '../../../utils/overlays';
|
||||
import { createAnimation } from '@utils/animation/animation';
|
||||
|
||||
describe('toast: custom html', () => {
|
||||
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', () => {
|
||||
it('should correctly inherit attributes on host', async () => {
|
||||
const page = await newSpecPage({
|
||||
@ -144,3 +119,99 @@ describe('toast: 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -55,3 +55,4 @@ export interface ToastAnimationPosition {
|
||||
|
||||
export type ToastPresentOptions = ToastPositionAlias & ToastAnimationPosition;
|
||||
export type ToastDismissOptions = ToastPositionAlias & ToastAnimationPosition;
|
||||
export type ToastSwipeGestureDirection = 'vertical';
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import type { ComponentInterface, EventEmitter } 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 type { Gesture } from '@utils/gesture';
|
||||
import { raf } from '@utils/helpers';
|
||||
import { createLockController } from '@utils/lock-controller';
|
||||
import { printIonWarning } from '@utils/logging';
|
||||
import {
|
||||
GESTURE,
|
||||
createDelegateController,
|
||||
createTriggerController,
|
||||
dismiss,
|
||||
@ -29,6 +31,7 @@ import { iosLeaveAnimation } from './animations/ios.leave';
|
||||
import { mdEnterAnimation } from './animations/md.enter';
|
||||
import { mdLeaveAnimation } from './animations/md.leave';
|
||||
import { getAnimationPosition } from './animations/utils';
|
||||
import { createSwipeToDismissGesture } from './gestures/swipe-to-dismiss';
|
||||
import type {
|
||||
ToastButton,
|
||||
ToastPosition,
|
||||
@ -36,6 +39,7 @@ import type {
|
||||
ToastPresentOptions,
|
||||
ToastDismissOptions,
|
||||
ToastAnimationPosition,
|
||||
ToastSwipeGestureDirection,
|
||||
} from './toast-interface';
|
||||
|
||||
// TODO(FW-2832): types
|
||||
@ -64,6 +68,7 @@ export class Toast implements ComponentInterface, OverlayInterface {
|
||||
private readonly triggerController = createTriggerController();
|
||||
private customHTMLEnabled = config.get('innerHTMLTemplatesEnabled', ENABLE_HTML_CONTENT_DEFAULT);
|
||||
private durationTimeout?: ReturnType<typeof setTimeout>;
|
||||
private gesture?: Gesture;
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
@ -373,6 +426,13 @@ export class Toast implements ComponentInterface, OverlayInterface {
|
||||
}
|
||||
|
||||
this.lastPresentedPosition = undefined;
|
||||
|
||||
/**
|
||||
* If the Toast has a swipe gesture then we can
|
||||
* safely destroy it now that it is dismissed.
|
||||
*/
|
||||
this.destroySwipeGesture();
|
||||
|
||||
unlock();
|
||||
|
||||
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') {
|
||||
if (buttons.length === 0) {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user