diff --git a/web/src/composables/useCardAnimation.test.ts b/web/src/composables/useCardAnimation.test.ts new file mode 100644 index 0000000..04005c1 --- /dev/null +++ b/web/src/composables/useCardAnimation.test.ts @@ -0,0 +1,104 @@ +import { ref } from 'vue' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock animejs before importing the composable +vi.mock('animejs', () => ({ + animate: vi.fn(), + spring: vi.fn(() => 'mock-spring'), + utils: { set: vi.fn() }, +})) + +import { useCardAnimation } from './useCardAnimation' +import { animate, utils } from 'animejs' + +const mockAnimate = animate as ReturnType +const mockSet = utils.set as ReturnType + +function makeEl() { + return document.createElement('div') +} + +describe('useCardAnimation', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('pickup() calls animate with ball shape', () => { + const el = makeEl() + const cardEl = ref(el) + const motion = { rich: ref(true) } + const { pickup } = useCardAnimation(cardEl, motion) + pickup() + expect(mockAnimate).toHaveBeenCalledWith( + el, + expect.objectContaining({ scale: 0.55, borderRadius: '50%' }), + ) + }) + + it('pickup() is a no-op when motion.rich is false', () => { + const el = makeEl() + const cardEl = ref(el) + const motion = { rich: ref(false) } + const { pickup } = useCardAnimation(cardEl, motion) + pickup() + expect(mockAnimate).not.toHaveBeenCalled() + }) + + it('setDragPosition() calls utils.set with translated coords', () => { + const el = makeEl() + const cardEl = ref(el) + const motion = { rich: ref(true) } + const { setDragPosition } = useCardAnimation(cardEl, motion) + setDragPosition(50, 30) + expect(mockSet).toHaveBeenCalledWith(el, expect.objectContaining({ x: 50, y: -50 })) + // y = deltaY - 80 = 30 - 80 = -50 + }) + + it('snapBack() calls animate returning to card shape', () => { + const el = makeEl() + const cardEl = ref(el) + const motion = { rich: ref(true) } + const { snapBack } = useCardAnimation(cardEl, motion) + snapBack() + expect(mockAnimate).toHaveBeenCalledWith( + el, + expect.objectContaining({ x: 0, y: 0, scale: 1 }), + ) + }) + + it('animateDismiss("label") calls animate', () => { + const el = makeEl() + const cardEl = ref(el) + const motion = { rich: ref(true) } + const { animateDismiss } = useCardAnimation(cardEl, motion) + animateDismiss('label') + expect(mockAnimate).toHaveBeenCalled() + }) + + it('animateDismiss("discard") calls animate', () => { + const el = makeEl() + const cardEl = ref(el) + const motion = { rich: ref(true) } + const { animateDismiss } = useCardAnimation(cardEl, motion) + animateDismiss('discard') + expect(mockAnimate).toHaveBeenCalled() + }) + + it('animateDismiss("skip") calls animate', () => { + const el = makeEl() + const cardEl = ref(el) + const motion = { rich: ref(true) } + const { animateDismiss } = useCardAnimation(cardEl, motion) + animateDismiss('skip') + expect(mockAnimate).toHaveBeenCalled() + }) + + it('animateDismiss is a no-op when motion.rich is false', () => { + const el = makeEl() + const cardEl = ref(el) + const motion = { rich: ref(false) } + const { animateDismiss } = useCardAnimation(cardEl, motion) + animateDismiss('label') + expect(mockAnimate).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/composables/useCardAnimation.ts b/web/src/composables/useCardAnimation.ts new file mode 100644 index 0000000..00a2f8b --- /dev/null +++ b/web/src/composables/useCardAnimation.ts @@ -0,0 +1,67 @@ +import { type Ref } from 'vue' +import { animate, spring, utils } from 'animejs' + +const BALL_SCALE = 0.55 +const BALL_RADIUS = '50%' +const CARD_RADIUS = '1rem' +const PICKUP_Y_OFFSET = 80 // px above finger +const PICKUP_DURATION = 200 + +// Anime.js v4: spring() takes an object { mass, stiffness, damping, velocity } +const SNAP_SPRING = spring({ mass: 1, stiffness: 80, damping: 10 }) + +interface Motion { rich: Ref } + +export function useCardAnimation( + cardEl: Ref, + motion: Motion, +) { + function pickup() { + if (!motion.rich.value || !cardEl.value) return + // Anime.js v4: animate(target, params) — all props + timing in one object + animate(cardEl.value, { + scale: BALL_SCALE, + borderRadius: BALL_RADIUS, + y: -PICKUP_Y_OFFSET, + duration: PICKUP_DURATION, + ease: SNAP_SPRING, + }) + } + + function setDragPosition(dx: number, dy: number) { + if (!cardEl.value) return + // utils.set() for instant (no-animation) position update — keeps Anime cache consistent + utils.set(cardEl.value, { x: dx, y: dy - PICKUP_Y_OFFSET }) + } + + function snapBack() { + if (!motion.rich.value || !cardEl.value) return + animate(cardEl.value, { + x: 0, + y: 0, + scale: 1, + borderRadius: CARD_RADIUS, + ease: SNAP_SPRING, + }) + } + + function animateDismiss(type: 'label' | 'skip' | 'discard') { + if (!motion.rich.value || !cardEl.value) return + const el = cardEl.value + if (type === 'label') { + animate(el, { y: '-120%', scale: 0.85, opacity: 0, duration: 280, ease: 'out(3)' }) + } else if (type === 'discard') { + // Anime.js v4 keyframe array: array of param objects, each can have its own duration + animate(el, { + keyframes: [ + { scale: 0.95, rotate: 2, filter: 'brightness(0.6) sepia(1) hue-rotate(-20deg)', duration: 140 }, + { scale: 0, rotate: 8, opacity: 0, duration: 210 }, + ], + }) + } else if (type === 'skip') { + animate(el, { x: '110%', rotate: 5, opacity: 0, duration: 260, ease: 'out(2)' }) + } + } + + return { pickup, setDragPosition, snapBack, animateDismiss } +}