Questionnaire
A question flow that walks one step at a time and still posts as a single form.
The demo above uses method="get", so submitting it puts the
answers in the address bar where you can read them. Yours will post.
Installation
uvx django_shadcn@latest add questionnaire
Usage
<c-questionnaire action="{% url 'onboarding' %}" method="post">
{% csrf_token %}
<c-questionnaire.progress />
<c-questionnaire.item>
<c-questionnaire.title>What should we prototype next?</c-questionnaire.title>
<c-questionnaire.choices>
<c-questionnaire.choice name="direction" value="delegation" required>
Sub-agent delegation
</c-questionnaire.choice>
<c-questionnaire.choice name="direction" value="questions" required>
Question prompts
</c-questionnaire.choice>
</c-questionnaire.choices>
<c-questionnaire.error />
</c-questionnaire.item>
<c-questionnaire.actions>
<c-questionnaire.previous />
<c-questionnaire.skip />
<c-questionnaire.next />
<c-questionnaire.submit />
</c-questionnaire.actions>
</c-questionnaire>
One form, every question
The questions are not swapped in and out. All of them are rendered, inside one
<form>, and Alpine hides the ones that are not the current step.
That is the whole design, and two things follow from it:
- The final POST carries every answer, including the ones given five steps ago. There is nothing to stash, no hidden mirror of the state, no session to keep in step with the page.
- Radio and checkbox groups keep their native behaviour — arrow keys,
grouping by
name, andrequiredmeaning what it means everywhere else.
An inactive question is hidden and inert, so neither the tab order nor a
screen reader reaches it. A control inside it still posts: only disabled
would drop a value, and nothing here is disabled.
Questions
Each question is an item. Give the controls a name and the view reads them
back under that name — the component adds nothing of its own to the payload.
<c-questionnaire.item>
<c-questionnaire.title>Pick one</c-questionnaire.title>
<c-questionnaire.description>Optional helper text.</c-questionnaire.description>
<c-questionnaire.choices>
<c-questionnaire.choice name="single" value="a" required>The first</c-questionnaire.choice>
<c-questionnaire.choice name="single" value="b" required>The second</c-questionnaire.choice>
</c-questionnaire.choices>
<c-questionnaire.error />
</c-questionnaire.item>
title renders the <legend> of the item's <fieldset>, so it names the
group for a screen reader without any wiring.
Several answers
type="checkbox" turns the choices into a multi-select. The view reads them
with getlist.
<c-questionnaire.choice type="checkbox" name="signals" value="progress">
Progress
</c-questionnaire.choice>
A freeform answer
input sits inside choices, alongside the fixed ones, for the answer you did
not think of.
<c-questionnaire.choices>
<c-questionnaire.choice name="direction" value="delegation">Delegation</c-questionnaire.choice>
<c-questionnaire.input name="direction_other" aria-label="Another direction"
placeholder="Type another direction" />
</c-questionnaire.choices>
It has no visible label of its own, so give it an aria-label.
A longer answer
choices is not required. An item can hold anything.
<c-questionnaire.item>
<c-questionnaire.title>Anything else?</c-questionnaire.title>
<c-textarea name="notes" placeholder="Optional" />
</c-questionnaire.item>
Required, and skipping
Put required on the controls, not on the item. The browser is what decides
whether an answer counts, and next simply refuses to move while the current
question has a control the browser calls invalid — moving focus there and
showing its message.
skip appears only when the current question has no required control, so an
optional question can be passed over and a required one cannot.
For a radio group, required on the choices means one of them must be
chosen, which is what you want. A checkbox group is different: required
on a checkbox means that one box must be ticked, so there is no way to say
"at least one of these" in HTML. Leave those optional and check them in the
view.
Why the form is novalidate
Left alone, the browser refuses to submit an invalid form and tries to focus the offending control. Here that control is usually inside a question that is hidden, so the focus fails and the browser reports nothing at all — the form would look broken while doing exactly what it was told.
So the form carries novalidate and the checking is driven from the
component: on next for the current question, and on submit for every
question, jumping back to the first one that fails. The messages are still the
browser's own.
Reading the answers
An ordinary view, and nothing else:
def onboarding(request):
if request.method == 'POST':
answers = {
'direction': request.POST.get('direction'),
'direction_other': request.POST.get('direction_other', ''),
'signals': request.POST.getlist('signals'),
'timing': request.POST.get('timing'),
}
...
A Django Form works just as well: the payload is a flat set of names, so
declare the fields and let it validate. Server-side validation is not optional
just because the page did some too — novalidate means a client with no
JavaScript posts everything at once, unchecked.
Without JavaScript
Nothing is hidden until Alpine hides it, so a page with no JavaScript shows every question at once, in one long form, and the submit button still works. The step-by-step is an improvement on top of a form that already functions.