{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "question-card",
  "title": "Question card",
  "description": "An AI agent asks the person to choose — the question, options as full rows with the recommended one first, a free-text row for anything else, and Continue that waits for an answer.",
  "registryDependencies": [
    "button",
    "input",
    "radio-group",
    "https://www.quilldesignsystem.com/r/ai-mark.json",
    "https://www.quilldesignsystem.com/r/tone-badge.json"
  ],
  "files": [
    {
      "path": "registry/lib/question-card.tsx",
      "content": "'use client'\n\nimport * as React from 'react'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'\nimport { AiMark } from '@/components/ui/ai-mark'\nimport { ToneBadge } from '@/components/ui/tone-badge'\nimport { cn } from '@/lib/utils'\n\n/** The value of the free-text row. */\nexport const OTHER_ANSWER = '__other__'\n\nexport type QuestionOption = {\n  value: string\n  label: string\n  /** One line on what choosing this means. */\n  description?: string\n}\n\n/** `text` is set only for the free-text row. */\nexport type QuestionAnswer = { value: string; text?: string }\n\nexport type QuestionCardProps = {\n  /** The question, in plain words. It names the radio group. */\n  question: string\n  options: QuestionOption[]\n  /** Names the card for screen readers; the question names the options, so each is read once. */\n  title?: string\n  /** The value of the agent's pick: it moves to the top and gets the \"Recommended\" tag. */\n  recommended?: string\n  /** The free-text row, always last. */\n  otherLabel?: string\n  otherDescription?: string\n  otherPlaceholder?: string\n  /** Controlled when passed (null means nothing chosen); leave it out to let the card keep its own. */\n  answer?: QuestionAnswer | null\n  onAnswerChange?: (answer: QuestionAnswer | null) => void\n  onContinue: (answer: QuestionAnswer) => void\n  continueLabel?: string\n  /** Shows the skip button. */\n  onSkip?: () => void\n  skipLabel?: string\n  /** Once answered: one line that replaces the buttons (\"Sending it to the growth team.\"). The rows lock. */\n  outcome?: string\n  className?: string\n}\n\n/** An AI agent asks the person to choose — the question, options as full rows with the recommended one first, a free-text row for anything else, and Continue that waits for an answer. */\nexport function QuestionCard({\n  question, options, title = 'The agent has a question', recommended,\n  otherLabel = 'Something else', otherDescription = 'Tell the agent what you want.', otherPlaceholder,\n  answer, onAnswerChange, onContinue, continueLabel = 'Continue', onSkip, skipLabel = 'Skip, let the agent decide', outcome, className,\n}: QuestionCardProps) {\n  const baseId = React.useId()\n  const [ownAnswer, setOwnAnswer] = React.useState<QuestionAnswer | null>(null)\n  // What was last typed in the field, so choosing another row and coming back does not lose it.\n  const lastText = React.useRef('')\n  const decided = Boolean(outcome)\n  const cardRef = React.useRef<HTMLDivElement>(null)\n  const statusRef = React.useRef<HTMLDivElement>(null)\n  const wasDecided = React.useRef(decided)\n  // A controlled answer with nowhere to report changes would give rows that do nothing: lock them instead.\n  const locked = decided || (answer !== undefined && !onAnswerChange)\n\n  // Set when the person presses this card's own Continue or Skip, or Enter in its field. An outcome can also arrive on\n  // its own (a timeout, a decision made on another screen), and focus resting on the page body looks the same in both cases.\n  const decidedHere = React.useRef(false)\n\n  // Pressing Continue or Skip removes the button that had focus. Keep the person's place inside the card, but only\n  // after a press in this card, and only if they have not moved on to something else since.\n  // preventScroll: the line is beside the button they just pressed, so the page has no reason to jump.\n  React.useEffect(() => {\n    if (decided && !wasDecided.current && decidedHere.current) {\n      const active = document.activeElement\n      if (!active || active === document.body || cardRef.current?.contains(active)) statusRef.current?.focus({ preventScroll: true })\n    }\n    if (decided) decidedHere.current = false\n    wasDecided.current = decided\n  }, [decided])\n\n  // A value can belong to one row only: later repeats, and rows borrowing the free-text value, are dropped.\n  const unique = options.filter((option, index) => (\n    option.value !== OTHER_ANSWER && options.findIndex((other) => other.value === option.value) === index\n  ))\n  const lead = unique.find((option) => option.value === recommended)\n  const rows: QuestionOption[] = [\n    ...(lead ? [lead, ...unique.filter((option) => option !== lead)] : unique),\n    { value: OTHER_ANSWER, label: otherLabel, description: otherDescription },\n  ]\n\n  const given = answer === undefined ? ownAnswer : answer\n  // A chosen value no row has (a retired option) counts as nothing chosen.\n  const current = given && rows.some((option) => option.value === given.value) ? given : null\n  const isOther = current?.value === OTHER_ANSWER\n  const text = current?.value === OTHER_ANSWER ? current.text ?? '' : ''\n  const ready = current !== null && (!isOther || text.trim().length > 0)\n\n  const change = (next: QuestionAnswer | null) => {\n    if (answer === undefined) setOwnAnswer(next)\n    onAnswerChange?.(next)\n  }\n  const choose = (value: string) => {\n    if (locked || value === current?.value) return\n    change(value === OTHER_ANSWER ? { value, text: lastText.current } : { value })\n  }\n  const submit = () => {\n    if (!ready || !current) return\n    decidedHere.current = true\n    onContinue(isOther ? { value: OTHER_ANSWER, text: text.trim() } : { value: current.value })\n  }\n\n  return (\n    <div ref={cardRef} data-slot=\"question-card\" role=\"group\" aria-labelledby={`${baseId}-card-title`}\n      className={cn('grid grid-cols-[minmax(0,1fr)] gap-2.5 rounded-xl border border-border bg-card px-4 py-3.5 text-sm shadow-sm', className)}>\n      <div className=\"flex items-center gap-2\">\n        <AiMark size=\"icon-md\" />\n        <p id={`${baseId}-card-title`} className=\"min-w-0 flex-1 font-semibold break-words text-ink-soft\">{title}</p>\n      </div>\n      <p id={`${baseId}-question`} className=\"font-semibold break-words text-foreground\">{question}</p>\n      <RadioGroup aria-labelledby={`${baseId}-question`} value={current?.value ?? null} onValueChange={(value) => choose(String(value))} disabled={locked} className=\"grid-cols-[minmax(0,1fr)] gap-1.5\">\n        {rows.map((option, index) => {\n          const selected = current?.value === option.value\n          const tagged = lead !== undefined && option === lead\n          const titleId = `${baseId}-title-${index}`\n          const tagId = `${baseId}-tag-${index}`\n          const lineId = `${baseId}-line-${index}`\n          return (\n            // The whole row is the target. A press on the radio is left to the radio group (it clicks its own hidden\n            // input, whose click does not reach this row), so one press never reports two changes. A press in the\n            // field reaches the row but the row is already chosen, so choose() does nothing.\n            <div key={option.value} data-slot=\"question-option\"\n              onClick={(event) => { if (!(event.target as HTMLElement).closest('[role=\"radio\"]')) choose(option.value) }}\n              className={cn(\n                'grid grid-cols-[1rem_minmax(0,1fr)] items-start gap-x-2.5 rounded-lg border bg-background px-3 py-2.5',\n                // border-input, not the divider line: an option row is a control and must read as a shape in the dark themes.\n                selected ? 'border-foreground ring-1 ring-foreground ring-inset' : 'border-input',\n                locked ? 'cursor-default' : 'cursor-pointer',\n                !locked && !selected && 'hover:border-foreground',\n              )}>\n              {/* A box one line tall keeps the radio on the title's line. */}\n              <span className=\"flex h-[1lh] items-center\">\n                <RadioGroupItem value={option.value} aria-labelledby={tagged ? `${titleId} ${tagId}` : titleId} aria-describedby={option.description ? lineId : undefined} />\n              </span>\n              <span className=\"grid min-w-0 grid-cols-[minmax(0,1fr)] gap-0.5\">\n                <span className=\"flex flex-wrap items-center gap-x-1.5\">\n                  <span id={titleId} data-slot=\"option-title\" className=\"font-semibold break-words text-foreground min-w-0\">{option.label}</span>\n                  {tagged ? <span id={tagId} className=\"inline-flex\"><ToneBadge tone=\"moss\" size=\"sm\">Recommended</ToneBadge></span> : null}\n                </span>\n                {option.description ? <span id={lineId} className=\"text-xs break-words text-muted-foreground\">{option.description}</span> : null}\n                {option.value === OTHER_ANSWER && selected ? (\n                  <Input\n                    aria-label={`${question}: ${option.label}`}\n                    aria-describedby={option.description ? lineId : undefined}\n                    placeholder={otherPlaceholder}\n                    value={text}\n                    disabled={locked}\n                    className=\"mt-1.5 bg-background\"\n                    onChange={(event) => { lastText.current = event.target.value; change({ value: OTHER_ANSWER, text: event.target.value }) }}\n                    onKeyDown={(event) => {\n                      // The radio group listens for arrow keys on everything inside it, and with the caret at either end\n                      // of the text it would move to (and choose) another option. In the field, arrows move the caret.\n                      if (event.key.startsWith('Arrow')) { event.stopPropagation(); return }\n                      // Enter that only confirms a word in an input method (Japanese, Chinese, Korean) is not \"continue\".\n                      // Chrome marks it isComposing; Safari sends it just after, as keyCode 229.\n                      if (event.key === 'Enter' && !event.nativeEvent.isComposing && event.nativeEvent.keyCode !== 229) submit()\n                    }}\n                  />\n                ) : null}\n              </span>\n            </div>\n          )\n        })}\n      </RadioGroup>\n      {decided ? null : (\n        <div className=\"flex flex-wrap items-center gap-1.5\">\n          <Button type=\"button\" disabled={!ready} onClick={submit}>{continueLabel}</Button>\n          <span className=\"flex-1\" />\n          {onSkip ? <Button type=\"button\" variant=\"ghost\" onClick={() => { decidedHere.current = true; onSkip() }}>{skipLabel}</Button> : null}\n        </div>\n      )}\n      {/* On the page from the start so the outcome arrives into a live region. While empty it is absolutely\n          positioned: a real box that takes no grid row and no gap. With words in it the box is only as wide as\n          they are, with a little room on every side paid back in negative margin, so the focus ring it gets after a\n          decision hugs the words and neither the words nor the card move. */}\n      <div ref={statusRef} role=\"status\" tabIndex={-1} className=\"rounded-md outline-hidden focus-visible:ring-3 focus-visible:ring-ring/50 empty:absolute not-empty:w-fit not-empty:max-w-[calc(100%+0.75rem)] not-empty:-mx-1.5 not-empty:-my-0.5 not-empty:px-1.5 not-empty:py-0.5\">\n        {outcome ? <p className=\"text-xs break-words text-muted-foreground\">{outcome}</p> : null}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/question-card.tsx"
    }
  ],
  "docs": "An AI agent asks the person to choose — the question, options as full rows with the recommended one first, a free-text row for anything else, and Continue that waits for an answer. Do: Give every option a title and one line on what choosing it means. Don't: Offer a row of bare buttons — people cannot weigh options they have to guess at. Do: Pass the agent's pick as recommended so it comes first with the moss tag. Don't: Tag several options as recommended, or bury the recommendation in the middle of the list. Do: Keep the free-text row, and offer Skip when the agent can decide for itself. Don't: Force a choice between options when none of them may fit. Use radio-group when an ordinary form asks the question; no AI agent is waiting on it. Use approval-card when the agent already knows what it wants to do and needs a yes or a no.",
  "type": "registry:ui"
}