{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"approval-card","type":"registry:ui","title":"Approval Card","description":"An AI assistant asking before it acts: what it wants to do, exactly what that means, and Approve or Deny.","dependencies":["lucide-react"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/button.json"],"files":[{"path":"registry/dashral/approval-card.tsx","type":"registry:file","target":"components/dashral/approval-card.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { CircleCheck, CircleX, ShieldAlert, TriangleAlert } from \"lucide-react\"\n\nimport { useLocale } from \"./lib/locale\"\nimport { cn } from \"./lib/utils\"\nimport { Button } from \"./button\"\n\n/** \"pending\" waits for a decision; \"approved\" (once), \"always\" (for the rest of the conversation) or \"denied\" after. */\ntype ApprovalStatus = \"pending\" | \"approved\" | \"always\" | \"denied\"\n\ntype ApprovalDetail = { label: React.ReactNode; value: React.ReactNode }\n\ntype ApprovalCardProps = Omit<React.ComponentProps<\"section\">, \"title\" | \"children\"> & {\n  /** What the assistant wants to do, as an action: \"Send 3 payment reminders\". */\n  title: React.ReactNode\n  /** Why, or what will happen. */\n  description?: React.ReactNode\n  /** What exactly it will do: label and value rows (\"To\", \"Northwind, Sagara\"), or your own content. */\n  details?: ApprovalDetail[] | React.ReactNode\n  /** Controlled decision, e.g. from a saved conversation. Uncontrolled, it starts pending and keeps what's chosen. */\n  status?: ApprovalStatus\n  /** Called with \"once\" or \"always\"; while a promise it returns is pending, the button shows it's working. */\n  onApprove?: (scope: \"once\" | \"always\") => void | Promise<void>\n  onDeny?: () => void | Promise<void>\n  /** Offers \"Always allow\" beside \"Allow once\", for actions the reader may trust for the rest of the conversation. */\n  allowAlways?: boolean\n  /** An action that can't be undone (deleting, paying): approving it is red. */\n  destructive?: boolean\n  /** Replaces the shield (or the warning sign when destructive). */\n  icon?: React.ReactNode\n  /** Your words on the buttons. */\n  approveLabel?: string\n  denyLabel?: string\n  /** Shown on the decided line after the decision, e.g. \"Reminders sent\". */\n  result?: React.ReactNode\n  classNames?: { details?: string; actions?: string }\n}\n\nconst isRows = (value: unknown): value is ApprovalDetail[] =>\n  Array.isArray(value) && value.every((row) => typeof row === \"object\" && row !== null && \"label\" in row && \"value\" in row)\n\n/**\n * The assistant asking before it acts: what it wants to do, exactly what that means, and Approve or Deny (with\n * \"Always allow\" if you offer it). Once decided it folds into one line that says what was chosen.\n */\nfunction ApprovalCard({\n  title,\n  description,\n  details,\n  status: statusProp,\n  onApprove,\n  onDeny,\n  allowAlways = false,\n  destructive = false,\n  icon,\n  approveLabel,\n  denyLabel,\n  result,\n  className,\n  classNames,\n  ...props\n}: ApprovalCardProps) {\n  const { t } = useLocale()\n  const titleId = React.useId()\n  const [ownStatus, setOwnStatus] = React.useState<ApprovalStatus>(\"pending\")\n  const status = statusProp ?? ownStatus\n  const [busy, setBusy] = React.useState<\"once\" | \"always\" | \"deny\" | null>(null)\n\n  async function decide(choice: \"once\" | \"always\" | \"deny\") {\n    setBusy(choice)\n    try {\n      await (choice === \"deny\" ? onDeny?.() : onApprove?.(choice))\n      if (statusProp === undefined) setOwnStatus(choice === \"deny\" ? \"denied\" : choice === \"always\" ? \"always\" : \"approved\")\n    } finally {\n      setBusy(null)\n    }\n  }\n\n  const decided = status !== \"pending\"\n  const denied = status === \"denied\"\n  const decision = denied ? t(\"denied\") : status === \"always\" ? t(\"alwaysAllowed\") : t(\"approved\")\n\n  // The buttons go when it's decided: keep the keyboard on the card instead of losing focus to the page.\n  const root = React.useRef<HTMLElement>(null)\n  const hadFocus = React.useRef(false)\n  React.useEffect(() => {\n    if (decided && hadFocus.current) root.current?.focus()\n  }, [decided])\n\n  const DecidedIcon = denied ? CircleX : CircleCheck\n\n  return (\n    <section\n      ref={root}\n      data-slot=\"approval-card\"\n      data-status={status}\n      aria-labelledby={titleId}\n      tabIndex={decided ? -1 : undefined}\n      onFocusCapture={() => (hadFocus.current = !decided)}\n      onBlurCapture={(event) => {\n        if (!event.currentTarget.contains(event.relatedTarget as Node | null)) hadFocus.current = false\n      }}\n      className={cn(\n        \"rounded-xl border bg-card text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/50\",\n        decided ? \"flex items-center gap-2.5 px-3.5 py-2.5\" : cn(\"overflow-hidden shadow-xs\", destructive ? \"border-destructive/30\" : \"border-primary/25\"),\n        className\n      )}\n      {...props}\n    >\n      {/* There from the start, so the decision is heard when it's made. */}\n      <span role=\"status\" className=\"sr-only\">\n        {decided ? decision : \"\"}\n      </span>\n      {decided ? (\n        <>\n          <DecidedIcon aria-hidden className={cn(\"size-4 shrink-0\", denied ? \"text-muted-foreground\" : \"text-success-text\")} />\n          <span id={titleId} className=\"min-w-0 flex-1 truncate font-medium\" dir=\"auto\">\n            {title}\n          </span>\n          <span aria-hidden className={cn(\"shrink-0 text-xs\", denied ? \"text-muted-foreground\" : \"text-success-text\")}>\n            {result ?? decision}\n          </span>\n        </>\n      ) : (\n        <>\n          <div className=\"flex gap-3 p-4\">\n            <span\n              aria-hidden\n              className={cn(\n                \"grid size-8 shrink-0 place-items-center rounded-lg\",\n                destructive ? \"bg-destructive/10 text-destructive-text\" : \"bg-primary/10 text-primary-text\"\n              )}\n            >\n              {icon ?? (destructive ? <TriangleAlert className=\"size-4\" /> : <ShieldAlert className=\"size-4\" />)}\n            </span>\n            <div className=\"min-w-0 flex-1\">\n              <p className=\"text-xs font-medium text-muted-foreground\">{t(\"needsApproval\")}</p>\n              <h3 id={titleId} className=\"mt-0.5 font-medium\" dir=\"auto\">\n                {title}\n              </h3>\n              {description && (\n                <p className=\"mt-1 text-muted-foreground\" dir=\"auto\">\n                  {description}\n                </p>\n              )}\n              {details !== undefined &&\n                (isRows(details) ? (\n                  <dl className={cn(\"mt-3 grid gap-x-4 gap-y-1.5 rounded-lg border bg-muted/40 px-3 py-2.5 sm:grid-cols-[auto_1fr]\", classNames?.details)}>\n                    {details.map((row, index) => (\n                      <React.Fragment key={index}>\n                        <dt className=\"text-xs text-muted-foreground sm:pt-px\">{row.label}</dt>\n                        <dd className=\"min-w-0 font-medium [overflow-wrap:anywhere] max-sm:mb-1\" dir=\"auto\">\n                          {row.value}\n                        </dd>\n                      </React.Fragment>\n                    ))}\n                  </dl>\n                ) : (\n                  <div className={cn(\"mt-3\", classNames?.details)}>{details}</div>\n                ))}\n            </div>\n          </div>\n          <div className={cn(\"flex flex-wrap justify-end gap-2 border-t bg-muted/30 px-4 py-3\", classNames?.actions)}>\n            <Button variant=\"outline\" size=\"sm\" onClick={() => decide(\"deny\")} loading={busy === \"deny\"} disabled={busy !== null && busy !== \"deny\"}>\n              {denyLabel ?? t(\"deny\")}\n            </Button>\n            {allowAlways && (\n              <Button variant=\"outline\" size=\"sm\" onClick={() => decide(\"always\")} loading={busy === \"always\"} disabled={busy !== null && busy !== \"always\"}>\n                {t(\"allowAlways\")}\n              </Button>\n            )}\n            <Button\n              size=\"sm\"\n              color={destructive ? \"destructive\" : \"primary\"}\n              onClick={() => decide(\"once\")}\n              loading={busy === \"once\"}\n              disabled={busy !== null && busy !== \"once\"}\n            >\n              {approveLabel ?? (allowAlways ? t(\"allowOnce\") : t(\"approve\"))}\n            </Button>\n          </div>\n        </>\n      )}\n    </section>\n  )\n}\n\nexport { ApprovalCard, type ApprovalCardProps, type ApprovalStatus, type ApprovalDetail }\n"}],"cssVars":{"theme":{"color-primary-text":"var(--primary-text, var(--primary))","color-destructive-foreground":"var(--destructive-foreground)","color-destructive-text":"var(--destructive-text)","color-success":"var(--success)","color-success-foreground":"var(--success-foreground)","color-success-text":"var(--success-text)"},"light":{"destructive-foreground":"oklch(0.985 0 0)","destructive-text":"#bd2800","success":"oklch(0.508 0.118 165.612)","success-foreground":"oklch(0.985 0 0)","success-text":"oklch(0.432 0.095 166.913)"},"dark":{"destructive-foreground":"oklch(0.2 0 0)","destructive-text":"#ff8a5c","success":"oklch(0.696 0.17 162.48)","success-foreground":"oklch(0.2 0.04 160)","success-text":"oklch(0.765 0.177 163.223)"}}}