# Questionnaire

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

```slint
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

```slint
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](/docs/components/kbd) cap, so an answer is one keystroke.

```slint
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.

```slint
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.

```slint
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.

```slint
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.

```slint
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 {}", …)`.

```slint
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](/docs/components/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](#usage) above.

## Accessibility

- **The question is named by its `Field`, and by nothing inside it.** [Field](/docs/components/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.
