Decide a swipe from the release point and stop stray snap-backs
- Track the pointerup position before choosing Try it, Pass or snap-back, so the outcome follows where the card was let go (SQ-107-01). - Keep the snap-back animation and cancel it when a new drag or a decision starts (SQ-107-02). - Treat losing pointer capture before the release as a cancel, springing the card back and resetting its stamps (SQ-107-03). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,10 @@ export const DECK_DEPTH = [0, 1, 2] as const
|
|||||||
const DIFFICULTY_LABEL: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
|
const DIFFICULTY_LABEL: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
|
||||||
|
|
||||||
// SwipeHandlers are the pointer handlers that drag the top card.
|
// SwipeHandlers are the pointer handlers that drag the top card.
|
||||||
export type SwipeHandlers = Pick<DOMAttributes<HTMLElement>, 'onPointerDown' | 'onPointerMove' | 'onPointerUp' | 'onPointerCancel'>
|
export type SwipeHandlers = Pick<
|
||||||
|
DOMAttributes<HTMLElement>,
|
||||||
|
'onPointerDown' | 'onPointerMove' | 'onPointerUp' | 'onPointerCancel' | 'onLostPointerCapture'
|
||||||
|
>
|
||||||
|
|
||||||
export type DeckCardProps = {
|
export type DeckCardProps = {
|
||||||
quest: SideQuest
|
quest: SideQuest
|
||||||
|
|||||||
@@ -687,15 +687,18 @@ describe('DiscoverPage', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// press, drag and release put a pointer down on the card at (200, 300),
|
// press, drag and release put a pointer down on the card at (200, 300),
|
||||||
// move it by dx, dy, and let go.
|
// move it by dx, dy, and let go where it is, or dx, dy from the press.
|
||||||
|
let pointer = { dx: 0, dy: 0 }
|
||||||
function press(card: Element) {
|
function press(card: Element) {
|
||||||
|
pointer = { dx: 0, dy: 0 }
|
||||||
fireEvent.pointerDown(card, { pointerId: 7, isPrimary: true, button: 0, clientX: 200, clientY: 300 })
|
fireEvent.pointerDown(card, { pointerId: 7, isPrimary: true, button: 0, clientX: 200, clientY: 300 })
|
||||||
}
|
}
|
||||||
function drag(card: Element, dx: number, dy: number) {
|
function drag(card: Element, dx: number, dy: number) {
|
||||||
|
pointer = { dx, dy }
|
||||||
fireEvent.pointerMove(card, { pointerId: 7, isPrimary: true, clientX: 200 + dx, clientY: 300 + dy })
|
fireEvent.pointerMove(card, { pointerId: 7, isPrimary: true, clientX: 200 + dx, clientY: 300 + dy })
|
||||||
}
|
}
|
||||||
function release(card: Element) {
|
function release(card: Element, dx = pointer.dx, dy = pointer.dy) {
|
||||||
fireEvent.pointerUp(card, { pointerId: 7, isPrimary: true, button: 0 })
|
fireEvent.pointerUp(card, { pointerId: 7, isPrimary: true, button: 0, clientX: 200 + dx, clientY: 300 + dy })
|
||||||
}
|
}
|
||||||
const stamps = (card: HTMLElement) => [within(card).getByText('Try it!'), within(card).getByText('Pass')]
|
const stamps = (card: HTMLElement) => [within(card).getByText('Try it!'), within(card).getByText('Pass')]
|
||||||
|
|
||||||
@@ -831,6 +834,123 @@ describe('DiscoverPage', () => {
|
|||||||
expect(sent(server.fetchMock)).toEqual([])
|
expect(sent(server.fetchMock)).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['Try it', 60, 130, 'accept'],
|
||||||
|
['Pass', -60, -130, 'decline'],
|
||||||
|
] as const)('%s from where a release past 110 px lets go, though the last move was short of it', async (_, moved, at, action) => {
|
||||||
|
const server = stubServer([BOWLS, TIKKA], {
|
||||||
|
[`POST /api/v1/suggestions/s1/${action}`]: () => {
|
||||||
|
server.deck = [TIKKA]
|
||||||
|
return action === 'accept' ? accepted(BOWLS) : declined(BOWLS)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const animations = stubAnimate({ autoFinish: false })
|
||||||
|
await renderPage()
|
||||||
|
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||||
|
|
||||||
|
press(card)
|
||||||
|
drag(card, moved, 0)
|
||||||
|
release(card, at, 20)
|
||||||
|
const from = `translate(${at}px,6px) rotate(${at / 18}deg)`
|
||||||
|
await waitFor(() => expect(on(animations, card)).toHaveLength(1))
|
||||||
|
expect(on(animations, card).map((a) => a.keyframes[0]!.transform)).toEqual([action === 'accept' ? from : `${from} rotateY(0deg)`])
|
||||||
|
await finishRunning(animations)
|
||||||
|
expect(sent(server.fetchMock)).toEqual([`POST /api/v1/suggestions/s1/${action}`])
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['right', 150, 40],
|
||||||
|
['left', -150, -40],
|
||||||
|
])('springs the card back from where a release short of 110 px %s lets go, though the last move was past it', async (_, moved, at) => {
|
||||||
|
const server = stubServer([BOWLS, TIKKA])
|
||||||
|
const animations = stubAnimate({ autoFinish: false })
|
||||||
|
await renderPage()
|
||||||
|
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||||
|
|
||||||
|
press(card)
|
||||||
|
drag(card, moved, 0)
|
||||||
|
release(card, at, 0)
|
||||||
|
expect(animations.map((a) => [a.target, a.keyframes, a.options])).toEqual([
|
||||||
|
[card, [{ transform: `translate(${at}px,0px) rotate(${at / 18}deg)` }, { transform: 'translate(0px,0px) rotate(0deg)' }], { duration: 420, easing: SPRING }],
|
||||||
|
])
|
||||||
|
expect(card.style.transform).toBe('')
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
|
expect(sent(server.fetchMock)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops a snap-back when the card is grabbed again, so it follows the new drag', async () => {
|
||||||
|
stubServer([BOWLS, TIKKA])
|
||||||
|
const animations = stubAnimate({ autoFinish: false })
|
||||||
|
await renderPage()
|
||||||
|
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||||
|
|
||||||
|
press(card)
|
||||||
|
drag(card, 80, 0)
|
||||||
|
release(card)
|
||||||
|
const [spring] = animations
|
||||||
|
expect(spring!.playState).toBe('running')
|
||||||
|
|
||||||
|
press(card)
|
||||||
|
expect(spring!.playState).toBe('idle')
|
||||||
|
drag(card, 36, 0)
|
||||||
|
expect(card.style.transform).toBe('translate(36px,0px) rotate(2deg)')
|
||||||
|
expect(animations).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops a snap-back when a decision starts', async () => {
|
||||||
|
stubServer([BOWLS, TIKKA], { 'POST /api/v1/suggestions/s1/decline': () => declined(BOWLS) })
|
||||||
|
const animations = stubAnimate({ autoFinish: false })
|
||||||
|
await renderPage()
|
||||||
|
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||||
|
|
||||||
|
press(card)
|
||||||
|
drag(card, -80, 0)
|
||||||
|
release(card)
|
||||||
|
const [spring] = animations
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Pass' }))
|
||||||
|
expect(spring!.playState).toBe('idle')
|
||||||
|
expect(on(animations, card)).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('springs the card back when it loses the pointer capture before the release, deciding nothing', async () => {
|
||||||
|
const server = stubServer([BOWLS, TIKKA], { 'POST /api/v1/suggestions/s1/accept': () => accepted(BOWLS) })
|
||||||
|
const animations = stubAnimate({ autoFinish: false })
|
||||||
|
await renderPage()
|
||||||
|
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||||
|
const [tryStamp, passStamp] = stamps(card)
|
||||||
|
|
||||||
|
press(card)
|
||||||
|
drag(card, 150, 0)
|
||||||
|
fireEvent.lostPointerCapture(card, { pointerId: 7, isPrimary: true })
|
||||||
|
expect(animations.map((a) => [a.target, a.options])).toEqual([[card, { duration: 420, easing: SPRING }]])
|
||||||
|
expect(card.style.transform).toBe('')
|
||||||
|
expect(tryStamp!.style.opacity).toBe('')
|
||||||
|
expect(passStamp!.style.opacity).toBe('')
|
||||||
|
|
||||||
|
// The drag is over: later moves and the release do nothing.
|
||||||
|
drag(card, 180, 0)
|
||||||
|
release(card)
|
||||||
|
expect(card.style.transform).toBe('')
|
||||||
|
expect(animations).toHaveLength(1)
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
|
expect(sent(server.fetchMock)).toEqual([])
|
||||||
|
|
||||||
|
// The capture going after a release leaves its decision alone.
|
||||||
|
press(card)
|
||||||
|
drag(card, 140, 0)
|
||||||
|
release(card)
|
||||||
|
await waitFor(() => expect(on(animations, card)).toHaveLength(2))
|
||||||
|
fireEvent.lostPointerCapture(card, { pointerId: 7, isPrimary: true })
|
||||||
|
expect(animations).toHaveLength(3)
|
||||||
|
expect(card.style.transform).toBe('translate(140px,0px) rotate(7.777777777777778deg)')
|
||||||
|
await finishRunning(animations)
|
||||||
|
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept'])
|
||||||
|
})
|
||||||
|
|
||||||
it('springs the card back when the pointer is canceled, however far it went', async () => {
|
it('springs the card back when the pointer is canceled, however far it went', async () => {
|
||||||
const server = stubServer([BOWLS])
|
const server = stubServer([BOWLS])
|
||||||
const animations = stubAnimate({ autoFinish: false })
|
const animations = stubAnimate({ autoFinish: false })
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { Heart, X } from 'lucide-react'
|
import { Heart, X } from 'lucide-react'
|
||||||
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type Ref } from 'react'
|
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type Ref } from 'react'
|
||||||
import { useJob } from '../api/index.ts'
|
import { useJob } from '../api/index.ts'
|
||||||
import { Card } from '../ui/Card.tsx'
|
import { Card } from '../ui/Card.tsx'
|
||||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||||
@@ -127,6 +127,13 @@ export function DiscoverPage() {
|
|||||||
const exit = useRef<Exit | null>(null)
|
const exit = useRef<Exit | null>(null)
|
||||||
const leaving = useRef<string | null>(null)
|
const leaving = useRef<string | null>(null)
|
||||||
const drag = useRef<Drag | null>(null)
|
const drag = useRef<Drag | null>(null)
|
||||||
|
// The top card's snap-back while it may still run; a new drag or a
|
||||||
|
// decision stops it so it does not fight the card's new motion.
|
||||||
|
const springing = useRef<Animation | null>(null)
|
||||||
|
const stopSpring = useCallback(() => {
|
||||||
|
springing.current?.cancel()
|
||||||
|
springing.current = null
|
||||||
|
}, [])
|
||||||
|
|
||||||
const { mutate: mutateDecision } = decision
|
const { mutate: mutateDecision } = decision
|
||||||
const deciding = useRef(false)
|
const deciding = useRef(false)
|
||||||
@@ -168,6 +175,7 @@ export function DiscoverPage() {
|
|||||||
const decide = useCallback(
|
const decide = useCallback(
|
||||||
(choice: Choice, from?: string) => {
|
(choice: Choice, from?: string) => {
|
||||||
if (top === undefined || !lock()) return
|
if (top === undefined || !lock()) return
|
||||||
|
stopSpring()
|
||||||
const card = deckRef.current!.querySelector('article')!
|
const card = deckRef.current!.querySelector('article')!
|
||||||
if (from === undefined && drag.current !== null) {
|
if (from === undefined && drag.current !== null) {
|
||||||
drag.current = null
|
drag.current = null
|
||||||
@@ -206,41 +214,54 @@ export function DiscoverPage() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
[top, lock, send],
|
[top, lock, send, stopSpring],
|
||||||
)
|
)
|
||||||
|
|
||||||
// swipe drags the top card: it follows the pointer, captured from the
|
// swipe drags the top card: it follows the pointer, captured from the
|
||||||
// press, until the release decides past the threshold or springs it back
|
// press, until the release decides past the threshold or springs it back
|
||||||
// short of it. Input while busy is ignored, and a drag held into a busy
|
// short of it. The release decides from where the pointer let go, which
|
||||||
// spell (a Retry) is put back on release.
|
// may differ from its last move. A cancel, or losing the capture before
|
||||||
|
// the release, springs it back from its last move. Input while busy is
|
||||||
|
// ignored, and a drag held into a busy spell (a Retry) is put back on
|
||||||
|
// release.
|
||||||
const swipe = useMemo<SwipeHandlers>(() => {
|
const swipe = useMemo<SwipeHandlers>(() => {
|
||||||
function release(card: HTMLElement, pointerId: number, decides: boolean) {
|
function track(card: HTMLElement, current: Drag, event: PointerEvent<HTMLElement>) {
|
||||||
|
current.dx = event.clientX - current.x
|
||||||
|
current.transform = follow(card, current.dx, event.clientY - current.y)
|
||||||
|
}
|
||||||
|
function release(event: PointerEvent<HTMLElement>, decides: boolean) {
|
||||||
|
const card = event.currentTarget
|
||||||
const current = drag.current
|
const current = drag.current
|
||||||
if (current === null || current.pointerId !== pointerId) return
|
if (current === null || current.pointerId !== event.pointerId) return
|
||||||
drag.current = null
|
drag.current = null
|
||||||
|
const moved = current.transform !== null || event.clientX !== current.x || event.clientY !== current.y
|
||||||
|
if (decides && moved && !deciding.current) track(card, current, event)
|
||||||
if (current.transform === null) return
|
if (current.transform === null) return
|
||||||
if (deciding.current) unfollow(card)
|
if (deciding.current) unfollow(card)
|
||||||
else if (decides && current.dx > SWIPE_THRESHOLD) decide('try', current.transform)
|
else if (decides && current.dx > SWIPE_THRESHOLD) decide('try', current.transform)
|
||||||
else if (decides && current.dx < -SWIPE_THRESHOLD) decide('pass', current.transform)
|
else if (decides && current.dx < -SWIPE_THRESHOLD) decide('pass', current.transform)
|
||||||
else if (prefersReducedMotion()) unfollow(card)
|
else if (prefersReducedMotion()) unfollow(card)
|
||||||
else snapBack(card, current.transform)
|
else springing.current = snapBack(card, current.transform)
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
onPointerDown: (event) => {
|
onPointerDown: (event) => {
|
||||||
if (deciding.current || !event.isPrimary || event.button !== 0) return
|
if (deciding.current || !event.isPrimary || event.button !== 0) return
|
||||||
|
stopSpring()
|
||||||
event.currentTarget.setPointerCapture(event.pointerId)
|
event.currentTarget.setPointerCapture(event.pointerId)
|
||||||
drag.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, dx: 0, transform: null }
|
drag.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, dx: 0, transform: null }
|
||||||
},
|
},
|
||||||
onPointerMove: (event) => {
|
onPointerMove: (event) => {
|
||||||
const current = drag.current
|
const current = drag.current
|
||||||
if (current === null || current.pointerId !== event.pointerId || deciding.current) return
|
if (current === null || current.pointerId !== event.pointerId || deciding.current) return
|
||||||
current.dx = event.clientX - current.x
|
track(event.currentTarget, current, event)
|
||||||
current.transform = follow(event.currentTarget, current.dx, event.clientY - current.y)
|
|
||||||
},
|
},
|
||||||
onPointerUp: (event) => release(event.currentTarget, event.pointerId, true),
|
onPointerUp: (event) => release(event, true),
|
||||||
onPointerCancel: (event) => release(event.currentTarget, event.pointerId, false),
|
onPointerCancel: (event) => release(event, false),
|
||||||
|
// After a release the drag is already over, so this only ends a drag
|
||||||
|
// whose capture was lost before it.
|
||||||
|
onLostPointerCapture: (event) => release(event, false),
|
||||||
}
|
}
|
||||||
}, [decide])
|
}, [decide, stopSpring])
|
||||||
|
|
||||||
// Leaving the page stops an exit before its decision is sent.
|
// Leaving the page stops an exit before its decision is sent.
|
||||||
useEffect(() => () => exit.current?.cancel(), [])
|
useEffect(() => () => exit.current?.cancel(), [])
|
||||||
|
|||||||
Reference in New Issue
Block a user