feat(web): add SwipeAction touch gesture component

Implements right-swipe-to-complete with angle-based lock-in (horizontal
must exceed 2x vertical), 100px threshold, green checkmark background
reveal, and CSS transition for snap-back and completion animation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 17:29:14 +01:00
parent 2f83e8fe2f
commit ac0fd6c72f
@@ -0,0 +1,148 @@
<script>
/**
* @type {() => void}
*/
export let onSwipe;
let offsetX = 0;
let swiping = false;
let locked = false;
let completed = false;
/** @type {number|null} */
let startX = null;
/** @type {number|null} */
let startY = null;
const THRESHOLD = 100;
/**
* @param {TouchEvent} e
*/
function handleTouchStart(e) {
if (completed) return;
const touch = e.touches[0];
startX = touch.clientX;
startY = touch.clientY;
locked = false;
swiping = false;
}
/**
* @param {TouchEvent} e
*/
function handleTouchMove(e) {
if (completed || startX === null || startY === null) return;
const touch = e.touches[0];
const deltaX = touch.clientX - startX;
const deltaY = touch.clientY - startY;
if (!locked && !swiping) {
// Angle-based lock-in: horizontal must dominate
if (Math.abs(deltaX) > 10 && Math.abs(deltaX) > Math.abs(deltaY) * 2) {
swiping = true;
locked = true;
} else if (Math.abs(deltaY) > 10) {
// Vertical scroll — abort
startX = null;
startY = null;
return;
}
}
if (swiping) {
e.preventDefault();
// Only allow right swipe
offsetX = Math.max(0, deltaX);
}
}
function handleTouchEnd() {
if (completed || !swiping) {
resetState();
return;
}
if (offsetX >= THRESHOLD) {
completed = true;
// Animate to full width before firing callback
offsetX = window.innerWidth;
setTimeout(() => {
onSwipe();
}, 200);
} else {
offsetX = 0;
}
swiping = false;
startX = null;
startY = null;
}
function resetState() {
offsetX = 0;
swiping = false;
locked = false;
startX = null;
startY = null;
}
$: progress = Math.min(offsetX / THRESHOLD, 1);
$: transitioning = !swiping && offsetX !== 0;
</script>
<div
class="swipe-container"
on:touchstart={handleTouchStart}
on:touchmove={handleTouchMove}
on:touchend={handleTouchEnd}
on:touchcancel={resetState}
>
<div
class="swipe-background"
style:opacity={progress}
>
<svg class="check-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
</div>
<div
class="swipe-content"
class:transitioning
style:transform="translateX({offsetX}px)"
>
<slot />
</div>
</div>
<style>
.swipe-container {
position: relative;
overflow: hidden;
touch-action: pan-y;
}
.swipe-background {
position: absolute;
inset: 0;
background-color: var(--color-success);
display: flex;
align-items: center;
padding-left: var(--spacing-lg);
}
.check-icon {
width: 1.5rem;
height: 1.5rem;
color: white;
}
.swipe-content {
position: relative;
background-color: var(--bg-primary);
}
.swipe-content.transitioning {
transition: transform 0.2s ease-out;
}
</style>