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