feat(avocet): add useCardAnimation composable with Anime.js
TDD: 8 tests written first (red), then composable implemented (green). Adapts to Anime.js v4 API: 2-arg animate(), object-param spring(), utils.set() for instant drag-position updates without cache desync.
This commit is contained in:
parent
d418a719f0
commit
4bea1b6812
2 changed files with 171 additions and 0 deletions
104
web/src/composables/useCardAnimation.test.ts
Normal file
104
web/src/composables/useCardAnimation.test.ts
Normal file
|
|
@ -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<typeof vi.fn>
|
||||
const mockSet = utils.set as ReturnType<typeof vi.fn>
|
||||
|
||||
function makeEl() {
|
||||
return document.createElement('div')
|
||||
}
|
||||
|
||||
describe('useCardAnimation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('pickup() calls animate with ball shape', () => {
|
||||
const el = makeEl()
|
||||
const cardEl = ref<HTMLElement | null>(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<HTMLElement | null>(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<HTMLElement | null>(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<HTMLElement | null>(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<HTMLElement | null>(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<HTMLElement | null>(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<HTMLElement | null>(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<HTMLElement | null>(el)
|
||||
const motion = { rich: ref(false) }
|
||||
const { animateDismiss } = useCardAnimation(cardEl, motion)
|
||||
animateDismiss('label')
|
||||
expect(mockAnimate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
67
web/src/composables/useCardAnimation.ts
Normal file
67
web/src/composables/useCardAnimation.ts
Normal file
|
|
@ -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<boolean> }
|
||||
|
||||
export function useCardAnimation(
|
||||
cardEl: Ref<HTMLElement | null>,
|
||||
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 }
|
||||
}
|
||||
Loading…
Reference in a new issue