diff --git a/web/src/discover/DiscoverPage.test.tsx b/web/src/discover/DiscoverPage.test.tsx index 43be0a5..a98ecb0 100644 --- a/web/src/discover/DiscoverPage.test.tsx +++ b/web/src/discover/DiscoverPage.test.tsx @@ -1040,6 +1040,45 @@ describe('DiscoverPage', () => { 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 () => { const server = stubServer([BOWLS, TIKKA], { 'POST /api/v1/suggestions/s1/accept': () => { diff --git a/web/src/discover/DiscoverPage.tsx b/web/src/discover/DiscoverPage.tsx index dd5eb3a..12535ec 100644 --- a/web/src/discover/DiscoverPage.tsx +++ b/web/src/discover/DiscoverPage.tsx @@ -221,8 +221,9 @@ export function DiscoverPage() { // 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 // 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 + // the release, springs it back from its last move. One pointer drags at + // 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. const swipe = useMemo(() => { function track(card: HTMLElement, current: Drag, event: PointerEvent) { @@ -245,7 +246,7 @@ export function DiscoverPage() { } return { onPointerDown: (event) => { - if (deciding.current || !event.isPrimary || event.button !== 0) return + if (drag.current !== null || deciding.current || !event.isPrimary || event.button !== 0) return stopSpring() event.currentTarget.setPointerCapture(event.pointerId) 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. 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 // behind it move up a depth. - const topId = top?.id useLayoutEffect(() => { if (leaving.current === null || leaving.current === topId) return leaving.current = null