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:
parent
704bc5e788
commit
8023c8f357
@ -109,24 +109,40 @@ 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 (
|
||||||
|
<form action={deleteRunAction} className="flex w-full">
|
||||||
|
<input type="hidden" name="runId" value={runId} />
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-4" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
return (
|
||||||
<div className="flex w-full items-stretch gap-px bg-border">
|
|
||||||
<form
|
<form
|
||||||
action={isArchived ? unarchiveRunAction : archiveRunAction}
|
action={isArchived ? unarchiveRunAction : archiveRunAction}
|
||||||
className="flex-1"
|
className="flex w-full"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="runId" value={runId} />
|
<input type="hidden" name="runId" value={runId} />
|
||||||
<button
|
<button
|
||||||
@ -142,18 +158,6 @@ function ActionShelf({ runId, isArchived }: ActionShelfProps) {
|
|||||||
{isArchived ? "Restore" : "Archive"}
|
{isArchived ? "Restore" : "Archive"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form action={deleteRunAction} className="flex-1">
|
|
||||||
<input type="hidden" name="runId" value={runId} />
|
|
||||||
<button
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<Trash2Icon className="size-4" />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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,10 +234,19 @@ export default async function RemindersPage({ searchParams }: PageProps) {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{visible.length > 0 ? (
|
{visible.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-muted-foreground sm:hidden">
|
||||||
|
Swipe a row left to Delete, or right to{" "}
|
||||||
|
{status === "paused" ? "Restart" : "Pause"}.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{visible.map((reminder) => (
|
{visible.map((reminder) => {
|
||||||
|
const canPause = reminder.status === "active";
|
||||||
|
const canRestart =
|
||||||
|
reminder.status === "paused" || reminder.status === "ended";
|
||||||
|
const cardBody = (
|
||||||
<Link
|
<Link
|
||||||
key={reminder.id}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
href={`/reminders/${reminder.id}` as any}
|
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"
|
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
@ -238,8 +295,51 @@ export default async function RemindersPage({ searchParams }: PageProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
);
|
||||||
|
|
||||||
|
// 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 ? (
|
||||||
|
<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>
|
</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">
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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. */}
|
||||||
|
{leftActions && (
|
||||||
<div
|
<div
|
||||||
aria-hidden={offset === 0}
|
aria-hidden={offset <= 0}
|
||||||
|
className="absolute inset-y-0 left-0 flex items-stretch"
|
||||||
|
style={{ width: SHELF_WIDTH }}
|
||||||
|
>
|
||||||
|
{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"
|
className="absolute inset-y-0 right-0 flex items-stretch"
|
||||||
style={{ width: SHELF_WIDTH }}
|
style={{ width: SHELF_WIDTH }}
|
||||||
>
|
>
|
||||||
{actions}
|
{rightActions}
|
||||||
</div>
|
</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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user