Approval Card
AI & AgentsHuman-in-the-loop questions the agent asks before acting.
Interactive Live Preview
components/ui/approval-card.tsxTypeScript · React 19
1"use client";23import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";4import { Button } from "@/components/atoms/Button";56/* ─────────────────────────────────────────────────────────7 * APPROVAL CARD (human-in-the-loop)8 * One question at a time. The stack slides vertically as you9 * move between questions (the card's height animates to fit),10 * the step counter rolls like an odometer, and the footer uses11 * pill actions — a quiet Skip and a dark Continue with a ⏎.12 * Single-choice answers auto-advance; multi-select waits.13 * ───────────────────────────────────────────────────────── */1415const QUESTIONS = [16 {17 q: "How many flavors should we launch?",18 type: "radio" as const,19 options: ["Three (core line)", "Five (full case)", "Just one hero"],20 },21 {22 q: "Which mix-ins should we stock?",23 type: "check" as const,24 options: ["Chocolate chips", "Waffle bits", "Sprinkles"],25 },26 {27 q: "Which market do we enter first?",28 type: "radio" as const,29 options: ["Food trucks", "Grocery freezers", "Scoop shops"],30 },31];3233const ROLL_MS = 400;34const SLIDE = "360ms cubic-bezier(0.22, 1, 0.36, 1)";3536/* odometer digits — each character that changes rolls up (or down) */37function RollingDigits({ value }: { value: string }) {38 const prevRef = useRef(value);39 const [oldVal, setOldVal] = useState(value);40 const [newVal, setNewVal] = useState(value);41 const [rolling, setRolling] = useState(false);42 const [shifted, setShifted] = useState(false);43 const [dir, setDir] = useState<"up" | "down">("up");4445 useEffect(() => {46 if (prevRef.current === value) return;47 const from = prevRef.current;48 prevRef.current = value;49 const fromN = parseInt(from, 10);50 const toN = parseInt(value, 10);51 setDir(Number.isFinite(fromN) && Number.isFinite(toN) && toN < fromN ? "down" : "up");52 setOldVal(from);53 setNewVal(value);54 setRolling(true);55 setShifted(false);5657 let raf2 = 0;58 const raf1 = requestAnimationFrame(() => {59 raf2 = requestAnimationFrame(() => setShifted(true));60 });61 const done = setTimeout(() => {62 setRolling(false);63 setOldVal(value);64 setShifted(false);65 }, ROLL_MS);6667 return () => {68 cancelAnimationFrame(raf1);69 cancelAnimationFrame(raf2);70 clearTimeout(done);71 };72 }, [value]);7374 const chars = rolling ? newVal : oldVal;7576 return (77 <>78 {Array.from({ length: chars.length }, (_, i) => {79 const o = oldVal[i] ?? "";80 const n = chars[i] ?? "";81 if (!rolling || o === n) {82 return <span key={`${i}-${n}`}>{n}</span>;83 }84 const top = dir === "down" ? n : o;85 const bottom = dir === "down" ? o : n;86 const restY = dir === "down" ? "0" : "-1em";87 const startY = dir === "down" ? "-1em" : "0";88 return (89 <span90 key={`${i}-${o}-${n}-${dir}`}91 style={{ display: "inline-block", position: "relative", overflow: "hidden", height: "1em", lineHeight: "1em", verticalAlign: "-0.05em" }}92 >93 <span94 style={{95 display: "flex",96 flexDirection: "column",97 transition: "transform 350ms cubic-bezier(0.4, 0, 0.2, 1)",98 transform: `translateY(${shifted ? restY : startY})`,99 }}100 >101 <span style={{ height: "1em", lineHeight: "1em" }}>{top}</span>102 <span style={{ height: "1em", lineHeight: "1em" }}>{bottom}</span>103 </span>104 </span>105 );106 })}107 </>108 );109}110111function Ico({ path, size = 14, sw = 2 }: { path: React.ReactNode; size?: number; sw?: number }) {112 return (113 <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round" aria-hidden>114 {path}115 </svg>116 );117}118119export default function ApprovalCard({120 onSubmitted,121 resettable = true,122}: {123 onSubmitted?: () => void;124 resettable?: boolean;125 variant?: string;126} = {}) {127 const [qi, setQi] = useState(0);128 const [answers, setAnswers] = useState<Record<number, number[]>>({});129 const [custom, setCustom] = useState<Record<number, string>>({});130 const [sent, setSent] = useState(false);131 const [open, setOpen] = useState(true);132133 const advanceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);134 const questionRefs = useRef<(HTMLDivElement | null)[]>([]);135 const measured = useRef(false);136 const [viewportH, setViewportH] = useState<number | undefined>(undefined);137 const [trackY, setTrackY] = useState(0);138 const [animate, setAnimate] = useState(false);139140 const last = qi === QUESTIONS.length - 1;141 const selected = answers[qi] ?? [];142 const hasAnswer = selected.length > 0 || Boolean(custom[qi]?.trim());143144 const sync = (withAnim: boolean) => {145 const item = questionRefs.current[qi];146 if (!item) return;147 const reduce = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;148 setViewportH(item.offsetHeight);149 setTrackY(item.offsetTop);150 setAnimate(withAnim && !reduce);151 };152153 useLayoutEffect(() => {154 const withAnim = measured.current;155 measured.current = true;156 sync(withAnim);157 // eslint-disable-next-line react-hooks/exhaustive-deps158 }, [qi, answers, custom, open, sent]);159160 useEffect(() => {161 const id = requestAnimationFrame(() => sync(measured.current));162 return () => cancelAnimationFrame(id);163 // eslint-disable-next-line react-hooks/exhaustive-deps164 }, [qi]);165166 useEffect(() => () => { if (advanceTimer.current) clearTimeout(advanceTimer.current); }, []);167168 const goTo = (next: number) => {169 if (advanceTimer.current) clearTimeout(advanceTimer.current);170 setQi(Math.min(Math.max(next, 0), QUESTIONS.length - 1));171 };172173 const send = () => {174 if (advanceTimer.current) clearTimeout(advanceTimer.current);175 setSent(true);176 onSubmitted?.();177 };178179 const advance = () => {180 if (last) send();181 else goTo(qi + 1);182 };183184 const toggle = (index: number) => {185 const type = QUESTIONS[qi].type;186 setAnswers((current) => {187 const picked = current[qi] ?? [];188 const next = type === "radio"189 ? [index]190 : picked.includes(index)191 ? picked.filter((item) => item !== index)192 : [...picked, index];193 return { ...current, [qi]: next };194 });195 if (type === "radio") {196 setCustom((current) => ({ ...current, [qi]: "" }));197 if (advanceTimer.current) clearTimeout(advanceTimer.current);198 advanceTimer.current = setTimeout(() => {199 if (last) send();200 else setQi((current) => Math.min(QUESTIONS.length - 1, current + 1));201 }, 480);202 }203 };204205 const reset = () => {206 setQi(0);207 setAnswers({});208 setCustom({});209 setSent(false);210 setOpen(true);211 measured.current = false;212 };213214 if (!open) {215 return (216 <button type="button" onClick={() => setOpen(true)} className="rounded-control bg-surface px-3 py-2 text-[12.5px] font-medium text-ink shadow-btn transition-colors duration-150 hover:bg-hover">217 Open approval218 </button>219 );220 }221222 if (sent) {223 return (224 <div className="flex w-full max-w-80 items-center gap-3" style={{ animation: "pop-in 260ms cubic-bezier(0.23,1,0.32,1) both" }}>225 <span className="inline-flex items-center gap-1.5 rounded-full bg-green-tint py-1 pr-2.5 pl-1 text-[12.5px] font-medium text-green">226 <span className="flex size-4.5 items-center justify-center rounded-full bg-green text-white">227 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>228 </span>229 Answers sent230 </span>231 {resettable && (232 <button type="button" onClick={reset} className="text-[12px] font-medium text-ink-3 transition-colors duration-150 hover:text-ink">233 Start over234 </button>235 )}236 </div>237 );238 }239240 return (241 <div className="w-full max-w-80">242 <div className="relative overflow-hidden rounded-card bg-surface shadow-card" style={{ animation: "fade-up 380ms cubic-bezier(0.23,1,0.32,1) both" }}>243 <button244 type="button"245 aria-label="Dismiss"246 onClick={() => setOpen(false)}247 className="primitive-icon-button absolute right-2.5 top-2.5 z-10 text-ink-3 transition-colors duration-100 hover:bg-hover hover:text-ink"248 >249 <Ico size={14} sw={2.2} path={<path d="M18 6L6 18M6 6l12 12" />} />250 </button>251 <div className="primitive-card-pad">252 {/* the question itself is the heading */}253 <div254 className="overflow-hidden"255 style={{ height: viewportH, transition: animate ? `height ${SLIDE}` : undefined }}256 aria-live="polite"257 >258 <div259 style={{260 display: "flex",261 flexDirection: "column",262 gap: 26,263 transform: `translate3d(0, ${-trackY}px, 0)`,264 transition: animate ? `transform ${SLIDE}` : undefined,265 willChange: "transform",266 }}267 >268 {QUESTIONS.map((question, qIdx) => {269 const active = qIdx === qi;270 const picked = answers[qIdx] ?? [];271 const questionStyle: CSSProperties = {272 opacity: active ? 1 : 0,273 transition: animate ? `opacity ${SLIDE}` : undefined,274 pointerEvents: active ? undefined : "none",275 };276 return (277 <div278 key={qIdx}279 ref={(el) => { questionRefs.current[qIdx] = el; }}280 aria-hidden={active ? undefined : true}281 style={questionStyle}282 >283 <div className="pr-7 text-[14px] font-medium text-ink">{question.q}</div>284 <div className="mt-2 flex flex-col gap-0.5">285 {question.options.map((option, i) => {286 const on = picked.includes(i);287 return (288 <button289 key={option}290 type="button"291 aria-pressed={on}292 tabIndex={active ? 0 : -1}293 onClick={() => { if (active) toggle(i); }}294 className="flex items-center gap-2 rounded-control px-1.5 py-1 text-left transition-colors duration-100 hover:bg-hover"295 >296 <span297 className={`flex size-4 shrink-0 items-center justify-center transition-colors duration-200298 ${question.type === "radio" ? "rounded-full" : "rounded-[5px]"}299 ${on ? "bg-ink text-canvas" : "shadow-[inset_0_0_0_1.5px_var(--line-strong)] text-transparent"}`}300 >301 {question.type === "radio" ? (302 <span className="size-1.5 rounded-full bg-canvas transition-transform duration-200" style={{ transform: on ? "scale(1)" : "scale(0)" }} />303 ) : (304 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>305 )}306 </span>307 <span className={`text-[13px] transition-colors duration-200 ${on ? "text-ink" : "text-ink-2"}`}>308 {option}309 </span>310 </button>311 );312 })}313 <label className="flex items-center gap-2 rounded-control px-1.5 py-1 transition-colors duration-100 focus-within:bg-hover hover:bg-hover">314 <span aria-hidden="true" className="size-4 shrink-0" />315 <input316 value={custom[qIdx] ?? ""}317 tabIndex={active ? 0 : -1}318 onChange={(event) => {319 if (!active) return;320 setCustom((current) => ({ ...current, [qIdx]: event.target.value }));321 if (question.type === "radio") setAnswers((current) => ({ ...current, [qIdx]: [] }));322 }}323 onKeyDown={(event) => {324 if (event.key === "Enter" && hasAnswer) {325 event.preventDefault();326 advance();327 }328 }}329 placeholder="Something else…"330 aria-label="Custom answer"331 className="min-w-0 flex-1 bg-transparent text-[13px] text-ink outline-none placeholder:text-ink-3"332 />333 </label>334 </div>335 </div>336 );337 })}338 </div>339 </div>340 </div>341342 {/* footer — step nav (rolling counter) + pill actions */}343 <div className="primitive-card-footer flex items-center justify-between gap-3">344 <div className="flex items-center gap-1 text-ink-3">345 <button346 type="button"347 aria-label="Previous question"348 disabled={qi <= 0}349 onClick={() => goTo(qi - 1)}350 className="flex size-[18px] items-center justify-center rounded-[5px] transition-colors duration-100 enabled:hover:text-ink disabled:opacity-30"351 >352 <Ico size={14} path={<path d="M18 15l-6-6-6 6" />} />353 </button>354 <span className="inline-flex items-center text-[12px] font-medium tabular-nums text-ink-3" style={{ letterSpacing: "-0.1px", lineHeight: 1 }}>355 <RollingDigits value={`${qi + 1} / ${QUESTIONS.length}`} />356 </span>357 <button358 type="button"359 aria-label="Next question"360 disabled={last}361 onClick={() => goTo(qi + 1)}362 className="flex size-[18px] items-center justify-center rounded-[5px] transition-colors duration-100 enabled:hover:text-ink disabled:opacity-30"363 >364 <Ico size={14} path={<path d="M6 9l6 6 6-6" />} />365 </button>366 </div>367368 <div className="-mr-0.5 flex items-center gap-1.5">369 <Button variant="ghost" size="sm" onClick={() => setOpen(false)}>370 Skip371 </Button>372 <Button variant="accent" size="sm" disabled={!hasAnswer} onClick={advance}>373 {last ? "Send" : "Continue"}374 </Button>375 </div>376 </div>377 </div>378 </div>379 );380}381
Props & Specification
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | string | "default" | Visual state and layout presentation mode. |
| className | string | "" | Optional Tailwind CSS wrapper class overrides. |