feat(avocet): EmailCardStack — swipe gestures, depth shadows, dismissal classes

This commit is contained in:
pyr0ball 2026-03-03 16:16:09 -08:00
parent e7f08ce685
commit 9b5a1ae752
2 changed files with 197 additions and 0 deletions

View file

@ -0,0 +1,59 @@
import { mount } from '@vue/test-utils'
import EmailCardStack from './EmailCardStack.vue'
import { describe, it, expect } from 'vitest'
const item = {
id: 'abc',
subject: 'Interview at Acme',
body: 'We would like to schedule...',
from: 'hr@acme.com',
date: '2026-03-01',
source: 'imap:test',
}
describe('EmailCardStack', () => {
it('renders the email subject', () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
expect(w.text()).toContain('Interview at Acme')
})
it('renders shadow cards for depth effect', () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
expect(w.findAll('.card-shadow')).toHaveLength(2)
})
it('applies dismiss-label class when dismissType is label', () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false, dismissType: 'label' } })
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 wrapperClasses = w.find('.card-wrapper').classes()
expect(wrapperClasses).not.toContain('dismiss-label')
expect(wrapperClasses).not.toContain('dismiss-discard')
expect(wrapperClasses).not.toContain('dismiss-skip')
})
it('emits drag-start on dragstart event', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
await w.find('.card-stack').trigger('dragstart')
expect(w.emitted('drag-start')).toBeTruthy()
})
it('emits drag-end on dragend event', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
await w.find('.card-stack').trigger('dragend')
expect(w.emitted('drag-end')).toBeTruthy()
})
})

View file

@ -0,0 +1,138 @@
<template>
<div
class="card-stack"
ref="stackEl"
:draggable="motion.rich.value"
@dragstart="onDragStart"
@dragend="onDragEnd"
>
<!-- Depth shadow cards (visual stack effect) -->
<div class="card-shadow card-shadow-2" aria-hidden="true" />
<div class="card-shadow card-shadow-1" aria-hidden="true" />
<!-- Active card -->
<div
class="card-wrapper"
ref="cardEl"
:class="dismissClass"
:style="cardStyle"
>
<EmailCard
:item="item"
:expanded="isExpanded"
@expand="isExpanded = true"
@collapse="isExpanded = false"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useSwipe } from '@vueuse/core'
import { useMotion } from '../composables/useMotion'
import EmailCard from './EmailCard.vue'
import type { QueueItem } from '../stores/label'
const props = defineProps<{
item: QueueItem
isBucketMode: boolean
dismissType?: 'label' | 'skip' | 'discard' | null
}>()
const emit = defineEmits<{
label: [name: string]
skip: []
discard: []
'drag-start': []
'drag-end': []
}>()
const motion = useMotion()
const cardEl = ref<HTMLElement | null>(null)
const stackEl = ref<HTMLElement | null>(null)
const isExpanded = ref(false)
const dragX = ref(0)
const { isSwiping, lengthX } = useSwipe(cardEl, {
threshold: 60,
onSwipeEnd(_, dir) {
if (dir === 'left') emit('discard')
if (dir === 'right') emit('skip')
dragX.value = 0
},
onSwipe() {
if (motion.rich.value) dragX.value = lengthX.value * -1
},
})
const dismissClass = computed(() => {
if (!props.dismissType) return null
return `dismiss-${props.dismissType}`
})
const cardStyle = computed(() => {
if (!motion.rich.value || !isSwiping.value) return {}
const tilt = dragX.value * 0.05
const opacity = Math.abs(dragX.value) > 20 ? 0.9 : 1
const color = dragX.value < -20 ? 'rgba(244,67,54,0.15)'
: dragX.value > 20 ? 'rgba(255,152,0,0.15)'
: 'transparent'
return {
transform: `translateX(${dragX.value}px) rotate(${tilt}deg)`,
opacity,
background: color,
transition: isSwiping.value ? 'none' : 'all 0.3s ease',
}
})
function onDragStart() { emit('drag-start') }
function onDragEnd() { emit('drag-end') }
</script>
<style scoped>
.card-stack {
position: relative;
min-height: 200px;
}
.card-shadow {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
border-radius: var(--radius-card, 1rem);
background: var(--color-surface-raised, #fff);
border: 1px solid var(--color-border, #e0e4ed);
}
.card-shadow-1 { transform: translateY(8px) scale(0.97); opacity: 0.6; }
.card-shadow-2 { transform: translateY(16px) scale(0.94); opacity: 0.35; }
.card-wrapper {
position: relative;
z-index: 1;
border-radius: var(--radius-card, 1rem);
background: var(--color-surface-raised, #fff);
will-change: transform, opacity;
}
/* Dismissal animations — only active under .rich-motion on root */
:global(.rich-motion) .card-wrapper.dismiss-label {
animation: fileAway var(--card-dismiss, 350ms ease-in) forwards;
}
:global(.rich-motion) .card-wrapper.dismiss-discard {
animation: crumple var(--card-dismiss, 350ms ease-in) forwards;
}
:global(.rich-motion) .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; }
}
</style>