{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "questionnaire",
  "title": "Questionnaire",
  "description": "Multi-step questions an agent asks before starting, with single, multiple, and freeform answers.",
  "dependencies": [
    "lucide-react@^1.31.0"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/questionnaire/questionnaire.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowLeft, ArrowRight, Check, TriangleAlert } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type QuestionChoice = {\n  id: string\n  label: React.ReactNode\n  description?: React.ReactNode\n}\n\nexport type Question = {\n  id: string\n  prompt: React.ReactNode\n  description?: React.ReactNode\n  choices?: QuestionChoice[]\n  multiple?: boolean\n  freeform?: boolean\n  freeformLabel?: string\n  freeformPlaceholder?: string\n  required?: boolean\n}\n\n/** Chosen choice ids per question, with any freeform answer last. */\nexport type QuestionnaireAnswers = Record<string, string[]>\n\nexport type QuestionnaireProps = Omit<\n  React.HTMLAttributes<HTMLFormElement>,\n  \"onSubmit\" | \"children\"\n> & {\n  questions: Question[]\n  answers?: QuestionnaireAnswers\n  defaultAnswers?: QuestionnaireAnswers\n  onAnswersChange?: (answers: QuestionnaireAnswers) => void\n  onSubmit?: (answers: QuestionnaireAnswers) => void\n  shortcuts?: boolean\n  showProgress?: boolean\n  /** Offers an open answer on every question. A question can opt out. */\n  freeform?: boolean\n  previousLabel?: string\n  nextLabel?: string\n  skipLabel?: string\n  submitLabel?: string\n  requiredMessage?: string\n}\n\nconst FREEFORM = \"__freeform__\"\n\nfunction answered(answers: QuestionnaireAnswers, question: Question) {\n  return (answers[question.id] ?? []).some((value) => value.trim().length > 0)\n}\n\nexport function Questionnaire({\n  questions,\n  answers,\n  defaultAnswers = {},\n  onAnswersChange,\n  onSubmit,\n  shortcuts = true,\n  showProgress = true,\n  freeform = true,\n  previousLabel = \"Back\",\n  nextLabel = \"Next\",\n  skipLabel = \"Skip\",\n  submitLabel = \"Submit\",\n  requiredMessage = \"Choose an answer to continue.\",\n  className,\n  ...formProps\n}: QuestionnaireProps) {\n  const reactId = React.useId()\n  const [index, setIndex] = React.useState(0)\n  const [uncontrolled, setUncontrolled] =\n    React.useState<QuestionnaireAnswers>(defaultAnswers)\n  const [showError, setShowError] = React.useState(false)\n\n  const value = answers ?? uncontrolled\n  const question = questions[Math.min(index, questions.length - 1)]\n\n  if (!question) return null\n\n  const isLast = index === questions.length - 1\n  const complete = answered(value, question)\n  const blocked = Boolean(question.required) && !complete\n\n  const commit = (next: QuestionnaireAnswers) => {\n    if (answers === undefined) setUncontrolled(next)\n    onAnswersChange?.(next)\n  }\n\n  const setChoice = (choiceId: string, checked: boolean) => {\n    const current = value[question.id] ?? []\n    const freeform = current.filter((entry) => entry.startsWith(FREEFORM))\n    const chosen = current.filter((entry) => !entry.startsWith(FREEFORM))\n\n    const nextChosen = question.multiple\n      ? checked\n        ? [...chosen, choiceId]\n        : chosen.filter((entry) => entry !== choiceId)\n      : [choiceId]\n\n    setShowError(false)\n    commit({ ...value, [question.id]: [...nextChosen, ...freeform] })\n  }\n\n  const setFreeform = (text: string) => {\n    const current = value[question.id] ?? []\n    const chosen = current.filter((entry) => !entry.startsWith(FREEFORM))\n    const next = text.trim() ? [...chosen, `${FREEFORM}${text}`] : chosen\n\n    setShowError(false)\n    commit({ ...value, [question.id]: next })\n  }\n\n  const advance = () => {\n    if (blocked) {\n      setShowError(true)\n      return\n    }\n\n    setShowError(false)\n    if (isLast) onSubmit?.(value)\n    else setIndex((current) => current + 1)\n  }\n\n  const freeformValue =\n    (value[question.id] ?? [])\n      .find((entry) => entry.startsWith(FREEFORM))\n      ?.slice(FREEFORM.length) ?? \"\"\n\n  const chosen = new Set(\n    (value[question.id] ?? []).filter((entry) => !entry.startsWith(FREEFORM))\n  )\n\n  const errorId = `${reactId}-error`\n  const showFreeform = question.freeform ?? freeform\n\n  // The hints on each choice have to do something, so number keys pick the\n  // choice they label, unless the caller is typing into the freeform field.\n  const onKeyDown = (event: React.KeyboardEvent<HTMLFormElement>) => {\n    if (!shortcuts) return\n    if (event.metaKey || event.ctrlKey || event.altKey) return\n\n    // A text field has no type attribute unless one is set, so treat anything\n    // that is not a radio or checkbox as somewhere the caller is typing.\n    const target = event.target as HTMLInputElement\n    const typing =\n      target.tagName === \"TEXTAREA\" ||\n      (target.tagName === \"INPUT\" &&\n        target.type !== \"radio\" &&\n        target.type !== \"checkbox\")\n\n    if (typing) return\n\n    const position = Number(event.key)\n    if (!Number.isInteger(position) || position < 1) return\n\n    const choice = question.choices?.[position - 1]\n    if (!choice) return\n\n    event.preventDefault()\n    setChoice(choice.id, !chosen.has(choice.id))\n  }\n\n  return (\n    <form\n      data-slot=\"questionnaire\"\n      className={cn(\n        \"border-border bg-card text-card-foreground rounded-[var(--radius)] border\",\n        className\n      )}\n      onKeyDown={onKeyDown}\n      onSubmit={(event) => {\n        event.preventDefault()\n        advance()\n      }}\n      {...formProps}\n    >\n      {showProgress ? (\n        <div\n          data-slot=\"questionnaire-progress\"\n          className=\"border-border flex items-center gap-3 border-b px-4 py-2.5\"\n        >\n          <p aria-live=\"polite\" className=\"text-muted-foreground text-xs\">\n            Question {index + 1} of {questions.length}\n          </p>\n\n          <span\n            aria-hidden=\"true\"\n            className=\"bg-muted ml-auto h-1 w-24 overflow-hidden rounded-full\"\n          >\n            <span\n              className=\"bg-foreground block h-full transition-[width] duration-300 motion-reduce:transition-none\"\n              style={{ width: `${((index + 1) / questions.length) * 100}%` }}\n            />\n          </span>\n        </div>\n      ) : null}\n\n      <fieldset className=\"border-0 p-0\">\n        <legend\n          data-slot=\"questionnaire-prompt\"\n          className=\"px-4 pt-4 text-sm font-semibold text-pretty\"\n        >\n          {question.prompt}\n        </legend>\n\n        <div className=\"px-4 pt-2 pb-4\">\n          {question.description ? (\n            <p className=\"text-muted-foreground mb-3 text-sm leading-relaxed\">\n              {question.description}\n            </p>\n          ) : null}\n\n          <div className=\"grid gap-1.5\">\n            {(question.choices ?? []).map((choice, choiceIndex) => {\n              const id = `${reactId}-${question.id}-${choice.id}`\n              const isChosen = chosen.has(choice.id)\n\n              return (\n                <label\n                  key={choice.id}\n                  data-slot=\"questionnaire-choice\"\n                  data-chosen={isChosen || undefined}\n                  htmlFor={id}\n                  className={cn(\n                    \"border-border flex cursor-pointer items-start gap-2.5 rounded-[calc(var(--radius)-0.25rem)] border px-3 py-2 text-sm transition-colors duration-150 motion-reduce:transition-none\",\n                    isChosen\n                      ? \"border-foreground bg-muted/60\"\n                      : \"hover:bg-muted/40\"\n                  )}\n                >\n                  <input\n                    id={id}\n                    className=\"mt-0.5\"\n                    name={`${reactId}-${question.id}`}\n                    type={question.multiple ? \"checkbox\" : \"radio\"}\n                    checked={isChosen}\n                    aria-describedby={showError ? errorId : undefined}\n                    onChange={(event) =>\n                      setChoice(choice.id, event.target.checked)\n                    }\n                  />\n\n                  <span className=\"min-w-0 flex-1\">\n                    <span className=\"font-medium\">{choice.label}</span>\n                    {choice.description ? (\n                      <span className=\"text-muted-foreground mt-0.5 block text-xs leading-relaxed\">\n                        {choice.description}\n                      </span>\n                    ) : null}\n                  </span>\n\n                  {shortcuts && choiceIndex < 9 ? (\n                    <kbd\n                      aria-hidden=\"true\"\n                      className=\"border-border text-muted-foreground rounded border px-1 font-[family-name:var(--font-mono),monospace] text-[0.65rem]\"\n                    >\n                      {choiceIndex + 1}\n                    </kbd>\n                  ) : null}\n                </label>\n              )\n            })}\n          </div>\n\n          {showFreeform ? (\n            <div className=\"mt-1.5\">\n              <label\n                className=\"sr-only\"\n                htmlFor={`${reactId}-${question.id}-freeform`}\n              >\n                {question.freeformLabel ?? \"Another answer\"}\n              </label>\n              <input\n                id={`${reactId}-${question.id}-freeform`}\n                className=\"border-border placeholder:text-muted-foreground focus-visible:ring-ring min-h-11 w-full rounded-[calc(var(--radius)-0.25rem)] border px-3 text-sm focus-visible:ring-2 focus-visible:outline-none\"\n                placeholder={question.freeformPlaceholder ?? \"Something else…\"}\n                value={freeformValue}\n                onChange={(event) => setFreeform(event.target.value)}\n              />\n            </div>\n          ) : null}\n\n          {showError ? (\n            <p\n              id={errorId}\n              role=\"alert\"\n              className=\"text-destructive mt-2 flex items-center gap-1.5 text-xs\"\n            >\n              <TriangleAlert aria-hidden=\"true\" size={13} />\n              {requiredMessage}\n            </p>\n          ) : null}\n        </div>\n      </fieldset>\n\n      <div\n        data-slot=\"questionnaire-actions\"\n        className=\"border-border flex items-center gap-2 border-t px-3 py-2.5\"\n      >\n        <button\n          type=\"button\"\n          disabled={index === 0}\n          className=\"text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex min-h-9 items-center gap-1.5 rounded-full px-2.5 text-xs font-semibold transition-colors duration-150 focus-visible:ring-2 focus-visible:outline-none disabled:opacity-40 motion-reduce:transition-none\"\n          onClick={() => {\n            setShowError(false)\n            setIndex((current) => Math.max(current - 1, 0))\n          }}\n        >\n          <ArrowLeft aria-hidden=\"true\" size={13} />\n          {previousLabel}\n        </button>\n\n        {!question.required ? (\n          <button\n            type=\"button\"\n            className=\"text-muted-foreground hover:text-foreground focus-visible:ring-ring ml-auto inline-flex min-h-9 items-center rounded-full px-2.5 text-xs font-semibold transition-colors duration-150 focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none\"\n            onClick={() => {\n              setShowError(false)\n              if (isLast) onSubmit?.(value)\n              else setIndex((current) => current + 1)\n            }}\n          >\n            {skipLabel}\n          </button>\n        ) : null}\n\n        <button\n          type=\"submit\"\n          data-slot=\"questionnaire-next\"\n          className={cn(\n            \"bg-foreground text-background focus-visible:ring-ring inline-flex min-h-9 items-center gap-1.5 rounded-full px-3.5 text-xs font-semibold transition-opacity duration-150 focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none\",\n            question.required ? \"ml-auto\" : \"\"\n          )}\n        >\n          {isLast ? submitLabel : nextLabel}\n          {isLast ? (\n            <Check aria-hidden=\"true\" size={13} />\n          ) : (\n            <ArrowRight aria-hidden=\"true\" size={13} />\n          )}\n        </button>\n      </div>\n    </form>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "ai",
    "form",
    "questions",
    "agent"
  ],
  "type": "registry:ui"
}