From d7ffd48c08562cb9c273126999371e0d352d0b0d Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 2 Nov 2026 21:46:47 -0800 Subject: [PATCH] fix(Toast): a quick flick dismisses a toast of any height A release toward the edge dismissed past max(40% of the card's height, 48 px), or as a flick only when it had also travelled 48 px. On any toast up to 120 px tall the two floors are the same distance, so a flick could never dismiss a one-line toast. The flick now needs no travel of its own beyond the slop the swipe already passed. The flick's speed was also timed with Date.now() in the handlers. Browsers deliver touch and pointer moves on the next frame, so the handlers trail the finger by up to two frames (measured in Chromium: a 33 ms flick handled over 55 ms), which halved the speed of a short flick. It is now timed by the input events' own timeStamp. --- .changeset/toast-flick-any-height.md | 7 + .../core/src/Toast/ToastViewport.test.tsx | 253 ++++++++++++++++-- packages/core/src/Toast/useToastGesture.ts | 34 ++- 3 files changed, 258 insertions(+), 36 deletions(-) create mode 100644 .changeset/toast-flick-any-height.md diff --git a/.changeset/toast-flick-any-height.md b/.changeset/toast-flick-any-height.md new file mode 100644 index 0000000000000..d9629239f427b --- /dev/null +++ b/.changeset/toast-flick-any-height.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] A quick flick toward a toast's edge now dismisses it whatever its height. Before, a flick also had to travel 48 px (the distance that already dismisses a one-line toast), so on any toast up to 120 px tall a flick could only snap back; and its speed was timed by when the browser handled the touches rather than when they happened, which in browsers that deliver moves on the next frame made a quick flick look up to twice as slow. + +@vjeux diff --git a/packages/core/src/Toast/ToastViewport.test.tsx b/packages/core/src/Toast/ToastViewport.test.tsx index 39a62e2a7b33d..99ce4c664d95e 100644 --- a/packages/core/src/Toast/ToastViewport.test.tsx +++ b/packages/core/src/Toast/ToastViewport.test.tsx @@ -1434,37 +1434,238 @@ describe('Toast swipe dismissal', () => { } }); - it('snaps back without dismissing after a short drag', () => { - const {visualToast, onHide} = renderSwipeToast(); + // A one-line toast is 56 px tall, and 40% of that is below the 48 px floor. + // A short swipe dismisses it only as a flick. + function mockClock(start = 1_000) { + let time = start; + const spy = vi.spyOn(Date, 'now').mockImplementation(() => time); + return { + advance(ms: number) { + time += ms; + }, + restore() { + spy.mockRestore(); + }, + }; + } - act(() => { - fireEvent.pointerDown(visualToast, { - pointerId: 7, - pointerType: 'pen', - button: 0, - clientX: 0, - clientY: 0, + it('dismisses a quick pen flick on a one-line toast', () => { + const clock = mockClock(); + try { + const {visualToast, onHide} = renderSwipeToast( + SWIPE_TOAST, + 'bottomEnd', + 'Swipe toast', + 56, + ); + + act(() => { + fireEvent.pointerDown(visualToast, { + pointerId: 9, + pointerType: 'pen', + button: 0, + clientX: 0, + clientY: 0, + }); }); - fireEvent.pointerMove(visualToast, { - pointerId: 7, - pointerType: 'pen', - clientX: 0, - clientY: 24, + clock.advance(30); + act(() => { + fireEvent.pointerMove(visualToast, { + pointerId: 9, + pointerType: 'pen', + clientX: 0, + clientY: 40, + }); + fireEvent.pointerUp(visualToast, { + pointerId: 9, + pointerType: 'pen', + clientX: 0, + clientY: 40, + }); }); - fireEvent.pointerUp(visualToast, { - pointerId: 7, - pointerType: 'pen', - clientX: 0, - clientY: 24, + + expect(onHide).toHaveBeenCalledWith('manual'); + expect(onHide).toHaveBeenCalledTimes(1); + } finally { + clock.restore(); + } + }); + + it('dismisses a quick touch flick on a one-line toast at the top', () => { + const clock = mockClock(); + try { + const {visualToast, onHide} = renderSwipeToast( + SWIPE_TOAST, + 'topEnd', + 'Swipe toast', + 56, + ); + const touchEvent = ( + type: 'touchstart' | 'touchmove' | 'touchend', + clientY: number, + ) => { + const event = new Event(type, {bubbles: true, cancelable: true}); + const touch = {identifier: 32, clientX: 10, clientY}; + Object.defineProperty(event, 'touches', { + value: type === 'touchend' ? [] : [touch], + }); + Object.defineProperty(event, 'changedTouches', {value: [touch]}); + visualToast.dispatchEvent(event); + return event; + }; + + act(() => { + touchEvent('touchstart', 100); + }); + clock.advance(15); + let move: Event; + act(() => { + move = touchEvent('touchmove', 80); + }); + clock.advance(15); + act(() => { + touchEvent('touchmove', 60); + touchEvent('touchend', 60); }); - }); - expect(onHide).not.toHaveBeenCalled(); - expect(visualToast.style.getPropertyValue('--_toast-swipe-y')).toBe(''); - expect(visualToast.style.getPropertyValue('--_toast-swipe-opacity')).toBe( - '', - ); - expect(visualToast.style.getPropertyValue('--_toast-swipe-scale')).toBe(''); + expect(move!.defaultPrevented).toBe(true); + expect(onHide).toHaveBeenCalledWith('manual'); + expect(onHide).toHaveBeenCalledTimes(1); + } finally { + clock.restore(); + } + }); + + it('times a flick by its input events, not by when the handlers run', () => { + // Browsers can run move handlers well after the finger moved. Here the + // 40 px flick takes 30 ms, and the page handles its moves and lift 60 ms + // after touch-down. + const clock = mockClock(); + try { + const {visualToast, onHide} = renderSwipeToast( + SWIPE_TOAST, + 'topEnd', + 'Swipe toast', + 56, + ); + const touchEvent = ( + type: 'touchstart' | 'touchmove' | 'touchend', + clientY: number, + ) => { + const event = new Event(type, {bubbles: true, cancelable: true}); + const touch = {identifier: 33, clientX: 10, clientY}; + Object.defineProperty(event, 'touches', { + value: type === 'touchend' ? [] : [touch], + }); + Object.defineProperty(event, 'changedTouches', {value: [touch]}); + return event; + }; + + act(() => { + visualToast.dispatchEvent(touchEvent('touchstart', 100)); + }); + clock.advance(15); + const firstMove = touchEvent('touchmove', 80); + clock.advance(15); + const secondMove = touchEvent('touchmove', 60); + const lift = touchEvent('touchend', 60); + clock.advance(30); + act(() => { + visualToast.dispatchEvent(firstMove); + visualToast.dispatchEvent(secondMove); + visualToast.dispatchEvent(lift); + }); + + expect(onHide).toHaveBeenCalledWith('manual'); + } finally { + clock.restore(); + } + }); + + it('snaps back after a slow short drag on a one-line toast', () => { + const clock = mockClock(); + try { + const {visualToast, onHide} = renderSwipeToast( + SWIPE_TOAST, + 'bottomEnd', + 'Swipe toast', + 56, + ); + + act(() => { + fireEvent.pointerDown(visualToast, { + pointerId: 10, + pointerType: 'pen', + button: 0, + clientX: 0, + clientY: 0, + }); + }); + clock.advance(400); + act(() => { + fireEvent.pointerMove(visualToast, { + pointerId: 10, + pointerType: 'pen', + clientX: 0, + clientY: 40, + }); + fireEvent.pointerUp(visualToast, { + pointerId: 10, + pointerType: 'pen', + clientX: 0, + clientY: 40, + }); + }); + + expect(onHide).not.toHaveBeenCalled(); + expect(visualToast.style.getPropertyValue('--_toast-swipe-y')).toBe(''); + } finally { + clock.restore(); + } + }); + + it('snaps back without dismissing after a short drag', () => { + // A slow drag: 24 px over 400 ms is far below flick speed. + const clock = mockClock(); + try { + const {visualToast, onHide} = renderSwipeToast(); + + act(() => { + fireEvent.pointerDown(visualToast, { + pointerId: 7, + pointerType: 'pen', + button: 0, + clientX: 0, + clientY: 0, + }); + }); + clock.advance(400); + act(() => { + fireEvent.pointerMove(visualToast, { + pointerId: 7, + pointerType: 'pen', + clientX: 0, + clientY: 24, + }); + fireEvent.pointerUp(visualToast, { + pointerId: 7, + pointerType: 'pen', + clientX: 0, + clientY: 24, + }); + }); + + expect(onHide).not.toHaveBeenCalled(); + expect(visualToast.style.getPropertyValue('--_toast-swipe-y')).toBe(''); + expect(visualToast.style.getPropertyValue('--_toast-swipe-opacity')).toBe( + '', + ); + expect(visualToast.style.getPropertyValue('--_toast-swipe-scale')).toBe( + '', + ); + } finally { + clock.restore(); + } }); it('fades and subtly scales only after accepted vertical edge swipe intent', () => { diff --git a/packages/core/src/Toast/useToastGesture.ts b/packages/core/src/Toast/useToastGesture.ts index d0b0167555d06..d015f96b55a5c 100644 --- a/packages/core/src/Toast/useToastGesture.ts +++ b/packages/core/src/Toast/useToastGesture.ts @@ -15,7 +15,9 @@ import type {PointerEvent as ReactPointerEvent, RefObject} from 'react'; const DRAG_PROMOTION_SLOP = 8; const SWIPE_DISMISS_RATIO = 0.4; -const FLICK_MIN_DISTANCE = 48; +/** The dismiss distance of a card too short for its height ratio to reach it. */ +const SWIPE_DISMISS_MIN_DISTANCE = 48; +/** Average speed, px/ms, at which a release dismisses whatever its travel. */ const FLICK_VELOCITY = 1.2; const VERTICAL_INTENT_RATIO = 1.2; const SWIPE_EXIT_DISTANCE = '120%'; @@ -29,6 +31,11 @@ interface GesturePoint { pointerId: number; clientX: number; clientY: number; + /** + * The input event's `timeStamp`, which is when the finger was there. + * Browsers can run a move's handler a frame or two later. + */ + time: number; } interface GestureState { @@ -132,14 +139,14 @@ export function useToastGesture({ pointerId: point.pointerId, startX: point.clientX, startY: point.clientY, - startTime: Date.now(), + startTime: point.time, direction, intent: 'pending', pausedTimer, surfaceSize, dismissThreshold: Math.max( surfaceSize * SWIPE_DISMISS_RATIO, - FLICK_MIN_DISTANCE, + SWIPE_DISMISS_MIN_DISTANCE, ), }; return true; @@ -229,11 +236,12 @@ export function useToastGesture({ 0, (point.clientY - state.startY) * state.direction, ); - const elapsed = Math.max(1, Date.now() - state.startTime); + const elapsed = Math.max(1, point.time - state.startTime); + // A flick needs no travel floor. The swipe passed the slop toward the + // edge before the hook claimed it. const isDismissed = state.intent === 'vertical' && - (travel >= state.dismissThreshold || - (travel >= FLICK_MIN_DISTANCE && travel / elapsed > FLICK_VELOCITY)); + (travel >= state.dismissThreshold || travel / elapsed > FLICK_VELOCITY); gestureRef.current = null; root.style.removeProperty('transition-duration'); @@ -270,6 +278,7 @@ export function useToastGesture({ pointerId: event.pointerId, clientX: event.clientX, clientY: event.clientY, + time: event.timeStamp, }, event.target, ) @@ -291,6 +300,7 @@ export function useToastGesture({ pointerId: event.pointerId, clientX: event.clientX, clientY: event.clientY, + time: event.timeStamp, }, () => event.preventDefault(), () => rootRef.current?.releasePointerCapture?.(event.pointerId), @@ -310,6 +320,7 @@ export function useToastGesture({ pointerId: event.pointerId, clientX: event.clientX, clientY: event.clientY, + time: event.timeStamp, }); }, [endGesture], @@ -328,16 +339,19 @@ export function useToastGesture({ [resetGesture], ); + // Native touch listeners on the card root. `touchmove` is not passive, + // so a claimed swipe can stop page scrolling. useEffect(() => { const root = rootRef.current; if (!root || !enabled) { return; } - const point = (touch: Touch): GesturePoint => ({ + const point = (touch: Touch, event: TouchEvent): GesturePoint => ({ source: 'touch', pointerId: touch.identifier, clientX: touch.clientX, clientY: touch.clientY, + time: event.timeStamp, }); const changedTouch = (event: TouchEvent) => { const state = gestureRef.current; @@ -358,7 +372,7 @@ export function useToastGesture({ } const touch = event.changedTouches[0]; if (touch != null) { - beginGesture(point(touch), event.target); + beginGesture(point(touch, event), event.target); } }; const handleTouchMove = (event: TouchEvent) => { @@ -368,7 +382,7 @@ export function useToastGesture({ } const touch = changedTouch(event); if (touch) { - moveGesture(point(touch), () => { + moveGesture(point(touch, event), () => { if (event.cancelable) { event.preventDefault(); } @@ -382,7 +396,7 @@ export function useToastGesture({ } const touch = changedTouch(event); if (touch) { - endGesture(point(touch)); + endGesture(point(touch, event)); } else if (event.touches.length === 0) { resetGesture(true); }