{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "questionnaire",
  "title": "Questionnaire",
  "description": "Questions one at a time, set large. The count rolls and a hairline fills; each question turns in from the side you're heading. At the end your answers are written into one sentence, in italic, arriving word by word.",
  "registryDependencies": [
    "https://thedirectors.agency/ui/r/0nlytype.json",
    "https://thedirectors.agency/ui/r/button.json",
    "https://thedirectors.agency/ui/r/field.json",
    "https://thedirectors.agency/ui/r/fraction.json",
    "https://thedirectors.agency/ui/r/radio-group.json"
  ],
  "files": [
    {
      "path": "registry/0nlytype/ui/questionnaire.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { Field, FieldError, Textarea } from \"@/components/ui/field\"\nimport { Fraction } from \"@/components/ui/fraction\"\nimport { RadioGroup, RadioGroupItem } from \"@/components/ui/radio-group\"\n\ntype Answers = Record<string, string | undefined>\n\ntype Question = {\n  id: string\n  question: React.ReactNode\n  /** The choices. Leave it out for a free-text question, which is always optional. */\n  options?: string[]\n  /** Free-text questions only: the label over the line. */\n  label?: string\n  /** Free-text questions only. */\n  placeholder?: string\n}\n\ntype QuestionnaireProps = Omit<React.ComponentProps<\"form\">, \"onSubmit\" | \"children\"> & {\n  /**\n   * sentence (the default): one question at a time; at the end, your answers written into one sentence.\n   * interview: every question asked stays above the next, small, with your answer beside it, like the\n   * printed interview's Q and A; press an answer to go back to it. definition: at the end you are\n   * defined, as the dictionary sets an entry: the headword reversed out of ink, then the sentence.\n   */\n  variant?: \"sentence\" | \"interview\" | \"definition\"\n  questions: Question[]\n  /** Writes the answers into one sentence. Only what was answered is in `answers`. */\n  sentence: (answers: Answers) => string\n  onComplete?: (answers: Answers) => void\n  /** The last question's action. */\n  submitLabel?: string\n  /** Said when a choice is needed to go on. */\n  error?: string\n  /** Definition only: the headword and its part of speech, from the answers. */\n  entry?: (answers: Answers) => { word: string; kind: string }\n  /** Answers to start from, to resume a questionnaire. */\n  defaultAnswers?: Answers\n  /** The question to start at, to resume one (its length is the end). */\n  defaultStep?: number\n}\n\n/**\n * Questions one at a time, set large. In an interview the asked ones stay above as Q and A; in a\n * definition the end is a dictionary entry. The count rolls, the hairline fills, and each\n * question turns in from the side you're heading. At the end your answers are written\n * into one sentence in italic, word by word.\n */\nfunction Questionnaire({\n  variant = \"sentence\",\n  questions,\n  sentence,\n  onComplete,\n  submitLabel = \"Send answers\",\n  error = \"Choose one to go on, or skip this question.\",\n  entry,\n  defaultAnswers,\n  defaultStep = 0,\n  className,\n  ...props\n}: QuestionnaireProps) {\n  const base = React.useId()\n  const total = questions.length\n  const [at, setAt] = React.useState(() => Math.min(Math.max(0, defaultStep), total))\n  const [dir, setDir] = React.useState<1 | -1>(1)\n  const [turned, setTurned] = React.useState(false)\n  const [answers, setAnswers] = React.useState<Answers>(() => defaultAnswers ?? {})\n  const [missing, setMissing] = React.useState(false)\n  const step = React.useRef<HTMLDivElement>(null)\n  const again = React.useRef<HTMLButtonElement>(null)\n  const moved = React.useRef(false)\n\n  const done = at >= total\n  const q = questions[at]\n  const clean = (): Answers => Object.fromEntries(Object.entries(answers).filter(([, v]) => v?.trim()))\n  const said = done && variant === \"definition\" && entry ? entry(clean()) : null\n\n  // The count rolls the way you're heading (the fraction turns its own figures over).\n  const n = Math.min(at + 1, total)\n\n  // Focus goes to each new question (not on first load).\n  React.useEffect(() => {\n    if (!moved.current) return\n    if (done) again.current?.focus({ preventScroll: true })\n    else (step.current?.querySelector<HTMLElement>(\"input:checked\") ?? step.current?.querySelector<HTMLElement>(\"input, textarea\"))?.focus({ preventScroll: true })\n  }, [at, done])\n\n  function go(to: number, heading: 1 | -1) {\n    moved.current = true\n    setDir(heading)\n    setTurned(true)\n    setMissing(false)\n    setAt(to)\n  }\n\n  function set(id: string, value: string) {\n    setMissing(false)\n    setAnswers((a) => ({ ...a, [id]: value }))\n  }\n\n  return (\n    <form\n      data-slot=\"questionnaire\"\n      noValidate\n      data-variant={variant}\n      className={cn(\"ot-quest\", className)}\n      onSubmit={(e) => {\n        e.preventDefault()\n        if (done) return\n        if (q.options && !answers[q.id]) return setMissing(true)\n        if (at === total - 1) onComplete?.(clean())\n        go(at + 1, 1)\n      }}\n      {...props}\n    >\n      <div data-slot=\"questionnaire-head\" className=\"ot-quest-head\">\n        <Fraction aria-hidden=\"true\" count={n} total={total} />\n        <progress max={total} value={n} aria-label={done ? `All ${total} answered` : `Question ${n} of ${total}`} />\n      </div>\n\n      {variant === \"interview\" && at > 0 ? (\n        <ol data-slot=\"questionnaire-log\" className=\"ot-quest-log\">\n          {questions.slice(0, at).map((p, i) => {\n            const a = answers[p.id]?.trim()\n            return (\n              <li key={p.id}>\n                <p id={`${base}-log-${p.id}`} className=\"ot-quest-log-q\">\n                  <span className=\"ot-quest-hang\" aria-hidden=\"true\">Q</span>\n                  {p.question}\n                </p>\n                <p className=\"ot-quest-log-a\">\n                  <span className=\"ot-quest-hang\" aria-hidden=\"true\">A</span>\n                  <button type=\"button\" aria-describedby={`${base}-log-${p.id}`} onClick={() => go(i, -1)}>\n                    {a ? <span className=\"ot-yours\">{a}</span> : \"Skipped\"}\n                    <span className=\"ot-sr\">, change</span>\n                  </button>\n                </p>\n              </li>\n            )\n          })}\n        </ol>\n      ) : null}\n\n      {q ? (\n        <div\n          key={at}\n          ref={step}\n          data-slot=\"questionnaire-step\"\n          data-arriving={turned ? \"\" : undefined}\n          className=\"ot-quest-step\"\n          style={{ \"--from\": dir * 3 } as React.CSSProperties}\n        >\n          <p id={`${base}-${q.id}`} data-slot=\"questionnaire-question\" className=\"ot-quest-q\">\n            {q.question}\n          </p>\n          {q.options ? (\n            <RadioGroup aria-labelledby={`${base}-${q.id}`} value={answers[q.id] ?? \"\"} onValueChange={(v) => set(q.id, v)}>\n              {q.options.map((o) => (\n                <RadioGroupItem key={o} value={o}>\n                  {o}\n                </RadioGroupItem>\n              ))}\n            </RadioGroup>\n          ) : (\n            <Field label={q.label ?? \"Optional\"}>\n              <Textarea\n                rows={2}\n                maxLength={200}\n                placeholder={q.placeholder}\n                aria-describedby={`${base}-${q.id}`}\n                value={answers[q.id] ?? \"\"}\n                onChange={(e) => set(q.id, e.target.value)}\n              />\n            </Field>\n          )}\n        </div>\n      ) : null}\n\n      {/* Always mounted, so the sentence is announced when it arrives. */}\n      <div data-slot=\"questionnaire-end\" data-done={done ? \"\" : undefined} className=\"ot-quest-step\">\n        <div data-slot=\"questionnaire-said\" className=\"ot-quest-said\" aria-live=\"polite\">\n          {said ? (\n            <p data-slot=\"questionnaire-entry\" className=\"ot-quest-entry\">\n              <span className=\"ot-quest-word ot-yours\">{said.word}</span>\n              <span className=\"ot-quest-kind\">{said.kind}</span>\n            </p>\n          ) : null}\n          {done ? (\n            <p data-slot=\"questionnaire-sentence\" className=\"ot-quest-sentence\">\n              {sentence(clean())\n                .split(\" \")\n                .map((w, i) => (\n                  <React.Fragment key={i}>\n                    {i ? \" \" : null}\n                    <span className=\"ot-yours\" style={{ \"--i\": i } as React.CSSProperties}>\n                      {w}\n                    </span>\n                  </React.Fragment>\n                ))}\n            </p>\n          ) : null}\n        </div>\n        {done ? (\n          <Button\n            ref={again}\n            variant=\"quiet\"\n            onClick={() => {\n              setAnswers({})\n              go(0, -1)\n            }}\n          >\n            Start again\n          </Button>\n        ) : null}\n      </div>\n\n      {missing ? <FieldError role=\"alert\">{error}</FieldError> : null}\n\n      {done ? null : (\n        <div data-slot=\"questionnaire-actions\" className=\"ot-quest-actions\">\n          {at > 0 ? (\n            <Button variant=\"quiet\" onClick={() => go(at - 1, -1)}>\n              Back\n            </Button>\n          ) : null}\n          {at < total - 1 ? (\n            <Button\n              variant=\"quiet\"\n              onClick={() => {\n                setAnswers((a) => ({ ...a, [q.id]: undefined }))\n                go(at + 1, 1)\n              }}\n            >\n              Skip\n            </Button>\n          ) : null}\n          <Button variant=\"statement\" type=\"submit\">\n            {at === total - 1 ? submitLabel : \"Next\"}\n          </Button>\n        </div>\n      )}\n    </form>\n  )\n}\n\nexport { Questionnaire, type QuestionnaireProps, type Question, type Answers }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/0nlytype/styles/questionnaire.css",
      "content": "@layer components {\n  /* ── ot-quest ── questions one at a time; your answers are written into one sentence */\n  .ot-quest { display: grid; gap: var(--ot-space-6); }\n  .ot-quest-head { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; column-gap: var(--ot-space-5); }\n  .ot-quest-head .ot-fraction { font-size: var(--ot-mf); }\n  .ot-quest-head .ot-fraction > .ot-yours { display: inline-block; }\n  .ot-quest-head progress { -webkit-appearance: none; appearance: none; width: 100%; height: var(--ot-stroke); border: 0; background: var(--ot-rule); color: var(--ot-ink); }\n  .ot-quest-head progress::-webkit-progress-bar { background: transparent; } /* the element draws the track, or a translucent rule would be laid on itself */\n  .ot-quest-head progress::-webkit-progress-value { background: var(--ot-ink); transition: inline-size var(--ot-andante) var(--ot-breath); }\n  .ot-quest-head progress::-moz-progress-bar { background: var(--ot-ink); }\n  .ot-quest-step { display: grid; gap: var(--ot-space-5); min-width: 0; }\n  .ot-quest-step[data-arriving] { animation: ot-quest-turn var(--ot-andante) var(--ot-exhale) backwards; }\n  /* The sentence's home stays in the tree so its live region is heard; until the end it takes no room. */\n  .ot-quest-step[data-slot=\"questionnaire-end\"]:not([data-done]) { position: absolute; }\n  .ot-quest-step > .ot-btn { justify-self: start; }\n  .ot-quest-q { font-size: var(--ot-f); line-height: var(--ot-f-lh); letter-spacing: var(--ot-f-tr); font-weight: 300; color: var(--ot-ink); text-wrap: balance; }\n  .ot-quest-sentence { font-size: var(--ot-mf); line-height: 1.35; letter-spacing: var(--ot-mf-tr); font-weight: 300; color: var(--ot-ink); text-wrap: pretty; }\n  .ot-quest-sentence > .ot-yours { display: inline-block; animation: ot-arrive var(--ot-andante) var(--ot-exhale) backwards; animation-delay: calc(5 * var(--ot-arpeggio) + var(--i, 0) * var(--ot-arpeggio)); }\n  .ot-quest-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--ot-space-5) var(--ot-space-6); }\n  .ot-quest-actions > [data-variant=\"statement\"] { margin-inline-start: auto; }\n  /* interview: the printed interview's Q and A. What's been asked stays, small, above the next question;\n     the letters hang in the margin, and step in where there's no margin to hang them in. */\n  .ot-quest-log { display: grid; gap: var(--ot-space-4); margin: 0; padding: 0; list-style: none; }\n  .ot-quest-log > li { display: grid; gap: var(--ot-space-1); animation: ot-arrive var(--ot-andante) var(--ot-exhale) backwards; }\n  .ot-quest-log p { display: grid; grid-template-columns: var(--ot-space-5) minmax(0, 1fr); align-items: baseline; margin: 0; }\n  .ot-quest-hang { font-size: var(--ot-pp); line-height: 1; font-weight: 600; letter-spacing: 0.04em; color: var(--ot-pencil); }\n  .ot-quest-log-q { font-size: var(--ot-p); line-height: var(--ot-p-lh); color: var(--ot-graphite); }\n  .ot-quest-log-a { font-size: var(--ot-mp); line-height: var(--ot-mp-lh); }\n  .ot-quest-log-a > button { justify-self: start; padding: 0; border: 0; background: none; font: inherit; color: var(--ot-pencil); text-align: start; cursor: pointer; text-decoration: underline var(--ot-hairline) transparent; text-underline-offset: 0.25em; transition: text-decoration-color var(--ot-allegro) var(--ot-breath); }\n  .ot-quest-log-a > button > .ot-yours { color: var(--ot-ink); }\n  .ot-quest-log-a > button:hover, .ot-quest[data-force~=\"hover\"] .ot-quest-log > li:last-child .ot-quest-log-a > button { text-decoration-color: var(--ot-rule-strong); }\n  .ot-quest-log-a > button:active { text-decoration-color: var(--ot-ink); }\n  @media (min-width: 860px) { .ot-quest-log p { margin-inline-start: calc(-1 * var(--ot-space-5)); } }\n\n  /* definition: after \"the uncreative\", the end set as a dictionary entry. The headword is yours, in italic,\n     reversed out of an ink block that draws across behind it; the part of speech under it, then the sense. */\n  .ot-quest-said { display: grid; gap: var(--ot-space-4); }\n  .ot-quest-entry { display: grid; justify-items: start; gap: var(--ot-space-2); margin: 0; }\n  .ot-quest-word { position: relative; isolation: isolate; padding: 0.02em 0.14em 0.1em 0.06em; font-size: var(--ot-ff); line-height: 1; letter-spacing: var(--ot-ff-tr); color: var(--ot-paper); overflow-wrap: anywhere; }\n  .ot-quest-word::before { content: \"\"; position: absolute; inset: 0; z-index: -1; background: var(--ot-ink); transform-origin: 0 50%; animation: ot-draw-x var(--ot-andante) var(--ot-exhale) backwards; }\n  :is([dir=\"rtl\"], [dir=\"rtl\"] *) .ot-quest-word::before { transform-origin: 100% 50%; }\n  .ot-quest-kind { font-size: var(--ot-pp); line-height: var(--ot-pp-lh); font-weight: 600; letter-spacing: 0.04em; color: var(--ot-graphite); }\n  .ot-quest[data-variant=\"definition\"] .ot-quest-sentence > .ot-yours { animation-delay: calc(var(--ot-andante) / 2 + var(--i, 0) * var(--ot-arpeggio)); }\n  .ot-quest[data-variant=\"definition\"] .ot-quest-sentence { max-width: 32ch; font-size: var(--ot-mp); line-height: var(--ot-mp-lh); letter-spacing: var(--ot-mp-tr); }\n  @media (max-width: 520px) { .ot-quest-word { font-size: var(--ot-f); } }\n  @media (forced-colors: active) { .ot-quest-word { forced-color-adjust: none; color: Canvas; } .ot-quest-word::before { background: CanvasText; } }\n\n  @keyframes ot-quest-turn { from { opacity: 0; translate: calc(var(--from, 1) * 8px) 0; } }\n}\n",
      "type": "registry:file",
      "target": "styles/0nlytype/questionnaire.css"
    }
  ],
  "css": {
    "@import \"../styles/0nlytype/questionnaire.css\"": {}
  },
  "meta": {
    "movement": "XI",
    "underneath": "native",
    "contract": "ot-quest"
  },
  "type": "registry:ui"
}
