{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "quiz-1",
  "title": "Quiz 1: questions with feedback",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A short quiz: one question at a time with progress, single or multiple answers, instant right or wrong with the reason, locked once checked, then a score against the pass mark with a review of every question and a retake.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/quiz-1/quiz-1.tsx",
      "content": "// Ballmac UI: Quiz 1. https://ui.ballmac.com/blocks/quiz-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowRight, Check, CircleAlert, CircleCheck, RotateCcw, X } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { cn } from \"@/lib/utils\"\n\ntype Quiz1Question = {\n  id: string\n  prompt: string\n  options: { id: string; text: string }[]\n  /** The id of the right option, or ids when more than one is right. */\n  answer: string | string[]\n  /** Why it is right, shown after checking. */\n  explanation?: string\n}\n\ntype Quiz1Result = { score: number; total: number; passed: boolean; answers: Record<string, string[]> }\n\ntype Quiz1Props = Omit<React.ComponentProps<\"section\">, \"title\" | \"onSubmit\"> & {\n  title?: string\n  questions?: Quiz1Question[]\n  /** Share needed to pass, from 0 to 1. */\n  passMark?: number\n  /** Called once when the last answer is checked. */\n  onComplete?: (result: Quiz1Result) => void\n  /** Start on the result with these answers (question id to chosen option ids), for showing the end screen. */\n  defaultAnswers?: Record<string, string[]>\n}\n\nconst sample: Quiz1Question[] = [\n  { id: \"q1\", prompt: \"A task has no due date and nobody assigned. What is the first thing to do?\", options: [{ id: \"a\", text: \"Give it the earliest date you can\" }, { id: \"b\", text: \"Decide who owns it\" }, { id: \"c\", text: \"Leave it until someone asks\" }, { id: \"d\", text: \"Split it into five smaller tasks\" }], answer: \"b\", explanation: \"Work without an owner does not move. A date can follow once someone is responsible.\" },\n  { id: \"q2\", prompt: \"Which of these make an estimate more reliable? Choose all that apply.\", options: [{ id: \"a\", text: \"Using how long similar work took before\" }, { id: \"b\", text: \"Asking the person who will do it\" }, { id: \"c\", text: \"Picking the number your manager hopes for\" }, { id: \"d\", text: \"Adding time for review and fixes\" }], answer: [\"a\", \"b\", \"d\"], explanation: \"Evidence, the person doing the work, and honest slack beat optimism every time.\" },\n  { id: \"q3\", prompt: \"Two tasks depend on the same unfinished task. What should you watch?\", options: [{ id: \"a\", text: \"Only the two later tasks\" }, { id: \"b\", text: \"The unfinished task they both wait for\" }, { id: \"c\", text: \"Nothing until a date slips\" }], answer: \"b\", explanation: \"A delay in the shared task moves both. It is the one to protect.\" },\n  { id: \"q4\", prompt: \"How often should a weekly plan be reviewed?\", options: [{ id: \"a\", text: \"At the start of the week, and when something big changes\" }, { id: \"b\", text: \"Only at the end of the month\" }, { id: \"c\", text: \"Never, plans should not change\" }], answer: \"a\", explanation: \"A plan is a guess that gets better as you learn. Review it when it can still change the week.\" },\n]\n\nconst asArray = (a: string | string[]) => (Array.isArray(a) ? a : [a])\nconst same = (x: string[], y: string[]) => x.length === y.length && x.every((v) => y.includes(v))\n\nfunction Ring({ value, pass }: { value: number; pass: boolean }) {\n  const r = 52\n  const c = 2 * Math.PI * r\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 120 120\" className=\"size-36 -rotate-90 rtl:rotate-90\">\n      <circle cx=\"60\" cy=\"60\" r={r} fill=\"none\" strokeWidth=\"10\" className=\"stroke-muted\" />\n      <circle cx=\"60\" cy=\"60\" r={r} fill=\"none\" strokeWidth=\"10\" strokeLinecap=\"round\" strokeDasharray={c} strokeDashoffset={c * (1 - value)} className={cn(\"transition-[stroke-dashoffset] duration-1000 ease-out motion-reduce:transition-none\", pass ? \"stroke-chart-2\" : \"stroke-destructive\")} />\n    </svg>\n  )\n}\n\n/**\n * A short quiz: one question at a time with a progress bar, single or multiple answers chosen with radio buttons or checkboxes (number keys pick\n * too), checking an answer says right or wrong at once with why, and the end shows the score against the pass mark with a review of every\n * question and a retake. Answers are locked once checked, so the review is honest.\n */\nfunction Quiz1({ title = \"Check your understanding\", questions = sample, passMark = 0.7, onComplete, defaultAnswers, className, ...props }: Quiz1Props) {\n  const id = React.useId()\n  const [i, setI] = React.useState(0)\n  const [picked, setPicked] = React.useState<string[]>([])\n  const [checked, setChecked] = React.useState(false)\n  const [answers, setAnswers] = React.useState<Record<string, string[]>>(defaultAnswers ?? {})\n  const [finished, setFinished] = React.useState(!!defaultAnswers)\n  const headingRef = React.useRef<HTMLHeadingElement>(null)\n  const first = React.useRef(true)\n  const q = questions[i]\n  const multi = q ? asArray(q.answer).length > 1 : false\n\n  React.useEffect(() => {\n    if (first.current) first.current = false\n    else headingRef.current?.focus()\n  }, [i, finished])\n\n  if (!questions.length || !q)\n    return (\n      <section data-slot=\"quiz-1\" aria-label=\"Quiz\" className={cn(\"mx-auto w-full max-w-2xl px-4 py-10\", className)} {...props}>\n        <p className=\"text-muted-foreground rounded-2xl border border-dashed px-6 py-12 text-center text-sm\">This quiz has no questions yet.</p>\n      </section>\n    )\n\n  const right = checked && same(picked, asArray(q.answer))\n  const score = questions.filter((x) => same(answers[x.id] ?? [], asArray(x.answer))).length\n  const passed = score / questions.length >= passMark\n\n  const choose = (oid: string) => {\n    if (checked) return\n    setPicked((p) => (multi ? (p.includes(oid) ? p.filter((x) => x !== oid) : [...p, oid]) : [oid]))\n  }\n  const check = () => {\n    setAnswers((a) => ({ ...a, [q.id]: picked }))\n    setChecked(true)\n  }\n  const next = () => {\n    if (i + 1 < questions.length) {\n      setI(i + 1)\n      setPicked([])\n      setChecked(false)\n    } else {\n      setFinished(true)\n      const total = questions.length\n      const s = questions.filter((x) => same(answers[x.id] ?? [], asArray(x.answer))).length\n      onComplete?.({ score: s, total, passed: s / total >= passMark, answers })\n    }\n  }\n  const retake = () => {\n    setI(0)\n    setPicked([])\n    setChecked(false)\n    setAnswers({})\n    setFinished(false)\n  }\n\n  return (\n    <section data-slot=\"quiz-1\" aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-2xl px-4 py-10 sm:px-6\", className)} {...props}>\n      <div className=\"bg-card overflow-hidden rounded-3xl border shadow-sm\">\n        <div role=\"progressbar\" aria-label=\"Quiz progress\" aria-valuemin={0} aria-valuemax={questions.length} aria-valuenow={finished ? questions.length : i + (checked ? 1 : 0)} className=\"bg-muted h-1.5\">\n          <div className=\"bg-foreground h-full transition-[width] duration-500 motion-reduce:transition-none\" style={{ width: `${((finished ? questions.length : i + (checked ? 1 : 0)) / questions.length) * 100}%` }} />\n        </div>\n\n        {finished ? (\n          <div className=\"p-6 sm:p-10\">\n            <div role=\"status\" className=\"flex flex-col items-center text-center\">\n              <div className=\"relative\">\n                <Ring value={score / questions.length} pass={passed} />\n                <span className=\"absolute inset-0 flex items-center justify-center text-3xl font-semibold tabular-nums\">\n                  {score}/{questions.length}\n                </span>\n              </div>\n              <h2 id={`${id}-title`} ref={headingRef} tabIndex={-1} className=\"mt-5 text-2xl font-semibold tracking-tight outline-none\">\n                {passed ? \"You passed\" : \"Not quite yet\"}\n              </h2>\n              <p className=\"text-muted-foreground mt-2 max-w-sm text-pretty\">\n                {passed ? `${Math.round((score / questions.length) * 100)}% correct. The pass mark is ${Math.round(passMark * 100)}%.` : `${Math.round((score / questions.length) * 100)}% correct, and you need ${Math.round(passMark * 100)}%. Read the answers below and try again.`}\n              </p>\n            </div>\n            <ol className=\"mt-8 grid gap-3\">\n              {questions.map((x, n) => {\n                const ok = same(answers[x.id] ?? [], asArray(x.answer))\n                return (\n                  <li key={x.id} className={cn(\"rounded-2xl border p-4\", ok ? \"border-chart-2/30\" : \"border-destructive/30 bg-destructive/5\")}>\n                    <p className=\"flex items-start gap-2.5 text-sm font-medium\">\n                      {ok ? <CircleCheck aria-hidden=\"true\" className=\"text-chart-2 mt-0.5 size-4 shrink-0\" /> : <CircleAlert aria-hidden=\"true\" className=\"text-destructive mt-0.5 size-4 shrink-0\" />}\n                      <span>\n                        <span className=\"sr-only\">{ok ? \"Correct. \" : \"Incorrect. \"}</span>\n                        {n + 1}. {x.prompt}\n                      </span>\n                    </p>\n                    {!ok ? (\n                      <p className=\"text-muted-foreground mt-2 ps-6.5 text-sm text-pretty\">\n                        You chose {(answers[x.id] ?? []).map((o) => x.options.find((p) => p.id === o)?.text).join(\"; \") || \"nothing\"}. Right answer: {asArray(x.answer).map((o) => x.options.find((p) => p.id === o)?.text).join(\"; \")}.\n                      </p>\n                    ) : null}\n                    {x.explanation ? <p className=\"text-muted-foreground mt-2 ps-6.5 text-sm text-pretty\">{x.explanation}</p> : null}\n                  </li>\n                )\n              })}\n            </ol>\n            <div className=\"mt-8 flex justify-center\">\n              <Button variant=\"outline\" onClick={retake}>\n                <RotateCcw aria-hidden=\"true\" />\n                Take it again\n              </Button>\n            </div>\n          </div>\n        ) : (\n          <div className=\"p-6 sm:p-10\">\n            <p className=\"text-muted-foreground text-sm font-medium tabular-nums\">\n              {title} · Question {i + 1} of {questions.length}\n            </p>\n            <h2 id={`${id}-title`} ref={headingRef} tabIndex={-1} className=\"mt-3 text-2xl font-semibold tracking-tight text-balance outline-none sm:text-3xl\">\n              {q.prompt}\n            </h2>\n            <fieldset className=\"mt-7 grid gap-3\" disabled={false}>\n              <legend className=\"sr-only\">{multi ? \"Choose all the answers that apply\" : \"Choose one answer\"}</legend>\n              {multi ? <p className=\"text-muted-foreground -mb-1 text-sm\">Choose all that apply.</p> : null}\n              {q.options.map((o, n) => {\n                const on = picked.includes(o.id)\n                const isRight = asArray(q.answer).includes(o.id)\n                const state = !checked ? \"idle\" : isRight ? \"right\" : on ? \"wrong\" : \"idle\"\n                return (\n                  <label\n                    key={o.id}\n                    className={cn(\n                      \"focus-within:ring-ring/50 flex cursor-pointer items-center gap-4 rounded-2xl border p-4 transition-colors focus-within:ring-[3px] motion-reduce:transition-none\",\n                      checked && \"cursor-default\",\n                      !checked && on && \"border-foreground bg-accent/40\",\n                      !checked && !on && \"hover:bg-accent/30\",\n                      state === \"right\" && \"border-chart-2 bg-chart-2/10\",\n                      state === \"wrong\" && \"border-destructive bg-destructive/10\"\n                    )}\n                  >\n                    <input type={multi ? \"checkbox\" : \"radio\"} name={`${id}-q`} checked={on} disabled={checked} onChange={() => choose(o.id)} className=\"sr-only\" />\n                    <span aria-hidden=\"true\" className={cn(\"flex size-6 shrink-0 items-center justify-center border text-xs font-semibold tabular-nums\", multi ? \"rounded-md\" : \"rounded-full\", on && !checked && \"bg-foreground text-background border-foreground\", state === \"right\" && \"bg-chart-2 border-chart-2 text-white\", state === \"wrong\" && \"bg-destructive border-destructive text-white\")}>\n                      {state === \"right\" ? <Check className=\"size-4\" /> : state === \"wrong\" ? <X className=\"size-4\" /> : on ? <Check className=\"size-4\" /> : n + 1}\n                    </span>\n                    <span className=\"text-sm font-medium text-pretty sm:text-base\">\n                      {o.text}\n                      {checked && isRight ? <> <span className=\"sr-only\">(correct answer)</span></> : null}\n                      {checked && on && !isRight ? <> <span className=\"sr-only\">(your answer, wrong)</span></> : null}\n                    </span>\n                  </label>\n                )\n              })}\n            </fieldset>\n\n            {checked ? (\n              <div role=\"status\" className={cn(\"mt-6 rounded-2xl border p-4 text-sm\", right ? \"border-chart-2/40 bg-chart-2/10\" : \"border-destructive/40 bg-destructive/10\")}>\n                <p className=\"flex items-center gap-2 font-semibold\">\n                  {right ? <CircleCheck aria-hidden=\"true\" className=\"size-4\" /> : <CircleAlert aria-hidden=\"true\" className=\"size-4\" />}\n                  {right ? \"That is right\" : \"Not quite\"}\n                </p>\n                {q.explanation ? <p className=\"text-muted-foreground mt-1.5 text-pretty\">{q.explanation}</p> : null}\n              </div>\n            ) : null}\n\n            <div className=\"mt-8 flex justify-end\">\n              {checked ? (\n                <Button onClick={next}>\n                  {i + 1 < questions.length ? \"Next question\" : \"See my score\"}\n                  <ArrowRight aria-hidden=\"true\" className=\"rtl:rotate-180\" />\n                </Button>\n              ) : (\n                <Button onClick={check} disabled={!picked.length}>\n                  Check answer\n                </Button>\n              )}\n            </div>\n          </div>\n        )}\n      </div>\n    </section>\n  )\n}\n\nexport { Quiz1, type Quiz1Props, type Quiz1Question, type Quiz1Result }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/quiz-1/quiz-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "quiz",
      "questions",
      "test",
      "exam",
      "assessment",
      "learning",
      "score",
      "pass mark",
      "trivia",
      "knowledge check"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "A quiz. <Quiz1 title questions={[{ id, prompt, options: [{ id, text }], answer: 'b' | ['a', 'd'], explanation }]} passMark={0.7} onComplete={({ score, total, passed, answers }) => ...} />. An answer array means choose all that apply (checkboxes); one id means radio buttons. Answers lock once checked. The result shows the score ring, pass or fail, and every question with what you chose when you were wrong.",
      "whenToUse": [
        "A knowledge check at the end of a lesson",
        "A quick assessment or onboarding quiz"
      ],
      "whenNotToUse": [
        "Collecting opinions (use survey-1)",
        "Timed exams with proctoring"
      ],
      "composesWith": [
        "course-1",
        "onboarding-1",
        "app-shell-1"
      ],
      "a11y": [
        {
          "keys": "Arrow keys / Space",
          "action": "Options are real radio buttons or checkboxes; the group is named 'Choose one answer' or 'Choose all the answers that apply'"
        },
        {
          "keys": "Checking",
          "action": "The result is announced politely ('That is right' or 'Not quite' with the reason), and the correct and wrong options say so in words"
        },
        {
          "keys": "Next",
          "action": "Focus moves to the new question's heading, or to the result heading at the end"
        }
      ],
      "customization": [
        "passMark sets the share needed",
        "onComplete fires once, after the last answer is checked",
        "Checked answers cannot be changed, so the review reflects what the person chose"
      ]
    },
    "statesNone": "Self-contained: it holds its own answers and has nothing to load, submit or fail.",
    "examples": [
      "quiz-1-demo",
      "quiz-1-score",
      "quiz-1-empty"
    ],
    "url": "https://ui.ballmac.com/components/quiz-1"
  },
  "categories": [
    "blocks",
    "quiz"
  ],
  "type": "registry:block"
}