Files
inkreach-agent/scripts/beautifului/approval-card.json
T
Rick Ye a5951849c8 v0.6.0: beautifului 整合 + 会话/工作区/队列/权限/命令/附件全链路
- beautifului.dev 组件直连:Sidebar Nav、Tool Chips、Approval Card(抽屉化)、Streaming/Thinking、Code Block、Selection Actions、Prompt Bar、Search
2026-09-06 09:57:14 +08:00

21 lines
18 KiB
JSON

{
"$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 <span key={`${i}-${n}`}>{n}</span>;\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 <span\n key={`${i}-${o}-${n}-${dir}`}\n style={{ display: \"inline-block\", position: \"relative\", overflow: \"hidden\", height: \"1em\", lineHeight: \"1em\", verticalAlign: \"-0.05em\" }}\n >\n <span\n style={{\n display: \"flex\",\n flexDirection: \"column\",\n transition: \"transform 350ms cubic-bezier(0.4, 0, 0.2, 1)\",\n transform: `translateY(${shifted ? restY : startY})`,\n }}\n >\n <span style={{ height: \"1em\", lineHeight: \"1em\" }}>{top}</span>\n <span style={{ height: \"1em\", lineHeight: \"1em\" }}>{bottom}</span>\n </span>\n </span>\n );\n })}\n </>\n );\n}\n\nfunction Ico({ path, size = 14, sw = 2 }: { path: React.ReactNode; size?: number; sw?: number }) {\n return (\n <svg width={size} height={size} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={sw} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n {path}\n </svg>\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<ApprovalLabels>;\n onSubmitted?: (answers: Record<number, number[]>) => 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<Record<number, number[]>>({});\n const [custom, setCustom] = useState<Record<number, string>>({});\n const [sent, setSent] = useState(false);\n const [open, setOpen] = useState(true);\n\n const advanceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n const questionRefs = useRef<(HTMLDivElement | null)[]>([]);\n const measured = useRef(false);\n const [viewportH, setViewportH] = useState<number | undefined>(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 <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\">\n Open approval\n </button>\n );\n }\n\n if (sent) {\n return (\n <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\" }}>\n <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\">\n <span className=\"flex size-4.5 items-center justify-center rounded-full bg-green text-white\">\n <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>\n </span>\n {t.sentMessage}\n </span>\n {resettable && (\n <button type=\"button\" onClick={reset} className=\"text-[12px] font-medium text-ink-3 transition-colors duration-150 hover:text-ink\">\n Start over\n </button>\n )}\n </div>\n );\n }\n\n return (\n <div className=\"w-full max-w-80\">\n <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\" }}>\n <button\n type=\"button\"\n aria-label=\"Dismiss\"\n onClick={() => 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 <Ico size={14} sw={2.2} path={<path d=\"M18 6L6 18M6 6l12 12\" />} />\n </button>\n <div className=\"primitive-card-pad\">\n {/* the question itself is the heading */}\n <div\n className=\"overflow-hidden\"\n style={{ height: viewportH, transition: animate ? `height ${SLIDE}` : undefined }}\n aria-live=\"polite\"\n >\n <div\n style={{\n display: \"flex\",\n flexDirection: \"column\",\n gap: 26,\n transform: `translate3d(0, ${-trackY}px, 0)`,\n transition: animate ? `transform ${SLIDE}` : undefined,\n willChange: \"transform\",\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 <div\n key={qIdx}\n ref={(el) => { questionRefs.current[qIdx] = el; }}\n aria-hidden={active ? undefined : true}\n style={questionStyle}\n >\n <div className=\"pr-7 text-[14px] font-medium text-ink\">{question.q}</div>\n <GlideMenu className=\"mt-2.5 flex flex-col gap-1\" highlightClassName=\"inset-x-0 rounded-control bg-hover\">\n {question.options.map((option, i) => {\n const on = picked.includes(i);\n return (\n <button\n key={option}\n type=\"button\"\n data-menu-row\n aria-pressed={on}\n tabIndex={active ? 0 : -1}\n onClick={() => { 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 <span\n className={`flex size-4 shrink-0 items-center justify-center transition-colors duration-200\n ${question.type === \"radio\" ? \"rounded-full\" : \"rounded-[5px]\"}\n ${on ? \"bg-ink text-canvas\" : \"shadow-[inset_0_0_0_1.5px_var(--line-strong)] text-transparent\"}`}\n >\n {question.type === \"radio\" ? (\n <span className=\"size-1.5 rounded-full bg-canvas transition-transform duration-200\" style={{ transform: on ? \"scale(1)\" : \"scale(0)\" }} />\n ) : (\n <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>\n )}\n </span>\n <span className={`text-[13px] leading-none transition-colors duration-200 ${on ? \"text-ink\" : \"text-ink-2\"}`}>\n {option}\n </span>\n </button>\n );\n })}\n <label data-menu-row className=\"relative z-10 flex items-center gap-1.5 rounded-control pl-1 pr-2 py-1 transition-colors duration-100\">\n <input\n value={custom[qIdx] ?? \"\"}\n tabIndex={active ? 0 : -1}\n onChange={(event) => {\n if (!active) return;\n setCustom((current) => ({ ...current, [qIdx]: event.target.value }));\n if (question.type === \"radio\") setAnswers((current) => ({ ...current, [qIdx]: [] }));\n }}\n onKeyDown={(event) => {\n if (event.key === \"Enter\" && hasAnswer) {\n event.preventDefault();\n advance();\n }\n }}\n placeholder={t.customPlaceholder}\n aria-label=\"Custom answer\"\n className=\"min-w-0 flex-1 bg-transparent pl-1.5 text-[13px] text-ink outline-none placeholder:text-ink-3\"\n />\n </label>\n </GlideMenu>\n </div>\n );\n })}\n </div>\n </div>\n </div>\n\n {/* footer — step nav (rolling counter) + pill actions */}\n <div className=\"primitive-card-footer flex items-center justify-between gap-3\">\n <div className=\"flex items-center gap-1 text-ink-3\">\n <button\n type=\"button\"\n aria-label=\"Previous question\"\n disabled={qi <= 0}\n onClick={() => 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 <Ico size={14} path={<path d=\"M18 15l-6-6-6 6\" />} />\n </button>\n <span className=\"inline-flex items-center text-[12px] font-medium tabular-nums text-ink-3\" style={{ letterSpacing: \"-0.1px\", lineHeight: 1 }}>\n <RollingDigits value={`${qi + 1} / ${questions.length}`} />\n </span>\n <button\n type=\"button\"\n aria-label=\"Next question\"\n disabled={last}\n onClick={() => 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 <Ico size={14} path={<path d=\"M6 9l6 6 6-6\" />} />\n </button>\n </div>\n\n <div className=\"-mr-0.5 flex items-center gap-1.5\">\n <Button variant=\"ghost\" size=\"sm\" onClick={() => (last ? setOpen(false) : goTo(qi + 1))}>\n {t.skip}\n </Button>\n <Button variant=\"accent\" size=\"sm\" disabled={!hasAnswer} onClick={advance}>\n {last ? t.send : t.continue}\n </Button>\n </div>\n </div>\n </div>\n </div>\n );\n}\n",
"type": "registry:component",
"target": "components/primitives/ApprovalCard.tsx"
}
]
}