feat: bidirectional swipe — left=Delete, right=Archive/Pause; reminders list too

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 `<form>` 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) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 12:24:55 +08:00
parent 704bc5e788
commit 8023c8f357
4 changed files with 393 additions and 167 deletions

View File

@ -109,51 +109,55 @@ interface PageProps {
searchParams: Promise<{ filter?: string }>; searchParams: Promise<{ filter?: string }>;
} }
interface ActionShelfProps { interface ShelfButtonProps {
runId: string; runId: string;
isArchived: boolean; isArchived: boolean;
} }
/** /**
* The right-side reveal shelf for swipeable activity rows. * Left-shelf (revealed by swiping the row RIGHT). Hard-delete button.
* * iOS-Mail-style: destructive action lives on the leading edge.
* 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.
*/ */
function ActionShelf({ runId, isArchived }: ActionShelfProps) { function DeleteShelfButton({ runId }: ShelfButtonProps) {
return ( return (
<div className="flex w-full items-stretch gap-px bg-border"> <form action={deleteRunAction} className="flex w-full">
<form <input type="hidden" name="runId" value={runId} />
action={isArchived ? unarchiveRunAction : archiveRunAction} <button
className="flex-1" type="submit"
aria-label="Delete"
className="flex h-full w-full flex-col items-center justify-center gap-1 bg-destructive/15 text-destructive hover:bg-destructive/25 text-xs font-medium"
> >
<input type="hidden" name="runId" value={runId} /> <Trash2Icon className="size-4" />
<button Delete
type="submit" </button>
aria-label={isArchived ? "Restore" : "Archive"} </form>
className="flex h-full w-full flex-col items-center justify-center gap-1 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 text-xs font-medium" );
> }
{isArchived ? (
<ArchiveRestoreIcon className="size-4" /> /**
) : ( * Right-shelf (revealed by swiping the row LEFT). Archive (or Restore
<ArchiveIcon className="size-4" /> * when the row is already archived). Non-destructive trailing action.
)} */
{isArchived ? "Restore" : "Archive"} function ArchiveShelfButton({ runId, isArchived }: ShelfButtonProps) {
</button> return (
</form> <form
<form action={deleteRunAction} className="flex-1"> action={isArchived ? unarchiveRunAction : archiveRunAction}
<input type="hidden" name="runId" value={runId} /> className="flex w-full"
<button >
type="submit" <input type="hidden" name="runId" value={runId} />
aria-label="Delete" <button
className="flex h-full w-full flex-col items-center justify-center gap-1 bg-destructive/15 text-destructive hover:bg-destructive/25 text-xs font-medium" type="submit"
> aria-label={isArchived ? "Restore" : "Archive"}
<Trash2Icon className="size-4" /> className="flex h-full w-full flex-col items-center justify-center gap-1 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 text-xs font-medium"
Delete >
</button> {isArchived ? (
</form> <ArchiveRestoreIcon className="size-4" />
</div> ) : (
<ArchiveIcon className="size-4" />
)}
{isArchived ? "Restore" : "Archive"}
</button>
</form>
); );
} }
@ -227,7 +231,7 @@ export default async function ActivityPage({ searchParams }: PageProps) {
{filtered.length > 0 ? ( {filtered.length > 0 ? (
<> <>
<p className="text-xs text-muted-foreground sm:hidden"> <p className="text-xs text-muted-foreground sm:hidden">
Swipe a row left for {showingArchived ? "Restore" : "Archive"} / Delete. Swipe left to Delete, or right to {showingArchived ? "Restore" : "Archive"}.
</p> </p>
{/* Mobile: swipeable cards */} {/* Mobile: swipeable cards */}
@ -278,12 +282,12 @@ export default async function ActivityPage({ searchParams }: PageProps) {
return ( return (
<SwipeableRow <SwipeableRow
key={run.id} key={run.id}
actions={ // Right swipe → reveal left shelf → Archive (non-destructive).
<ActionShelf leftActions={
runId={run.id} <ArchiveShelfButton runId={run.id} isArchived={Boolean(run.archivedAt)} />
isArchived={Boolean(run.archivedAt)}
/>
} }
// Left swipe → reveal right shelf → Delete (destructive).
rightActions={<DeleteShelfButton runId={run.id} isArchived={Boolean(run.archivedAt)} />}
> >
{card} {card}
</SwipeableRow> </SwipeableRow>

View File

@ -1,5 +1,14 @@
import Link from "next/link"; 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 { DateTime } from "luxon";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@ -14,6 +23,12 @@ import {
type ReminderRow, type ReminderRow,
} from "@/lib/reminder-filter"; } from "@/lib/reminder-filter";
import { ReminderFilterBar } from "@/components/reminder-filter-bar"; 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 { db } from "@/lib/db";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
@ -41,6 +56,39 @@ const STATUS_STYLES: Record<string, string> = {
"bg-red-500/15 text-red-600 dark:bg-red-500/20 dark:text-red-400 border-transparent", "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<void>;
bg: string;
}) {
return (
<form action={action} className="flex w-full">
<input type="hidden" name="reminderId" value={reminderId} />
<button
type="submit"
aria-label={label}
className={`flex h-full w-full flex-col items-center justify-center gap-1 text-xs font-medium ${bg}`}
>
{icon}
{label}
</button>
</form>
);
}
function StatusPill({ status }: { status: string }) { function StatusPill({ status }: { status: string }) {
const cls = const cls =
STATUS_STYLES[status] ?? STATUS_STYLES[status] ??
@ -186,60 +234,112 @@ export default async function RemindersPage({ searchParams }: PageProps) {
</Tabs> </Tabs>
{visible.length > 0 ? ( {visible.length > 0 ? (
<div className="flex flex-col gap-3"> <>
{visible.map((reminder) => ( <p className="text-xs text-muted-foreground sm:hidden">
<Link Swipe a row left to Delete, or right to{" "}
key={reminder.id} {status === "paused" ? "Restart" : "Pause"}.
// eslint-disable-next-line @typescript-eslint/no-explicit-any </p>
href={`/reminders/${reminder.id}` as any}
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<Card className="transition-shadow hover:shadow-md hover:ring-foreground/20">
<CardContent className="flex items-center gap-3 py-3 px-4">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<StatusPill status={reminder.status} />
<span className="text-sm font-medium leading-none truncate">
{reminder.name}
</span>
</div>
<p className="text-xs text-muted-foreground truncate">
{reminder.accountLabel}
{reminder.groupNames && ` · ${reminder.groupNames}`}
</p>
</div>
<div className="shrink-0 text-right space-y-1"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground"> {visible.map((reminder) => {
<CalendarIcon className="size-3 shrink-0" /> const canPause = reminder.status === "active";
<span>{formatWhen(reminder.scheduledAt, tz)}</span> const canRestart =
</div> reminder.status === "paused" || reminder.status === "ended";
{reminder.rrule && reminder.scheduledAt ? ( const cardBody = (
<div className="flex items-center justify-end gap-1 text-xs text-primary/80"> <Link
<RepeatIcon className="size-3 shrink-0" /> // eslint-disable-next-line @typescript-eslint/no-explicit-any
<span> href={`/reminders/${reminder.id}` as any}
{describeRecurrence( className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
specFromRrule(reminder.rrule), >
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone), <Card className="transition-shadow hover:shadow-md hover:ring-foreground/20">
)} <CardContent className="flex items-center gap-3 py-3 px-4">
</span> <div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<StatusPill status={reminder.status} />
<span className="text-sm font-medium leading-none truncate">
{reminder.name}
</span>
</div>
<p className="text-xs text-muted-foreground truncate">
{reminder.accountLabel}
{reminder.groupNames && ` · ${reminder.groupNames}`}
</p>
</div> </div>
) : null}
{reminder.groupCount > 0 && ( <div className="shrink-0 text-right space-y-1">
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground"> <div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
<UsersIcon className="size-3 shrink-0" /> <CalendarIcon className="size-3 shrink-0" />
<span> <span>{formatWhen(reminder.scheduledAt, tz)}</span>
{reminder.groupCount}{" "} </div>
{reminder.groupCount === 1 ? "group" : "groups"} {reminder.rrule && reminder.scheduledAt ? (
</span> <div className="flex items-center justify-end gap-1 text-xs text-primary/80">
<RepeatIcon className="size-3 shrink-0" />
<span>
{describeRecurrence(
specFromRrule(reminder.rrule),
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
)}
</span>
</div>
) : null}
{reminder.groupCount > 0 && (
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
<UsersIcon className="size-3 shrink-0" />
<span>
{reminder.groupCount}{" "}
{reminder.groupCount === 1 ? "group" : "groups"}
</span>
</div>
)}
</div> </div>
)} </CardContent>
</div> </Card>
</CardContent> </Link>
</Card> );
</Link>
))} // Right swipe → left shelf → Pause (active) / Restart (paused or
</div> // 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 ? (
<ReminderShelfButton
reminderId={reminder.id}
label="Pause"
icon={<PauseIcon className="size-4" />}
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 ? (
<ReminderShelfButton
reminderId={reminder.id}
label="Restart"
icon={<PlayIcon className="size-4" />}
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 (
<SwipeableRow
key={reminder.id}
leftActions={leftShelf}
rightActions={
<ReminderShelfButton
reminderId={reminder.id}
label="Delete"
icon={<Trash2Icon className="size-4" />}
action={deleteReminderAction}
bg="bg-destructive/15 text-destructive hover:bg-destructive/25"
/>
}
>
{cardBody}
</SwipeableRow>
);
})}
</div>
</>
) : ( ) : (
<Card> <Card>
<CardContent className="flex flex-col items-center gap-4 py-12 text-center"> <CardContent className="flex flex-col items-center gap-4 py-12 text-center">

View File

@ -2,77 +2,154 @@ import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { import {
computeSwipeNext, computeSwipeNext,
snapPosition,
SwipeableRow, SwipeableRow,
SWIPE_REVEAL_THRESHOLD, SWIPE_REVEAL_THRESHOLD,
SWIPE_SHELF_WIDTH, SWIPE_SHELF_WIDTH,
} from "./swipeable-row"; } 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", () => { describe("computeSwipeNext — drag math", () => {
it("returns 0 offset and snap=closed when there's no drag", () => { 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, dragOffset: 0,
snapAfterRelease: 0, snapAfterRelease: 0,
}); });
}); });
it("clamps positive drags to 0 (can't drag past the closed position)", () => { it("clamps drags right past +SHELF_WIDTH (can't pull past fully-open left shelf)", () => {
expect(computeSwipeNext(0, 25).dragOffset).toBe(0); expect(computeSwipeNext(0, 500, BOTH_SHELVES).dragOffset).toBe(SWIPE_SHELF_WIDTH);
expect(computeSwipeNext(-30, 200).dragOffset).toBe(0); 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)", () => { it("clamps drags left past -SHELF_WIDTH (can't pull past fully-open right shelf)", () => {
expect(computeSwipeNext(0, -500).dragOffset).toBe(-SWIPE_SHELF_WIDTH); expect(computeSwipeNext(0, -500, BOTH_SHELVES).dragOffset).toBe(-SWIPE_SHELF_WIDTH);
expect(computeSwipeNext(-SWIPE_SHELF_WIDTH, -50).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", () => { it("right-only row can't drag right at all (clamped at 0)", () => {
const dx = -(SWIPE_REVEAL_THRESHOLD - 1); expect(computeSwipeNext(0, 200, RIGHT_ONLY).dragOffset).toBe(0);
expect(computeSwipeNext(0, dx).snapAfterRelease).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", () => { it("left-only row can't drag left at all (clamped at 0)", () => {
const dxAt = -SWIPE_REVEAL_THRESHOLD; expect(computeSwipeNext(0, -200, LEFT_ONLY).dragOffset).toBe(0);
const dxPast = -(SWIPE_REVEAL_THRESHOLD + 10); // Can still drag right toward the left shelf.
expect(computeSwipeNext(0, dxAt).snapAfterRelease).toBe(-SWIPE_SHELF_WIDTH); expect(computeSwipeNext(0, 200, LEFT_ONLY).dragOffset).toBe(SWIPE_SHELF_WIDTH);
expect(computeSwipeNext(0, dxPast).snapAfterRelease).toBe(-SWIPE_SHELF_WIDTH);
}); });
it("respects the previous offset when measuring against threshold", () => { it("snaps closed below threshold in either direction", () => {
// Already partway open (-20) and you drag another -50 → -70, which const dxLeft = -(SWIPE_REVEAL_THRESHOLD - 1);
// is past the threshold so it snaps fully open. const dxRight = SWIPE_REVEAL_THRESHOLD - 1;
expect(computeSwipeNext(-20, -50).snapAfterRelease).toBe(-SWIPE_SHELF_WIDTH); expect(computeSwipeNext(0, dxLeft, BOTH_SHELVES).snapAfterRelease).toBe(0);
// Already partway open (-50) and you drag back +20 → -30, well shy expect(computeSwipeNext(0, dxRight, BOTH_SHELVES).snapAfterRelease).toBe(0);
// of the threshold so it snaps back closed. });
expect(computeSwipeNext(-50, 20).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", () => { 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( const html = renderToStaticMarkup(
<SwipeableRow actions={<button type="button">Archive</button>}> <SwipeableRow
leftActions={<button type="button">Delete</button>}
rightActions={<button type="button">Archive</button>}
>
<div>Row body</div> <div>Row body</div>
</SwipeableRow>, </SwipeableRow>,
); );
expect(html).toContain('data-testid="swipeable-row"'); expect(html).toContain('data-testid="swipeable-row"');
// Row starts in the closed state so the shelf is `aria-hidden` to AT. expect(html).toContain("Delete");
expect(html).toMatch(/aria-hidden="true"/);
expect(html).toContain("Archive"); expect(html).toContain("Archive");
expect(html).toContain("Row body"); 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/); 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( const html = renderToStaticMarkup(
<SwipeableRow actions={<span>x</span>}> <SwipeableRow
leftActions={<span>L</span>}
rightActions={<span>R</span>}
>
<div>body</div> <div>body</div>
</SwipeableRow>, </SwipeableRow>,
); );
// 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("translateX(0px)");
expect(html).toContain('data-state="closed"'); 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(
<SwipeableRow rightActions={<span>R</span>}>
<div>body</div>
</SwipeableRow>,
);
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(
<SwipeableRow leftActions={<span>L</span>}>
<div>body</div>
</SwipeableRow>,
);
expect(onlyLeft).toContain("L");
expect((onlyLeft.match(/class="absolute inset-y-0/g) ?? []).length).toBe(1);
}); });
}); });

View File

@ -4,31 +4,38 @@ import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils"; 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 * Drag the row left to pull the right-side shelf into view, or drag
* mouse) the row to the left to drag the shelf into view; release past * right to pull the left-side shelf into view. Past REVEAL_THRESHOLD
* REVEAL_THRESHOLD to lock the shelf open, otherwise spring back to * the shelf locks open; below the threshold it springs back closed.
* closed. Tapping anywhere outside the row when it's open also closes * Tapping outside an open row also closes it.
* it. Optional `actions` slot for the right side (Archive / Delete).
* *
* 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 * The component is direction-agnostic both shelves accept arbitrary
* closed dragging (release threshold) open * action buttons. Activity rows wire it as:
* open tap outside closed * 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 * Pointer events only no third-party gesture lib to keep the
* keep the bundle small and avoid SSR / hydration headaches. * bundle small and avoid SSR / hydration headaches.
*/ */
const REVEAL_THRESHOLD = 60; // px — lock shelf open past this drag const REVEAL_THRESHOLD = 60; // px — how far you have to drag to lock shelf open
const SHELF_WIDTH = 132; // px — width of the right-side action shelf const SHELF_WIDTH = 88; // px — width of each shelf (one button each side)
interface SwipeableRowProps { interface SwipeableRowProps {
/** Right-side action shelf revealed on swipe-left. */ /** Right-side shelf, revealed by swiping LEFT (offset goes negative). */
actions: React.ReactNode; rightActions?: React.ReactNode;
/** Row body — the visible content above the shelf. */ /** Left-side shelf, revealed by swiping RIGHT (offset goes positive). */
leftActions?: React.ReactNode;
/** Row body — the visible content above the shelves. */
children: React.ReactNode; children: React.ReactNode;
/** className for the OUTER wrapper (positioning, margins). */ /** className for the OUTER wrapper (positioning, margins). */
className?: string; className?: string;
@ -37,12 +44,16 @@ interface SwipeableRowProps {
} }
export function SwipeableRow({ export function SwipeableRow({
actions, rightActions,
leftActions,
children, children,
className, className,
rowClassName, rowClassName,
}: SwipeableRowProps) { }: 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 [offset, setOffset] = useState(0);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
@ -61,13 +72,15 @@ export function SwipeableRow({
}, [offset]); }, [offset]);
function clamp(next: number): number { function clamp(next: number): number {
if (next > 0) return 0; // Limit drags to the available shelf width on each side.
if (next < -SHELF_WIDTH) return -SHELF_WIDTH; const maxLeft = leftActions ? SHELF_WIDTH : 0;
const maxRight = rightActions ? SHELF_WIDTH : 0;
if (next > maxLeft) return maxLeft;
if (next < -maxRight) return -maxRight;
return next; return next;
} }
function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) { function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) {
// Ignore right-click / pinch / etc.
if (e.button !== 0 && e.pointerType === "mouse") return; if (e.button !== 0 && e.pointerType === "mouse") return;
dragStart.current = { x: e.clientX, baseOffset: offset }; dragStart.current = { x: e.clientX, baseOffset: offset };
setDragging(true); setDragging(true);
@ -83,25 +96,37 @@ export function SwipeableRow({
if (!dragging) return; if (!dragging) return;
setDragging(false); setDragging(false);
dragStart.current = null; dragStart.current = null;
// Snap to the nearest stable position based on REVEAL_THRESHOLD. setOffset((prev) => snapPosition(prev, { leftActions: !!leftActions, rightActions: !!rightActions }));
setOffset((prev) => (prev <= -REVEAL_THRESHOLD ? -SHELF_WIDTH : 0));
} }
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className={cn("relative overflow-hidden rounded-xl", className)} className={cn("relative overflow-hidden rounded-xl", className)}
data-state={offset === 0 ? "closed" : offset === -SHELF_WIDTH ? "open" : "dragging"} data-state={offset === 0 ? "closed" : "open"}
data-testid="swipeable-row" data-testid="swipeable-row"
> >
{/* Action shelf — pinned to the right, revealed by translation of the row above. */} {/* Left shelf — pinned to the left, revealed by swipe-right. */}
<div {leftActions && (
aria-hidden={offset === 0} <div
className="absolute inset-y-0 right-0 flex items-stretch" aria-hidden={offset <= 0}
style={{ width: SHELF_WIDTH }} className="absolute inset-y-0 left-0 flex items-stretch"
> style={{ width: SHELF_WIDTH }}
{actions} >
</div> {leftActions}
</div>
)}
{/* Right shelf — pinned to the right, revealed by swipe-left. */}
{rightActions && (
<div
aria-hidden={offset >= 0}
className="absolute inset-y-0 right-0 flex items-stretch"
style={{ width: SHELF_WIDTH }}
>
{rightActions}
</div>
)}
{/* Sliding row body. */} {/* Sliding row body. */}
<div <div
@ -112,7 +137,7 @@ export function SwipeableRow({
style={{ style={{
transform: `translateX(${offset}px)`, transform: `translateX(${offset}px)`,
transition: dragging ? "none" : "transform 200ms ease-out", transition: dragging ? "none" : "transform 200ms ease-out",
touchAction: "pan-y", // allow vertical scroll, capture horizontal drag touchAction: "pan-y",
}} }}
className={cn("relative bg-card", rowClassName)} className={cn("relative bg-card", rowClassName)}
> >
@ -123,18 +148,38 @@ export function SwipeableRow({
} }
/** /**
* Pure helper: given a pointer drag delta in px and the previous offset, * Pure helper: given the post-drag offset and which shelves exist,
* compute the next offset (clamped) and the post-release snap target. * decide what offset to snap to on release.
* Exposed so unit tests can exercise the logic without a DOM. */
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( export function computeSwipeNext(
baseOffset: number, baseOffset: number,
dx: number, dx: number,
shelves: { leftActions: boolean; rightActions: boolean } = {
leftActions: true,
rightActions: true,
},
): { dragOffset: number; snapAfterRelease: number } { ): { dragOffset: number; snapAfterRelease: number } {
const maxLeft = shelves.leftActions ? SHELF_WIDTH : 0;
const maxRight = shelves.rightActions ? SHELF_WIDTH : 0;
const raw = baseOffset + dx; const raw = baseOffset + dx;
const clamped = raw > 0 ? 0 : raw < -SHELF_WIDTH ? -SHELF_WIDTH : raw; let clamped = raw > maxLeft ? maxLeft : raw < -maxRight ? -maxRight : raw;
const snap = clamped <= -REVEAL_THRESHOLD ? -SHELF_WIDTH : 0; // Avoid `-0` slipping out when a side has no shelf (`-maxRight` is
return { dragOffset: clamped, snapAfterRelease: snap }; // `-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; export const SWIPE_REVEAL_THRESHOLD = REVEAL_THRESHOLD;