feat(avocet): EmailCardStack — swipe gestures, depth shadows, dismissal classes
This commit is contained in:
parent
e7f08ce685
commit
9b5a1ae752
2 changed files with 197 additions and 0 deletions
59
web/src/components/EmailCardStack.test.ts
Normal file
59
web/src/components/EmailCardStack.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
138
web/src/components/EmailCardStack.vue
Normal file
138
web/src/components/EmailCardStack.vue
Normal 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>
|
||||
Loading…
Reference in a new issue