Questionnaire
A multi-step form that asks its questions one at a time, with progress, validation and navigation.
Usage
import {
Question,
QuestionChoice,
QuestionChoices,
QuestionPrompt,
Questionnaire,
QuestionnaireActions,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSubmit,
} from "@kanzo-tech/ui";<Questionnaire onSubmit={(answers) => file(answers)}>
<QuestionnaireProgress />
<Question name="outcome" required>
<QuestionPrompt>How did it end?</QuestionPrompt>
<QuestionChoices>
<QuestionChoice description="Delivered, verified, and paid." value="settled">
Settled
</QuestionChoice>
<QuestionChoice description="The party did not come back." value="failed">
Failed
</QuestionChoice>
</QuestionChoices>
</Question>
<QuestionnaireActions>
<QuestionnairePrevious />
<QuestionnaireNext />
<QuestionnaireSubmit />
</QuestionnaireActions>
</Questionnaire>Anatomy
Questionnaire
├── QuestionnaireProgress
├── Question …
└── QuestionnaireActions
├── QuestionnairePrevious
├── QuestionnaireSkip
├── QuestionnaireNext
└── QuestionnaireSubmitEach Question is the question part — the same part a guided chat asks one
of per turn. This page is the sequence around it.
What the root owns, and what it does not
The root owns the order of the questions, which one is active, the answers, validation, progress and navigation. That is the whole of it.
Everything around those is the parent's: whether the form is on screen at all, what happens to
the payload, whether an answer routes somewhere else. A questionnaire that decided when it
appeared could not be put in a dialog, a sidebar and a page, and every product wants it in a
different one — so onSubmit hands you the answers and stops there.
Resuming a half-finished session is that division in one line: the parent stored the answers and
the position, and hands both back as defaultAnswers and defaultStep.
<Questionnaire defaultAnswers={saved.answers} defaultStep={saved.step} onAnswersChange={save}>There is no navigation state machine here. Ark's Steps machine already owns the step index,
hasNextStep / hasPrevStep, the percentage and — the part that is easy to miss — the guards
that stop Next. A required question blocking and a skippable one not blocking are two callbacks
into that machine, not two branches of ours.
What a question adds in a sequence
A question's own props are the whole vocabulary, and the root reads them off each Question in
source order, so it never needs a second list beside the ones you wrote:
requiredrefusesNextuntil the question is answered — a non-blank string, or at least one selection. It does not disable the button: a disabled control says nothing about what is missing. PressingNextreveals the question'sQuestionErrorinstead, and answering clears it.skippabledrawsQuestionnaireSkipon that question alone, and letsNextthrough with an empty answer.nameis the key the answer is filed under in the payload.
A QuestionError given children says them instead of the default sentence:
<QuestionError>Name at least one — “nothing” is not actionable.</QuestionError>Single and multiple choice, a typed line or paragraph, and what a choice looks like are the question part's.
Shortcuts
shortcuts puts 1–9 on the visible question's choices, in order, and prints the key on a
Kbd chip on each card so it is visible rather than folklore.
It is opt-in and has no default, because it registers a window listener on bare, unmodified
keys and a design system does not get to claim those in your keymap without being asked. Typing
in a text control is ignored; a focused radio or checkbox is not — both are <input> elements,
and treating them the same would switch the shortcuts off the moment somebody clicked an option.
Progress
QuestionnaireProgress is a reading, not a bar: Question 2 of 4, small and muted, above the
question. It is still a role="progressbar" — counting questions rather than percent, with
aria-valuetext carrying the same sentence and aria-live="polite" announcing the move — so the
position is machine-readable without a trough on screen. Ark's Progress is not underneath it,
because zag puts the role on the track: a progress with no painted track has no role at all.
Its sentence is translations.progress, a function of the position and the count, so the eye and
aria-valuetext read the same words in every language.
Translations
Every word the questionnaire writes is an entry of the root's translations, its questions' included:
progress(position, count), navigation (the actions row's name), previous, skip, next,
submit, and the questions' unanswered. A question's own translations win for that question, and an
action given children says those instead.
<Questionnaire
translations={{
progress: (position, count) => `Pregunta ${position} de ${count}`,
navigation: "Navegación del cuestionario",
previous: "Atrás",
skip: "Omitir",
next: "Siguiente",
submit: "Enviar",
unanswered: "Responde para seguir.",
}}
>Actions
QuestionnaireActions is a three-column row — Back at the start, Skip and Next at the end — and it
needs an accessible name, for which it supplies a default. It is deliberately not a
ButtonGroup: that collapses the inner radii and overlaps its children by a pixel, which is right
for a cluster acting on one thing and wrong for the two ends of a journey. The columns hold their
places while Back and Skip come and go, so nothing slides sideways between questions.
QuestionnairePrevious draws itself only where there is somewhere to go back to, so the first
question's row is a single solid Next and nothing else. QuestionnaireNext and
QuestionnaireSubmit are a pair rather than a choice: Next stands down on the last question and
Submit takes its place in the same column, so writing both is what makes the row complete.
API Reference
Questionnaire
Extends ark.form (minus onSubmit, which is redefined).
| Prop | Type | Default |
|---|---|---|
defaultAnswers | QuestionnaireAnswers | {} |
defaultStep | number | 0 |
onAnswersChange | (answers) => void | — |
onStepChange | (details) => void | — |
onSubmit | (answers) => void | — |
shortcuts | boolean | false |
translations | Partial<QuestionnaireTranslations> | English |
The payload is QuestionnaireAnswers, a record of QuestionAnswers keyed by each question's
name.
The rest
QuestionnaireProgress is a <div> carrying the progressbar role, and the four action parts extend
Button — so variant, size and children are yours on every one of them.
useQuestionnaire reads the root's context, for a part of your own that has to know where it is: the
answers, the ordered questions (each a QuestionnaireQuestion — name, multiple, required,
skippable), Ark's steps API and the resolved translations. Inside a question, useQuestion is
the question part's.
QuestionnaireProps and QuestionnaireActionsProps are exported, so a wrapper can take the same
props without restating them.