feat(avocet): replace swipe+HTML5-drag with unified pointer-events toss gesture

This commit is contained in:
pyr0ball 2026-03-05 10:38:52 -08:00
parent a8b1c89c62
commit 2bbd925c41
2 changed files with 195 additions and 82 deletions

View file

@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import EmailCardStack from './EmailCardStack.vue' import EmailCardStack from './EmailCardStack.vue'
import { describe, it, expect } from 'vitest' import { describe, it, expect, vi } from 'vitest'
const item = { const item = {
id: 'abc', id: 'abc',
@ -45,15 +45,70 @@ describe('EmailCardStack', () => {
expect(wrapperClasses).not.toContain('dismiss-skip') expect(wrapperClasses).not.toContain('dismiss-skip')
}) })
it('emits drag-start on dragstart event', async () => { // JSDOM doesn't implement setPointerCapture — mock it on the element.
// Also use dispatchEvent(new PointerEvent) directly because @vue/test-utils
// .trigger() tries to assign clientX on a MouseEvent (read-only in JSDOM).
function mockPointerCapture(element: Element) {
;(element as any).setPointerCapture = vi.fn()
;(element as any).releasePointerCapture = vi.fn()
}
function fire(element: Element, type: string, init: PointerEventInit) {
element.dispatchEvent(new PointerEvent(type, { bubbles: true, ...init }))
}
it('emits drag-start on pointerdown', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } }) const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
await w.find('.card-stack').trigger('dragstart') const el = w.find('.card-wrapper').element
mockPointerCapture(el)
fire(el, 'pointerdown', { pointerId: 1, clientX: 200, clientY: 300 })
await w.vm.$nextTick()
expect(w.emitted('drag-start')).toBeTruthy() expect(w.emitted('drag-start')).toBeTruthy()
}) })
it('emits drag-end on dragend event', async () => { it('emits drag-end on pointerup', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } }) const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
await w.find('.card-stack').trigger('dragend') const el = w.find('.card-wrapper').element
mockPointerCapture(el)
fire(el, 'pointerdown', { pointerId: 1, clientX: 200, clientY: 300 })
fire(el, 'pointerup', { pointerId: 1, clientX: 200, clientY: 300 })
await w.vm.$nextTick()
expect(w.emitted('drag-end')).toBeTruthy() expect(w.emitted('drag-end')).toBeTruthy()
}) })
it('emits discard when released in left zone (x < 7% viewport)', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
const el = w.find('.card-wrapper').element
mockPointerCapture(el)
// JSDOM window.innerWidth defaults to 1024; 7% = 71.7px
fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
fire(el, 'pointermove', { pointerId: 1, clientX: 30, clientY: 300 })
fire(el, 'pointerup', { pointerId: 1, clientX: 30, clientY: 300 })
await w.vm.$nextTick()
expect(w.emitted('discard')).toBeTruthy()
})
it('emits skip when released in right zone (x > 93% viewport)', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
const el = w.find('.card-wrapper').element
mockPointerCapture(el)
// JSDOM window.innerWidth defaults to 1024; 93% = 952px
fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
fire(el, 'pointermove', { pointerId: 1, clientX: 1000, clientY: 300 })
fire(el, 'pointerup', { pointerId: 1, clientX: 1000, clientY: 300 })
await w.vm.$nextTick()
expect(w.emitted('skip')).toBeTruthy()
})
it('does not emit action on pointerup without movement past zone', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
const el = w.find('.card-wrapper').element
mockPointerCapture(el)
fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
fire(el, 'pointerup', { pointerId: 1, clientX: 512, clientY: 300 })
await w.vm.$nextTick()
expect(w.emitted('discard')).toBeFalsy()
expect(w.emitted('skip')).toBeFalsy()
expect(w.emitted('label')).toBeFalsy()
})
}) })

View file

@ -2,10 +2,6 @@
<div <div
class="card-stack" class="card-stack"
:class="{ 'bucket-mode': isBucketMode && motion.rich.value }" :class="{ 'bucket-mode': isBucketMode && motion.rich.value }"
ref="stackEl"
:draggable="motion.rich.value"
@dragstart="onDragStart"
@dragend="onDragEnd"
> >
<!-- Depth shadow cards (visual stack effect) --> <!-- Depth shadow cards (visual stack effect) -->
<div class="card-shadow card-shadow-2" aria-hidden="true" /> <div class="card-shadow card-shadow-2" aria-hidden="true" />
@ -15,8 +11,12 @@
<div <div
class="card-wrapper" class="card-wrapper"
ref="cardEl" ref="cardEl"
:class="dismissClass" :class="[dismissClass, { 'is-held': isHeld }]"
:style="cardStyle" :style="cardStyle"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerCancel"
> >
<EmailCard <EmailCard
:item="item" :item="item"
@ -30,7 +30,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useSwipe } from '@vueuse/core'
import { useMotion } from '../composables/useMotion' import { useMotion } from '../composables/useMotion'
import EmailCard from './EmailCard.vue' import EmailCard from './EmailCard.vue'
import type { QueueItem } from '../stores/label' import type { QueueItem } from '../stores/label'
@ -47,25 +46,107 @@ const emit = defineEmits<{
discard: [] discard: []
'drag-start': [] 'drag-start': []
'drag-end': [] 'drag-end': []
'zone-hover': ['discard' | 'skip' | null]
'bucket-hover': [string | null]
}>() }>()
const motion = useMotion() const motion = useMotion()
const cardEl = ref<HTMLElement | null>(null) const cardEl = ref<HTMLElement | null>(null)
const stackEl = ref<HTMLElement | null>(null)
const isExpanded = ref(false) const isExpanded = ref(false)
const dragX = ref(0)
const { isSwiping, lengthX } = useSwipe(cardEl, { // Toss gesture state
threshold: 60, const isHeld = ref(false)
onSwipeEnd(_, dir) { const pickupX = ref(0)
if (dir === 'left') emit('discard') const pickupY = ref(0)
if (dir === 'right') emit('skip') const deltaX = ref(0)
dragX.value = 0 const deltaY = ref(0)
}, const hoveredZone = ref<'discard' | 'skip' | null>(null)
onSwipe() { const hoveredBucketName = ref<string | null>(null)
if (motion.rich.value) dragX.value = lengthX.value * -1
}, // Zone threshold: 7% of viewport width on each side
}) const ZONE_PCT = 0.07
function onPointerDown(e: PointerEvent) {
if (!motion.rich.value) return
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
pickupX.value = e.clientX
pickupY.value = e.clientY
deltaX.value = 0
deltaY.value = 0
isHeld.value = true
hoveredZone.value = null
hoveredBucketName.value = null
emit('drag-start')
}
function onPointerMove(e: PointerEvent) {
if (!isHeld.value) return
deltaX.value = e.clientX - pickupX.value
deltaY.value = e.clientY - pickupY.value
const vw = window.innerWidth
const zone: 'discard' | 'skip' | null =
e.clientX < vw * ZONE_PCT ? 'discard' :
e.clientX > vw * (1 - ZONE_PCT) ? 'skip' :
null
if (zone !== hoveredZone.value) {
hoveredZone.value = zone
emit('zone-hover', zone)
}
// Bucket detection via hit-test (works through overlapping elements).
// Optional chain guards against JSDOM in tests (doesn't implement elementsFromPoint).
const els = document.elementsFromPoint?.(e.clientX, e.clientY) ?? []
const bucket = els.find(el => el.hasAttribute('data-label-key'))
const bucketName = bucket?.getAttribute('data-label-key') ?? null
if (bucketName !== hoveredBucketName.value) {
hoveredBucketName.value = bucketName
emit('bucket-hover', bucketName)
}
}
function onPointerUp(e: PointerEvent) {
if (!isHeld.value) return
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
isHeld.value = false
emit('drag-end')
emit('zone-hover', null)
emit('bucket-hover', null)
if (hoveredZone.value === 'discard') {
hoveredZone.value = null
hoveredBucketName.value = null
emit('discard')
} else if (hoveredZone.value === 'skip') {
hoveredZone.value = null
hoveredBucketName.value = null
emit('skip')
} else if (hoveredBucketName.value) {
const name = hoveredBucketName.value
hoveredZone.value = null
hoveredBucketName.value = null
emit('label', name)
} else {
// Snap back reset deltas
deltaX.value = 0
deltaY.value = 0
hoveredZone.value = null
hoveredBucketName.value = null
}
}
function onPointerCancel(e: PointerEvent) {
if (!isHeld.value) return
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
isHeld.value = false
deltaX.value = 0
deltaY.value = 0
hoveredZone.value = null
hoveredBucketName.value = null
emit('drag-end')
emit('zone-hover', null)
emit('bucket-hover', null)
}
const dismissClass = computed(() => { const dismissClass = computed(() => {
if (!props.dismissType) return null if (!props.dismissType) return null
@ -73,52 +154,25 @@ const dismissClass = computed(() => {
}) })
const cardStyle = computed(() => { const cardStyle = computed(() => {
if (!motion.rich.value || !isSwiping.value) return {} if (!motion.rich.value || !isHeld.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(e: DragEvent) { // Aura color: zone > bucket > neutral
if (motion.rich.value && e.dataTransfer) { const aura =
// Custom drag ghost: small crumpled-paper ball hoveredZone.value === 'discard' ? 'rgba(244,67,54,0.25)' :
const ghost = document.createElement('div') hoveredZone.value === 'skip' ? 'rgba(255,152,0,0.25)' :
ghost.setAttribute('aria-hidden', 'true') hoveredBucketName.value ? 'rgba(42,96,128,0.20)' :
Object.assign(ghost.style, { 'transparent'
position: 'fixed',
top: '-200px', return {
left: '0', transform: `translate(${deltaX.value}px, ${deltaY.value}px) scale(0.35)`,
width: '80px',
height: '80px',
borderRadius: '50%', borderRadius: '50%',
background: '#e4ebf5', background: aura,
border: '3px solid #2A6080', transition: 'border-radius 150ms ease, background 150ms ease',
display: 'flex', cursor: 'grabbing',
alignItems: 'center', zIndex: 100,
justifyContent: 'center', userSelect: 'none',
fontSize: '1.75rem', }
boxShadow: '0 4px 20px rgba(0,0,0,0.25)',
transform: 'rotate(-5deg)',
}) })
ghost.textContent = '✉️'
document.body.appendChild(ghost)
e.dataTransfer.setDragImage(ghost, 40, 40)
// Remove after browser captures RAF is too fast, 0ms timeout works
setTimeout(() => {
if (document.body.contains(ghost)) document.body.removeChild(ghost)
}, 0)
}
emit('drag-start')
}
function onDragEnd() { emit('drag-end') }
</script> </script>
<style scoped> <style scoped>
@ -147,9 +201,8 @@ function onDragEnd() { emit('drag-end') }
} }
.card-stack.bucket-mode .card-wrapper { .card-stack.bucket-mode .card-wrapper {
transform: scale(0.25) rotate(-4deg); clip-path: inset(35% 15% round 0.75rem);
transform-origin: top center; opacity: 0.35;
border-radius: 50% !important;
pointer-events: none; pointer-events: none;
} }
@ -169,11 +222,16 @@ function onDragEnd() { emit('drag-end') }
z-index: 1; z-index: 1;
border-radius: var(--radius-card, 1rem); border-radius: var(--radius-card, 1rem);
background: var(--color-surface-raised, #fff); background: var(--color-surface-raised, #fff);
will-change: transform, opacity; will-change: clip-path, opacity;
clip-path: inset(0% 0% round 1rem);
touch-action: none; /* prevent scroll from stealing the gesture */
cursor: grab;
transition: transition:
transform 280ms cubic-bezier(0.34, 1.56, 0.64, 1), clip-path 260ms cubic-bezier(0.4, 0, 0.2, 1),
border-radius 280ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 220ms ease;
opacity 280ms ease; }
.card-wrapper.is-held {
cursor: grabbing;
} }
/* Dismissal animations dismiss class is only applied during the motion.rich await window, /* Dismissal animations dismiss class is only applied during the motion.rich await window,
@ -202,7 +260,7 @@ function onDragEnd() { emit('drag-end') }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.card-stack, .card-stack,
.card-stack.bucket-mode .card-wrapper { .card-wrapper {
transition: none; transition: none;
} }
} }