improved ux of swipe to delete (you can't click on the project for now tho)

This commit is contained in:
corgifist 2025-08-02 20:04:33 +03:00
parent c52cd12b3d
commit 2631fb79bb
5 changed files with 174 additions and 286 deletions

View File

@ -36,7 +36,7 @@ import Link from "next/link";
import StickyTopContainer from "@/components/sticky-top-container"; import StickyTopContainer from "@/components/sticky-top-container";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import truncate from "@/lib/truncate"; import truncate from "@/lib/truncate";
import SwipeToDelete, { useSwipeToDeleteContext } from "@/components/swipe-to-delete"; import SwipeToDelete from "@/components/swipe-to-delete";
type SortingType = "byCreationDate" type SortingType = "byCreationDate"
| "byEditDate" | "byEditDate"
@ -450,10 +450,10 @@ const ProjectContainer = ({
</AspectRatio> </AspectRatio>
); );
return isMobile ? ( return isMobile && !selecting ? (
<div className=" w-[100% + 5 * var(--spacing)] -mx-5 origin-center overflow-hidden"> <div className=" w-[100% + 5 * var(--spacing)] -mx-5 overflow-hidden">
<SwipeToDelete onDelete={() => deleteProject(project.uuid)} transitionDuration={200} deleteThreshold={50}> <SwipeToDelete onDelete={() => deleteProject(project.uuid)}>
<div className="w-screen bg-background px-5 py-2 -mt-[1px] h-[calc(100% + 2px)]"> <div className="w-screen bg-background px-5 py-2">
{projectComponent} {projectComponent}
</div> </div>
</SwipeToDelete> </SwipeToDelete>
@ -463,7 +463,6 @@ const ProjectContainer = ({
export default function Home(): ReactNode { export default function Home(): ReactNode {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { deleting } = useSwipeToDeleteContext();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [debouncedSearch] = useDebounce(search, 300); const [debouncedSearch] = useDebounce(search, 300);
const [selecting, setSelecting] = useState(false); const [selecting, setSelecting] = useState(false);

View File

@ -6,7 +6,6 @@ import ThemeProvider from "./theme-provider";
import Analytics from "./analytics"; import Analytics from "./analytics";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import PWAHead from "./pwa-head"; import PWAHead from "./pwa-head";
import { SwipeToDeleteContextProvider } from "@/components/swipe-to-delete";
const geist = Geist({ const geist = Geist({
variable: "--font-geist", variable: "--font-geist",
@ -44,9 +43,7 @@ export default async function RootLayout({
</head> </head>
<body className={`${geist.variable} ${geistMono.variable} antialiased`}> <body className={`${geist.variable} ${geistMono.variable} antialiased`}>
<ThemeProvider> <ThemeProvider>
<SwipeToDeleteContextProvider> {children}
{children}
</SwipeToDeleteContextProvider>
<Toaster/> <Toaster/>
</ThemeProvider> </ThemeProvider>
</body> </body>

View File

@ -1,226 +1,192 @@
'use client'; 'use client'
import React, { createContext, Dispatch, ReactNode, SetStateAction, useCallback, useContext, useEffect, useRef, useState } from "react"; import { useRef, useState, useEffect, FC, ReactNode } from 'react'
import "./styles.css";
export interface Props { type SwipeToDeleteProps = {
children: ReactNode;
onDelete: () => void; onDelete: () => void;
onDeleteConfirm?: (onSuccess: () => void, onCancel: () => void) => void; height?: number ;
deleteComponent?: React.ReactNode; backgroundClass?: string;
disabled?: boolean;
height?: number;
transitionDuration?: number;
deleteWidth?: number;
deleteThreshold?: number;
showDeleteAction?: boolean;
deleteColor?: string;
deleteText?: string; deleteText?: string;
className?: string; fadeOnDeletion?: boolean;
id?: string;
rtl?: boolean;
children?: React.ReactNode;
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any const SwipeToDelete: FC<SwipeToDeleteProps> = ({
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<SetStateAction<boolean>>;
}
const SwipeToDeleteContext = createContext<SwipeToDeleteContextProps | undefined>(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 (
<SwipeToDeleteContext.Provider value={{ deleting: deleting, setDeleting: setDeleting }}>
{children}
</SwipeToDeleteContext.Provider>
);
};
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,
children, children,
}: Props) => { onDelete,
const { deleting, setDeleting } = useSwipeToDeleteContext(); height = 60,
const [touching, setTouching] = useState(false); backgroundClass = 'oklch(63.7% 0.237 25.331)',
const [translate, setTranslate] = useState(0); deleteText = 'Delete',
const [internalDeleting, setInternalDeleting] = useState(false); fadeOnDeletion = true
}) => {
const startTouchPosition = useRef(0);
const initTranslate = useRef(0);
const container = useRef<HTMLDivElement>(null); const container = useRef<HTMLDivElement>(null);
const containerWidth: number = container.current?.getBoundingClientRect().width || 0; const content = useRef<HTMLDivElement>(null);
const deleteWithoutConfirmThreshold: number = containerWidth * (deleteThreshold / 100); const text = useRef<HTMLButtonElement>(null);
const onStart = useCallback( // drag state
(event: React.TouchEvent | React.MouseEvent) => { const [dragX, setDragX] = useState(0);
if (disabled) return; const [dragging, setDragging] = useState(false);
if (touching) return; const [startX, setStartX] = useState(0);
startTouchPosition.current = cursorPosition(event); const [velocity, setVelocity] = useState(0);
initTranslate.current = translate; const lastTimeRef = useRef<number>(0);
setTouching(true); const lastXRef = useRef<number>(0);
setDeleting(true);
},
[disabled, touching, translate, deleting, setDeleting]
);
useEffect(() => { // collapse state
// setDeleting(touching); const [isCollapsing, setIsCollapsing] = useState(false);
}, [touching]);
useEffect(() => { // measure width and thresholds
const root = container.current; const width = container.current?.offsetWidth ?? window.innerWidth;
root?.style.setProperty("--rstdiHeight", height + "px"); const threshold = width / 2;
root?.style.setProperty("--rstdiTransitionDuration", transitionDuration + "ms"); const rubberMax = width * 0.7;
root?.style.setProperty("--rstdiIsRtl", rtl ? "1" : "-1");
root?.style.setProperty("--rstdiDeleteColor", deleteColor);
root?.style.setProperty("--rstdiDeleteWidth", deleteWidth + "px");
}, [deleteColor, deleteWidth, height, rtl, transitionDuration, deleting]);
useEffect(() => { // rubber-band effect
const root = container.current; const rubber = (delta: number, customRubberMax?: number) => {
root?.style.setProperty("--rstdiTranslate", translate * (rtl ? -1 : 1) + "px"); const max = customRubberMax ?? rubberMax;
const shiftDelete = -translate >= deleteWithoutConfirmThreshold; const sign = delta < 0 ? -1 : 1;
root?.style.setProperty( const abs = Math.abs(delta);
`--rstdiButtonMargin${rtl ? "Right" : "Left"}`, if (abs <= max) return delta;
(shiftDelete ? containerWidth + translate : containerWidth - deleteWidth) + "px" return sign * (max + Math.sqrt(abs - max));
); }
}, [translate, deleteWidth, containerWidth, rtl, deleteWithoutConfirmThreshold]);
const onMove = useCallback( // do we show the sticky delete inside content?
function (event: TouchEvent | MouseEvent) { const isSticky = dragX < -rubberMax;
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]
);
// 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( // pointer move
// eslint-disable-next-line @typescript-eslint/no-explicit-any const handleMove = (pageX: number) => {
function (event: MouseEvent): any { if (!dragging) return;
onMove(event); const now = performance.now();
}, const dt = now - lastTimeRef.current;
[onMove] 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( const handleDelete = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // slide away
function (event: TouchEvent): any { content.current?.classList.add('ios-ease');
onMove(event); setDragX(-width);
},
[onMove]
);
const onDeleteConfirmed = useCallback(() => { // collapse after a slight delay (via CSS)
setInternalDeleting(() => true); setIsCollapsing(true);
window.setTimeout(onDelete, transitionDuration); setTimeout(onDelete, 300); // matches the CSS timings below
}, [onDelete, transitionDuration]); };
const onDeleteCancel = useCallback(() => { // pointer end
setTouching(() => false); const handleEnd = () => {
setTranslate(() => 0); if (!dragging) return;
setInternalDeleting(() => false); setDragging(false);
startTouchPosition.current = 0;
initTranslate.current = 0;
}, [onDelete, transitionDuration]);
const onDeleteClick = useCallback(() => { const shouldDelete =
if (onDeleteConfirm) { Math.abs(dragX) > threshold ||
onDeleteConfirm(onDeleteConfirmed, onDeleteCancel); velocity < -1000;
} else { if (!shouldDelete) {
onDeleteConfirmed(); 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( handleDelete();
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]
);
useEffect(() => { useEffect(() => {
if (touching) { const node = container.current
window.addEventListener("mousemove", onMouseMove); if (!node) return
window.addEventListener("touchmove", onTouchMove); const onDown = (e: PointerEvent) => {
window.addEventListener("mouseup", onMouseUp); node.setPointerCapture(e.pointerId)
window.addEventListener("touchend", onMouseUp); handleStart(e.pageX)
} else {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("touchmove", onTouchMove);
window.removeEventListener("mouseup", onMouseUp);
window.removeEventListener("touchend", onMouseUp);
} }
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 () => { return () => {
window.removeEventListener("mousemove", onMouseMove); node.removeEventListener('pointerdown', onDown)
window.removeEventListener("touchmove", onTouchMove); node.removeEventListener('pointermove', onMove)
window.removeEventListener("mouseup", onMouseUp); node.removeEventListener('pointerup', onUp)
window.removeEventListener("touchend", onMouseUp); }
}; }, [dragging, dragX, velocity])
}, [onMouseMove, onMouseUp, onTouchMove, touching]);
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 ( return (
<div id={id} className={`rstdi${internalDeleting ? " deleting" : ""} ${className}`} ref={container}> <div
<div className={`delete${internalDeleting ? " deleting" : ""}`}> ref={container}
<button onClick={onDeleteClick}>{deleteComponent ? deleteComponent : deleteText}</button> className="relative overflow-hidden select-none"
</div> style={{
height: isCollapsing ? 0 : height,
transition: isCollapsing
? 'height 300ms cubic-bezier(0.24, 1.04, 0.56, 1)'
: undefined,
}}
>
{/* Fixed red background + delete text */}
<div <div
className={`content${internalDeleting ? " deleting" : ""}${!touching ? " transition" : ""}`} className={`absolute inset-0 flex items-center justify-end pr-4`}
onMouseDown={onStart} style={{
onTouchStart={onStart}> background: background,
transition: dragX > 1 ? '' : 'background 300ms'
}}
>
<button className="text-white font-semibold h-full" style={{
transform: deleteTransform,
transition: 'transform 300ms cubic-bezier(0.24, 1.04, 0.56, 1), opacity 300ms',
opacity: isCollapsing ? opacityTransparent : 1
}} onClick={handleDelete} ref={text}>
{deleteText}
</button>
</div>
{/* Swipeable content */}
<div
ref={content}
className="relative ios-ease"
style={{
position: 'absolute',
inset: 0,
transform: `translateX(${dragX}px)`,
transition: dragging
? ''
: 'transform 300ms cubic-bezier(0.24, 1.04, 0.56, 1)',
touchAction: 'none',
pointerEvents: 'none',
willChange: 'transform',
backfaceVisibility: 'hidden',
transformStyle: 'preserve-3d'
}}
>
{children} {children}
</div> </div>
</div> </div>
); );
}; };
export default SwipeToDelete;

View File

@ -1,7 +1,3 @@
import { SwipeToDelete, SwipeToDeleteContextProvider, useSwipeToDeleteContext } from "./SwipeToDelete"; import SwipeToDelete from "./SwipeToDelete";
export default SwipeToDelete; export default SwipeToDelete;
export {
SwipeToDeleteContextProvider,
useSwipeToDeleteContext
};

View File

@ -1,78 +1,8 @@
/* rstdi = react-swipe-to-delete-ios */ /* iOS-style timing */
.ios-ease {
.rstdi { transition-timing-function: cubic-bezier(0.24, 1.04, 0.56, 1);
--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;
} }
/* Rubber-band “resistance”: smaller drag as you pull harder */
.rstdi *, [data-rubber] {
.rstdi *:before, touch-action: none;
.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;
} }