feat(avocet): add toss-zone overlays and grid-rise animation to LabelView

This commit is contained in:
pyr0ball 2026-03-05 13:41:52 -08:00
parent 2bbd925c41
commit f8e911c48f
2 changed files with 108 additions and 6 deletions

View file

@ -1,6 +1,7 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import LabelView from './LabelView.vue'
import EmailCardStack from '../components/EmailCardStack.vue'
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock fetch globally
@ -43,4 +44,49 @@ describe('LabelView', () => {
expect(w.text()).toContain('Skip')
expect(w.text()).toContain('Discard')
})
const queueItem = {
id: 'test-1', subject: 'Test Email', body: 'Test body',
from: 'test@test.com', date: '2026-03-05', source: 'test',
}
// Return queue items for /api/queue, empty array for /api/config/labels
function mockFetchWithQueue() {
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) =>
Promise.resolve({
ok: true,
json: async () => (url as string).includes('/api/queue')
? { items: [queueItem], total: 1 }
: [],
text: async () => '',
})
))
}
it('renders toss zone overlays when isHeld is true (after drag-start)', async () => {
mockFetchWithQueue()
const w = mount(LabelView, { global: { plugins: [createPinia()] } })
await new Promise(r => setTimeout(r, 0))
await w.vm.$nextTick()
// Zone overlays should not exist before drag
expect(w.find('.toss-zone-left').exists()).toBe(false)
// Emit drag-start from EmailCardStack child
const cardStack = w.findComponent(EmailCardStack)
cardStack.vm.$emit('drag-start')
await w.vm.$nextTick()
expect(w.find('.toss-zone-left').exists()).toBe(true)
expect(w.find('.toss-zone-right').exists()).toBe(true)
})
it('bucket-grid-footer has grid-active class while card is held', async () => {
mockFetchWithQueue()
const w = mount(LabelView, { global: { plugins: [createPinia()] } })
await new Promise(r => setTimeout(r, 0))
await w.vm.$nextTick()
expect(w.find('.bucket-grid-footer').classes()).not.toContain('grid-active')
const cardStack = w.findComponent(EmailCardStack)
cardStack.vm.$emit('drag-start')
await w.vm.$nextTick()
expect(w.find('.bucket-grid-footer').classes()).toContain('grid-active')
})
})

View file

@ -36,23 +36,44 @@
<!-- Card stack + label grid -->
<template v-else>
<!-- Toss edge zones thin trip-wires at screen edges, visible only while card is held -->
<Transition name="zone-fade">
<div
v-if="isHeld && motion.rich.value"
class="toss-zone toss-zone-left"
:class="{ active: hoveredZone === 'discard' }"
aria-hidden="true"
></div>
</Transition>
<Transition name="zone-fade">
<div
v-if="isHeld && motion.rich.value"
class="toss-zone toss-zone-right"
:class="{ active: hoveredZone === 'skip' }"
aria-hidden="true"
></div>
</Transition>
<div class="card-stack-wrapper">
<EmailCardStack
:item="store.current"
:is-bucket-mode="isDragging"
:is-bucket-mode="isHeld"
:dismiss-type="dismissType"
@label="handleLabel"
@skip="handleSkip"
@discard="handleDiscard"
@drag-start="isDragging = true"
@drag-end="isDragging = false"
@drag-start="isHeld = true"
@drag-end="isHeld = false"
@zone-hover="hoveredZone = $event"
@bucket-hover="hoveredBucket = $event"
/>
</div>
<div class="bucket-grid-footer">
<div class="bucket-grid-footer" :class="{ 'grid-active': isHeld }">
<LabelBucketGrid
:labels="labels"
:is-bucket-mode="isDragging"
:is-bucket-mode="isHeld"
:hovered-bucket="hoveredBucket"
@label="handleLabel"
/>
</div>
@ -86,7 +107,9 @@ const motion = useMotion() // only needed to pass to child — actual value u
const loading = ref(true)
const apiError = ref(false)
const isDragging = ref(false)
const isHeld = ref(false)
const hoveredZone = ref<'discard' | 'skip' | null>(null)
const hoveredBucket = ref<string | null>(null)
const labels = ref<any[]>([])
const dismissType = ref<'label' | 'skip' | 'discard' | null>(null)
@ -408,5 +431,38 @@ onUnmounted(() => {
background: var(--color-bg, var(--color-surface, #f0f4fc));
padding: 0.5rem 0 0.75rem;
z-index: 10;
transition: transform 250ms cubic-bezier(0.34, 1.56, 0.64, 1),
background 200ms ease;
}
.bucket-grid-footer.grid-active {
transform: translateY(-8px);
}
/* ── Toss edge zones ── */
.toss-zone {
position: fixed;
top: 0;
bottom: 0;
width: 7%;
z-index: 50;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
opacity: 0.25;
transition: opacity 200ms ease, background 200ms ease;
}
.toss-zone-left { left: 0; background: rgba(244, 67, 54, 0.12); color: #ef4444; }
.toss-zone-right { right: 0; background: rgba(255, 152, 0, 0.12); color: #f97316; }
.toss-zone.active {
opacity: 0.85;
background: color-mix(in srgb, currentColor 25%, transparent);
}
/* Zone transition */
.zone-fade-enter-active,
.zone-fade-leave-active { transition: opacity 180ms ease; }
.zone-fade-enter-from,
.zone-fade-leave-to { opacity: 0; }
</style>