Accounts list (mobile):
* Each row is a SwipeableRow. Swipe right reveals Pair (or Unpair if
the account is connected). Swipe left reveals Groups + Delete.
* The right shelf widens to 176px so two buttons fit comfortably; a
new \`leftShelfWidth\`/\`rightShelfWidth\` prop on SwipeableRow drives
the override (default 88 stays for single-button shelves).
Accounts list (desktop): unchanged grid of clickable cards.
Account detail:
* New "Name" card at the top opens /accounts/[id]/edit/label, the
dedicated rename surface (mirrors the reminder edit-name pattern).
* Paired-at row now shows the full timestamp ("10 May 2026, 3:33:04 pm")
via toLocaleString instead of toLocaleDateString.
Reminder wizard:
* Disconnected accounts on the "Account" step are no longer
clickable. They render as a non-link with aria-disabled, dimmed
to opacity-50 with cursor-not-allowed and a "Pair this account
before scheduling a reminder from it" tooltip. The bot has no
live session for those accounts, so this prevents broken submits.
renameAccountAction validates the label, rejects duplicates within
the same operator, and revalidates /accounts and the detail page.
Tests added:
* AccountSwipeableRow — 6 SSR tests (shelves rendered, conditional
Pair/Unpair, Groups + Delete shelf, 176px shelf width, hidden
accountId field).
* EditAccountLabelForm — 5 SSR + payload tests (prefill, Save
button, required + maxLength=60, action call shape).
* StepAccount — 4 SSR tests (connected → Link, disconnected → no
Link + aria-disabled, opacity/cursor styles, "Not connected"
copy).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
210 lines
7.0 KiB
TypeScript
210 lines
7.0 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
/**
|
|
* SwipeableRow — gesture-driven row with reveal-on-swipe action shelves.
|
|
*
|
|
* 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.
|
|
*
|
|
* ← drag left drag right →
|
|
* ┌─────────────┐──┐ ┌──┌─────────────┐
|
|
* │ row body │R │ │ L│ row body │
|
|
* └─────────────┘──┘ └──└─────────────┘
|
|
* ↑ ↑
|
|
* Right shelf Left shelf
|
|
*
|
|
* 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)
|
|
*
|
|
* Pointer events only — no third-party gesture lib — to keep the
|
|
* bundle small and avoid SSR / hydration headaches.
|
|
*/
|
|
|
|
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 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;
|
|
/** className for the inner sliding row (background, padding). */
|
|
rowClassName?: string;
|
|
/** Override the default 88px right-shelf width. Use a multiple of
|
|
* 88 when stacking multiple action buttons in the shelf. */
|
|
rightShelfWidth?: number;
|
|
/** Override the default 88px left-shelf width. */
|
|
leftShelfWidth?: number;
|
|
}
|
|
|
|
export function SwipeableRow({
|
|
rightActions,
|
|
leftActions,
|
|
children,
|
|
className,
|
|
rowClassName,
|
|
rightShelfWidth,
|
|
leftShelfWidth,
|
|
}: SwipeableRowProps) {
|
|
const rightWidth = rightShelfWidth ?? SHELF_WIDTH;
|
|
const leftWidth = leftShelfWidth ?? SHELF_WIDTH;
|
|
// `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<HTMLDivElement | null>(null);
|
|
const dragStart = useRef<{ x: number; baseOffset: number } | null>(null);
|
|
|
|
// Close the shelf when the user taps anywhere outside an open row.
|
|
useEffect(() => {
|
|
if (offset === 0) return;
|
|
function onDocPointer(e: PointerEvent) {
|
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
setOffset(0);
|
|
}
|
|
}
|
|
window.addEventListener("pointerdown", onDocPointer);
|
|
return () => window.removeEventListener("pointerdown", onDocPointer);
|
|
}, [offset]);
|
|
|
|
function clamp(next: number): number {
|
|
// Limit drags to the available shelf width on each side.
|
|
const maxLeft = leftActions ? leftWidth : 0;
|
|
const maxRight = rightActions ? rightWidth : 0;
|
|
if (next > maxLeft) return maxLeft;
|
|
if (next < -maxRight) return -maxRight;
|
|
return next;
|
|
}
|
|
|
|
function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) {
|
|
if (e.button !== 0 && e.pointerType === "mouse") return;
|
|
dragStart.current = { x: e.clientX, baseOffset: offset };
|
|
setDragging(true);
|
|
}
|
|
|
|
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
|
if (!dragging || !dragStart.current) return;
|
|
const dx = e.clientX - dragStart.current.x;
|
|
setOffset(clamp(dragStart.current.baseOffset + dx));
|
|
}
|
|
|
|
function handlePointerUp() {
|
|
if (!dragging) return;
|
|
setDragging(false);
|
|
dragStart.current = null;
|
|
setOffset((prev) =>
|
|
snapPosition(prev, {
|
|
leftActions: !!leftActions,
|
|
rightActions: !!rightActions,
|
|
leftWidth,
|
|
rightWidth,
|
|
}),
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={cn("relative overflow-hidden rounded-xl", className)}
|
|
data-state={offset === 0 ? "closed" : "open"}
|
|
data-testid="swipeable-row"
|
|
>
|
|
{/* Left shelf — pinned to the left, revealed by swipe-right. */}
|
|
{leftActions && (
|
|
<div
|
|
aria-hidden={offset <= 0}
|
|
className="absolute inset-y-0 left-0 flex items-stretch"
|
|
style={{ width: leftWidth }}
|
|
>
|
|
{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: rightWidth }}
|
|
>
|
|
{rightActions}
|
|
</div>
|
|
)}
|
|
|
|
{/* Sliding row body. */}
|
|
<div
|
|
onPointerDown={handlePointerDown}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={handlePointerUp}
|
|
onPointerCancel={handlePointerUp}
|
|
style={{
|
|
transform: `translateX(${offset}px)`,
|
|
transition: dragging ? "none" : "transform 200ms ease-out",
|
|
touchAction: "pan-y",
|
|
}}
|
|
className={cn("relative bg-card", rowClassName)}
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
leftWidth?: number;
|
|
rightWidth?: number;
|
|
},
|
|
): number {
|
|
const lw = shelves.leftWidth ?? SHELF_WIDTH;
|
|
const rw = shelves.rightWidth ?? SHELF_WIDTH;
|
|
if (offset >= REVEAL_THRESHOLD && shelves.leftActions) return lw;
|
|
if (offset <= -REVEAL_THRESHOLD && shelves.rightActions) return -rw;
|
|
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;
|
|
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;
|
|
export const SWIPE_SHELF_WIDTH = SHELF_WIDTH;
|