yiekheng 670eaf493c feat(web): swipeable account rows, editable label, disabled-account guard
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>
2026-05-10 15:42:10 +08:00

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;