Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #107.
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DOMAttributes } from 'react'
|
||||||
import { DIFFICULTIES, type Difficulty } from '../dinners/api.ts'
|
import { DIFFICULTIES, type Difficulty } from '../dinners/api.ts'
|
||||||
import { Photo } from '../ui/Photo.tsx'
|
import { Photo } from '../ui/Photo.tsx'
|
||||||
import type { SideQuest } from './api.ts'
|
import type { SideQuest } from './api.ts'
|
||||||
@@ -8,25 +9,35 @@ 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.
|
||||||
|
export type SwipeHandlers = Pick<
|
||||||
|
DOMAttributes<HTMLElement>,
|
||||||
|
'onPointerDown' | 'onPointerMove' | 'onPointerUp' | 'onPointerCancel' | 'onLostPointerCapture'
|
||||||
|
>
|
||||||
|
|
||||||
export type DeckCardProps = {
|
export type DeckCardProps = {
|
||||||
quest: SideQuest
|
quest: SideQuest
|
||||||
// 0 is the top card; 1 and 2 stack behind it.
|
// 0 is the top card; 1 and 2 stack behind it.
|
||||||
depth: number
|
depth: number
|
||||||
|
// swipe makes the card draggable; only the top card gets it.
|
||||||
|
swipe?: SwipeHandlers
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeckCard is one Side quest in the deck. Only the top card is read out and
|
// DeckCard is one Side quest in the deck. Only the top card is read out and
|
||||||
// reachable; the cards behind it are the stack's edge, kept rendered so they
|
// reachable; the cards behind it are the stack's edge, kept rendered so they
|
||||||
// can be promoted. Its "Try it!" and "Pass" stamps stay hidden until a
|
// can be promoted. Its "Try it!" and "Pass" stamps stay hidden until a
|
||||||
// decision shows them, and its shade until Pass folds it (see motion.ts).
|
// decision or a drag shows them, and its shade until Pass folds it (see
|
||||||
export function DeckCard({ quest, depth }: DeckCardProps) {
|
// motion.ts).
|
||||||
|
export function DeckCard({ quest, depth, swipe }: DeckCardProps) {
|
||||||
const top = depth === 0
|
const top = depth === 0
|
||||||
const titleId = `side-quest-${quest.id}`
|
const titleId = `side-quest-${quest.id}`
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
className={`${styles.card} ${styles[`depth${depth}`]}`}
|
className={`${styles.card} ${styles[`depth${depth}`]}${swipe === undefined ? '' : ` ${styles.draggable}`}`}
|
||||||
aria-labelledby={top ? titleId : undefined}
|
aria-labelledby={top ? titleId : undefined}
|
||||||
aria-hidden={top ? undefined : true}
|
aria-hidden={top ? undefined : true}
|
||||||
inert={!top}
|
inert={!top}
|
||||||
|
{...swipe}
|
||||||
>
|
>
|
||||||
<Photo id={quest.photo_id} name={quest.name} radius="24px" inset="10px" sizes="400px" className={styles.photo} />
|
<Photo id={quest.photo_id} name={quest.name} radius="24px" inset="10px" sizes="400px" className={styles.photo} />
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
@@ -41,7 +52,7 @@ export function DeckCard({ quest, depth }: DeckCardProps) {
|
|||||||
<span className={`${styles.stamp} ${styles.tryStamp}`} data-part="try-stamp" aria-hidden="true">
|
<span className={`${styles.stamp} ${styles.tryStamp}`} data-part="try-stamp" aria-hidden="true">
|
||||||
Try it!
|
Try it!
|
||||||
</span>
|
</span>
|
||||||
<span className={`${styles.stamp} ${styles.passStamp}`} aria-hidden="true">
|
<span className={`${styles.stamp} ${styles.passStamp}`} data-part="pass-stamp" aria-hidden="true">
|
||||||
Pass
|
Pass
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.shade} data-part="shade" />
|
<span className={styles.shade} data-part="shade" />
|
||||||
|
|||||||
@@ -104,6 +104,12 @@
|
|||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The top card follows a drag instead of scrolling the page. */
|
||||||
|
.draggable {
|
||||||
|
touch-action: none;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
.depth1 {
|
.depth1 {
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
transform: translateY(8px) scale(0.95);
|
transform: translateY(8px) scale(0.95);
|
||||||
|
|||||||
@@ -668,4 +668,501 @@ describe('DiscoverPage', () => {
|
|||||||
expect(sent(server.fetchMock)).toEqual([])
|
expect(sent(server.fetchMock)).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('swipe', () => {
|
||||||
|
const accepted = (quest: typeof BOWLS) => jsonResponse(200, { suggestion: { ...quest, status: 'accepted' }, replenish_job_id: null })
|
||||||
|
const declined = (quest: typeof BOWLS) => jsonResponse(200, { suggestion: { ...quest, status: 'declined' }, replenish_job_id: null })
|
||||||
|
const on = (animations: FakeAnimation[], target: Element) => animations.filter((a) => a.target === target)
|
||||||
|
const SPRING = 'cubic-bezier(.34,1.4,.64,1)'
|
||||||
|
let capture: Mock<(pointerId: number) => void>
|
||||||
|
|
||||||
|
// jsdom has no pointer capture.
|
||||||
|
beforeEach(() => {
|
||||||
|
capture = vi.fn()
|
||||||
|
Element.prototype.setPointerCapture = capture
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Reflect.deleteProperty(Element.prototype, 'setPointerCapture')
|
||||||
|
})
|
||||||
|
|
||||||
|
// press, drag and release put a pointer down on the card at (200, 300),
|
||||||
|
// 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) {
|
||||||
|
pointer = { dx: 0, dy: 0 }
|
||||||
|
fireEvent.pointerDown(card, { pointerId: 7, isPrimary: true, button: 0, clientX: 200, clientY: 300 })
|
||||||
|
}
|
||||||
|
function drag(card: Element, dx: number, dy: number) {
|
||||||
|
pointer = { dx, dy }
|
||||||
|
fireEvent.pointerMove(card, { pointerId: 7, isPrimary: true, clientX: 200 + dx, clientY: 300 + dy })
|
||||||
|
}
|
||||||
|
function release(card: Element, dx = pointer.dx, dy = pointer.dy) {
|
||||||
|
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')]
|
||||||
|
|
||||||
|
it('makes the top card follow the pointer, captured, with the stamps showing how far it has gone', async () => {
|
||||||
|
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)
|
||||||
|
expect(declaredStyle(card).getPropertyValue('touch-action')).toBe('none')
|
||||||
|
|
||||||
|
press(card)
|
||||||
|
expect(capture).toHaveBeenCalledExactlyOnceWith(7)
|
||||||
|
expect(capture.mock.contexts[0]).toBe(card)
|
||||||
|
|
||||||
|
drag(card, 54, 20)
|
||||||
|
expect(card.style.transform).toBe('translate(54px,6px) rotate(3deg)')
|
||||||
|
expect(tryStamp!.style.opacity).toBe('0.54')
|
||||||
|
expect(passStamp!.style.opacity).toBe('0')
|
||||||
|
|
||||||
|
drag(card, -36, -10)
|
||||||
|
expect(card.style.transform).toBe('translate(-36px,-3px) rotate(-2deg)')
|
||||||
|
expect(tryStamp!.style.opacity).toBe('0')
|
||||||
|
expect(passStamp!.style.opacity).toBe('0.36')
|
||||||
|
|
||||||
|
drag(card, -180, 0)
|
||||||
|
expect(tryStamp!.style.opacity).toBe('0')
|
||||||
|
expect(passStamp!.style.opacity).toBe('1')
|
||||||
|
expect(animations).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Try it on a release past 110 px right: throws the card from where it was let go, in 340 ms with no wiggle, then records it', async () => {
|
||||||
|
const server = stubServer([BOWLS, TIKKA, ORZO], {
|
||||||
|
'POST /api/v1/suggestions/s1/accept': () => {
|
||||||
|
server.deck = [TIKKA, ORZO]
|
||||||
|
return accepted(BOWLS)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const animations = stubAnimate({ autoFinish: false })
|
||||||
|
await renderPage()
|
||||||
|
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||||
|
const pill = await screen.findByText('Quest log · 6')
|
||||||
|
const tryButton = screen.getByRole('button', { name: 'Try it' })
|
||||||
|
|
||||||
|
press(card)
|
||||||
|
drag(card, 126, 30)
|
||||||
|
release(card)
|
||||||
|
// No button bump, no wiggle: the stamp pops and the card flies.
|
||||||
|
expect(animations.map((a) => a.target)).toEqual([within(card).getByText('Try it!')])
|
||||||
|
await waitFor(() => expect(tryButton).toBeDisabled())
|
||||||
|
expect(animations).toHaveLength(2)
|
||||||
|
const fly = animations[1]!
|
||||||
|
expect(fly.target).toBe(card)
|
||||||
|
expect(fly.keyframes).toEqual([
|
||||||
|
{ transform: 'translate(126px,9px) rotate(7deg)' },
|
||||||
|
{ transform: 'translate(150%,-60px) rotate(28deg)', opacity: 0 },
|
||||||
|
])
|
||||||
|
expect(fly.options).toEqual({ duration: 340, easing: 'cubic-bezier(.55,0,.8,.4)', fill: 'forwards' })
|
||||||
|
expect(sent(server.fetchMock)).toEqual([])
|
||||||
|
|
||||||
|
await finishRunning(animations)
|
||||||
|
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept'])
|
||||||
|
expect(on(animations, pill)).toHaveLength(1)
|
||||||
|
const next = await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' })
|
||||||
|
expect(on(animations, next).map((a) => a.options)).toEqual([{ duration: 300, easing: 'cubic-bezier(.2,.8,.2,1)' }])
|
||||||
|
await waitFor(() => expect(tryButton).toBeEnabled())
|
||||||
|
expect(next.style.transform).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Pass on a release past 110 px left: folds the card from where it was let go, then records it', async () => {
|
||||||
|
const server = stubServer([BOWLS, TIKKA], {
|
||||||
|
'POST /api/v1/suggestions/s1/decline': () => {
|
||||||
|
server.deck = [TIKKA]
|
||||||
|
return declined(BOWLS)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const animations = stubAnimate({ autoFinish: false })
|
||||||
|
await renderPage()
|
||||||
|
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||||
|
const pill = await screen.findByText('Quest log · 6')
|
||||||
|
const shade = card.querySelector('[data-part="shade"]')!
|
||||||
|
|
||||||
|
press(card)
|
||||||
|
drag(card, -144, 20)
|
||||||
|
release(card)
|
||||||
|
expect(animations.map((a) => a.target)).toEqual([shade, card])
|
||||||
|
expect(animations[1]!.keyframes).toEqual([
|
||||||
|
{ transformOrigin: '0% 50%', transform: 'translate(-144px,6px) rotate(-8deg) rotateY(0deg)', easing: 'cubic-bezier(.45,0,.85,.55)' },
|
||||||
|
{ transformOrigin: '0% 50%', transform: 'translate(-144px,6px) rotate(-8deg) rotateY(-80deg)', offset: 0.45, easing: 'cubic-bezier(.4,0,.9,.6)' },
|
||||||
|
{ transformOrigin: '0% 50%', transform: 'translate(-45%,560px) rotate(-35deg) rotateY(-90deg)', opacity: 0 },
|
||||||
|
])
|
||||||
|
expect(animations[1]!.options).toEqual({ duration: 720, fill: 'forwards' })
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Pass' })).toBeDisabled())
|
||||||
|
expect(sent(server.fetchMock)).toEqual([])
|
||||||
|
|
||||||
|
await finishRunning(animations)
|
||||||
|
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline'])
|
||||||
|
await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' })
|
||||||
|
expect(on(animations, pill)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['right', 110],
|
||||||
|
['left', -110],
|
||||||
|
])('springs the card back from a release of 110 px %s, resetting the stamps and deciding nothing', async (_, dx) => {
|
||||||
|
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)
|
||||||
|
|
||||||
|
press(card)
|
||||||
|
drag(card, dx, 0)
|
||||||
|
expect([tryStamp!.style.opacity, passStamp!.style.opacity]).toContain('1')
|
||||||
|
release(card)
|
||||||
|
expect(animations).toHaveLength(1)
|
||||||
|
expect(animations[0]!.target).toBe(card)
|
||||||
|
expect(animations[0]!.keyframes).toEqual([{ transform: `translate(${dx}px,0px) rotate(${dx / 18}deg)` }, { transform: 'translate(0px,0px) rotate(0deg)' }])
|
||||||
|
expect(animations[0]!.options).toEqual({ duration: 420, easing: SPRING })
|
||||||
|
expect(card.style.transform).toBe('')
|
||||||
|
expect(tryStamp!.style.opacity).toBe('')
|
||||||
|
expect(passStamp!.style.opacity).toBe('')
|
||||||
|
expect(getComputedStyle(tryStamp!).opacity).toBe('0')
|
||||||
|
expect(getComputedStyle(passStamp!).opacity).toBe('0')
|
||||||
|
|
||||||
|
// A later move with the pointer up does nothing.
|
||||||
|
drag(card, 200, 0)
|
||||||
|
expect(card.style.transform).toBe('')
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
|
expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled()
|
||||||
|
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 () => {
|
||||||
|
const server = stubServer([BOWLS])
|
||||||
|
const animations = stubAnimate({ autoFinish: false })
|
||||||
|
await renderPage()
|
||||||
|
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||||
|
|
||||||
|
press(card)
|
||||||
|
drag(card, 180, 0)
|
||||||
|
fireEvent.pointerCancel(card, { pointerId: 7, isPrimary: true })
|
||||||
|
expect(animations.map((a) => a.options)).toEqual([{ duration: 420, easing: SPRING }])
|
||||||
|
expect(card.style.transform).toBe('')
|
||||||
|
expect(sent(server.fetchMock)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores a drag while busy', async () => {
|
||||||
|
const server = 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' })
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Pass' }))
|
||||||
|
const started = animations.length
|
||||||
|
press(card)
|
||||||
|
drag(card, 160, 0)
|
||||||
|
release(card)
|
||||||
|
expect(capture).not.toHaveBeenCalled()
|
||||||
|
expect(card.style.transform).toBe('')
|
||||||
|
expect(animations).toHaveLength(started)
|
||||||
|
await finishRunning(animations)
|
||||||
|
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('puts a drag held while a Retry goes out back at once on release, deciding nothing', async () => {
|
||||||
|
const retry = deferred()
|
||||||
|
let calls = 0
|
||||||
|
const server = stubServer([BOWLS, TIKKA], {
|
||||||
|
'POST /api/v1/suggestions/s1/accept': () => (++calls === 1 ? errorResponse(500, 'internal') : retry.promise),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Try it' }))
|
||||||
|
const alert = await screen.findByRole('alert')
|
||||||
|
|
||||||
|
press(card)
|
||||||
|
drag(card, 60, 0)
|
||||||
|
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled())
|
||||||
|
drag(card, -160, 0)
|
||||||
|
expect(card.style.transform).toBe('translate(60px,0px) rotate(3.3333333333333335deg)')
|
||||||
|
const started = vi.mocked(Element.prototype.animate).mock.calls.length
|
||||||
|
release(card)
|
||||||
|
expect(card.style.transform).toBe('')
|
||||||
|
expect(within(card).getByText('Try it!').style.opacity).toBe('')
|
||||||
|
expect(vi.mocked(Element.prototype.animate).mock.calls.length).toBe(started)
|
||||||
|
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept', 'POST /api/v1/suggestions/s1/accept'])
|
||||||
|
retry.resolve(accepted(BOWLS))
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drags only the top card', async () => {
|
||||||
|
const server = stubServer([BOWLS, TIKKA, ORZO])
|
||||||
|
const animations = stubAnimate({ autoFinish: false })
|
||||||
|
const { container } = await renderPage()
|
||||||
|
await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||||
|
const [, second, third] = container.querySelectorAll<HTMLElement>('article')
|
||||||
|
|
||||||
|
for (const card of [second!, third!]) {
|
||||||
|
expect(declaredStyle(card).getPropertyValue('touch-action')).toBe('')
|
||||||
|
press(card)
|
||||||
|
drag(card, 160, 0)
|
||||||
|
release(card)
|
||||||
|
expect(card.style.transform).toBe('')
|
||||||
|
}
|
||||||
|
expect(capture).not.toHaveBeenCalled()
|
||||||
|
expect(animations).toEqual([])
|
||||||
|
expect(sent(server.fetchMock)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores a press that is not the primary button', async () => {
|
||||||
|
stubServer([BOWLS])
|
||||||
|
await renderPage()
|
||||||
|
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||||
|
|
||||||
|
fireEvent.pointerDown(card, { pointerId: 7, isPrimary: true, button: 2, clientX: 200, clientY: 300 })
|
||||||
|
drag(card, 160, 0)
|
||||||
|
expect(capture).not.toHaveBeenCalled()
|
||||||
|
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': () => {
|
||||||
|
server.deck = [TIKKA]
|
||||||
|
return accepted(BOWLS)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const animations = stubAnimate({ autoFinish: false })
|
||||||
|
await renderPage()
|
||||||
|
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||||
|
const [tryStamp] = stamps(card)
|
||||||
|
|
||||||
|
press(card)
|
||||||
|
drag(card, -60, 0)
|
||||||
|
fireEvent.keyDown(window, { key: 'ArrowRight' })
|
||||||
|
expect(card.style.transform).toBe('')
|
||||||
|
expect(tryStamp!.style.opacity).toBe('')
|
||||||
|
// The button bump, stamp pop and wiggle of a Try it from rest.
|
||||||
|
expect(animations.map((a) => a.target)).toEqual([screen.getByRole('button', { name: 'Try it' }), tryStamp, card])
|
||||||
|
release(card)
|
||||||
|
expect(animations).toHaveLength(3)
|
||||||
|
await finishRunning(animations)
|
||||||
|
await finishRunning(animations)
|
||||||
|
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('puts a swiped card back at rest when its decision is refused', async () => {
|
||||||
|
stubServer([BOWLS], { 'POST /api/v1/suggestions/s1/decline': () => errorResponse(500, 'internal') })
|
||||||
|
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)
|
||||||
|
release(card)
|
||||||
|
await finishRunning(animations)
|
||||||
|
await screen.findByRole('alert')
|
||||||
|
expect(animations.map((a) => a.playState)).toEqual(['idle', 'idle'])
|
||||||
|
expect(card.style.transform).toBe('')
|
||||||
|
expect(tryStamp!.style.opacity).toBe('')
|
||||||
|
expect(passStamp!.style.opacity).toBe('')
|
||||||
|
expect(screen.getByRole('button', { name: 'Pass' })).toBeEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['Try it', 140, 'accept'],
|
||||||
|
['Pass', -140, 'decline'],
|
||||||
|
] as const)('%s by swipe with reduced motion fades the card where it was let go', async (_, dx, action) => {
|
||||||
|
mockViewport(390, { reducedMotion: true })
|
||||||
|
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, dx, 0)
|
||||||
|
release(card)
|
||||||
|
expect(animations.map((a) => [a.target, a.keyframes, a.options])).toEqual([[card, [{ opacity: 1 }, { opacity: 0 }], { duration: 200, fill: 'forwards' }]])
|
||||||
|
expect(card.style.transform).toBe(`translate(${dx}px,0px) rotate(${dx / 18}deg)`)
|
||||||
|
await finishRunning(animations)
|
||||||
|
expect(sent(server.fetchMock)).toEqual([`POST /api/v1/suggestions/s1/${action}`])
|
||||||
|
await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' })
|
||||||
|
expect(animations).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('puts the card back at once, without the spring, from a short swipe with reduced motion', async () => {
|
||||||
|
mockViewport(390, { reducedMotion: true })
|
||||||
|
stubServer([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)
|
||||||
|
expect(animations).toEqual([])
|
||||||
|
expect(card.style.transform).toBe('')
|
||||||
|
expect(within(card).getByText('Try it!').style.opacity).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -18,10 +18,30 @@ import {
|
|||||||
useStartBatch,
|
useStartBatch,
|
||||||
type Choice,
|
type Choice,
|
||||||
} from './api.ts'
|
} from './api.ts'
|
||||||
import { DeckCard, DECK_DEPTH } from './DeckCard.tsx'
|
import { DeckCard, DECK_DEPTH, type SwipeHandlers } from './DeckCard.tsx'
|
||||||
import { bump, fadeOut, isAbort, pass, prefersReducedMotion, promote, tryIt, type Exit } from './motion.ts'
|
import {
|
||||||
|
bump,
|
||||||
|
fadeOut,
|
||||||
|
follow,
|
||||||
|
isAbort,
|
||||||
|
pass,
|
||||||
|
prefersReducedMotion,
|
||||||
|
promote,
|
||||||
|
snapBack,
|
||||||
|
tryIt,
|
||||||
|
unfollow,
|
||||||
|
type Exit,
|
||||||
|
} from './motion.ts'
|
||||||
import styles from './DiscoverPage.module.css'
|
import styles from './DiscoverPage.module.css'
|
||||||
|
|
||||||
|
// SWIPE_THRESHOLD is how far, in px, the top card must be dragged left or
|
||||||
|
// right for its release to Pass or Try it.
|
||||||
|
const SWIPE_THRESHOLD = 110
|
||||||
|
|
||||||
|
// A Drag is the pointer dragging the top card: where it went down, and the
|
||||||
|
// card's drag transform so far.
|
||||||
|
type Drag = { pointerId: number; x: number; y: number; dx: number; transform: string | null }
|
||||||
|
|
||||||
// isEditable says whether a key press belongs to a field being typed in,
|
// isEditable says whether a key press belongs to a field being typed in,
|
||||||
// which the deck's ← / → keys leave alone.
|
// which the deck's ← / → keys leave alone.
|
||||||
function isEditable(target: EventTarget | null): boolean {
|
function isEditable(target: EventTarget | null): boolean {
|
||||||
@@ -66,12 +86,12 @@ function QuestLogPill({ dinners, ref }: { dinners: { id: string; name: string }[
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DiscoverPage is mock 1a: the deck of Side quests to Try it or Pass, one
|
// DiscoverPage is mock 1a: the deck of Side quests to Try it or Pass, one
|
||||||
// at a time from the top card. A decision from the buttons or the ← / → keys
|
// at a time from the top card. A decision from the buttons, the ← / → keys
|
||||||
// throws the top card out (motion.ts) and sends the decision once it is
|
// or a swipe of the top card throws it out (motion.ts) and sends the
|
||||||
// gone; Retry sends a refused one again. Input is locked from the press
|
// decision once it is gone; Retry sends a refused one again. Input is
|
||||||
// until the decision settles: the lock is a ref, set before anything
|
// locked from the press until the decision settles: the lock is a ref, set
|
||||||
// starts, so a second press before the next render does nothing, and busy
|
// before anything starts, so a second press before the next render does
|
||||||
// mirrors it to disable the buttons.
|
// nothing, and busy mirrors it to disable the buttons.
|
||||||
export function DiscoverPage() {
|
export function DiscoverPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const deck = useDeck()
|
const deck = useDeck()
|
||||||
@@ -106,6 +126,14 @@ export function DiscoverPage() {
|
|||||||
// Side quest the last decision sent was for, until the deck moves past it.
|
// Side quest the last decision sent was for, until the deck moves past it.
|
||||||
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)
|
||||||
|
// 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)
|
||||||
@@ -141,17 +169,38 @@ export function DiscoverPage() {
|
|||||||
)
|
)
|
||||||
// decide bumps the button, throws the top card out — or, motion reduced,
|
// decide bumps the button, throws the top card out — or, motion reduced,
|
||||||
// fades it — then sends the decision; Try it bumps the Quest log pill too.
|
// fades it — then sends the decision; Try it bumps the Quest log pill too.
|
||||||
|
// A swipe's decision comes from the card's drag transform: it is thrown
|
||||||
|
// from there without a bump or wiggle. A button or key decision ends a
|
||||||
|
// drag in progress, putting the card back first.
|
||||||
const decide = useCallback(
|
const decide = useCallback(
|
||||||
(choice: Choice) => {
|
(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) {
|
||||||
|
drag.current = null
|
||||||
|
unfollow(card)
|
||||||
|
}
|
||||||
const reduced = prefersReducedMotion()
|
const reduced = prefersReducedMotion()
|
||||||
let run: Exit
|
let run: Exit
|
||||||
if (reduced) {
|
if (reduced) {
|
||||||
run = fadeOut(card)
|
run = fadeOut(card)
|
||||||
} else {
|
} else if (from === undefined) {
|
||||||
bump((choice === 'try' ? tryRef : passRef).current!)
|
bump((choice === 'try' ? tryRef : passRef).current!)
|
||||||
run = choice === 'try' ? tryIt(card) : pass(card)
|
run = choice === 'try' ? tryIt(card) : pass(card)
|
||||||
|
} else {
|
||||||
|
run = choice === 'try' ? tryIt(card, from) : pass(card, from)
|
||||||
|
}
|
||||||
|
if (from !== undefined) {
|
||||||
|
// Putting a swiped card back puts it back at rest.
|
||||||
|
const thrown = run
|
||||||
|
run = {
|
||||||
|
finished: thrown.finished,
|
||||||
|
cancel: () => {
|
||||||
|
thrown.cancel()
|
||||||
|
unfollow(card)
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
exit.current = run
|
exit.current = run
|
||||||
run.finished.then(
|
run.finished.then(
|
||||||
@@ -165,15 +214,67 @@ export function DiscoverPage() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
[top, lock, send],
|
[top, lock, send, stopSpring],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// swipe drags the top card: it follows the pointer, captured from the
|
||||||
|
// 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. 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<SwipeHandlers>(() => {
|
||||||
|
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
|
||||||
|
if (current === null || current.pointerId !== event.pointerId) return
|
||||||
|
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 (deciding.current) unfollow(card)
|
||||||
|
else if (decides && current.dx > SWIPE_THRESHOLD) decide('try', current.transform)
|
||||||
|
else if (decides && current.dx < -SWIPE_THRESHOLD) decide('pass', current.transform)
|
||||||
|
else if (prefersReducedMotion()) unfollow(card)
|
||||||
|
else springing.current = snapBack(card, current.transform)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
onPointerDown: (event) => {
|
||||||
|
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 }
|
||||||
|
},
|
||||||
|
onPointerMove: (event) => {
|
||||||
|
const current = drag.current
|
||||||
|
if (current === null || current.pointerId !== event.pointerId || deciding.current) return
|
||||||
|
track(event.currentTarget, current, event)
|
||||||
|
},
|
||||||
|
onPointerUp: (event) => release(event, true),
|
||||||
|
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, 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(), [])
|
||||||
|
|
||||||
|
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
|
||||||
@@ -239,7 +340,7 @@ export function DiscoverPage() {
|
|||||||
<>
|
<>
|
||||||
<div ref={deckRef} className={styles.deck}>
|
<div ref={deckRef} className={styles.deck}>
|
||||||
{deck.data.slice(0, DECK_DEPTH.length).map((quest, depth) => (
|
{deck.data.slice(0, DECK_DEPTH.length).map((quest, depth) => (
|
||||||
<DeckCard key={quest.id} quest={quest} depth={depth} />
|
<DeckCard key={quest.id} quest={quest} depth={depth} swipe={depth === 0 ? swipe : undefined} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
import { stubAnimate, type FakeAnimation } from '../test/animate.ts'
|
import { stubAnimate, type FakeAnimation } from '../test/animate.ts'
|
||||||
import { bump, fadeOut, isAbort, pass, promote, tryIt } from './motion.ts'
|
import { bump, dragTransform, fadeOut, follow, isAbort, pass, promote, snapBack, tryIt, unfollow } from './motion.ts'
|
||||||
|
|
||||||
let animations: FakeAnimation[]
|
let animations: FakeAnimation[]
|
||||||
|
|
||||||
@@ -15,10 +15,11 @@ afterEach(() => {
|
|||||||
// card builds a deck card with the parts DeckCard marks.
|
// card builds a deck card with the parts DeckCard marks.
|
||||||
function card() {
|
function card() {
|
||||||
const article = document.createElement('article')
|
const article = document.createElement('article')
|
||||||
article.innerHTML = '<span data-part="try-stamp"></span><span data-part="shade"></span>'
|
article.innerHTML = '<span data-part="try-stamp"></span><span data-part="pass-stamp"></span><span data-part="shade"></span>'
|
||||||
return {
|
return {
|
||||||
article,
|
article,
|
||||||
stamp: article.querySelector('[data-part="try-stamp"]')!,
|
stamp: article.querySelector<HTMLElement>('[data-part="try-stamp"]')!,
|
||||||
|
passStamp: article.querySelector<HTMLElement>('[data-part="pass-stamp"]')!,
|
||||||
shade: article.querySelector('[data-part="shade"]')!,
|
shade: article.querySelector('[data-part="shade"]')!,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,8 +170,52 @@ describe('motion', () => {
|
|||||||
expect(animations.map((a) => a.playState)).toEqual(['idle', 'idle'])
|
expect(animations.map((a) => a.playState)).toEqual(['idle', 'idle'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('drags: translate(dx, dy·0.3) rotate(dx/18 deg)', () => {
|
||||||
|
expect(dragTransform(90, 20)).toBe('translate(90px,6px) rotate(5deg)')
|
||||||
|
expect(dragTransform(-36, -10)).toBe('translate(-36px,-3px) rotate(-2deg)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[50, '0.5', '0'],
|
||||||
|
[-50, '0', '0.5'],
|
||||||
|
[160, '1', '0'],
|
||||||
|
[-160, '0', '1'],
|
||||||
|
[0, '0', '0'],
|
||||||
|
])('follows a drag of %i px with the stamps at Try it! %s, Pass %s', (dx, tryOpacity, passOpacity) => {
|
||||||
|
const { article, stamp, passStamp } = card()
|
||||||
|
expect(follow(article, dx, 0)).toBe(dragTransform(dx, 0))
|
||||||
|
expect(article.style.transform).toBe(dragTransform(dx, 0))
|
||||||
|
expect(stamp.style.opacity).toBe(tryOpacity)
|
||||||
|
expect(passStamp.style.opacity).toBe(passOpacity)
|
||||||
|
expect(animations).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unfollows at once, back to the stylesheet', () => {
|
||||||
|
const { article, stamp, passStamp } = card()
|
||||||
|
follow(article, -80, 12)
|
||||||
|
unfollow(article)
|
||||||
|
expect(article.getAttribute('style')).toBe('')
|
||||||
|
expect(stamp.getAttribute('style')).toBe('')
|
||||||
|
expect(passStamp.getAttribute('style')).toBe('')
|
||||||
|
expect(animations).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('snaps back from the drag transform to rest, 420 ms spring, and hides the stamps', () => {
|
||||||
|
const { article, stamp, passStamp } = card()
|
||||||
|
const from = follow(article, 70, 30)
|
||||||
|
snapBack(article, from)
|
||||||
|
expect(article.style.transform).toBe('')
|
||||||
|
expect(stamp.style.opacity).toBe('')
|
||||||
|
expect(passStamp.style.opacity).toBe('')
|
||||||
|
expect(animations).toHaveLength(1)
|
||||||
|
expect(animations[0]!.target).toBe(article)
|
||||||
|
expect(animations[0]!.keyframes).toEqual([{ transform: 'translate(70px,9px) rotate(3.888888888888889deg)' }, { transform: 'translate(0px,0px) rotate(0deg)' }])
|
||||||
|
expect(animations[0]!.options).toEqual({ duration: 420, easing: 'cubic-bezier(.34,1.4,.64,1)' })
|
||||||
|
})
|
||||||
|
|
||||||
it('fails fast on a card without its parts', () => {
|
it('fails fast on a card without its parts', () => {
|
||||||
expect(() => tryIt(document.createElement('article'))).toThrow('deck card has no try-stamp')
|
expect(() => tryIt(document.createElement('article'))).toThrow('deck card has no try-stamp')
|
||||||
expect(() => pass(document.createElement('article'))).toThrow('deck card has no shade')
|
expect(() => pass(document.createElement('article'))).toThrow('deck card has no shade')
|
||||||
|
expect(() => follow(document.createElement('article'), 10, 0)).toThrow('deck card has no try-stamp')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// The deck's motion, from the handoff's Motion §2 (Try it), §3 (Pass),
|
// The deck's motion, from the handoff's Motion §2 (Try it), §3 (Pass),
|
||||||
// §5 (deck advance) and §6 (reduced motion), with the Web Animations API.
|
// §4 (swipe), §5 (deck advance) and §6 (reduced motion), with the Web
|
||||||
// The exits take the card's starting transform so the swipe gesture can
|
// Animations API. The exits take the card's starting transform so the swipe
|
||||||
// throw a dragged card from where it was let go.
|
// gesture can throw a dragged card from where it was let go.
|
||||||
|
|
||||||
const SPRING = 'cubic-bezier(.34,1.4,.64,1)'
|
const SPRING = 'cubic-bezier(.34,1.4,.64,1)'
|
||||||
const THROW = 'cubic-bezier(.55,0,.8,.4)'
|
const THROW = 'cubic-bezier(.55,0,.8,.4)'
|
||||||
@@ -93,6 +93,38 @@ export function pass(card: HTMLElement, from: string = REST): Exit {
|
|||||||
return exit.done(finished)
|
return exit.done(finished)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dragTransform is the top card's transform dragged dx, dy from where the
|
||||||
|
// pointer went down.
|
||||||
|
export function dragTransform(dx: number, dy: number): string {
|
||||||
|
return `translate(${dx}px,${dy * 0.3}px) rotate(${dx / 18}deg)`
|
||||||
|
}
|
||||||
|
|
||||||
|
// follow moves a dragged card to its drag transform and shows each stamp as
|
||||||
|
// far as the drag has gone toward its side, fully at 100 px. It returns the
|
||||||
|
// transform, for the exit or snap-back the release starts.
|
||||||
|
export function follow(card: HTMLElement, dx: number, dy: number): string {
|
||||||
|
const transform = dragTransform(dx, dy)
|
||||||
|
card.style.transform = transform
|
||||||
|
part(card, 'try-stamp').style.opacity = String(clamp(dx / 100))
|
||||||
|
part(card, 'pass-stamp').style.opacity = String(clamp(-dx / 100))
|
||||||
|
return transform
|
||||||
|
}
|
||||||
|
|
||||||
|
// unfollow puts a dragged card and its stamps back where the stylesheet
|
||||||
|
// places them, at once.
|
||||||
|
export function unfollow(card: HTMLElement) {
|
||||||
|
card.style.removeProperty('transform')
|
||||||
|
part(card, 'try-stamp').style.removeProperty('opacity')
|
||||||
|
part(card, 'pass-stamp').style.removeProperty('opacity')
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapBack springs a card let go short of a decision back to rest from its
|
||||||
|
// drag transform, and hides its stamps.
|
||||||
|
export function snapBack(card: HTMLElement, from: string): Animation {
|
||||||
|
unfollow(card)
|
||||||
|
return card.animate([{ transform: from }, { transform: REST }], { duration: 420, easing: SPRING })
|
||||||
|
}
|
||||||
|
|
||||||
// fadeOut is both exits under prefers-reduced-motion: reduce.
|
// fadeOut is both exits under prefers-reduced-motion: reduce.
|
||||||
export function fadeOut(card: HTMLElement): Exit {
|
export function fadeOut(card: HTMLElement): Exit {
|
||||||
const exit = recorder()
|
const exit = recorder()
|
||||||
@@ -110,6 +142,10 @@ export function promote(cards: HTMLElement[]): Animation[] {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clamp(value: number): number {
|
||||||
|
return Math.min(1, Math.max(0, value))
|
||||||
|
}
|
||||||
|
|
||||||
// part is the card's element marked data-part="name", which DeckCard renders.
|
// part is the card's element marked data-part="name", which DeckCard renders.
|
||||||
function part(card: HTMLElement, name: string): HTMLElement {
|
function part(card: HTMLElement, name: string): HTMLElement {
|
||||||
const element = card.querySelector<HTMLElement>(`[data-part="${name}"]`)
|
const element = card.querySelector<HTMLElement>(`[data-part="${name}"]`)
|
||||||
|
|||||||
Reference in New Issue
Block a user