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