Let one pointer drag the Discover top card at a time
A second primary pointer pressing mid-drag replaced the drag record, so neither release cleared the first pointer's transform and the card stayed displaced. A press while a drag is in progress is now ignored, and a new top card ends a drag left on the old one so it cannot block the next. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1040,6 +1040,45 @@ describe('DiscoverPage', () => {
|
|||||||
expect(card.style.transform).toBe('')
|
expect(card.style.transform).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['without moving', null],
|
||||||
|
['after moving', 160],
|
||||||
|
])('ignores a second primary pointer pressing and releasing %s mid-drag, then springs the card back clean', async (_, moves) => {
|
||||||
|
const server = stubServer([BOWLS, TIKKA])
|
||||||
|
const animations = stubAnimate({ autoFinish: false })
|
||||||
|
await renderPage()
|
||||||
|
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||||
|
const [tryStamp, passStamp] = stamps(card)
|
||||||
|
// A touch (id 7) drags; a mouse (id 1), also primary, comes and goes.
|
||||||
|
const mouse = (x: number) => ({ pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, clientX: 400 + x, clientY: 300 })
|
||||||
|
|
||||||
|
press(card)
|
||||||
|
drag(card, 72, 0)
|
||||||
|
fireEvent.pointerDown(card, mouse(0))
|
||||||
|
if (moves !== null) fireEvent.pointerMove(card, mouse(moves))
|
||||||
|
fireEvent.pointerUp(card, mouse(moves ?? 0))
|
||||||
|
fireEvent.lostPointerCapture(card, mouse(moves ?? 0))
|
||||||
|
expect(capture).toHaveBeenCalledExactlyOnceWith(7)
|
||||||
|
expect(card.style.transform).toBe('translate(72px,0px) rotate(4deg)')
|
||||||
|
expect(animations).toEqual([])
|
||||||
|
|
||||||
|
release(card)
|
||||||
|
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('')
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
|
expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled()
|
||||||
|
expect(sent(server.fetchMock)).toEqual([])
|
||||||
|
|
||||||
|
// The card can be dragged again.
|
||||||
|
press(card)
|
||||||
|
drag(card, 36, 0)
|
||||||
|
expect(card.style.transform).toBe('translate(36px,0px) rotate(2deg)')
|
||||||
|
})
|
||||||
|
|
||||||
it('a key decision mid-drag ends the drag and throws the card from rest', async () => {
|
it('a key decision mid-drag ends the drag and throws the card from rest', async () => {
|
||||||
const server = stubServer([BOWLS, TIKKA], {
|
const server = stubServer([BOWLS, TIKKA], {
|
||||||
'POST /api/v1/suggestions/s1/accept': () => {
|
'POST /api/v1/suggestions/s1/accept': () => {
|
||||||
|
|||||||
@@ -221,8 +221,9 @@ export function DiscoverPage() {
|
|||||||
// 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. The release decides from where the pointer let go, which
|
// short of it. The release decides from where the pointer let go, which
|
||||||
// may differ from its last move. A cancel, or losing the capture before
|
// 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
|
// the release, springs it back from its last move. One pointer drags at
|
||||||
// ignored, and a drag held into a busy spell (a Retry) is put back on
|
// a time: a press while a drag is in progress is ignored, as is input
|
||||||
|
// while busy, and a drag held into a busy spell (a Retry) is put back on
|
||||||
// release.
|
// release.
|
||||||
const swipe = useMemo<SwipeHandlers>(() => {
|
const swipe = useMemo<SwipeHandlers>(() => {
|
||||||
function track(card: HTMLElement, current: Drag, event: PointerEvent<HTMLElement>) {
|
function track(card: HTMLElement, current: Drag, event: PointerEvent<HTMLElement>) {
|
||||||
@@ -245,7 +246,7 @@ export function DiscoverPage() {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
onPointerDown: (event) => {
|
onPointerDown: (event) => {
|
||||||
if (deciding.current || !event.isPrimary || event.button !== 0) return
|
if (drag.current !== null || deciding.current || !event.isPrimary || event.button !== 0) return
|
||||||
stopSpring()
|
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 }
|
||||||
@@ -266,9 +267,14 @@ export function DiscoverPage() {
|
|||||||
// 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(), [])
|
||||||
|
|
||||||
|
const topId = top?.id
|
||||||
|
// A drag belongs to the card it started on, which a new top card ends.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
drag.current = null
|
||||||
|
}, [topId])
|
||||||
|
|
||||||
// Once the deck moves past the card a decision was sent for, the cards
|
// Once the deck moves past the card a decision was sent for, the cards
|
||||||
// behind it move up a depth.
|
// behind it move up a depth.
|
||||||
const topId = top?.id
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (leaving.current === null || leaving.current === topId) return
|
if (leaving.current === null || leaving.current === topId) return
|
||||||
leaving.current = null
|
leaving.current = null
|
||||||
|
|||||||
Reference in New Issue
Block a user