From 2631fb79bbcf574993c3286cbb0edf25c9a3ce37 Mon Sep 17 00:00:00 2001 From: corgifist Date: Sat, 2 Aug 2025 20:04:33 +0300 Subject: [PATCH] improved ux of swipe to delete (you can't click on the project for now tho) --- src/app/(dashboard)/page.tsx | 11 +- src/app/layout.tsx | 5 +- .../swipe-to-delete/SwipeToDelete.tsx | 354 ++++++++---------- src/components/swipe-to-delete/index.ts | 8 +- src/components/swipe-to-delete/styles.css | 82 +--- 5 files changed, 174 insertions(+), 286 deletions(-) diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx index c066f0c..993211e 100644 --- a/src/app/(dashboard)/page.tsx +++ b/src/app/(dashboard)/page.tsx @@ -36,7 +36,7 @@ import Link from "next/link"; import StickyTopContainer from "@/components/sticky-top-container"; import { useRouter } from "next/navigation"; import truncate from "@/lib/truncate"; -import SwipeToDelete, { useSwipeToDeleteContext } from "@/components/swipe-to-delete"; +import SwipeToDelete from "@/components/swipe-to-delete"; type SortingType = "byCreationDate" | "byEditDate" @@ -450,10 +450,10 @@ const ProjectContainer = ({ ); - return isMobile ? ( -
- deleteProject(project.uuid)} transitionDuration={200} deleteThreshold={50}> -
+ return isMobile && !selecting ? ( +
+ deleteProject(project.uuid)}> +
{projectComponent}
@@ -463,7 +463,6 @@ const ProjectContainer = ({ export default function Home(): ReactNode { const isMobile = useIsMobile(); - const { deleting } = useSwipeToDeleteContext(); const [search, setSearch] = useState(''); const [debouncedSearch] = useDebounce(search, 300); const [selecting, setSelecting] = useState(false); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index df61045..8fe1ddb 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,7 +6,6 @@ import ThemeProvider from "./theme-provider"; import Analytics from "./analytics"; import { Toaster } from "@/components/ui/sonner"; import PWAHead from "./pwa-head"; -import { SwipeToDeleteContextProvider } from "@/components/swipe-to-delete"; const geist = Geist({ variable: "--font-geist", @@ -44,9 +43,7 @@ export default async function RootLayout({ - - {children} - + {children} diff --git a/src/components/swipe-to-delete/SwipeToDelete.tsx b/src/components/swipe-to-delete/SwipeToDelete.tsx index 40f8dd9..fbbd03f 100644 --- a/src/components/swipe-to-delete/SwipeToDelete.tsx +++ b/src/components/swipe-to-delete/SwipeToDelete.tsx @@ -1,226 +1,192 @@ -'use client'; -import React, { createContext, Dispatch, ReactNode, SetStateAction, useCallback, useContext, useEffect, useRef, useState } from "react"; -import "./styles.css"; +'use client' +import { useRef, useState, useEffect, FC, ReactNode } from 'react' -export interface Props { +type SwipeToDeleteProps = { + children: ReactNode; onDelete: () => void; - onDeleteConfirm?: (onSuccess: () => void, onCancel: () => void) => void; - deleteComponent?: React.ReactNode; - disabled?: boolean; - height?: number; - transitionDuration?: number; - deleteWidth?: number; - deleteThreshold?: number; - showDeleteAction?: boolean; - deleteColor?: string; + height?: number ; + backgroundClass?: string; deleteText?: string; - className?: string; - id?: string; - rtl?: boolean; - children?: React.ReactNode; + fadeOnDeletion?: boolean; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const cursorPosition = (event: any) => { - if (event?.touches?.[0]?.clientX) return event.touches[0].clientX; - if (event?.clientX) return event?.clientX; - if (event?.nativeEvent?.touches?.[0]?.clientX) return event.nativeEvent.touches[0].clientX; - return event?.nativeEvent?.clientX; -}; - -interface SwipeToDeleteContextProps { - deleting: boolean; - setDeleting: Dispatch>; -} - -const SwipeToDeleteContext = createContext(undefined); - -export const useSwipeToDeleteContext = (): SwipeToDeleteContextProps => { - const data = useContext(SwipeToDeleteContext); - if (!data) throw new Error("SwipeToDeleteContext is not defined"); - return data; -}; - -export const SwipeToDeleteContextProvider = ({ - children -}: { - children: ReactNode -}) => { - const [deleting, setDeleting] = useState(false); - - return ( - - {children} - - ); -}; - -export const SwipeToDelete = ({ - onDelete, - onDeleteConfirm, - deleteComponent, - disabled = false, - height = 50, - transitionDuration = 250, - deleteWidth = 75, - deleteThreshold = 75, - showDeleteAction = true, - deleteColor = "rgba(252, 58, 48, 1.00)", - deleteText = "Delete", - className = "", - id = "", - rtl = false, +const SwipeToDelete: FC = ({ children, -}: Props) => { - const { deleting, setDeleting } = useSwipeToDeleteContext(); - const [touching, setTouching] = useState(false); - const [translate, setTranslate] = useState(0); - const [internalDeleting, setInternalDeleting] = useState(false); - - const startTouchPosition = useRef(0); - const initTranslate = useRef(0); + onDelete, + height = 60, + backgroundClass = 'oklch(63.7% 0.237 25.331)', + deleteText = 'Delete', + fadeOnDeletion = true +}) => { const container = useRef(null); - const containerWidth: number = container.current?.getBoundingClientRect().width || 0; - const deleteWithoutConfirmThreshold: number = containerWidth * (deleteThreshold / 100); + const content = useRef(null); + const text = useRef(null); - const onStart = useCallback( - (event: React.TouchEvent | React.MouseEvent) => { - if (disabled) return; - if (touching) return; - startTouchPosition.current = cursorPosition(event); - initTranslate.current = translate; - setTouching(true); - setDeleting(true); - }, - [disabled, touching, translate, deleting, setDeleting] - ); + // drag state + const [dragX, setDragX] = useState(0); + const [dragging, setDragging] = useState(false); + const [startX, setStartX] = useState(0); + const [velocity, setVelocity] = useState(0); + const lastTimeRef = useRef(0); + const lastXRef = useRef(0); - useEffect(() => { - // setDeleting(touching); - }, [touching]); + // collapse state + const [isCollapsing, setIsCollapsing] = useState(false); - useEffect(() => { - const root = container.current; - root?.style.setProperty("--rstdiHeight", height + "px"); - root?.style.setProperty("--rstdiTransitionDuration", transitionDuration + "ms"); - root?.style.setProperty("--rstdiIsRtl", rtl ? "1" : "-1"); - root?.style.setProperty("--rstdiDeleteColor", deleteColor); - root?.style.setProperty("--rstdiDeleteWidth", deleteWidth + "px"); - }, [deleteColor, deleteWidth, height, rtl, transitionDuration, deleting]); + // measure width and thresholds + const width = container.current?.offsetWidth ?? window.innerWidth; + const threshold = width / 2; + const rubberMax = width * 0.7; - useEffect(() => { - const root = container.current; - root?.style.setProperty("--rstdiTranslate", translate * (rtl ? -1 : 1) + "px"); - const shiftDelete = -translate >= deleteWithoutConfirmThreshold; - root?.style.setProperty( - `--rstdiButtonMargin${rtl ? "Right" : "Left"}`, - (shiftDelete ? containerWidth + translate : containerWidth - deleteWidth) + "px" - ); - }, [translate, deleteWidth, containerWidth, rtl, deleteWithoutConfirmThreshold]); + // rubber-band effect + const rubber = (delta: number, customRubberMax?: number) => { + const max = customRubberMax ?? rubberMax; + const sign = delta < 0 ? -1 : 1; + const abs = Math.abs(delta); + if (abs <= max) return delta; + return sign * (max + Math.sqrt(abs - max)); + } - const onMove = useCallback( - function (event: TouchEvent | MouseEvent) { - if (!touching) return; - if (!rtl && cursorPosition(event) > startTouchPosition.current - initTranslate.current) - return setTranslate(0); - if (rtl && cursorPosition(event) < startTouchPosition.current - initTranslate.current) - return setTranslate(0); - setTranslate(cursorPosition(event) - startTouchPosition.current + initTranslate.current); - }, - [rtl, touching] - ); + // do we show the sticky delete inside content? + const isSticky = dragX < -rubberMax; + // pointer start + const handleStart = (pageX: number) => { + if (isCollapsing) return; + setDragging(true); + setStartX(pageX - dragX); + lastTimeRef.current = performance.now(); + lastXRef.current = pageX; + content.current?.classList.remove('ios-ease'); + }; - const onMouseMove = useCallback( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function (event: MouseEvent): any { - onMove(event); - }, - [onMove] - ); + // pointer move + const handleMove = (pageX: number) => { + if (!dragging) return; + const now = performance.now(); + const dt = now - lastTimeRef.current; + const dx = pageX - lastXRef.current; + setVelocity(dx / dt * 1000); + lastTimeRef.current = now; + lastXRef.current = pageX; + const raw = pageX - startX; + const x = dragX < 0 ? rubber(raw) : rubber(raw, width * 0.1); + setDragX(x); + }; - const onTouchMove = useCallback( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function (event: TouchEvent): any { - onMove(event); - }, - [onMove] - ); + const handleDelete = () => { + // slide away + content.current?.classList.add('ios-ease'); + setDragX(-width); - const onDeleteConfirmed = useCallback(() => { - setInternalDeleting(() => true); - window.setTimeout(onDelete, transitionDuration); - }, [onDelete, transitionDuration]); + // collapse after a slight delay (via CSS) + setIsCollapsing(true); + setTimeout(onDelete, 300); // matches the CSS timings below + }; - const onDeleteCancel = useCallback(() => { - setTouching(() => false); - setTranslate(() => 0); - setInternalDeleting(() => false); - startTouchPosition.current = 0; - initTranslate.current = 0; - }, [onDelete, transitionDuration]); + // pointer end + const handleEnd = () => { + if (!dragging) return; + setDragging(false); - const onDeleteClick = useCallback(() => { - if (onDeleteConfirm) { - onDeleteConfirm(onDeleteConfirmed, onDeleteCancel); - } else { - onDeleteConfirmed(); + const shouldDelete = + Math.abs(dragX) > threshold || + velocity < -1000; + if (!shouldDelete) { + content.current?.classList.add('ios-ease'); + text.current?.classList.add('ios-ease'); + const textWidth = text.current ? text.current.getBoundingClientRect().width : 0; + if (dragX < -50 && text.current) setDragX(-textWidth * 1.5); + else setDragX(0); + return; } - }, [onDeleteConfirm, onDeleteConfirmed]); - const onMouseUp = useCallback( - function () { - startTouchPosition.current = 0; - const acceptableMove = -deleteWidth * 0.7; - const showDelete = showDeleteAction ? (rtl ? -1 : 1) * translate < acceptableMove : false; - const notShowDelete = showDeleteAction ? (rtl ? -1 : 1) * translate >= acceptableMove : true; - const deleteWithoutConfirm = (rtl ? 1 : -1) * translate >= deleteWithoutConfirmThreshold; - if (deleteWithoutConfirm) { - setTranslate(() => -containerWidth); - } else if (notShowDelete) { - setTranslate(() => 0); - } else if (showDelete && !deleteWithoutConfirm) { - setTranslate(() => (rtl ? 1 : -1) * deleteWidth); - } - setTouching(() => false); - setDeleting(false); - if (deleteWithoutConfirm) onDeleteClick(); - }, - [containerWidth, deleteWidth, deleteWithoutConfirmThreshold, onDeleteClick, rtl, translate, deleting, setDeleting] - ); + handleDelete(); + } useEffect(() => { - if (touching) { - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("touchmove", onTouchMove); - window.addEventListener("mouseup", onMouseUp); - window.addEventListener("touchend", onMouseUp); - } else { - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("touchmove", onTouchMove); - window.removeEventListener("mouseup", onMouseUp); - window.removeEventListener("touchend", onMouseUp); + const node = container.current + if (!node) return + const onDown = (e: PointerEvent) => { + node.setPointerCapture(e.pointerId) + handleStart(e.pageX) } + const onMove = (e: PointerEvent) => handleMove(e.pageX) + const onUp = (e: PointerEvent) => { + handleEnd() + node.releasePointerCapture(e.pointerId) + } + node.addEventListener('pointerdown', onDown) + node.addEventListener('pointermove', onMove) + node.addEventListener('pointerup', onUp) return () => { - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("touchmove", onTouchMove); - window.removeEventListener("mouseup", onMouseUp); - window.removeEventListener("touchend", onMouseUp); - }; - }, [onMouseMove, onMouseUp, onTouchMove, touching]); + node.removeEventListener('pointerdown', onDown) + node.removeEventListener('pointermove', onMove) + node.removeEventListener('pointerup', onUp) + } + }, [dragging, dragX, velocity]) + + const deleteTransform = isCollapsing + ? `translateX(calc(${dragX}px + 5rem))` + : (isSticky ? `translateX(calc(${dragX}px + 5rem))` : `translateX(max(0rem, calc(${dragX}px + 5rem)))`); + + const opacityTransparent = fadeOnDeletion ? 0 : 1; + + const background = dragX < 0 + ? isCollapsing ? (opacityTransparent == 0 ? 'transparent' : backgroundClass) : backgroundClass + : 'transparent'; return ( -
-
- -
+
+ {/* Fixed red background + delete text */}
+ className={`absolute inset-0 flex items-center justify-end pr-4`} + style={{ + background: background, + transition: dragX > 1 ? '' : 'background 300ms' + }} + > + +
+ + {/* Swipeable content */} +
{children}
); -}; \ No newline at end of file +}; + +export default SwipeToDelete; \ No newline at end of file diff --git a/src/components/swipe-to-delete/index.ts b/src/components/swipe-to-delete/index.ts index ff2dd59..d4bd0da 100644 --- a/src/components/swipe-to-delete/index.ts +++ b/src/components/swipe-to-delete/index.ts @@ -1,7 +1,3 @@ -import { SwipeToDelete, SwipeToDeleteContextProvider, useSwipeToDeleteContext } from "./SwipeToDelete"; +import SwipeToDelete from "./SwipeToDelete"; -export default SwipeToDelete; -export { - SwipeToDeleteContextProvider, - useSwipeToDeleteContext -}; \ No newline at end of file +export default SwipeToDelete; \ No newline at end of file diff --git a/src/components/swipe-to-delete/styles.css b/src/components/swipe-to-delete/styles.css index d8fb7f2..673865c 100644 --- a/src/components/swipe-to-delete/styles.css +++ b/src/components/swipe-to-delete/styles.css @@ -1,78 +1,8 @@ -/* rstdi = react-swipe-to-delete-ios */ - -.rstdi { - --rstdiHeight: 30px; - --rstdiTransitionDuration: 250ms; - --rstdiTranslate: 0px; - --rstdiIsRtl: 0; - --rstdiDeleteColor: rgba(252, 58, 48, 1); - --rstdiDeleteWidth: 75px; - --rstdiButtonMarginRight: 0px; - --rstdiButtonMarginLeft: 0px; - - width: auto; - position: relative; - box-sizing: border-box; - height: 100%; - max-height: 100%; - overscroll-behavior: none; - overflow: hidden; +/* iOS-style timing */ +.ios-ease { + transition-timing-function: cubic-bezier(0.24, 1.04, 0.56, 1); } - -.rstdi *, -.rstdi *:before, -.rstdi *:after { - box-sizing: border-box; -} - -.rstdi.deleting { - transition: all var(--rstdiTransitionDuration) cubic-bezier(0.33, 1, 0.68, 1); - opacity: 0; - max-height: 0; -} - -.rstdi .content { - height: 100%; - width: auto; - position: relative; - transform: translateX(var(--rstdiTranslate)); -} - -.rstdi .content.transition { - transition: all var(--rstdiTransitionDuration) cubic-bezier(0.33, 1, 0.68, 1); -} - -.rstdi .content.deleting { - height: 0%; - width: auto; - position: relative; - transform: translateX(-100%); - transition: all var(--rstdiTransitionDuration) cubic-bezier(0.33, 1, 0.68, 1); -} - -.rstdi .delete { - position: absolute; - right: 0; - margin-top: 0px; - height: 100%; - width: 100%; - top: 0; - background: var(--rstdiDeleteColor); - font-weight: 400; - display: inline-flex; - justify-content: flex-start; - align-items: center; -} - -.rstdi .delete button { - width: var(--rstdiDeleteWidth); - transition: transform var(--rstdiTransitionDuration) cubic-bezier(0.33, 1, 0.68, 1); - transform: translateX(var(--rstdiButtonMarginLeft)); - text-align: center; - height: 100%; - background: transparent; - border: none; - color: white; - font-size: 1rem; - cursor: pointer; +/* Rubber-band “resistance”: smaller drag as you pull harder */ +[data-rubber] { + touch-action: none; } \ No newline at end of file