diff --git a/core/api.txt b/core/api.txt index a5619099b6..67e3adea16 100644 --- a/core/api.txt +++ b/core/api.txt @@ -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,position,"bottom" | "middle" | "top",'bottom',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,trigger,string | undefined,undefined,false,false ion-toast,method,dismiss,dismiss(data?: any, role?: string) => Promise diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 791fbfc9b9..f628ae10ed 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -39,7 +39,7 @@ import { SelectChangeEventDetail, SelectCompareFn, SelectInterface } from "./com import { SelectPopoverOption } from "./components/select-popover/select-popover-interface"; import { TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface"; import { TextareaChangeEventDetail, TextareaInputEventDetail } from "./components/textarea/textarea-interface"; -import { ToastButton, 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"; export { AccordionGroupChangeEventDetail } from "./components/accordion-group/accordion-group-interface"; export { AnimationBuilder, AutocompleteTypes, Color, ComponentProps, ComponentRef, FrameworkDelegate, StyleEventDetail, TextFieldTypes } from "./interface"; @@ -75,7 +75,7 @@ export { SelectChangeEventDetail, SelectCompareFn, SelectInterface } from "./com export { SelectPopoverOption } from "./components/select-popover/select-popover-interface"; export { TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface"; export { TextareaChangeEventDetail, TextareaInputEventDetail } from "./components/textarea/textarea-interface"; -export { ToastButton, 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 namespace Components { interface IonAccordion { @@ -3172,6 +3172,10 @@ export namespace Components { * Present the toast overlay after it has been created. */ "present": () => Promise; + /** + * 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). */ @@ -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. */ "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). */ diff --git a/core/src/components/modal/gestures/swipe-to-close.ts b/core/src/components/modal/gestures/swipe-to-close.ts index 1c9fe95a2f..1a321e36f3 100644 --- a/core/src/components/modal/gestures/swipe-to-close.ts +++ b/core/src/components/modal/gestures/swipe-to-close.ts @@ -2,6 +2,7 @@ import { getTimeGivenProgression } from '@utils/animation/cubic-bezier'; import { isIonContent, findClosestIonContent, disableContentScrollY, resetContentScrollY } from '@utils/content'; import { createGesture } from '@utils/gesture'; import { clamp, getElementRoot } from '@utils/helpers'; +import { OVERLAY_GESTURE_PRIORITY } from '@utils/overlays'; import type { Animation } from '../../../interface'; import type { GestureDetail } from '../../../utils/gesture'; @@ -292,7 +293,7 @@ export const createSwipeToCloseGesture = ( const gesture = createGesture({ el, gestureName: 'modalSwipeToClose', - gesturePriority: 39, + gesturePriority: OVERLAY_GESTURE_PRIORITY, direction: 'y', threshold: 10, canStart, diff --git a/core/src/components/toast/animations/ios.enter.ts b/core/src/components/toast/animations/ios.enter.ts index 55baa3bdd3..38638af29d 100644 --- a/core/src/components/toast/animations/ios.enter.ts +++ b/core/src/components/toast/animations/ios.enter.ts @@ -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; diff --git a/core/src/components/toast/animations/md.enter.ts b/core/src/components/toast/animations/md.enter.ts index a475389a49..873813020a 100644 --- a/core/src/components/toast/animations/md.enter.ts +++ b/core/src/components/toast/animations/md.enter.ts @@ -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; diff --git a/core/src/components/toast/animations/utils.ts b/core/src/components/toast/animations/utils.ts index a247a55342..d9057dda0d 100644 --- a/core/src/components/toast/animations/utils.ts +++ b/core/src/components/toast/animations/utils.ts @@ -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); +}; diff --git a/core/src/components/toast/gestures/swipe-to-dismiss.ts b/core/src/components/toast/gestures/swipe-to-dismiss.ts new file mode 100644 index 0000000000..d6bbfc51ce --- /dev/null +++ b/core/src/components/toast/gestures/swipe-to-dismiss.ts @@ -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; +}; diff --git a/core/src/components/toast/test/swipe-gesture/index.html b/core/src/components/toast/test/swipe-gesture/index.html new file mode 100644 index 0000000000..5798bbc0dc --- /dev/null +++ b/core/src/components/toast/test/swipe-gesture/index.html @@ -0,0 +1,78 @@ + + + + + Toast - Swipe Gesture + + + + + + + + + + + Open Top Toast + Open Middle Toast + Open Bottom Toast + + Open Top Toast with Anchor + Open Bottom Toast with Anchor + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/src/components/toast/test/swipe-gesture/toast.e2e.ts b/core/src/components/toast/test/swipe-gesture/toast.e2e.ts new file mode 100644 index 0000000000..ff8bb9bb88 --- /dev/null +++ b/core/src/components/toast/test/swipe-gesture/toast.e2e.ts @@ -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(); + }); + }); +}); diff --git a/core/src/components/toast/test/toast-config.spec.ts b/core/src/components/toast/test/toast-config.spec.ts new file mode 100644 index 0000000000..5ce837efdb --- /dev/null +++ b/core/src/components/toast/test/toast-config.spec.ts @@ -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: ``, + }); + + 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: ``, + }); + + const toast = page.body.querySelector('ion-toast'); + + expect(toast.duration).toBe(5000); + }); +}); diff --git a/core/src/components/toast/test/toast.spec.tsx b/core/src/components/toast/test/toast.spec.tsx index d59e7d32e9..7844c9c3b0 100644 --- a/core/src/components/toast/test/toast.spec.tsx +++ b/core/src/components/toast/test/toast.spec.tsx @@ -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: ``, - }); - - 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: ``, - }); - - 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); + }); + }); +}); diff --git a/core/src/components/toast/toast-interface.ts b/core/src/components/toast/toast-interface.ts index 19cb83ae4a..7ed338e3ca 100644 --- a/core/src/components/toast/toast-interface.ts +++ b/core/src/components/toast/toast-interface.ts @@ -55,3 +55,4 @@ export interface ToastAnimationPosition { export type ToastPresentOptions = ToastPositionAlias & ToastAnimationPosition; export type ToastDismissOptions = ToastPositionAlias & ToastAnimationPosition; +export type ToastSwipeGestureDirection = 'vertical'; diff --git a/core/src/components/toast/toast.tsx b/core/src/components/toast/toast.tsx index b7945400b5..da25cf5174 100644 --- a/core/src/components/toast/toast.tsx +++ b/core/src/components/toast/toast.tsx @@ -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; + 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; diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index 98df03aa4d..bd56ac3684 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -685,6 +685,7 @@ export const safeCall = (handler: any, arg?: any) => { export const BACKDROP = 'backdrop'; export const GESTURE = 'gesture'; +export const OVERLAY_GESTURE_PRIORITY = 39; /** * Creates a delegate controller. diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index 83cae5db28..d192372759 100644 --- a/packages/angular/src/directives/proxies.ts +++ b/packages/angular/src/directives/proxies.ts @@ -2201,7 +2201,7 @@ export declare interface IonTitle extends Components.IonTitle {} @ProxyCmp({ - inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', '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'] }) @Component({ @@ -2209,7 +2209,7 @@ export declare interface IonTitle extends Components.IonTitle {} changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', '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 { protected el: HTMLElement; diff --git a/packages/angular/standalone/src/directives/proxies.ts b/packages/angular/standalone/src/directives/proxies.ts index 022535606a..650d21c94e 100644 --- a/packages/angular/standalone/src/directives/proxies.ts +++ b/packages/angular/standalone/src/directives/proxies.ts @@ -1915,7 +1915,7 @@ export declare interface IonTitle extends Components.IonTitle {} @ProxyCmp({ 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'] }) @Component({ @@ -1923,7 +1923,7 @@ export declare interface IonTitle extends Components.IonTitle {} changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', '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 }) export class IonToast { diff --git a/packages/vue/src/components/Overlays.ts b/packages/vue/src/components/Overlays.ts index d938438c6f..93c32240fc 100644 --- a/packages/vue/src/components/Overlays.ts +++ b/packages/vue/src/components/Overlays.ts @@ -25,7 +25,7 @@ export const IonLoading = /*@__PURE__*/ defineOverlayContainer(' export const IonPicker = /*@__PURE__*/ defineOverlayContainer('ion-picker', defineIonPickerCustomElement, ['animated', 'backdropDismiss', 'buttons', 'columns', 'cssClass', 'duration', 'enterAnimation', 'htmlAttributes', 'isOpen', 'keyboardClose', 'leaveAnimation', 'mode', 'showBackdrop', 'trigger']); -export const IonToast = /*@__PURE__*/ defineOverlayContainer('ion-toast', defineIonToastCustomElement, ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'translucent', 'trigger']); +export const IonToast = /*@__PURE__*/ defineOverlayContainer('ion-toast', defineIonToastCustomElement, ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'swipeGesture', 'translucent', 'trigger']); export const IonModal = /*@__PURE__*/ defineOverlayContainer('ion-modal', defineIonModalCustomElement, ['animated', 'backdropBreakpoint', 'backdropDismiss', 'breakpoints', 'canDismiss', 'enterAnimation', 'handle', 'handleBehavior', 'htmlAttributes', 'initialBreakpoint', 'isOpen', 'keepContentsMounted', 'keyboardClose', 'leaveAnimation', 'mode', 'presentingElement', 'showBackdrop', 'trigger'], true);