feat(avocet): replace swipe+HTML5-drag with unified pointer-events toss gesture
This commit is contained in:
parent
a8b1c89c62
commit
2bbd925c41
2 changed files with 195 additions and 82 deletions
|
|
@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
@ -42,30 +41,112 @@ const props = defineProps<{
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
label: [name: string]
|
label: [name: string]
|
||||||
skip: []
|
skip: []
|
||||||
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
|
// Aura color: zone > bucket > neutral
|
||||||
const color = dragX.value < -20 ? 'rgba(244,67,54,0.15)'
|
const aura =
|
||||||
: dragX.value > 20 ? 'rgba(255,152,0,0.15)'
|
hoveredZone.value === 'discard' ? 'rgba(244,67,54,0.25)' :
|
||||||
: 'transparent'
|
hoveredZone.value === 'skip' ? 'rgba(255,152,0,0.25)' :
|
||||||
|
hoveredBucketName.value ? 'rgba(42,96,128,0.20)' :
|
||||||
|
'transparent'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
transform: `translateX(${dragX.value}px) rotate(${tilt}deg)`,
|
transform: `translate(${deltaX.value}px, ${deltaY.value}px) scale(0.35)`,
|
||||||
opacity,
|
borderRadius: '50%',
|
||||||
background: color,
|
background: aura,
|
||||||
transition: isSwiping.value ? 'none' : 'all 0.3s ease',
|
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>
|
</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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue