Skip to content
Glint UI

Questionnaire

The multi-step form — one question at a time, with the answer living in the question it answers.

Usage

import { Questionnaire, QuestionnaireQuestion } from "@glint/components/questionnaire.slint";
import { Tokens } from "@glint/theme/tokens.slint";
export component AppWindow inherits Window {
width: 560px;
height: 320px;
background: Tokens.color-background;
in-out property <[QuestionnaireQuestion]> questions: [
{
name: "plan",
prompt: "Which plan suits you?",
required: true,
choices: [
{ label: "Free", value: "free" },
{ label: "Team", value: "team" },
],
},
{
name: "seats",
prompt: "How many seats?",
allow-freeform: true,
freeform-placeholder: "e.g. 12",
},
];
VerticalLayout {
padding: 24px;
alignment: start;
Questionnaire {
questions <=> root.questions;
submitted => { debug("submit", root.questions[0].choices[0].on); }
}
}
}

The component owns the sequence, the gating and where the keyboard goes. You own the questions, the validation policy and the submission.

The answer lives in the question it answers (ADR-0037). Slint arrays do not grow and one cannot be built from another, so a separate list of answers could never be assembled inside the component: a multi-select answer is a set, and a set has to live somewhere the component can write one field at a time. So questions is two-way, a choice carries on, and a question carries text and skipped.

That makes two things fall out for free. Resuming a half-finished questionnaire is seeding the model with the answers already given; reading the answers is reading the model back — there is nothing else to collect.

QuestionnaireQuestionWhat it is
nameYour own key for the question. Never shown; it is what answer-changed reports
prompt, descriptionThe question, and the line under it
choicesThe options. Empty is a free-text question, which needs allow-freeform
multipleAny number of options may be on, rather than exactly one
requiredThe next step is refused until this one is answered
skippableOffers the Skip action
allow-freeform, freeform-placeholderA text entry under the options — the “Other” case, or the whole answer
shortcutsNumbers the first nine options and draws each key as a Kbd cap
text, skippedThe answer: what was typed, and whether the question was passed over
QuestionnaireChoiceWhat it is
label, valueWhat the option is called, and the value you know it by
descriptionA quieter second line under the label
disabledAn option this respondent cannot pick. It keeps its place and the arrows step over it
onThe answer: whether this option is part of it

Nothing here is a control Glint had to invent. Field associates the prompt, the description and the error; Radio and Checkbox are the choices, Input the free text, Progress the bar and Button the four actions. What this component adds is the state machine that sequences them.

Examples

The three shapes of a question

A question with choices and no multiple is an exclusive set: one option on, the arrows moving between them, the whole set taking a single tab stop — the radio pattern. With multiple, each box is its own tab stop, the way a column of checkboxes works. With no choices at all it is free text, and allow-freeform is what says so.

shortcuts numbers the first nine options and draws each key as a Kbd cap, so an answer is one keystroke.

A disabled option keeps its place rather than being dropped from choices — dropping it renumbers everything beside it. It dims, refuses every activation path and the arrows step over it.

Required, skippable, and the answer beside the options

required holds the forward action shut until the question has an answer; can-advance is the component’s published answer to “may this one be stepped past”. skippable offers the Skip action, which records that the question was passed over rather than left blank — and answering it later clears that again.

allow-freeform on a question that has choices adds a text entry under them — the “Other” case, spelled as a placeholder rather than as a labelled option. Text counts as an answer, so a required question is satisfied by either.

Validation is yours

error is the message for the question on screen; empty is valid. The component shows it — through the same Field that already associates the prompt and the description — and hands the question back the keyboard, so a respondent whose answer was refused lands on the control rather than hunting for it. Deciding what is invalid stays with you.

active-index is two-way for the same reason: a server that refuses the fourth answer is a host jumping back to the fourth question.

Reading the answers back

There is no payload to collect: the answers are fields of questions. A host reads choices[i].on for what was picked, text for what was typed, and skipped for what was passed over.

Laying it out yourself

The three parts are exported, so a host that wants the progress bar somewhere else — or a different frame around the question — keeps the same controls and the same rules about when each one is there. Questionnaire’s own root is one arrangement of them, not the only one.

QuestionnaireItem publishes answered and chosen-count, which is what a hand-rolled action bar gates on; the sequencing is then yours to write, the way the advanced, retreated and skipped callbacks below do.

Translating the actions

The four action labels are properties on Questionnaire, defaulting to the values QuestionnaireStrings holds. That global exists because the component draws its own action bar and so declares the same four properties QuestionnaireActions does — spelling the defaults twice is how the two drift apart.

progress-label is what assistive technology calls the bar; it defaults to @tr("Question {} of {}", …).

API Reference

Properties

PropertyTypeDefaultDescription
questionsin-out [QuestionnaireQuestion]no defaultThe questions, and the answers to them. Two-way: the answer is a field of the question it answers (ADR-0037), so this is what a host seeds to resume a half-finished set and reads back to submit one.
active-indexin-out int0Which question is on screen. Two-way, which is what lets a host jump back to the one a server refused.
errorin stringno defaultThe validation message for the active question; empty is valid. Setting it shows the message and hands the question back the keyboard.
previous-labelin stringQuestionnaireStrings.previous
skip-labelin stringQuestionnaireStrings.skip
next-labelin stringQuestionnaireStrings.next
submit-labelin stringQuestionnaireStrings.submit
progress-labelin string@tr("Question {} of {}", root.active-index + 1, root.questions.length)What assistive technology calls the progress bar.
can-advanceout boolno defaultWhether the forward action is live: a required question refuses to be stepped past until it has an answer.
is-lastout boolno defaultWhether the active question is the last one, which is what turns Next into Submit.

Callbacks

CallbackDescription
advanced()Fired after the active question moves forward, back, or is passed over.
retreated()
skipped()
submitted()Fired on the last question's forward action. Submitting is the host's: the answers are in questions.
answer-changed(string)Fired with the name of the question whose answer changed.

Functions

FunctionDescription
advance()Forward: the next question, or the submission when there is none.
retreat()Back one question. The answers already given stand.
skip()Pass over the active question, recording that it was passed over rather than left blank.

QuestionnaireItem

One question drawn: the prompt, its description, the options, the free-text entry and the validation message, inside the Field that associates them.

Properties

PropertyTypeDefaultDescription
questionsin-out [QuestionnaireQuestion]no defaultThe model and the position in it. Two-way, because the answer is written back into the question it answers (ADR-0037).
indexin int0
errorin stringno defaultThe validation message for this question; empty is valid. The host owns the policy — this only shows what it decided.
focus-passin intno defaultBumped by whoever navigates, to hand this question the keyboard.
chosen-countout intno defaultHow many of the options are on. The rows report themselves as they are built and the tally resets with the question, because there is no loop to count a model with.
answeredout boolno defaultWhether the question has an answer of any kind.

Callbacks

CallbackDescription
answer-changed(string)Fired when the respondent changes the answer, with the question's name.

QuestionnaireActions

The navigation strip: back on the left, skip and forward on the right.

Properties

PropertyTypeDefaultDescription
can-retreatin boolno defaultWhether there is a question behind this one.
can-skipin boolno defaultWhether this question may be passed over.
can-advancein booltrueWhether the forward action is live — off while a required question is unanswered.
is-lastin boolno defaultWhether forward means submitting rather than stepping.
previous-labelin stringQuestionnaireStrings.previous
skip-labelin stringQuestionnaireStrings.skip
next-labelin stringQuestionnaireStrings.next
submit-labelin stringQuestionnaireStrings.submit

Callbacks

CallbackDescription
retreated()
skipped()
advanced()

QuestionnaireProgress

The bar over the question. It is Progress with the name that says what the number counts, so it carries that component’s members too.

Properties

PropertyTypeDefaultDescription
valuein float0Current value, in whatever units minimummaximum names. Ignored while indeterminate.
minimumin float0The range value lives in. Raw domain numbers bind straight in — three steps of seven is minimum: 0; maximum: 7; value: 3, published as such, which is what assistive technology reads out as "3 of 7". The 0–100 default keeps a percentage a percentage.
maximumin float100
indeterminatein boolfalseThe work has started but its size is unknown: the fill gives way to a sweeping band and the control reports no value.
periodin duration1.4sTime for one sweep of the band across the track.
phaseout floatno defaultWhere the band sits: 0 with its trailing edge at the left of the track, 1 with its leading edge past the right. Public for the same reasons Spinner's rotation is — a host can drive matching motion from the same phase, and a test can watch the band actually move.
percentout floatno defaultHow far along that is as a fraction of the track, 0–100. Public because the track's own units are the consumer's: this is the number ProgressValue rounds, and the one a host drives a matching bar from.
positionin int1Which question is on screen, counting from one.
totalin int1How many there are.
labelin string@tr("Question {} of {}", root.position, root.total)What assistive technology calls the bar.

QuestionnaireStrings

Every action label the questionnaire ships as a default, in one place — the global both the component and QuestionnaireActions take theirs from.

Properties

PropertyTypeDefaultDescription
previousout stringno default
skipout stringno default
nextout stringno default
submitout stringno default

QuestionnaireQuestion and QuestionnaireChoice are data types rather than components; their fields are in Usage above.

Accessibility

  • The question is named by its Field, and by nothing inside it. Field is the group the prompt names, the description describes, and the error joins as one of the messages it already announces as a live region — so a question’s validation message is associated with it the way every Glint form row’s is. The set of options below carries its role, its option count and the axis its arrows walk, and no name: repeating the prompt there is a reader hearing the question again on the way into the answers (ADR-0044).
  • The exclusive set is a radio group, carrying its option count and its vertical orientation, with one tab stop for the whole set: ↑ / ← and ↓ / → move between the options and step over the ones that refuse, and picking one clears the previous.
  • The multiple set is a plain group, not a radio group: its rows are checkboxes, and a radio-group node over them would announce a contract the set does not keep. Each box is its own tab stop.
  • The keyboard follows the question. Moving to a question — or an error arriving on one — hands the keyboard to its first control. For a set of options that grab is keyboard-driven, so the focus ring comes with it: a respondent who pressed Next has to see where they landed. A free-text entry takes the keyboard without the ring, since a text cursor says where it is by itself.
  • A number key is a shortcut, not the only route. shortcuts draws each cap with accessible-role: none, because the option beside it already announces itself; the cap is the picture of the key that reaches it.
  • The progress bar says what it counts. QuestionnaireProgress publishes the position, the total and a name — Question 2 of 5 by default — rather than a bare percentage.
  • The free-text entry is named by the prompt, where the sets above it are not. It is a leaf rather than a container, and a leaf control nobody named is a control nobody can ask for — so a free-text question is the one shape that says its prompt on the control as well as on the group (ADR-0044).
  • A refused option keeps its place. It dims, reports itself unavailable and refuses every activation path (CONTEXT.md “Refusal”). In the exclusive set the arrows step over it as well; in the multiple set there are no arrows to step with, since each box is its own tab stop.