|
import { Delete01Icon } from 'hugeicons-react'; |
|
import { cn } from '@app/ui/cn'; |
|
import { PointerEvent, useRef, useState } from 'react'; |
|
import { animate, motion, useMotionValue, useTransform } from 'framer-motion'; |
|
const LongPressButton = ({ |
|
text: textFromProps, |
|
confirmTimeout = 2, |
|
onConfirm |
|
}: { |
|
text: string; |
|
confirmTimeout?: number; |
|
onConfirm?: () => void; |
|
}) => { |
|
|
|
const [state, setState] = useState<'idle' | 'inProgress' | 'complete'>('idle'); |
|
|
|
const ref = useRef<HTMLButtonElement>(null); |
|
const progress = useMotionValue(0); |
|
const fillRightOffset = useTransform(progress, (v) => `${(1 - v) * 100}%`); |
|
|
|
const startCountdown = () => { |
|
setState('inProgress'); |
|
animate(progress, 1, { duration: confirmTimeout, ease: 'linear' }).then(() => { |
|
if (progress.get() !== 1) return; // Animation cancelled |
|
setState('complete'); |
|
}); |
|
}; |
|
|
|
const cancelCountdown = () => { |
|
progress.stop() |
|
setState('idle'); |
|
animate(progress, 0, { duration: 0.2, ease: 'linear' }); |
|
}; |
|
|
|
const pointerUp = (e: PointerEvent<HTMLButtonElement>) => { |
|
const target = document.elementFromPoint(e.clientX, e.clientY); |
|
if (progress.get() === 1 && ref.current?.contains(target)) { |
|
progress.jump(1); |
|
setState('complete'); |
|
onConfirm?.(); |
|
} else { |
|
cancelCountdown(); |
|
} |
|
}; |
|
|
|
const pointerMove = (e: PointerEvent<HTMLButtonElement>) => { |
|
// Mouse will be handled by onPointerLeave |
|
if (e.pointerType === 'mouse') return; |
|
|
|
const target = document.elementFromPoint(e.clientX, e.clientY); |
|
if (!ref.current?.contains(target)) { |
|
cancelCountdown(); |
|
} |
|
}; |
|
|
|
const text = state === 'idle' |
|
? textFromProps |
|
: state === 'inProgress' |
|
? 'Hold to confirm' |
|
: 'Release to delete'; |
|
|
|
return ( |
|
<motion.button className={cn( |
|
"relative", |
|
"flex items-center min-h-[34px] outline-none border-none w-full justify-start group/btn space-x-1", |
|
)} |
|
ref={ref} |
|
onPointerDown={startCountdown} |
|
onPointerUp={pointerUp} |
|
onPointerCancel={cancelCountdown} |
|
onPointerLeave={(e) => { |
|
// For touchscreen browser always generates PointerLeave at |
|
// the end of touch, even if it ended on the element, so |
|
// we handle only mouse leave here |
|
if (e.pointerType === 'mouse') cancelCountdown(); |
|
}} |
|
onPointerMove={pointerMove} |
|
onContextMenuCapture={(e) => e.preventDefault()} |
|
> |
|
<motion.div |
|
style={{ |
|
right: fillRightOffset, |
|
}} |
|
className='flex absolute inset-y-0 left-0 right-[100%] bg-white z-[0] pointer-events-none rounded mix-blend-exclusion' /> |
|
<motion.div className={cn( |
|
"flex gap-2 px-2", |
|
"text-secondary")}> |
|
<Delete01Icon size={16} /> |
|
<p className={cn( |
|
"text-[14px] text-start leading-[16px] font-primary font-medium" |
|
)}>{text}</p> |
|
</motion.div> |
|
</motion.button> |
|
); |
|
} |
|
|
|
export default LongPressButton; |