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 EmailCardStack from './EmailCardStack.vue'
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
const item = {
id: 'abc',
@ -45,15 +45,70 @@ describe('EmailCardStack', () => {
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 } })
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()
})
it('emits drag-end on dragend event', async () => {
it('emits drag-end on pointerup', async () => {
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()
})
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
class="card-stack"
:class="{ 'bucket-mode': isBucketMode && motion.rich.value }"
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" />
@ -15,8 +11,12 @@
<div
class="card-wrapper"
ref="cardEl"
:class="dismissClass"
:class="[dismissClass, { 'is-held': isHeld }]"
:style="cardStyle"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerCancel"
>
<EmailCard
:item="item"
@ -30,7 +30,6 @@
<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'
@ -42,30 +41,112 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
label: [name: string]
skip: []
discard: []
'drag-start': []
'drag-end': []
label: [name: string]
skip: []
discard: []
'drag-start': []
'drag-end': []
'zone-hover': ['discard' | 'skip' | null]
'bucket-hover': [string | null]
}>()
const motion = useMotion()
const cardEl = ref<HTMLElement | null>(null)
const stackEl = ref<HTMLElement | null>(null)
const motion = useMotion()
const cardEl = 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
},
})
// Toss gesture state
const isHeld = ref(false)
const pickupX = ref(0)
const pickupY = ref(0)
const deltaX = ref(0)
const deltaY = ref(0)
const hoveredZone = ref<'discard' | 'skip' | null>(null)
const hoveredBucketName = ref<string | null>(null)
// 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(() => {
if (!props.dismissType) return null
@ -73,52 +154,25 @@ const dismissClass = computed(() => {
})
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'
if (!motion.rich.value || !isHeld.value) return {}
// Aura color: zone > bucket > neutral
const aura =
hoveredZone.value === 'discard' ? 'rgba(244,67,54,0.25)' :
hoveredZone.value === 'skip' ? 'rgba(255,152,0,0.25)' :
hoveredBucketName.value ? 'rgba(42,96,128,0.20)' :
'transparent'
return {
transform: `translateX(${dragX.value}px) rotate(${tilt}deg)`,
opacity,
background: color,
transition: isSwiping.value ? 'none' : 'all 0.3s ease',
transform: `translate(${deltaX.value}px, ${deltaY.value}px) scale(0.35)`,
borderRadius: '50%',
background: aura,
transition: 'border-radius 150ms ease, background 150ms ease',
cursor: 'grabbing',
zIndex: 100,
userSelect: 'none',
}
})
function onDragStart(e: DragEvent) {
if (motion.rich.value && e.dataTransfer) {
// Custom drag ghost: small crumpled-paper ball
const ghost = document.createElement('div')
ghost.setAttribute('aria-hidden', 'true')
Object.assign(ghost.style, {
position: 'fixed',
top: '-200px',
left: '0',
width: '80px',
height: '80px',
borderRadius: '50%',
background: '#e4ebf5',
border: '3px solid #2A6080',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
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>
<style scoped>
@ -147,9 +201,8 @@ function onDragEnd() { emit('drag-end') }
}
.card-stack.bucket-mode .card-wrapper {
transform: scale(0.25) rotate(-4deg);
transform-origin: top center;
border-radius: 50% !important;
clip-path: inset(35% 15% round 0.75rem);
opacity: 0.35;
pointer-events: none;
}
@ -169,11 +222,16 @@ function onDragEnd() { emit('drag-end') }
z-index: 1;
border-radius: var(--radius-card, 1rem);
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:
transform 280ms cubic-bezier(0.34, 1.56, 0.64, 1),
border-radius 280ms cubic-bezier(0.34, 1.56, 0.64, 1),
opacity 280ms ease;
clip-path 260ms cubic-bezier(0.4, 0, 0.2, 1),
opacity 220ms ease;
}
.card-wrapper.is-held {
cursor: grabbing;
}
/* 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) {
.card-stack,
.card-stack.bucket-mode .card-wrapper {
.card-wrapper {
transition: none;
}
}