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..b162f2c7ef64a 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,13 @@ 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 minimum travel of its own. The swipe already passed + // the slop toward the edge when it began. On a one-line toast, 40% of the + // height is below the 48 px floor, so only a flick dismisses a short swipe. 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 +279,7 @@ export function useToastGesture({ pointerId: event.pointerId, clientX: event.clientX, clientY: event.clientY, + time: event.timeStamp, }, event.target, ) @@ -291,6 +301,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 +321,7 @@ export function useToastGesture({ pointerId: event.pointerId, clientX: event.clientX, clientY: event.clientY, + time: event.timeStamp, }); }, [endGesture], @@ -333,11 +345,12 @@ export function useToastGesture({ 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 +371,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 +381,7 @@ export function useToastGesture({ } const touch = changedTouch(event); if (touch) { - moveGesture(point(touch), () => { + moveGesture(point(touch, event), () => { if (event.cancelable) { event.preventDefault(); } @@ -382,7 +395,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); }