"use client"; import { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode, } from "react"; import { animate, motion, useMotionValue, useTransform, type PanInfo, } from "motion/react"; import { useReducedMotion } from "../motion/Preferences.tsx"; import { EASE_EXPO } from "../motion/easings.ts"; import { useAnnouncer, type SignatureTone } from "./internal.ts"; export type TriageDirection = "left" | "right" | "up"; export type TriageDecision = { id: string; label: string; direction: TriageDirection; tone?: SignatureTone; }; type Decided = { item: T; decision: TriageDecision }; const KEYS: Record = { left: "ArrowLeft", right: "ArrowRight", up: "ArrowUp", }; const GLYPH: Record = { left: "←", right: "→", up: "↑", }; const THRESHOLD = 110; /** * Decisions at the speed of a gesture: fling the top card toward a decision, * or use arrow keys and buttons. Every decision can be undone. */ export function TriageDeck({ label, items, renderItem, itemLabel, decisions, onDecide, onUndo, empty = "Nothing left to review.", className = "", style, }: { label: string; items: readonly T[]; renderItem: (item: T) => ReactNode; /** A short name for announcements, e.g. the item's title. */ itemLabel: (item: T) => string; /** Up to one decision per direction: left, right and up. */ decisions: readonly TriageDecision[]; onDecide?: (item: T, decision: TriageDecision) => void; onUndo?: (item: T, decision: TriageDecision) => void; empty?: ReactNode; className?: string; style?: CSSProperties; }) { const id = useId(); const reduce = useReducedMotion(); const [message, announce] = useAnnouncer(); const [history, setHistory] = useState[]>([]); const [returning, setReturning] = useState<{ id: string; direction: TriageDirection; } | null>(null); const flinging = useRef(false); const top = useRef<{ fling: (decision: TriageDecision) => void } | null>( null, ); const decidedIds = new Set(history.map((h) => h.item.id)); const queue = items.filter((i) => !decidedIds.has(i.id)); const current = queue[0]; const byDirection = (d: TriageDirection) => decisions.find((x) => x.direction === d); const commit = (decision: TriageDecision) => { if (!current) return; flinging.current = false; setHistory((h) => [...h, { item: current, decision }]); setReturning(null); onDecide?.(current, decision); const next = queue[1]; announce( `${decision.label}: ${itemLabel(current)}.${next ? ` Next: ${itemLabel(next)}.` : " Queue cleared."}`, ); }; const decide = (decision: TriageDecision) => { if (!current || flinging.current) return; flinging.current = true; if (top.current) top.current.fling(decision); else commit(decision); }; const undo = () => { const last = history[history.length - 1]; if (!last || flinging.current) return; setHistory((h) => h.slice(0, -1)); setReturning({ id: last.item.id, direction: last.decision.direction }); onUndo?.(last.item, last.decision); announce(`Undid ${last.decision.label} on ${itemLabel(last.item)}.`); }; const onKey = (e: KeyboardEvent) => { if ((e.target as HTMLElement).closest("input, textarea, select")) return; const decision = decisions.find((d) => KEYS[d.direction] === e.key); if (decision && current) { e.preventDefault(); decide(decision); } else if ( (e.key === "Backspace" || e.key === "z" || e.key === "Z") && history.length ) { e.preventDefault(); undo(); } }; const tally = decisions.map((d) => ({ d, n: history.filter((h) => h.decision.id === d.id).length, })); const position = history.length + 1; const total = items.length; return (

{current ? ( <> {String(position).padStart(2, "0")} /{" "} {String(total).padStart(2, "0")} ) : ( "Done" )}

    {tally.map(({ d, n }) => (