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:
@@ -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>
|
||||
Reference in New Issue
Block a user