Questionnaire
One question at a time, in a real form.
src/components/rahti_ui/questionnaire.rs
Basic
A `<form>` holding a `<fieldset>` per question, of which exactly one is not `hidden`. The progress readout is a `role="progressbar"` with an `aria-live` region, so moving between questions is announced rather than silent.
Answer and press Next. Enter does the same thing, and Previous goes back without losing what was chosen — the answers live in real radios and checkboxes, so nothing has to be remembered on their behalf.
Shortcuts
shadcn's `shortcuts` recipe. `letters` runs A–Z and `numbers` runs 1–9; past that a single key runs out and the chips simply stop, which is the honest thing for a list of thirty answers.
letters
numbers
Press A, B or C — or 1, 2, 3 on the right — to pick without reaching for the mouse. The chip is `aria-hidden` and the key is on the control as `aria-keyshortcuts`, so a screen reader is told the shortcut once rather than reading a stray letter after every answer. The listener is bound to the form, so two questionnaires on a page do not answer each other.
Explicit skip
shadcn's "explicit skip" recipe, opt-in here. A skipped question is remembered as skipped — `data-status="skipped"` rather than `unanswered` — so a page's own CSS can tell "chose not to answer" from "has not got there yet".
Skip shows only where it is allowed: the first question is `required`, so there is no Skip on it, and there is none on the last question either because there is nowhere to skip to. Both are `hidden` and `inert` rather than disabled — a button nobody can use should not be a tab stop.
Freeform
shadcn's freeform recipe. The control is a real `<input name=…>`, so it posts with the rest and needs nothing said about it.
A freeform question is answered when its field has something in it that is not whitespace — which the runtime asks the input directly rather than keeping a copy of. Enter still advances, and would submit the form if the handler did not take it first; that is what the form's `novalidate` and the explicit Submit are for.
Required
The only validation this port has, and the only one it can have: a required question cannot be advanced past unanswered. shadcn's custom-validation recipe takes a function from the page, and a function is the one thing that cannot cross a component boundary here — the last section says what that rules out.
Press Next without choosing. The message is a `role="alert"`, so it is read out when it appears rather than sitting silently under a question somebody cannot see. Choosing an answer clears it.
One question
`show_progress(false)` takes the readout away, and the labels are all replaceable. The actions arrange themselves: the grid is three columns and each button knows which one it lands in.
One question, no progress, and the Submit is the only action — Previous is `hidden` on the first question and Next is `hidden` on the last, so a single-question questionnaire needs nothing turned off by hand.
As a form posts it
There is no hidden-field mirror here, unlike the Combobox and the Calendar. The controls a reader operates *are* the controls that post, which is the whole advantage of rendering the questions on the server.
Answer the three and submit. Every answer is a real control with a real `name`, so a radio posts one value, a checkbox group posts one value per box, and the freeform field posts what was typed — `?build=api&include=summary&include=diff¬es=…` out of a plain GET.
Where the classes came from
Not the registry, for once. `questionnaire` has no `new-york-v4` entry — it exists only in the `cn-*` marker style, where twenty-two markers carry the design and no published stylesheet defines them. What shadcn's documentation *renders*, though, is the expanded form, and that is what was copied slot by slot. So the design here is shadcn's own rather than a guess at it — which for a component with no utility source is worth being explicit about.
// the registry source — markers, and no stylesheet defines them "cn-questionnaire-choice group/questionnaire-choice relative flex min-h-11 cursor-pointer items-start text-start …" // what shadcn's own documentation renders — the markers resolved "group/questionnaire-choice relative flex min-h-11 cursor-pointer items-start gap-2.5 rounded-lg border border-input bg-transparent px-3 py-2.5 text-start text-sm transition-colors …" // …which is what this file copies.
The port
shadcn's component is a skin over `@shadcn/react/questionnaire`, which does the navigation, the validation, the progress and the shortcuts. There is no such package here, so this file is the behaviour too — the same arrangement the Calendar makes with `react-day-picker`. Of the fourteen recipes on shadcn's page, the ones that are prop combinations are here; **custom validation**, **conditional items**, **resume** and **controlled navigation** are not, and could not be — every one of them takes a function from the page, and a function is the one thing that cannot cross a `#[component]` boundary. The Date Picker gets around that wall with an event, but an event carries a message out and cannot bring an answer back.
// shadcn — fifteen parts over a primitive package
<Questionnaire items={items} onSubmit={handleSubmit}>
<QuestionnaireProgress />
{items.map((q) => (
<QuestionnaireItem name={q.name}>
<QuestionnaireTitle>{q.prompt}</QuestionnaireTitle>
<QuestionnaireChoices>…</QuestionnaireChoices>
</QuestionnaireItem>))}
<QuestionnaireActions>…</QuestionnaireActions>
</Questionnaire>
// rahti-ui — one component, and the questions as values
questionnaire()
.questions([
Question::choice("build", "What next?")
.required(true)
.choices([("api", "The API"), ("ui", "The UI")]),
Question::input("notes", "Anything else?"),
])
.shortcuts(QuestionnaireShortcuts::Letters)
.render()What it needs the runtime for
The questions are rendered by the server and the runtime holds one number — the opposite of what the Combobox and the Calendar do, and it is because a question does not change: only *which* one is showing does. `hidden` is served rather than bound, so the first paint is already correct and there is no flash of the whole form collapsing. But the paging is still the runtime's: with scripting off a reader sees the first question and cannot reach the second. A `<noscript>` stylesheet was tried and does not work here — hydration re-parses `<noscript>` into a *live* stylesheet so it would apply with scripting on too, and Tailwind's preflight hides `[hidden]` with an `!important` inside `@layer base`, which beats an unlayered `!important` because for important declarations the layer order runs backwards.
served, before any script runs
every <fieldset> and every control, real and named
the first question open, the rest hidden=""
the chosen answer styled by has-[>input:checked]:
the runtime, after
which question is open, the progress, the error,
which of the four buttons is showing