feat(avocet): wire Anime.js card animation into EmailCardStack
Replace CSS keyframe dismiss classes and inline cardStyle/deltaX/deltaY with useCardAnimation composable — pickup/setDragPosition/snapBack/animateDismiss are now called from pointer event handlers and a dismissType watcher.
This commit is contained in:
parent
3197252c31
commit
6c98ee6d69
2 changed files with 34 additions and 84 deletions
|
|
@ -2,6 +2,18 @@ import { mount } from '@vue/test-utils'
|
||||||
import EmailCardStack from './EmailCardStack.vue'
|
import EmailCardStack from './EmailCardStack.vue'
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('../composables/useCardAnimation', () => ({
|
||||||
|
useCardAnimation: vi.fn(() => ({
|
||||||
|
pickup: vi.fn(),
|
||||||
|
setDragPosition: vi.fn(),
|
||||||
|
snapBack: vi.fn(),
|
||||||
|
animateDismiss: vi.fn(),
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { useCardAnimation } from '../composables/useCardAnimation'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
|
||||||
const item = {
|
const item = {
|
||||||
id: 'abc',
|
id: 'abc',
|
||||||
subject: 'Interview at Acme',
|
subject: 'Interview at Acme',
|
||||||
|
|
@ -22,27 +34,13 @@ describe('EmailCardStack', () => {
|
||||||
expect(w.findAll('.card-shadow')).toHaveLength(2)
|
expect(w.findAll('.card-shadow')).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('applies dismiss-label class when dismissType is label', () => {
|
it('calls animateDismiss with type when dismissType prop changes', async () => {
|
||||||
const w = mount(EmailCardStack, { props: { item, isBucketMode: false, dismissType: 'label' } })
|
;(useCardAnimation as ReturnType<typeof vi.fn>).mockClear()
|
||||||
expect(w.find('.card-wrapper').classes()).toContain('dismiss-label')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('applies dismiss-discard class when dismissType is discard', () => {
|
|
||||||
const w = mount(EmailCardStack, { props: { item, isBucketMode: false, dismissType: 'discard' } })
|
|
||||||
expect(w.find('.card-wrapper').classes()).toContain('dismiss-discard')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('applies dismiss-skip class when dismissType is skip', () => {
|
|
||||||
const w = mount(EmailCardStack, { props: { item, isBucketMode: false, dismissType: 'skip' } })
|
|
||||||
expect(w.find('.card-wrapper').classes()).toContain('dismiss-skip')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('no dismiss class when dismissType is null', () => {
|
|
||||||
const w = mount(EmailCardStack, { props: { item, isBucketMode: false, dismissType: null } })
|
const w = mount(EmailCardStack, { props: { item, isBucketMode: false, dismissType: null } })
|
||||||
const wrapperClasses = w.find('.card-wrapper').classes()
|
const { animateDismiss } = (useCardAnimation as ReturnType<typeof vi.fn>).mock.results[0].value
|
||||||
expect(wrapperClasses).not.toContain('dismiss-label')
|
await w.setProps({ dismissType: 'label' })
|
||||||
expect(wrapperClasses).not.toContain('dismiss-discard')
|
await nextTick()
|
||||||
expect(wrapperClasses).not.toContain('dismiss-skip')
|
expect(animateDismiss).toHaveBeenCalledWith('label')
|
||||||
})
|
})
|
||||||
|
|
||||||
// JSDOM doesn't implement setPointerCapture — mock it on the element.
|
// JSDOM doesn't implement setPointerCapture — mock it on the element.
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,7 @@
|
||||||
<div
|
<div
|
||||||
class="card-wrapper"
|
class="card-wrapper"
|
||||||
ref="cardEl"
|
ref="cardEl"
|
||||||
:class="[dismissClass, { 'is-held': isHeld }]"
|
:class="{ 'is-held': isHeld }"
|
||||||
:style="cardStyle"
|
|
||||||
@pointerdown="onPointerDown"
|
@pointerdown="onPointerDown"
|
||||||
@pointermove="onPointerMove"
|
@pointermove="onPointerMove"
|
||||||
@pointerup="onPointerUp"
|
@pointerup="onPointerUp"
|
||||||
|
|
@ -29,8 +28,9 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { useMotion } from '../composables/useMotion'
|
import { useMotion } from '../composables/useMotion'
|
||||||
|
import { useCardAnimation } from '../composables/useCardAnimation'
|
||||||
import EmailCard from './EmailCard.vue'
|
import EmailCard from './EmailCard.vue'
|
||||||
import type { QueueItem } from '../stores/label'
|
import type { QueueItem } from '../stores/label'
|
||||||
|
|
||||||
|
|
@ -54,12 +54,16 @@ const motion = useMotion()
|
||||||
const cardEl = ref<HTMLElement | null>(null)
|
const cardEl = ref<HTMLElement | null>(null)
|
||||||
const isExpanded = ref(false)
|
const isExpanded = ref(false)
|
||||||
|
|
||||||
|
const { pickup, setDragPosition, snapBack, animateDismiss } = useCardAnimation(cardEl, motion)
|
||||||
|
|
||||||
|
watch(() => props.dismissType, (type) => {
|
||||||
|
if (type) animateDismiss(type)
|
||||||
|
})
|
||||||
|
|
||||||
// Toss gesture state
|
// Toss gesture state
|
||||||
const isHeld = ref(false)
|
const isHeld = ref(false)
|
||||||
const pickupX = ref(0)
|
const pickupX = ref(0)
|
||||||
const pickupY = ref(0)
|
const pickupY = ref(0)
|
||||||
const deltaX = ref(0)
|
|
||||||
const deltaY = ref(0)
|
|
||||||
const hoveredZone = ref<'discard' | 'skip' | null>(null)
|
const hoveredZone = ref<'discard' | 'skip' | null>(null)
|
||||||
const hoveredBucketName = ref<string | null>(null)
|
const hoveredBucketName = ref<string | null>(null)
|
||||||
|
|
||||||
|
|
@ -78,9 +82,8 @@ function onPointerDown(e: PointerEvent) {
|
||||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||||
pickupX.value = e.clientX
|
pickupX.value = e.clientX
|
||||||
pickupY.value = e.clientY
|
pickupY.value = e.clientY
|
||||||
deltaX.value = 0
|
|
||||||
deltaY.value = 0
|
|
||||||
isHeld.value = true
|
isHeld.value = true
|
||||||
|
pickup()
|
||||||
hoveredZone.value = null
|
hoveredZone.value = null
|
||||||
hoveredBucketName.value = null
|
hoveredBucketName.value = null
|
||||||
velocityBuf = []
|
velocityBuf = []
|
||||||
|
|
@ -89,8 +92,9 @@ function onPointerDown(e: PointerEvent) {
|
||||||
|
|
||||||
function onPointerMove(e: PointerEvent) {
|
function onPointerMove(e: PointerEvent) {
|
||||||
if (!isHeld.value) return
|
if (!isHeld.value) return
|
||||||
deltaX.value = e.clientX - pickupX.value
|
const dx = e.clientX - pickupX.value
|
||||||
deltaY.value = e.clientY - pickupY.value
|
const dy = e.clientY - pickupY.value
|
||||||
|
setDragPosition(dx, dy)
|
||||||
|
|
||||||
// Rolling velocity buffer — keep only the last FLING_WINDOW_MS of samples
|
// Rolling velocity buffer — keep only the last FLING_WINDOW_MS of samples
|
||||||
const now = performance.now()
|
const now = performance.now()
|
||||||
|
|
@ -163,9 +167,8 @@ function onPointerUp(e: PointerEvent) {
|
||||||
hoveredBucketName.value = null
|
hoveredBucketName.value = null
|
||||||
emit('label', name)
|
emit('label', name)
|
||||||
} else {
|
} else {
|
||||||
// Snap back — reset deltas
|
// Snap back
|
||||||
deltaX.value = 0
|
snapBack()
|
||||||
deltaY.value = 0
|
|
||||||
hoveredZone.value = null
|
hoveredZone.value = null
|
||||||
hoveredBucketName.value = null
|
hoveredBucketName.value = null
|
||||||
}
|
}
|
||||||
|
|
@ -175,8 +178,7 @@ function onPointerCancel(e: PointerEvent) {
|
||||||
if (!isHeld.value) return
|
if (!isHeld.value) return
|
||||||
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
||||||
isHeld.value = false
|
isHeld.value = false
|
||||||
deltaX.value = 0
|
snapBack()
|
||||||
deltaY.value = 0
|
|
||||||
hoveredZone.value = null
|
hoveredZone.value = null
|
||||||
hoveredBucketName.value = null
|
hoveredBucketName.value = null
|
||||||
velocityBuf = []
|
velocityBuf = []
|
||||||
|
|
@ -184,32 +186,6 @@ function onPointerCancel(e: PointerEvent) {
|
||||||
emit('zone-hover', null)
|
emit('zone-hover', null)
|
||||||
emit('bucket-hover', null)
|
emit('bucket-hover', null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const dismissClass = computed(() => {
|
|
||||||
if (!props.dismissType) return null
|
|
||||||
return `dismiss-${props.dismissType}`
|
|
||||||
})
|
|
||||||
|
|
||||||
const cardStyle = computed(() => {
|
|
||||||
if (!motion.rich.value || !isHeld.value) return {}
|
|
||||||
|
|
||||||
// Aura color: zone > bucket > neutral
|
|
||||||
const aura =
|
|
||||||
hoveredZone.value === 'discard' ? 'rgba(244,67,54,0.25)' :
|
|
||||||
hoveredZone.value === 'skip' ? 'rgba(255,152,0,0.25)' :
|
|
||||||
hoveredBucketName.value ? 'rgba(42,96,128,0.20)' :
|
|
||||||
'transparent'
|
|
||||||
|
|
||||||
return {
|
|
||||||
transform: `translate(${deltaX.value}px, ${deltaY.value - 80}px) scale(0.55)`,
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: aura,
|
|
||||||
transition: 'border-radius 150ms ease, background 150ms ease',
|
|
||||||
cursor: 'grabbing',
|
|
||||||
zIndex: 100,
|
|
||||||
userSelect: 'none',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -276,30 +252,6 @@ const cardStyle = computed(() => {
|
||||||
pointer-events: auto !important;
|
pointer-events: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dismissal animations — dismiss class is only applied during the motion.rich await window,
|
|
||||||
so no ancestor guard needed; :global(.rich-motion) was being miscompiled by Vue's scoped
|
|
||||||
CSS transformer (dropping the descendant selector entirely). */
|
|
||||||
.card-wrapper.dismiss-label {
|
|
||||||
animation: fileAway var(--card-dismiss, 350ms ease-in) forwards;
|
|
||||||
}
|
|
||||||
.card-wrapper.dismiss-discard {
|
|
||||||
animation: crumple var(--card-dismiss, 350ms ease-in) forwards;
|
|
||||||
}
|
|
||||||
.card-wrapper.dismiss-skip {
|
|
||||||
animation: slideUnder var(--card-skip, 300ms ease-out) forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fileAway {
|
|
||||||
to { transform: translateY(-120%) scale(0.85); opacity: 0; }
|
|
||||||
}
|
|
||||||
@keyframes crumple {
|
|
||||||
50% { transform: scale(0.95) rotate(2deg); filter: brightness(0.6) sepia(1) hue-rotate(-20deg); }
|
|
||||||
to { transform: scale(0) rotate(8deg); opacity: 0; }
|
|
||||||
}
|
|
||||||
@keyframes slideUnder {
|
|
||||||
to { transform: translateX(110%) rotate(5deg); opacity: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.card-stack,
|
.card-stack,
|
||||||
.card-wrapper {
|
.card-wrapper {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue