Questionnaire
The multi-step form — one question at a time, with the answer living in the question it answers.
import { Questionnaire, QuestionnaireQuestion } from "@glint/components/questionnaire.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 640px; height: 420px; background: Tokens.color-background;
property <string> state: "Answer the questions to move on.";
in-out property <[QuestionnaireQuestion]> questions: [ { name: "role", prompt: "What do you build?", description: "So we can start you in the right place.", required: true, shortcuts: true, choices: [ { label: "Desktop applications", value: "desktop" }, { label: "Embedded interfaces", value: "embedded" }, { label: "Both", value: "both" }, ], }, { name: "surfaces", prompt: "Which surfaces do you target?", multiple: true, skippable: true, choices: [ { label: "Linux", value: "linux" }, { label: "Windows", value: "windows" }, { label: "macOS", value: "macos" }, { label: "Bare metal", value: "mcu", description: "No operating system." }, ], }, { name: "anything-else", prompt: "Anything else we should know?", allow-freeform: true, freeform-placeholder: "Optional", skippable: true, }, ];
VerticalLayout { padding: 24px; spacing: 16px; alignment: start;
Questionnaire { questions <=> root.questions; answer-changed(name) => { root.state = "Answered: " + name; } skipped => { root.state = "Skipped a question."; } submitted => { root.state = "Submitted — the answers are in `questions`."; } }
Text { text: root.state; color: Tokens.color-muted-foreground; font-size: Tokens.typography-body-sm-size; wrap: word-wrap; } }}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.
QuestionnaireQuestion | What it is |
|---|---|
name | Your own key for the question. Never shown; it is what answer-changed reports |
prompt, description | The question, and the line under it |
choices | The options. Empty is a free-text question, which needs allow-freeform |
multiple | Any number of options may be on, rather than exactly one |
required | The next step is refused until this one is answered |
skippable | Offers the Skip action |
allow-freeform, freeform-placeholder | A text entry under the options — the “Other” case, or the whole answer |
shortcuts | Numbers the first nine options and draws each key as a Kbd cap |
text, skipped | The answer: what was typed, and whether the question was passed over |
QuestionnaireChoice | What it is |
|---|---|
label, value | What the option is called, and the value you know it by |
description | A quieter second line under the label |
disabled | An option this respondent cannot pick. It keeps its place and the arrows step over it |
on | The 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.
import { Questionnaire, QuestionnaireQuestion } from "@glint/components/questionnaire.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 640px; height: 440px; background: Tokens.color-background;
in-out property <[QuestionnaireQuestion]> questions: [ { name: "one-of", prompt: "Pick exactly one", description: "A single-choice question — press 1 or 2; the third refuses.", shortcuts: true, choices: [ { label: "Small", value: "s" }, { label: "Medium", value: "m" }, { label: "Large", value: "l", description: "Not available everywhere.", disabled: true }, ], }, { name: "any-of", prompt: "Pick as many as apply", description: "A multiple-choice question — each box is its own tab stop.", multiple: true, shortcuts: true, choices: [ { label: "Email", value: "email" }, { label: "Push", value: "push" }, { label: "SMS", value: "sms" }, ], }, { name: "free", prompt: "Tell us in your own words", description: "A free-text question — no choices at all.", allow-freeform: true, freeform-placeholder: "Type here", }, ];
VerticalLayout { padding: 24px; alignment: start;
Questionnaire { questions <=> root.questions; } }}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.
import { Questionnaire, QuestionnaireQuestion } from "@glint/components/questionnaire.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 640px; height: 420px; background: Tokens.color-background;
property <string> log: "Next stays shut until this question is answered.";
in-out property <[QuestionnaireQuestion]> questions: [ { name: "source", prompt: "How did you hear about Glint?", required: true, allow-freeform: true, freeform-placeholder: "Somewhere else…", choices: [ { label: "A colleague", value: "colleague" }, { label: "The Slint community", value: "community" }, ], }, { name: "newsletter", prompt: "Send you the monthly notes?", skippable: true, choices: [ { label: "Yes", value: "yes" }, { label: "No", value: "no" }, ], }, ];
VerticalLayout { padding: 24px; spacing: 12px; alignment: start;
Questionnaire { questions <=> root.questions; answer-changed(name) => { root.log = name + " has an answer now."; } skipped => { root.log = "Passed over — `skipped` is true on that question."; } submitted => { root.log = "Done."; } }
Text { text: root.log; color: Tokens.color-muted-foreground; font-size: Tokens.typography-body-sm-size; wrap: word-wrap; } }}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.
import { Questionnaire, QuestionnaireQuestion } from "@glint/components/questionnaire.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 640px; height: 400px; background: Tokens.color-background;
property <string> message: "";
in-out property <[QuestionnaireQuestion]> questions: [ { name: "seats", prompt: "How many seats do you need?", required: true, allow-freeform: true, freeform-placeholder: "e.g. 12", }, { name: "team-size", prompt: "How big is the team?", required: true, choices: [ { label: "Just me", value: "1" }, { label: "2–10", value: "10" }, { label: "More than 10", value: "many" }, ], }, ];
VerticalLayout { padding: 24px; alignment: start;
Questionnaire { questions <=> root.questions; error: root.message; // The host's policy, checked as the answer changes. answer-changed(name) => { root.message = (name == "seats" && root.questions[0].text != "" && !root.questions[0].text.is-float()) ? "Seats has to be a number." : ""; } } }}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.
import { Questionnaire, QuestionnaireQuestion } from "@glint/components/questionnaire.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 660px; height: 420px; background: Tokens.color-background;
in-out property <[QuestionnaireQuestion]> questions: [ { name: "os", prompt: "Which platforms do you ship on?", multiple: true, choices: [ // Seeded: a half-finished questionnaire is resumed by handing // the answers back in the model. { label: "Linux", value: "linux", on: true }, { label: "Windows", value: "windows" }, { label: "macOS", value: "macos" }, ], }, { name: "notes", prompt: "Anything else?", allow-freeform: true, freeform-placeholder: "Type something and watch the line below", }, ];
VerticalLayout { padding: 24px; spacing: 12px; alignment: start;
Questionnaire { questions <=> root.questions; }
Text { text: "Linux: " + (root.questions[0].choices[0].on ? "yes" : "no") + " · Windows: " + (root.questions[0].choices[1].on ? "yes" : "no") + " · macOS: " + (root.questions[0].choices[2].on ? "yes" : "no") + " · notes: “" + root.questions[1].text + "”"; color: Tokens.color-muted-foreground; font-size: Tokens.typography-body-sm-size; wrap: word-wrap; } }}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.
import { QuestionnaireProgress, QuestionnaireItem, QuestionnaireActions, QuestionnaireQuestion } from "@glint/components/questionnaire.slint";import { Card, CardContent } from "@glint/components/card.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 660px; height: 420px; background: Tokens.color-background;
in-out property <int> at: 0; property <int> focus-pass: 0;
in-out property <[QuestionnaireQuestion]> questions: [ { name: "colour", prompt: "Pick a colour", required: true, choices: [ { label: "Stone", value: "stone" }, { label: "Slate", value: "slate" }, ], }, { name: "why", prompt: "Why that one?", allow-freeform: true, freeform-placeholder: "Optional", skippable: true, }, ];
VerticalLayout { padding: 24px; spacing: 16px; alignment: start;
// The bar, hoisted out of the card the question sits in. QuestionnaireProgress { position: root.at + 1; total: root.questions.length; }
Card { CardContent { item := QuestionnaireItem { questions <=> root.questions; index: root.at; focus-pass: root.focus-pass; } } }
QuestionnaireActions { can-retreat: root.at > 0; can-skip: root.questions[root.at].skippable; can-advance: !root.questions[root.at].required || item.answered; is-last: root.at >= root.questions.length - 1; retreated => { if (root.at > 0) { root.at -= 1; root.focus-pass += 1; } } skipped => { root.questions[root.at].skipped = true; if (root.at < root.questions.length - 1) { root.at += 1; root.focus-pass += 1; } } advanced => { if (root.at < root.questions.length - 1) { root.at += 1; root.focus-pass += 1; } } } }}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 {}", …).
import { Questionnaire, QuestionnaireQuestion } from "@glint/components/questionnaire.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 640px; height: 360px; background: Tokens.color-background;
in-out property <[QuestionnaireQuestion]> questions: [ { name: "consent", prompt: "May we contact you about the beta?", skippable: true, choices: [ { label: "Yes, please", value: "yes" }, { label: "No, thanks", value: "no" }, ], }, ];
VerticalLayout { padding: 24px; alignment: start;
Questionnaire { questions <=> root.questions; previous-label: "Back"; skip-label: "Not now"; next-label: "Continue"; submit-label: "Send it"; progress-label: "Step 1 of 1"; } }}API Reference
Properties
| Property | Type | Default | Description |
|---|---|---|---|
questions | in-out [QuestionnaireQuestion] | no default | The 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-index | in-out int | 0 | Which question is on screen. Two-way, which is what lets a host jump back to the one a server refused. |
error | in string | no default | The validation message for the active question; empty is valid. Setting it shows the message and hands the question back the keyboard. |
previous-label | in string | QuestionnaireStrings.previous | |
skip-label | in string | QuestionnaireStrings.skip | |
next-label | in string | QuestionnaireStrings.next | |
submit-label | in string | QuestionnaireStrings.submit | |
progress-label | in string | @tr("Question {} of {}", root.active-index + 1, root.questions.length) | What assistive technology calls the progress bar. |
can-advance | out bool | no default | Whether the forward action is live: a required question refuses to be stepped past until it has an answer. |
is-last | out bool | no default | Whether the active question is the last one, which is what turns Next into Submit. |
Callbacks
| Callback | Description |
|---|---|
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
| Function | Description |
|---|---|
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
| Property | Type | Default | Description |
|---|---|---|---|
questions | in-out [QuestionnaireQuestion] | no default | The model and the position in it. Two-way, because the answer is written back into the question it answers (ADR-0037). |
index | in int | 0 | |
error | in string | no default | The validation message for this question; empty is valid. The host owns the policy — this only shows what it decided. |
focus-pass | in int | no default | Bumped by whoever navigates, to hand this question the keyboard. |
chosen-count | out int | no default | How 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. |
answered | out bool | no default | Whether the question has an answer of any kind. |
Callbacks
| Callback | Description |
|---|---|
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
| Property | Type | Default | Description |
|---|---|---|---|
can-retreat | in bool | no default | Whether there is a question behind this one. |
can-skip | in bool | no default | Whether this question may be passed over. |
can-advance | in bool | true | Whether the forward action is live — off while a required question is unanswered. |
is-last | in bool | no default | Whether forward means submitting rather than stepping. |
previous-label | in string | QuestionnaireStrings.previous | |
skip-label | in string | QuestionnaireStrings.skip | |
next-label | in string | QuestionnaireStrings.next | |
submit-label | in string | QuestionnaireStrings.submit |
Callbacks
| Callback | Description |
|---|---|
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
| Property | Type | Default | Description |
|---|---|---|---|
value | in float | 0 | Current value, in whatever units minimum…maximum names. Ignored while indeterminate. |
minimum | in float | 0 | The 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. |
maximum | in float | 100 | |
indeterminate | in bool | false | The work has started but its size is unknown: the fill gives way to a sweeping band and the control reports no value. |
period | in duration | 1.4s | Time for one sweep of the band across the track. |
phase | out float | no default | Where 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. |
percent | out float | no default | How 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. |
position | in int | 1 | Which question is on screen, counting from one. |
total | in int | 1 | How many there are. |
label | in 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
| Property | Type | Default | Description |
|---|---|---|---|
previous | out string | no default | |
skip | out string | no default | |
next | out string | no default | |
submit | out string | no 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.
shortcutsdraws each cap withaccessible-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.
QuestionnaireProgresspublishes the position, the total and a name —Question 2 of 5by 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.