{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "approval-card", "type": "registry:component", "title": "Approval Card", "description": "Human-in-the-loop questions the agent asks before acting.", "registryDependencies": [ "https://www.beautifului.dev/r/foundation.json", "https://www.beautifului.dev/r/button.json", "https://www.beautifului.dev/r/glide-menu.json" ], "files": [ { "path": "components/primitives/ApprovalCard.tsx", "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from \"react\";\nimport { Button } from \"@/components/atoms/Button\";\nimport GlideMenu from \"@/components/primitives/GlideMenu\";\n\n/* ─────────────────────────────────────────────────────────\n * APPROVAL CARD (human-in-the-loop)\n * One question at a time. The stack slides vertically as you\n * move between questions (the card's height animates to fit),\n * the step counter rolls like an odometer, and the footer uses\n * pill actions — a quiet Skip and a dark Continue with a ⏎.\n * Single-choice answers auto-advance; multi-select waits.\n * ───────────────────────────────────────────────────────── */\n\nexport type ApprovalQuestion = {\n q: string;\n type: \"radio\" | \"check\";\n options: string[];\n};\n\nconst QUESTIONS: ApprovalQuestion[] = [\n {\n q: \"How many flavors should we launch?\",\n type: \"radio\",\n options: [\"Three (core line)\", \"Five (full case)\", \"Just one hero\"],\n },\n {\n q: \"Which mix-ins should we stock?\",\n type: \"check\",\n options: [\"Chocolate chips\", \"Waffle bits\", \"Sprinkles\"],\n },\n {\n q: \"Which market do we enter first?\",\n type: \"radio\",\n options: [\"Food trucks\", \"Grocery freezers\", \"Scoop shops\"],\n },\n];\n\nexport type ApprovalLabels = {\n skip: string;\n continue: string;\n send: string;\n customPlaceholder: string;\n sentMessage: string;\n};\n\nconst DEFAULT_LABELS: ApprovalLabels = {\n skip: \"Skip\",\n continue: \"Continue\",\n send: \"Send\",\n customPlaceholder: \"Something else…\",\n sentMessage: \"Answers sent\",\n};\n\nconst ROLL_MS = 400;\nconst SLIDE = \"360ms cubic-bezier(0.22, 1, 0.36, 1)\";\n\n/* odometer digits — each character that changes rolls up (or down) */\nfunction RollingDigits({ value }: { value: string }) {\n const prevRef = useRef(value);\n const [oldVal, setOldVal] = useState(value);\n const [newVal, setNewVal] = useState(value);\n const [rolling, setRolling] = useState(false);\n const [shifted, setShifted] = useState(false);\n const [dir, setDir] = useState<\"up\" | \"down\">(\"up\");\n\n useEffect(() => {\n if (prevRef.current === value) return;\n const from = prevRef.current;\n prevRef.current = value;\n const fromN = parseInt(from, 10);\n const toN = parseInt(value, 10);\n setDir(Number.isFinite(fromN) && Number.isFinite(toN) && toN < fromN ? \"down\" : \"up\");\n setOldVal(from);\n setNewVal(value);\n setRolling(true);\n setShifted(false);\n\n let raf2 = 0;\n const raf1 = requestAnimationFrame(() => {\n raf2 = requestAnimationFrame(() => setShifted(true));\n });\n const done = setTimeout(() => {\n setRolling(false);\n setOldVal(value);\n setShifted(false);\n }, ROLL_MS);\n\n return () => {\n cancelAnimationFrame(raf1);\n cancelAnimationFrame(raf2);\n clearTimeout(done);\n };\n }, [value]);\n\n const chars = rolling ? newVal : oldVal;\n\n return (\n <>\n {Array.from({ length: chars.length }, (_, i) => {\n const o = oldVal[i] ?? \"\";\n const n = chars[i] ?? \"\";\n if (!rolling || o === n) {\n return {n};\n }\n const top = dir === \"down\" ? n : o;\n const bottom = dir === \"down\" ? o : n;\n const restY = dir === \"down\" ? \"0\" : \"-1em\";\n const startY = dir === \"down\" ? \"-1em\" : \"0\";\n return (\n \n \n {top}\n {bottom}\n \n \n );\n })}\n \n );\n}\n\nfunction Ico({ path, size = 14, sw = 2 }: { path: React.ReactNode; size?: number; sw?: number }) {\n return (\n \n {path}\n \n );\n}\n\nexport default function ApprovalCard({\n questions = QUESTIONS,\n labels,\n onSubmitted,\n onAnswerChange,\n resettable = true,\n}: {\n questions?: ApprovalQuestion[];\n labels?: Partial;\n onSubmitted?: (answers: Record) => void;\n onAnswerChange?: (questionIndex: number, answer: number[]) => void;\n resettable?: boolean;\n variant?: string;\n} = {}) {\n const t = { ...DEFAULT_LABELS, ...labels };\n const [qi, setQi] = useState(0);\n const [answers, setAnswers] = useState>({});\n const [custom, setCustom] = useState>({});\n const [sent, setSent] = useState(false);\n const [open, setOpen] = useState(true);\n\n const advanceTimer = useRef | null>(null);\n const questionRefs = useRef<(HTMLDivElement | null)[]>([]);\n const measured = useRef(false);\n const [viewportH, setViewportH] = useState(undefined);\n const [trackY, setTrackY] = useState(0);\n const [animate, setAnimate] = useState(false);\n // Until the first question is measured, render only the active one so the\n // initial (and SSR) height is Q1's height — not all questions stacked, which\n // would flash to full height and then shrink on mount.\n const [ready, setReady] = useState(false);\n\n const last = qi === questions.length - 1;\n const selected = answers[qi] ?? [];\n const hasAnswer = selected.length > 0 || Boolean(custom[qi]?.trim());\n\n const sync = (withAnim: boolean) => {\n const item = questionRefs.current[qi];\n if (!item) return;\n const reduce = typeof window !== \"undefined\" && window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n setViewportH(item.offsetHeight);\n setTrackY(item.offsetTop);\n setAnimate(withAnim && !reduce);\n };\n\n useLayoutEffect(() => {\n const withAnim = measured.current;\n measured.current = true;\n sync(withAnim);\n setReady(true);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [qi, answers, custom, open, sent]);\n\n useEffect(() => {\n const id = requestAnimationFrame(() => sync(measured.current));\n return () => cancelAnimationFrame(id);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [qi]);\n\n useEffect(() => () => { if (advanceTimer.current) clearTimeout(advanceTimer.current); }, []);\n\n const goTo = (next: number) => {\n if (advanceTimer.current) clearTimeout(advanceTimer.current);\n setQi(Math.min(Math.max(next, 0), questions.length - 1));\n };\n\n const send = () => {\n if (advanceTimer.current) clearTimeout(advanceTimer.current);\n setSent(true);\n onSubmitted?.(answers);\n };\n\n const advance = () => {\n if (last) send();\n else goTo(qi + 1);\n };\n\n const toggle = (index: number) => {\n const type = questions[qi].type;\n setAnswers((current) => {\n const picked = current[qi] ?? [];\n const next = type === \"radio\"\n ? [index]\n : picked.includes(index)\n ? picked.filter((item) => item !== index)\n : [...picked, index];\n onAnswerChange?.(qi, next);\n return { ...current, [qi]: next };\n });\n if (type === \"radio\") {\n setCustom((current) => ({ ...current, [qi]: \"\" }));\n if (advanceTimer.current) clearTimeout(advanceTimer.current);\n advanceTimer.current = setTimeout(() => {\n if (last) send();\n else setQi((current) => Math.min(questions.length - 1, current + 1));\n }, 480);\n }\n };\n\n const reset = () => {\n setQi(0);\n setAnswers({});\n setCustom({});\n setSent(false);\n setOpen(true);\n measured.current = false;\n };\n\n if (!open) {\n return (\n \n );\n }\n\n if (sent) {\n return (\n
\n \n \n \n \n {t.sentMessage}\n \n {resettable && (\n \n )}\n
\n );\n }\n\n return (\n
\n
\n setOpen(false)}\n 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\"\n >\n } />\n \n
\n {/* the question itself is the heading */}\n \n \n {questions.map((question, qIdx) => {\n const active = qIdx === qi;\n // Before the first measure, mount only the active question so the\n // card opens at its real height instead of flashing to full height.\n if (!ready && !active) return null;\n const picked = answers[qIdx] ?? [];\n const questionStyle: CSSProperties = {\n opacity: active ? 1 : 0,\n transition: animate ? `opacity ${SLIDE}` : undefined,\n pointerEvents: active ? undefined : \"none\",\n };\n return (\n { questionRefs.current[qIdx] = el; }}\n aria-hidden={active ? undefined : true}\n style={questionStyle}\n >\n
{question.q}
\n \n {question.options.map((option, i) => {\n const on = picked.includes(i);\n return (\n { if (active) toggle(i); }}\n className=\"relative z-10 flex items-center gap-1.5 rounded-control pl-1 pr-2 py-1 text-left transition-colors duration-100\"\n >\n \n {question.type === \"radio\" ? (\n \n ) : (\n \n )}\n \n \n {option}\n \n \n );\n })}\n \n \n
\n );\n })}\n
\n
\n \n\n {/* footer — step nav (rolling counter) + pill actions */}\n
\n
\n goTo(qi - 1)}\n className=\"flex size-[18px] items-center justify-center rounded-[5px] transition-colors duration-100 enabled:hover:text-ink disabled:opacity-30\"\n >\n } />\n \n \n \n \n goTo(qi + 1)}\n className=\"flex size-[18px] items-center justify-center rounded-[5px] transition-colors duration-100 enabled:hover:text-ink disabled:opacity-30\"\n >\n } />\n \n
\n\n
\n \n \n
\n
\n \n \n );\n}\n", "type": "registry:component", "target": "components/primitives/ApprovalCard.tsx" } ] }