improved swipe to delete component

This commit is contained in:
corgifist 2025-08-03 00:51:30 +03:00
parent 2631fb79bb
commit 85a497c830
3 changed files with 142 additions and 52 deletions

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { createContext, Dispatch, ReactNode, SetStateAction, useContext, useState } from "react"; import React, { createContext, Dispatch, ReactNode, SetStateAction, useContext, useRef, useState } from "react";
import { useLiveQuery } from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import { addProject, db, deleteProject } from "@/lib/db"; import { addProject, db, deleteProject } from "@/lib/db";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@ -408,6 +408,10 @@ const ProjectContainer = ({
project: Project project: Project
}): ReactNode => { }): ReactNode => {
const { selecting, selectedProjects, setSelectedProjects } = useSelectContext(); const { selecting, selectedProjects, setSelectedProjects } = useSelectContext();
const [container, setContainer] = useState<HTMLDivElement | undefined>();
const containerRef = (node: HTMLDivElement) => {
setContainer(node);
};
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const date = new Date(project.editDate); const date = new Date(project.editDate);
@ -451,14 +455,20 @@ const ProjectContainer = ({
); );
return isMobile && !selecting ? ( return isMobile && !selecting ? (
<div className=" w-[100% + 5 * var(--spacing)] -mx-5 overflow-hidden"> <div className="w-[100% + 5 * var(--spacing)] -mx-5 overflow-hidden">
<SwipeToDelete onDelete={() => deleteProject(project.uuid)}> <SwipeToDelete height={container ? Math.floor(container.getBoundingClientRect().height) : 210} onDelete={() => deleteProject(project.uuid)}>
<div className="w-screen bg-background px-5 py-2"> <div ref={containerRef} className="w-full bg-background px-5 py-2">
{projectComponent} {projectComponent}
</div> </div>
</SwipeToDelete> </SwipeToDelete>
</div> </div>
) : projectComponent; ) : (
<div className="w-[100% + 5 * var(--spacing)] -mx-5 overflow-hidden">
<div ref={containerRef} className="w-full bg-background px-5 py-2">
{projectComponent}
</div>
</div>
);
}; };
export default function Home(): ReactNode { export default function Home(): ReactNode {

View File

@ -1,13 +1,15 @@
'use client' 'use client'
import { useRef, useState, useEffect, FC, ReactNode } from 'react' import React, { useRef, useState, useEffect, FC, ReactNode } from 'react'
type SwipeToDeleteProps = { type SwipeToDeleteProps = {
children: ReactNode; children: ReactNode;
onDelete: () => void; onDelete: () => void;
height?: number ; height?: number | string;
backgroundClass?: string; backgroundClass?: string;
deleteText?: string; deleteText?: string;
fadeOnDeletion?: boolean; fadeOnDeletion?: boolean | `${boolean}`;
useBoldDeleteFont?: boolean | `${boolean}`;
threshold?: number;
} }
const SwipeToDelete: FC<SwipeToDeleteProps> = ({ const SwipeToDelete: FC<SwipeToDeleteProps> = ({
@ -16,7 +18,9 @@ const SwipeToDelete: FC<SwipeToDeleteProps> = ({
height = 60, height = 60,
backgroundClass = 'oklch(63.7% 0.237 25.331)', backgroundClass = 'oklch(63.7% 0.237 25.331)',
deleteText = 'Delete', deleteText = 'Delete',
fadeOnDeletion = true fadeOnDeletion = true,
useBoldDeleteFont = true,
threshold = 70
}) => { }) => {
const container = useRef<HTMLDivElement>(null); const container = useRef<HTMLDivElement>(null);
const content = useRef<HTMLDivElement>(null); const content = useRef<HTMLDivElement>(null);
@ -27,16 +31,17 @@ const SwipeToDelete: FC<SwipeToDeleteProps> = ({
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const [startX, setStartX] = useState(0); const [startX, setStartX] = useState(0);
const [velocity, setVelocity] = useState(0); const [velocity, setVelocity] = useState(0);
const [allowOverscroll, setAllowOverscroll] = useState(false);
const [isCollapsing, setIsCollapsing] = useState(false);
const [forceTransparentBackground, setForceTransparentBackground] = useState(false);
const lastTimeRef = useRef<number>(0); const lastTimeRef = useRef<number>(0);
const lastXRef = useRef<number>(0); const lastXRef = useRef<number>(0);
const lastYRef = useRef<number>(0);
// collapse state
const [isCollapsing, setIsCollapsing] = useState(false);
// measure width and thresholds // measure width and thresholds
const width = container.current?.offsetWidth ?? window.innerWidth; const width = container.current?.offsetWidth ?? window.innerWidth;
const threshold = width / 2; const rubberMax = width * threshold / 100;
const rubberMax = width * 0.7;
// rubber-band effect // rubber-band effect
const rubber = (delta: number, customRubberMax?: number) => { const rubber = (delta: number, customRubberMax?: number) => {
@ -47,10 +52,8 @@ const SwipeToDelete: FC<SwipeToDeleteProps> = ({
return sign * (max + Math.sqrt(abs - max)); return sign * (max + Math.sqrt(abs - max));
} }
// do we show the sticky delete inside content?
const isSticky = dragX < -rubberMax; const isSticky = dragX < -rubberMax;
// pointer start
const handleStart = (pageX: number) => { const handleStart = (pageX: number) => {
if (isCollapsing) return; if (isCollapsing) return;
setDragging(true); setDragging(true);
@ -60,19 +63,24 @@ const SwipeToDelete: FC<SwipeToDeleteProps> = ({
content.current?.classList.remove('ios-ease'); content.current?.classList.remove('ios-ease');
}; };
// pointer move const handleMove = (pageX: number, pageY: number) => {
const handleMove = (pageX: number) => {
if (!dragging) return; if (!dragging) return;
const now = performance.now(); const now = performance.now();
const dt = now - lastTimeRef.current; const dt = now - lastTimeRef.current;
const dx = pageX - lastXRef.current; const dx = pageX - lastXRef.current;
const dy = pageY - lastYRef.current;
setVelocity(dx / dt * 1000); setVelocity(dx / dt * 1000);
lastTimeRef.current = now; lastTimeRef.current = now;
lastXRef.current = pageX; lastXRef.current = pageX;
lastYRef.current = pageY;
if (Math.abs(dy) > 5 && dragX == 0) {
return;
}
const raw = pageX - startX; const raw = pageX - startX;
const x = dragX < 0 ? rubber(raw) : rubber(raw, width * 0.1); const x = dragX < 0 ? rubber(raw) : rubber(raw, width * 0.1);
setDragX(x); if (x < 0) setAllowOverscroll(true);
if (x <= 0 || (allowOverscroll && x >= 0)) setDragX(x);
}; };
const handleDelete = () => { const handleDelete = () => {
@ -80,25 +88,34 @@ const SwipeToDelete: FC<SwipeToDeleteProps> = ({
content.current?.classList.add('ios-ease'); content.current?.classList.add('ios-ease');
setDragX(-width); setDragX(-width);
// collapse after a slight delay (via CSS)
setIsCollapsing(true); setIsCollapsing(true);
setTimeout(onDelete, 300); // matches the CSS timings below setTimeout(onDelete, 300); // matches the CSS timings below
}; };
// pointer end let transparencyTimeout: NodeJS.Timeout | null = null;
const handleEnd = () => { const handleEnd = () => {
if (!dragging) return; if (!dragging) return;
setDragging(false); setDragging(false);
const shouldDelete = const shouldDelete =
Math.abs(dragX) > threshold || isSticky ||
velocity < -1000; velocity < -1000;
setAllowOverscroll(false);
if (!shouldDelete) { if (!shouldDelete) {
content.current?.classList.add('ios-ease'); content.current?.classList.add('ios-ease');
text.current?.classList.add('ios-ease'); text.current?.classList.add('ios-ease');
const textWidth = text.current ? text.current.getBoundingClientRect().width : 0; const textWidth = text.current ? text.current.getBoundingClientRect().width : 0;
if (dragX < -50 && text.current) setDragX(-textWidth * 1.5); if ((velocity < 0 || dragX < -textWidth * 1.5 && velocity > 0) && text.current) {
else setDragX(0); setDragX(-textWidth * 1.5);
} else if (allowOverscroll && dragX > 0) {
setDragX(0);
if (transparencyTimeout) {
clearTimeout(transparencyTimeout);
}
setForceTransparentBackground(true);
transparencyTimeout = setTimeout(() => setForceTransparentBackground(false), 150);
}
return; return;
} }
@ -106,42 +123,92 @@ const SwipeToDelete: FC<SwipeToDeleteProps> = ({
} }
useEffect(() => { useEffect(() => {
const node = container.current const node = window;
if (!node) return if (!node || !container.current) return;
const onDown = (e: PointerEvent) => {
node.setPointerCapture(e.pointerId) const eventOutsideOfContainer = (e: Event) => {
handleStart(e.pageX) return !(container.current?.contains(e.target as Node));
} };
const onMove = (e: PointerEvent) => handleMove(e.pageX)
const onUp = (e: PointerEvent) => { const handleTouchStart = (e: TouchEvent) => {
handleEnd() if (eventOutsideOfContainer(e)) {
node.releasePointerCapture(e.pointerId) setDragX(0);
} return;
node.addEventListener('pointerdown', onDown) }
node.addEventListener('pointermove', onMove) // e.preventDefault();
node.addEventListener('pointerup', onUp) handleStart(e.touches[0].pageX);
};
const handleTouchMove = (e: TouchEvent) => {
if (eventOutsideOfContainer(e)) {
setDragX(0);
return;
}
// e.preventDefault();
handleMove(e.touches[0].pageX, e.touches[0].pageY);
};
const handleMouseStart = (e: MouseEvent) => {
if (eventOutsideOfContainer(e)) {
setDragX(0);
return;
}
// e.preventDefault();
handleStart(e.pageX);
};
const handleMouseMove = (e: MouseEvent) => {
if (eventOutsideOfContainer(e)) {
setDragX(0);
return;
}
// e.preventDefault();
handleMove(e.pageX, e.pageY);
};
node.addEventListener("touchstart", handleTouchStart);
node.addEventListener("touchmove", handleTouchMove);
node.addEventListener("touchend", handleEnd);
node.addEventListener("mousedown", handleMouseStart);
node.addEventListener("mousemove", handleMouseMove);
node.addEventListener("mouseup", handleEnd);
return () => { return () => {
node.removeEventListener('pointerdown', onDown) node.removeEventListener("touchstart", handleTouchStart);
node.removeEventListener('pointermove', onMove) node.removeEventListener("touchmove", handleTouchMove);
node.removeEventListener('pointerup', onUp) node.removeEventListener("touchend", handleEnd);
node.removeEventListener("mousedown", handleMouseStart);
node.removeEventListener("mousemove", handleMouseMove);
node.removeEventListener("mouseup", handleEnd);
} }
}, [dragging, dragX, velocity]) }, [dragging, dragX, velocity, container]);
const deleteTransform = isCollapsing const deleteTransform = isCollapsing
? `translateX(calc(${dragX}px + 5rem))` ? `translateX(calc(${dragX}px + 5rem))`
: (isSticky ? `translateX(calc(${dragX}px + 5rem))` : `translateX(max(0rem, calc(${dragX}px + 5rem)))`); : (isSticky ? `translateX(calc(${dragX}px + 5rem))` : `translateX(max(0rem, calc(${dragX}px + 5rem)))`);
const opacityTransparent = fadeOnDeletion ? 0 : 1; const opacityTransparent = fadeOnDeletion ? 0 : 1;
const backgroundTransparent = opacityTransparent == 0 ? 'transparent' : backgroundClass;
const background = dragX < 0 let background = backgroundClass;
? isCollapsing ? (opacityTransparent == 0 ? 'transparent' : backgroundClass) : backgroundClass
: 'transparent'; if (isCollapsing) {
background = isCollapsing ? backgroundTransparent : backgroundClass;
}
if (allowOverscroll && dragX > 1 || forceTransparentBackground) {
background = 'transparent';
}
return ( return (
<div <div
ref={container} ref={container}
className="relative overflow-hidden select-none"
style={{ style={{
position: 'relative',
overflow: 'hidden',
userSelect: 'none',
height: isCollapsing ? 0 : height, height: isCollapsing ? 0 : height,
transition: isCollapsing transition: isCollapsing
? 'height 300ms cubic-bezier(0.24, 1.04, 0.56, 1)' ? 'height 300ms cubic-bezier(0.24, 1.04, 0.56, 1)'
@ -150,15 +217,22 @@ const SwipeToDelete: FC<SwipeToDeleteProps> = ({
> >
{/* Fixed red background + delete text */} {/* Fixed red background + delete text */}
<div <div
className={`absolute inset-0 flex items-center justify-end pr-4`} className={`inset-0 flex items-center justify-end`}
style={{ style={{
background: background, background: background,
position: 'absolute',
marginTop: '1px',
marginBottom: '1px',
paddingRight: '1rem',
transition: dragX > 1 ? '' : 'background 300ms' transition: dragX > 1 ? '' : 'background 300ms'
}} }}
> >
<button className="text-white font-semibold h-full" style={{ <button style={{
transform: deleteTransform, transform: deleteTransform,
transition: 'transform 300ms cubic-bezier(0.24, 1.04, 0.56, 1), opacity 300ms', transition: 'transform 300ms cubic-bezier(0.24, 1.04, 0.56, 1), opacity 300ms',
color: 'white',
fontWeight: useBoldDeleteFont ? 600 : '',
height: '100%',
opacity: isCollapsing ? opacityTransparent : 1 opacity: isCollapsing ? opacityTransparent : 1
}} onClick={handleDelete} ref={text}> }} onClick={handleDelete} ref={text}>
{deleteText} {deleteText}
@ -168,11 +242,11 @@ const SwipeToDelete: FC<SwipeToDeleteProps> = ({
{/* Swipeable content */} {/* Swipeable content */}
<div <div
ref={content} ref={content}
className="relative ios-ease" className="ios-ease"
style={{ style={{
position: 'absolute', position: 'relative',
inset: 0, inset: 0,
transform: `translateX(${dragX}px)`, transform: `translateX(calc(${dragX}px - 0.5px))`,
transition: dragging transition: dragging
? '' ? ''
: 'transform 300ms cubic-bezier(0.24, 1.04, 0.56, 1)', : 'transform 300ms cubic-bezier(0.24, 1.04, 0.56, 1)',

View File

@ -1,8 +1,14 @@
/* iOS-style timing */ /* iOS-style timing */
.ios-ease { .ios-ease {
transition-timing-function: cubic-bezier(0.24, 1.04, 0.56, 1); transition-timing-function: cubic-bezier(0.24, 1.04, 0.56, 1);
} }
/* Rubber-band “resistance”: smaller drag as you pull harder */ /* Rubber-band “resistance”: smaller drag as you pull harder */
[data-rubber] { [data-rubber] {
touch-action: none; touch-action: none;
}
.font-sharpen {
-webkit-transform: translateZ(0);
transform: translateZ(0);
} }