From 8023c8f3572e514f6d497bde7dc7f3200a1c9a3d Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 12:24:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20bidirectional=20swipe=20=E2=80=94=20lef?= =?UTF-8?q?t=3DDelete,=20right=3DArchive/Pause;=20reminders=20list=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups on the activity-row swipe work: 1. SwipeableRow now supports BOTH directions ---------------------------------------- The component grew a `leftActions` slot alongside the existing right shelf. Drag the row right to pull the left shelf into view (non-destructive action: Archive, Pause, etc.); drag left to pull the right shelf into view (destructive: Delete). Past REVEAL_THRESHOLD (60 px) the corresponding shelf locks open; below it, snaps closed. Each shelf is opt-in — omit a slot and the row only swipes one direction. - `computeSwipeNext` and the new `snapPosition` helper take a `{ leftActions, rightActions }` flag pair so the math knows which directions are valid. Drags toward a missing shelf get clamped to 0 instead of fully open. - Activity rows wired as iOS-Mail-style: leading edge (right swipe) = Archive/Restore (amber), trailing edge (left swipe) = Delete (destructive red). - Tests grew to 16 cases covering: snap-to-closed below threshold either way, snap-to-open at/past threshold either way, clamps don't escape the shelf width, missing-shelf rows don't snap to a non-existent open state, baseOffset-aware reverse-drag math, and SSR markup contracts (data-testid, data-state="closed", translateX(0px), aria-hidden=true on closed shelves, no orphaned shelf wrapper when only one slot is provided). Also fixed a `-0` slip in the clamp branch (`-maxRight` is `-0` when maxRight is 0) so call-site equality checks behave. 2. Reminders list rows are swipeable too ---------------------------------------- /reminders page now wraps each row in SwipeableRow: - Left swipe → Delete (always available, destructive). - Right swipe → Pause (when status is "active") OR Restart (when "paused" or "ended"). Other lifecycle states (failed) omit the right shelf entirely; the row only swipes one way. Each shelf button is a tiny `
` posting to the existing server action (delete / pause / restart) — no client-side state beyond the swipe gesture. Page revalidates after the action, list re-renders, row redraws in its new state. Reused the same shelf-button visual language as the activity tab (color-coded action, icon + label, dark-mode pairs) via a tiny inline `ReminderShelfButton` helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/app/activity/page.tsx | 90 ++++---- apps/web/src/app/reminders/page.tsx | 204 +++++++++++++----- .../web/src/components/swipeable-row.test.tsx | 141 +++++++++--- apps/web/src/components/swipeable-row.tsx | 125 +++++++---- 4 files changed, 393 insertions(+), 167 deletions(-) diff --git a/apps/web/src/app/activity/page.tsx b/apps/web/src/app/activity/page.tsx index c742a05..23b704c 100644 --- a/apps/web/src/app/activity/page.tsx +++ b/apps/web/src/app/activity/page.tsx @@ -109,51 +109,55 @@ interface PageProps { searchParams: Promise<{ filter?: string }>; } -interface ActionShelfProps { +interface ShelfButtonProps { runId: string; isArchived: boolean; } /** - * The right-side reveal shelf for swipeable activity rows. - * - * Two stacked buttons — Archive (or Restore, when the row is already - * archived) and Delete. Each is its own form submit so the row stays - * a server component; the page revalidates after the action lands. + * Left-shelf (revealed by swiping the row RIGHT). Hard-delete button. + * iOS-Mail-style: destructive action lives on the leading edge. */ -function ActionShelf({ runId, isArchived }: ActionShelfProps) { +function DeleteShelfButton({ runId }: ShelfButtonProps) { return ( -
- + + - -
- - -
-
+ + Delete + + + ); +} + +/** + * Right-shelf (revealed by swiping the row LEFT). Archive (or Restore + * when the row is already archived). Non-destructive trailing action. + */ +function ArchiveShelfButton({ runId, isArchived }: ShelfButtonProps) { + return ( +
+ + +
); } @@ -227,7 +231,7 @@ export default async function ActivityPage({ searchParams }: PageProps) { {filtered.length > 0 ? ( <>

- Swipe a row left for {showingArchived ? "Restore" : "Archive"} / Delete. + Swipe left to Delete, or right to {showingArchived ? "Restore" : "Archive"}.

{/* Mobile: swipeable cards */} @@ -278,12 +282,12 @@ export default async function ActivityPage({ searchParams }: PageProps) { return ( + // Right swipe → reveal left shelf → Archive (non-destructive). + leftActions={ + } + // Left swipe → reveal right shelf → Delete (destructive). + rightActions={} > {card} diff --git a/apps/web/src/app/reminders/page.tsx b/apps/web/src/app/reminders/page.tsx index 2dd84d1..d66ca71 100644 --- a/apps/web/src/app/reminders/page.tsx +++ b/apps/web/src/app/reminders/page.tsx @@ -1,5 +1,14 @@ import Link from "next/link"; -import { PlusIcon, BellIcon, CalendarIcon, UsersIcon, RepeatIcon } from "lucide-react"; +import { + PlusIcon, + BellIcon, + CalendarIcon, + UsersIcon, + RepeatIcon, + PauseIcon, + PlayIcon, + Trash2Icon, +} from "lucide-react"; import { DateTime } from "luxon"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -14,6 +23,12 @@ import { type ReminderRow, } from "@/lib/reminder-filter"; import { ReminderFilterBar } from "@/components/reminder-filter-bar"; +import { SwipeableRow } from "@/components/swipeable-row"; +import { + deleteReminderAction, + pauseReminderAction, + restartReminderAction, +} from "@/actions/reminders"; import { db } from "@/lib/db"; import { sql } from "drizzle-orm"; @@ -41,6 +56,39 @@ const STATUS_STYLES: Record = { "bg-red-500/15 text-red-600 dark:bg-red-500/20 dark:text-red-400 border-transparent", }; +/** + * Shared shelf-button component for swipeable reminder rows. Wraps a + * server action in a tiny form so the row stays a server component; + * the page revalidates after the action lands. + */ +function ReminderShelfButton({ + reminderId, + label, + icon, + action, + bg, +}: { + reminderId: string; + label: string; + icon: React.ReactNode; + action: (formData: FormData) => Promise; + bg: string; +}) { + return ( +
+ + +
+ ); +} + function StatusPill({ status }: { status: string }) { const cls = STATUS_STYLES[status] ?? @@ -186,60 +234,112 @@ export default async function RemindersPage({ searchParams }: PageProps) { {visible.length > 0 ? ( -
- {visible.map((reminder) => ( - - - -
-
- - - {reminder.name} - -
-

- {reminder.accountLabel} - {reminder.groupNames && ` · ${reminder.groupNames}`} -

-
+ <> +

+ Swipe a row left to Delete, or right to{" "} + {status === "paused" ? "Restart" : "Pause"}. +

-
-
- - {formatWhen(reminder.scheduledAt, tz)} -
- {reminder.rrule && reminder.scheduledAt ? ( -
- - - {describeRecurrence( - specFromRrule(reminder.rrule), - DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone), - )} - +
+ {visible.map((reminder) => { + const canPause = reminder.status === "active"; + const canRestart = + reminder.status === "paused" || reminder.status === "ended"; + const cardBody = ( + + + +
+
+ + + {reminder.name} + +
+

+ {reminder.accountLabel} + {reminder.groupNames && ` · ${reminder.groupNames}`} +

- ) : null} - {reminder.groupCount > 0 && ( -
- - - {reminder.groupCount}{" "} - {reminder.groupCount === 1 ? "group" : "groups"} - + +
+
+ + {formatWhen(reminder.scheduledAt, tz)} +
+ {reminder.rrule && reminder.scheduledAt ? ( +
+ + + {describeRecurrence( + specFromRrule(reminder.rrule), + DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone), + )} + +
+ ) : null} + {reminder.groupCount > 0 && ( +
+ + + {reminder.groupCount}{" "} + {reminder.groupCount === 1 ? "group" : "groups"} + +
+ )}
- )} -
-
-
- - ))} -
+ + + + ); + + // Right swipe → left shelf → Pause (active) / Restart (paused or + // ended). Left swipe → right shelf → Delete. For lifecycle + // states with no sensible secondary action (e.g. failed) we + // omit the left shelf so the row only swipes one direction. + const leftShelf = + canPause ? ( + } + action={pauseReminderAction} + bg="bg-amber-500/15 text-amber-700 hover:bg-amber-500/25 dark:bg-amber-500/20 dark:text-amber-400 dark:hover:bg-amber-500/30" + /> + ) : canRestart ? ( + } + action={restartReminderAction} + bg="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/25 dark:bg-emerald-500/20 dark:text-emerald-400 dark:hover:bg-emerald-500/30" + /> + ) : undefined; + + return ( + } + action={deleteReminderAction} + bg="bg-destructive/15 text-destructive hover:bg-destructive/25" + /> + } + > + {cardBody} + + ); + })} +
+ ) : ( diff --git a/apps/web/src/components/swipeable-row.test.tsx b/apps/web/src/components/swipeable-row.test.tsx index c1c4be2..890f896 100644 --- a/apps/web/src/components/swipeable-row.test.tsx +++ b/apps/web/src/components/swipeable-row.test.tsx @@ -2,77 +2,154 @@ import { describe, it, expect } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { computeSwipeNext, + snapPosition, SwipeableRow, SWIPE_REVEAL_THRESHOLD, SWIPE_SHELF_WIDTH, } from "./swipeable-row"; +const BOTH_SHELVES = { leftActions: true, rightActions: true }; +const RIGHT_ONLY = { leftActions: false, rightActions: true }; +const LEFT_ONLY = { leftActions: true, rightActions: false }; + +describe("snapPosition", () => { + it("stays closed when the offset is well inside the threshold", () => { + expect(snapPosition(0, BOTH_SHELVES)).toBe(0); + expect(snapPosition(SWIPE_REVEAL_THRESHOLD - 1, BOTH_SHELVES)).toBe(0); + expect(snapPosition(-(SWIPE_REVEAL_THRESHOLD - 1), BOTH_SHELVES)).toBe(0); + }); + + it("snaps to right shelf (negative) when dragged left past threshold", () => { + expect(snapPosition(-SWIPE_REVEAL_THRESHOLD, BOTH_SHELVES)).toBe(-SWIPE_SHELF_WIDTH); + expect(snapPosition(-(SWIPE_REVEAL_THRESHOLD + 50), BOTH_SHELVES)).toBe(-SWIPE_SHELF_WIDTH); + }); + + it("snaps to left shelf (positive) when dragged right past threshold", () => { + expect(snapPosition(SWIPE_REVEAL_THRESHOLD, BOTH_SHELVES)).toBe(SWIPE_SHELF_WIDTH); + expect(snapPosition(SWIPE_REVEAL_THRESHOLD + 50, BOTH_SHELVES)).toBe(SWIPE_SHELF_WIDTH); + }); + + it("doesn't snap to a shelf that doesn't exist (right-only row dragged right)", () => { + expect(snapPosition(SWIPE_REVEAL_THRESHOLD + 100, RIGHT_ONLY)).toBe(0); + }); + + it("doesn't snap to a shelf that doesn't exist (left-only row dragged left)", () => { + expect(snapPosition(-(SWIPE_REVEAL_THRESHOLD + 100), LEFT_ONLY)).toBe(0); + }); +}); + describe("computeSwipeNext — drag math", () => { it("returns 0 offset and snap=closed when there's no drag", () => { - expect(computeSwipeNext(0, 0)).toEqual({ + expect(computeSwipeNext(0, 0, BOTH_SHELVES)).toEqual({ dragOffset: 0, snapAfterRelease: 0, }); }); - it("clamps positive drags to 0 (can't drag past the closed position)", () => { - expect(computeSwipeNext(0, 25).dragOffset).toBe(0); - expect(computeSwipeNext(-30, 200).dragOffset).toBe(0); + it("clamps drags right past +SHELF_WIDTH (can't pull past fully-open left shelf)", () => { + expect(computeSwipeNext(0, 500, BOTH_SHELVES).dragOffset).toBe(SWIPE_SHELF_WIDTH); + expect(computeSwipeNext(SWIPE_SHELF_WIDTH, 50, BOTH_SHELVES).dragOffset).toBe( + SWIPE_SHELF_WIDTH, + ); }); - it("clamps drags beyond shelf width to -SHELF_WIDTH (can't pull past fully open)", () => { - expect(computeSwipeNext(0, -500).dragOffset).toBe(-SWIPE_SHELF_WIDTH); - expect(computeSwipeNext(-SWIPE_SHELF_WIDTH, -50).dragOffset).toBe(-SWIPE_SHELF_WIDTH); + it("clamps drags left past -SHELF_WIDTH (can't pull past fully-open right shelf)", () => { + expect(computeSwipeNext(0, -500, BOTH_SHELVES).dragOffset).toBe(-SWIPE_SHELF_WIDTH); + expect(computeSwipeNext(-SWIPE_SHELF_WIDTH, -50, BOTH_SHELVES).dragOffset).toBe( + -SWIPE_SHELF_WIDTH, + ); }); - it("snaps back to closed when released before the threshold", () => { - const dx = -(SWIPE_REVEAL_THRESHOLD - 1); - expect(computeSwipeNext(0, dx).snapAfterRelease).toBe(0); + it("right-only row can't drag right at all (clamped at 0)", () => { + expect(computeSwipeNext(0, 200, RIGHT_ONLY).dragOffset).toBe(0); + // Can still drag left toward the right shelf. + expect(computeSwipeNext(0, -200, RIGHT_ONLY).dragOffset).toBe(-SWIPE_SHELF_WIDTH); }); - it("snaps to fully open when released at or past the threshold", () => { - const dxAt = -SWIPE_REVEAL_THRESHOLD; - const dxPast = -(SWIPE_REVEAL_THRESHOLD + 10); - expect(computeSwipeNext(0, dxAt).snapAfterRelease).toBe(-SWIPE_SHELF_WIDTH); - expect(computeSwipeNext(0, dxPast).snapAfterRelease).toBe(-SWIPE_SHELF_WIDTH); + it("left-only row can't drag left at all (clamped at 0)", () => { + expect(computeSwipeNext(0, -200, LEFT_ONLY).dragOffset).toBe(0); + // Can still drag right toward the left shelf. + expect(computeSwipeNext(0, 200, LEFT_ONLY).dragOffset).toBe(SWIPE_SHELF_WIDTH); }); - it("respects the previous offset when measuring against threshold", () => { - // Already partway open (-20) and you drag another -50 → -70, which - // is past the threshold so it snaps fully open. - expect(computeSwipeNext(-20, -50).snapAfterRelease).toBe(-SWIPE_SHELF_WIDTH); - // Already partway open (-50) and you drag back +20 → -30, well shy - // of the threshold so it snaps back closed. - expect(computeSwipeNext(-50, 20).snapAfterRelease).toBe(0); + it("snaps closed below threshold in either direction", () => { + const dxLeft = -(SWIPE_REVEAL_THRESHOLD - 1); + const dxRight = SWIPE_REVEAL_THRESHOLD - 1; + expect(computeSwipeNext(0, dxLeft, BOTH_SHELVES).snapAfterRelease).toBe(0); + expect(computeSwipeNext(0, dxRight, BOTH_SHELVES).snapAfterRelease).toBe(0); + }); + + it("snaps fully open in the corresponding direction at or past threshold", () => { + expect(computeSwipeNext(0, -SWIPE_REVEAL_THRESHOLD, BOTH_SHELVES).snapAfterRelease).toBe( + -SWIPE_SHELF_WIDTH, + ); + expect(computeSwipeNext(0, SWIPE_REVEAL_THRESHOLD, BOTH_SHELVES).snapAfterRelease).toBe( + SWIPE_SHELF_WIDTH, + ); + }); + + it("respects baseOffset when snapping (open → close on small reverse drag)", () => { + // Open right shelf, then drag back by 10 px — still past threshold. + expect( + computeSwipeNext(-SWIPE_SHELF_WIDTH, 10, BOTH_SHELVES).snapAfterRelease, + ).toBe(-SWIPE_SHELF_WIDTH); + // Open right shelf, then drag back almost the full width — back + // inside the threshold envelope, snaps closed. + expect( + computeSwipeNext(-SWIPE_SHELF_WIDTH, SWIPE_SHELF_WIDTH - 10, BOTH_SHELVES).snapAfterRelease, + ).toBe(0); }); }); describe("SwipeableRow — SSR markup contract", () => { - it("renders the action shelf and the row body, wrapped in a positioned container", () => { + it("renders both shelves and the row body, wrapped in a positioned container", () => { const html = renderToStaticMarkup( - Archive}> + Delete} + rightActions={} + >
Row body
, ); expect(html).toContain('data-testid="swipeable-row"'); - // Row starts in the closed state so the shelf is `aria-hidden` to AT. - expect(html).toMatch(/aria-hidden="true"/); + expect(html).toContain("Delete"); expect(html).toContain("Archive"); expect(html).toContain("Row body"); - // The container must be `relative` + `overflow-hidden` so the shelf - // doesn't bleed past the row's bounds during drag. expect(html).toMatch(/class="relative overflow-hidden/); }); - it("starts with the row at translateX(0) (closed) and no transition during initial render", () => { + it("starts closed: translateX(0px) and data-state='closed', both shelves aria-hidden", () => { const html = renderToStaticMarkup( - x}> + L} + rightActions={R} + >
body
, ); - // A closed row's transform is exactly translateX(0px). Anything else - // means the SSR/client states would diverge. expect(html).toContain("translateX(0px)"); expect(html).toContain('data-state="closed"'); + // At offset 0, BOTH shelves are hidden from AT (offset <= 0 hides + // left, offset >= 0 hides right). + expect((html.match(/aria-hidden="true"/g) ?? []).length).toBe(2); + }); + + it("omits a shelf entirely when its prop is missing (no orphaned wrapper)", () => { + const onlyRight = renderToStaticMarkup( + R}> +
body
+
, + ); + expect(onlyRight).toContain("R"); + // Only one absolute-positioned shelf div should be in the markup. + expect((onlyRight.match(/class="absolute inset-y-0/g) ?? []).length).toBe(1); + + const onlyLeft = renderToStaticMarkup( + L}> +
body
+
, + ); + expect(onlyLeft).toContain("L"); + expect((onlyLeft.match(/class="absolute inset-y-0/g) ?? []).length).toBe(1); }); }); diff --git a/apps/web/src/components/swipeable-row.tsx b/apps/web/src/components/swipeable-row.tsx index 16b2e1b..e2018fe 100644 --- a/apps/web/src/components/swipeable-row.tsx +++ b/apps/web/src/components/swipeable-row.tsx @@ -4,31 +4,38 @@ import { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; /** - * SwipeableRow — gesture-driven row with reveal-on-swipe action buttons. + * SwipeableRow — gesture-driven row with reveal-on-swipe action shelves. * - * The row sits over a fixed-position action shelf. Drag (touch or - * mouse) the row to the left to drag the shelf into view; release past - * REVEAL_THRESHOLD to lock the shelf open, otherwise spring back to - * closed. Tapping anywhere outside the row when it's open also closes - * it. Optional `actions` slot for the right side (Archive / Delete). + * Drag the row left to pull the right-side shelf into view, or drag + * right to pull the left-side shelf into view. Past REVEAL_THRESHOLD + * the shelf locks open; below the threshold it springs back closed. + * Tapping outside an open row also closes it. * - * The state machine is intentionally tiny: + * ← drag left drag right → + * ┌─────────────┐──┐ ┌──┌─────────────┐ + * │ row body │R │ │ L│ row body │ + * └─────────────┘──┘ └──└─────────────┘ + * ↑ ↑ + * Right shelf Left shelf * - * closed → dragging → (release < threshold) → closed - * closed → dragging → (release ≥ threshold) → open - * open → tap outside → closed + * The component is direction-agnostic — both shelves accept arbitrary + * action buttons. Activity rows wire it as: + * leftActions = Archive (right swipe → non-destructive lives here) + * rightActions = Delete (left swipe → destructive lives here) * - * No third-party gesture library — touch / pointer events only — to - * keep the bundle small and avoid SSR / hydration headaches. + * Pointer events only — no third-party gesture lib — to keep the + * bundle small and avoid SSR / hydration headaches. */ -const REVEAL_THRESHOLD = 60; // px — lock shelf open past this drag -const SHELF_WIDTH = 132; // px — width of the right-side action shelf +const REVEAL_THRESHOLD = 60; // px — how far you have to drag to lock shelf open +const SHELF_WIDTH = 88; // px — width of each shelf (one button each side) interface SwipeableRowProps { - /** Right-side action shelf revealed on swipe-left. */ - actions: React.ReactNode; - /** Row body — the visible content above the shelf. */ + /** Right-side shelf, revealed by swiping LEFT (offset goes negative). */ + rightActions?: React.ReactNode; + /** Left-side shelf, revealed by swiping RIGHT (offset goes positive). */ + leftActions?: React.ReactNode; + /** Row body — the visible content above the shelves. */ children: React.ReactNode; /** className for the OUTER wrapper (positioning, margins). */ className?: string; @@ -37,12 +44,16 @@ interface SwipeableRowProps { } export function SwipeableRow({ - actions, + rightActions, + leftActions, children, className, rowClassName, }: SwipeableRowProps) { - // `offset` is the row's current x-translation in px (0 = closed, -SHELF_WIDTH = fully open). + // `offset` is the row's current x-translation in px: + // 0 → closed + // -SHELF_WIDTH → right shelf fully open + // +SHELF_WIDTH → left shelf fully open const [offset, setOffset] = useState(0); const [dragging, setDragging] = useState(false); const containerRef = useRef(null); @@ -61,13 +72,15 @@ export function SwipeableRow({ }, [offset]); function clamp(next: number): number { - if (next > 0) return 0; - if (next < -SHELF_WIDTH) return -SHELF_WIDTH; + // Limit drags to the available shelf width on each side. + const maxLeft = leftActions ? SHELF_WIDTH : 0; + const maxRight = rightActions ? SHELF_WIDTH : 0; + if (next > maxLeft) return maxLeft; + if (next < -maxRight) return -maxRight; return next; } function handlePointerDown(e: React.PointerEvent) { - // Ignore right-click / pinch / etc. if (e.button !== 0 && e.pointerType === "mouse") return; dragStart.current = { x: e.clientX, baseOffset: offset }; setDragging(true); @@ -83,25 +96,37 @@ export function SwipeableRow({ if (!dragging) return; setDragging(false); dragStart.current = null; - // Snap to the nearest stable position based on REVEAL_THRESHOLD. - setOffset((prev) => (prev <= -REVEAL_THRESHOLD ? -SHELF_WIDTH : 0)); + setOffset((prev) => snapPosition(prev, { leftActions: !!leftActions, rightActions: !!rightActions })); } return (
- {/* Action shelf — pinned to the right, revealed by translation of the row above. */} -
- {actions} -
+ {/* Left shelf — pinned to the left, revealed by swipe-right. */} + {leftActions && ( +
+ {leftActions} +
+ )} + + {/* Right shelf — pinned to the right, revealed by swipe-left. */} + {rightActions && ( +
= 0} + className="absolute inset-y-0 right-0 flex items-stretch" + style={{ width: SHELF_WIDTH }} + > + {rightActions} +
+ )} {/* Sliding row body. */}
@@ -123,18 +148,38 @@ export function SwipeableRow({ } /** - * Pure helper: given a pointer drag delta in px and the previous offset, - * compute the next offset (clamped) and the post-release snap target. - * Exposed so unit tests can exercise the logic without a DOM. + * Pure helper: given the post-drag offset and which shelves exist, + * decide what offset to snap to on release. + */ +export function snapPosition( + offset: number, + shelves: { leftActions: boolean; rightActions: boolean }, +): number { + if (offset >= REVEAL_THRESHOLD && shelves.leftActions) return SHELF_WIDTH; + if (offset <= -REVEAL_THRESHOLD && shelves.rightActions) return -SHELF_WIDTH; + return 0; +} + +/** + * Pure helper: compute the next dragOffset (clamped) and the + * post-release snap target for a drag with delta `dx`. */ export function computeSwipeNext( baseOffset: number, dx: number, + shelves: { leftActions: boolean; rightActions: boolean } = { + leftActions: true, + rightActions: true, + }, ): { dragOffset: number; snapAfterRelease: number } { + const maxLeft = shelves.leftActions ? SHELF_WIDTH : 0; + const maxRight = shelves.rightActions ? SHELF_WIDTH : 0; const raw = baseOffset + dx; - const clamped = raw > 0 ? 0 : raw < -SHELF_WIDTH ? -SHELF_WIDTH : raw; - const snap = clamped <= -REVEAL_THRESHOLD ? -SHELF_WIDTH : 0; - return { dragOffset: clamped, snapAfterRelease: snap }; + let clamped = raw > maxLeft ? maxLeft : raw < -maxRight ? -maxRight : raw; + // Avoid `-0` slipping out when a side has no shelf (`-maxRight` is + // `-0` when maxRight is 0). Normalize so call sites can `=== 0`. + if (Object.is(clamped, -0)) clamped = 0; + return { dragOffset: clamped, snapAfterRelease: snapPosition(clamped, shelves) }; } export const SWIPE_REVEAL_THRESHOLD = REVEAL_THRESHOLD;