feat(avocet): add toss-zone overlays and grid-rise animation to LabelView
This commit is contained in:
parent
2bbd925c41
commit
f8e911c48f
2 changed files with 108 additions and 6 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import LabelView from './LabelView.vue'
|
import LabelView from './LabelView.vue'
|
||||||
|
import EmailCardStack from '../components/EmailCardStack.vue'
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
// Mock fetch globally
|
// Mock fetch globally
|
||||||
|
|
@ -43,4 +44,49 @@ describe('LabelView', () => {
|
||||||
expect(w.text()).toContain('Skip')
|
expect(w.text()).toContain('Skip')
|
||||||
expect(w.text()).toContain('Discard')
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -36,23 +36,44 @@
|
||||||
|
|
||||||
<!-- Card stack + label grid -->
|
<!-- Card stack + label grid -->
|
||||||
<template v-else>
|
<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">
|
<div class="card-stack-wrapper">
|
||||||
<EmailCardStack
|
<EmailCardStack
|
||||||
:item="store.current"
|
:item="store.current"
|
||||||
:is-bucket-mode="isDragging"
|
:is-bucket-mode="isHeld"
|
||||||
:dismiss-type="dismissType"
|
:dismiss-type="dismissType"
|
||||||
@label="handleLabel"
|
@label="handleLabel"
|
||||||
@skip="handleSkip"
|
@skip="handleSkip"
|
||||||
@discard="handleDiscard"
|
@discard="handleDiscard"
|
||||||
@drag-start="isDragging = true"
|
@drag-start="isHeld = true"
|
||||||
@drag-end="isDragging = false"
|
@drag-end="isHeld = false"
|
||||||
|
@zone-hover="hoveredZone = $event"
|
||||||
|
@bucket-hover="hoveredBucket = $event"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bucket-grid-footer">
|
<div class="bucket-grid-footer" :class="{ 'grid-active': isHeld }">
|
||||||
<LabelBucketGrid
|
<LabelBucketGrid
|
||||||
:labels="labels"
|
:labels="labels"
|
||||||
:is-bucket-mode="isDragging"
|
:is-bucket-mode="isHeld"
|
||||||
|
:hovered-bucket="hoveredBucket"
|
||||||
@label="handleLabel"
|
@label="handleLabel"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -86,7 +107,9 @@ const motion = useMotion() // only needed to pass to child — actual value u
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const apiError = ref(false)
|
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 labels = ref<any[]>([])
|
||||||
const dismissType = ref<'label' | 'skip' | 'discard' | null>(null)
|
const dismissType = ref<'label' | 'skip' | 'discard' | null>(null)
|
||||||
|
|
||||||
|
|
@ -408,5 +431,38 @@ onUnmounted(() => {
|
||||||
background: var(--color-bg, var(--color-surface, #f0f4fc));
|
background: var(--color-bg, var(--color-surface, #f0f4fc));
|
||||||
padding: 0.5rem 0 0.75rem;
|
padding: 0.5rem 0 0.75rem;
|
||||||
z-index: 10;
|
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>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue