Questionnaire
Questionnaire guides a user through an ordered set of questions. It owns the
active item, answer state, validation, progress, and navigation. A containing
page, GroupBox, Dialog, or Sheet remains responsible for closing,
cancelling, persistence, transport, and application-specific branching.
#Import
use gpui_kit::component::questionnaire::{
Questionnaire, QuestionnaireActions, QuestionnaireChoice,
QuestionnaireChoiceDescription, QuestionnaireChoices, QuestionnaireDescription,
QuestionnaireError, QuestionnaireInput, QuestionnaireItem, QuestionnaireNext,
QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, QuestionnaireState,
QuestionnaireSubmit, QuestionnaireTitle,
};
#Usage
Create the item collection once and use one QuestionnaireState entity as the
source of truth for all parts.
use gpui_kit::component::input::InputState;
use gpui_kit::component::questionnaire::{
QuestionnaireChoiceDefinition, QuestionnaireInputDefinition,
QuestionnaireItemDefinition, QuestionnaireState,
};
let direction_input = cx.new(|cx| {
InputState::new(window, cx).placeholder("Type another answer…")
});
let items = vec![
QuestionnaireItemDefinition::new("direction", "What should we prototype next?")
.with_required(true)
.with_description("Choose a direction or write your own.")
.with_choices([
QuestionnaireChoiceDefinition::new("delegation", "Delegation")
.with_description("Show how work moves to a specialist."),
QuestionnaireChoiceDefinition::new("questions", "Question prompts"),
QuestionnaireChoiceDefinition::new("both", "Both together"),
])
.with_input(QuestionnaireInputDefinition::new(
direction_input,
"Another answer",
)),
QuestionnaireItemDefinition::new("detail", "How much detail should it include?")
.with_description("You can skip this question if you are not sure yet.")
.with_choices([
QuestionnaireChoiceDefinition::new("focused", "Focused"),
QuestionnaireChoiceDefinition::new("complete", "Complete flow"),
]),
];
let state = cx.new(|cx| {
QuestionnaireState::new(items, cx)
.expect("valid questionnaire schema")
});
Map every definition in the collection into the compound parts. The active
QuestionnaireItem is the only item rendered, so omitting an item from this
composition leaves the UI empty when navigation reaches that item.
Questionnaire::new(&state)
.child(QuestionnaireProgress::new(&state))
.child(
QuestionnaireItem::new(&state, "direction")
.child(QuestionnaireTitle::new(&state, "direction"))
.child(QuestionnaireDescription::new(&state, "direction"))
.child(
QuestionnaireChoices::new(&state, "direction")
.child(QuestionnaireChoice::new(&state, "direction", "delegation"))
.child(QuestionnaireChoice::new(&state, "direction", "questions"))
.child(QuestionnaireChoice::new(&state, "direction", "both"))
.child(QuestionnaireInput::new(&state, "direction")),
)
.child(QuestionnaireError::new(&state, "direction")),
)
.child(
QuestionnaireItem::new(&state, "detail")
.child(QuestionnaireTitle::new(&state, "detail"))
.child(QuestionnaireDescription::new(&state, "detail"))
.child(
QuestionnaireChoices::new(&state, "detail")
.child(QuestionnaireChoice::new(&state, "detail", "focused"))
.child(QuestionnaireChoice::new(&state, "detail", "complete")),
)
.child(QuestionnaireError::new(&state, "detail")),
)
.child(
QuestionnaireActions::new(&state)
.child(QuestionnairePrevious::new(&state))
.child(QuestionnaireSkip::new(&state))
.child(QuestionnaireNext::new(&state))
.child(QuestionnaireSubmit::new(&state)),
)
#Composition
Questionnaire
├── QuestionnaireProgress
├── QuestionnaireItem
│ ├── QuestionnaireTitle
│ ├── QuestionnaireDescription
│ ├── QuestionnaireChoices
│ │ ├── QuestionnaireChoice
│ │ │ └── QuestionnaireChoiceDescription (custom child)
│ │ └── QuestionnaireInput
│ └── QuestionnaireError
└── QuestionnaireActions
├── QuestionnairePrevious
├── QuestionnaireSkip
├── QuestionnaireNext
└── QuestionnaireSubmit
Every part accepts ordinary GPUI styling and can be composed with existing
Button, Input, Radio, Checkbox, Progress, Stepper, GroupBox, and
Dialog elements. Pass the same state entity to each part. A custom part
should read its corresponding state and call state methods for user actions;
it should not create a second answer store.
QuestionnaireChoice supplies the default indicator, content, and shortcut.
Adding children replaces the fallback label and description while preserving
choice activation, focus, state, and accessibility behavior. Use
QuestionnaireChoiceDescription::new() for secondary text in a custom choice
body. The following seams customize only the corresponding region:
use gpui_kit::{IntoElement as _, ParentElement as _, StyleRefinement, Styled as _, div};
use gpui_kit::component::{ActiveTheme as _, StyledExt as _};
use gpui_kit::component::questionnaire::{
QuestionnaireChoice, QuestionnaireChoiceDescription,
};
let _styled_choice = QuestionnaireChoice::new(&state, "direction", "questions")
.indicator_style(StyleRefinement::default().opacity(0.9))
.content_style(StyleRefinement::default().opacity(0.95))
.shortcut_style(StyleRefinement::default().opacity(0.8));
let _rendered_choice = QuestionnaireChoice::new(&state, "direction", "delegation")
.render_indicator(|choice, _, cx| {
div()
.size_4()
.rounded_full()
.bg(if choice.is_selected() {
cx.theme().primary
} else {
cx.theme().muted
})
.into_any_element()
})
.child(
div()
.child("Delegation")
.child(QuestionnaireChoiceDescription::new().child(
"Show how work moves to a specialist.",
)),
);
render_shortcut has the same renderer signature and receives the
QuestionnaireChoiceState; use it when an application wants to replace the
default Kbd hint. A renderer replaces that region completely, so its matching
style seam is not applied; style the custom renderer directly. The state
snapshot exposes is_selected, is_disabled, is_invalid, and shortcut for
custom rendering.
#Choices
An item is single-selection by default: activating a choice answers it and
makes Next available. with_multiple keeps every selected choice instead.
The answer reader preserves schema order, and a choice disabled later leaves
the effective answer.
Definition builders carry the initial snapshot: a choice can start selected, an item, a choice, or an input can start disabled, and a single-choice item may carry at most one default.
let tools_input = cx.new(|cx| InputState::new(window, cx));
let items = vec![
QuestionnaireItemDefinition::new("plan", "Which plan fits your team?")
.with_required(true)
.with_choices([
QuestionnaireChoiceDefinition::new("plus", "Plus").with_default_selected(true),
QuestionnaireChoiceDefinition::new("pro", "Pro"),
]),
QuestionnaireItemDefinition::new("tools", "Which tools do you use?")
.with_multiple(true)
.with_choices([
QuestionnaireChoiceDefinition::new("editor", "Editor"),
QuestionnaireChoiceDefinition::new("terminal", "Terminal"),
QuestionnaireChoiceDefinition::new("browser", "Browser").with_disabled(true),
])
.with_input(QuestionnaireInputDefinition::new(tools_input, "Something else")),
QuestionnaireItemDefinition::new("advanced", "Advanced preferences").with_disabled(true),
];
QuestionnaireState::new rejects duplicate item names, duplicate choice values
within an item, and multiple defaults on a single-choice item. Setters for
unknown items or choices return QuestionnaireSchemaError.
#Freeform answer
Add QuestionnaireInputDefinition to allow a user to enter an answer that is
not in the fixed choices. Give the input an accessible label; a placeholder is
not a label.
Whitespace-only input is unanswered. The input draft is kept when a fixed choice is selected, but it is submitted only when the freeform answer is active. In a multiple item, a non-empty freeform answer can accompany fixed choices.
#Validation
Required status validation is built in. Add a synchronous validator to an item
for domain-specific checks. The validator receives the current item, its
answer, and the complete enabled answer snapshot through
QuestionnaireValidationContext. Next validates the current item; Submit
validates all enabled items and focuses the first invalid item.
let item = QuestionnaireItemDefinition::new("handle", "Choose a handle")
.with_required(true)
.with_validator(|context| {
if context
.answer()
.freeform()
.is_some_and(|value| value.as_ref().len() >= 3)
{
Ok(())
} else {
Err("Use at least three characters.".into())
}
});
An optional unanswered item is invalid until the user explicitly skips it;
Skipped is intentionally valid. Disabled items and disabled controls do not
participate in validation. The first invalid item is selected on submit, and
focus goes to its filled input or selected choice before falling back to the
first enabled control.
Use external errors for schema or server responses. External errors belong to the host and remain until the host clears them.
state.update(cx, |state, cx| {
state
.set_external_error("handle", "This handle is already taken.", cx)
.expect("known questionnaire item");
});
// After the owner accepts a corrected answer or a new server response:
state.update(cx, |state, cx| {
state
.clear_external_error("handle", cx)
.expect("known questionnaire item");
});
reset clears internal validation attempts and errors, but preserves
owner-managed external errors.
#Navigation and submission
QuestionnaireState exposes the current item, ordered item states, and
navigation state for custom action layouts.
let state = state.read(cx);
let progress = state.progress();
let status = state.item_state("direction").map(|item| item.status());
let navigation = state.navigation_state();
let show_skip = navigation.is_skip_visible();
QuestionnaireNavigationState answers the same question for Previous,
Next, Submit, and is_confirmable; current_item and current_ix locate
the active item.
The default action layout shows Previous at the beginning, Next between
items, Skip only for the active optional item, and Submit at the end.
Hidden actions are not rendered and do not enter keyboard navigation. Disabled
items are removed from the navigation and progress totals. The three item
statuses are Unanswered, Answered, and Skipped.
#Skipping
Optional items can expose QuestionnaireSkip. A skip is an intentional valid
state, clears the item answer, and allows Next to continue. Required items do
not allow skipping. Re-entering an item and choosing an answer clears its
skipped state. Skipping the final enabled item requests submission after the
skip has been recorded.
#Events and submission
Subscribe to QuestionnaireEvent for active-item changes, answer changes,
completion, and successful submit. Completed is emitted on the transition
into a complete state; Submit is emitted for each successful explicit submit.
On the first successful submit, the order is Completed followed by Submit.
Changing answers or enabled conditions clears completion, so the next successful
submit can emit Completed again.
use gpui_kit::component::questionnaire::QuestionnaireEvent;
cx.subscribe(&state, |_, _, event, _| match event {
QuestionnaireEvent::CurrentItemChanged { current, .. } => {
println!("Current item: {:?}", current);
}
QuestionnaireEvent::AnswerChanged(change) => {
println!("Changed: {:?} ({:?})", change.item(), change.status());
}
QuestionnaireEvent::Completed(submission)
| QuestionnaireEvent::Submit(submission) => {
println!("Answers: {:?}", submission.items());
}
_ => {}
})
.detach();
Detaching keeps the callback alive until the subscribed entities are dropped.
Store the returned Subscription in the host instead when it needs to cancel
the listener earlier.
The submission is ordered by the item schema and contains only enabled items.
Each item includes its name, Unanswered/Answered/Skipped status, and
effective answer. It represents a validated local submission request; saving
it remotely remains the host application’s responsibility.
#Controlling the state
When a page owns the active item or needs to apply a saved answer after state creation, use the silent setters. They update the UI and focus as needed but do not emit user-interaction events.
use gpui_kit::component::questionnaire::QuestionnaireAnswer;
state.update(cx, |state, cx| {
state
.set_current_item("detail", window, cx)
.expect("known enabled questionnaire item");
state
.set_answer(
"direction",
QuestionnaireAnswer::new().with_choices(["delegation"]),
window,
cx,
)
.expect("known questionnaire item");
state
.set_input_value("direction", "A controlled draft", window, cx)
.expect("item has an input");
});
Use activate_choice, confirm_current, go_previous, go_next,
skip_current, and submit for user intent. Those paths emit the relevant
QuestionnaireEvent values. A host can also use set_item_disabled and
set_choice_disabled; disabling the current item moves focus to the next
enabled item, or to the previous one when there is no next item.
#Reset
Reset restores the initial choices and input drafts, clears intentional skips, validation attempts, and completion, and returns to the initial current item. It also focuses the restored current item.
state.update(cx, |state, cx| {
state.reset(window, cx);
});
External errors remain owner-managed across reset. If a reset should also
remove a server error, clear it explicitly with clear_external_error.
reset returns to the snapshot the schema was built with, so a saved draft
belongs in the definitions: InputState::default_value,
with_default_selected, and with_current_item establish that baseline.
Values applied later with set_answer, set_input_value, or
set_current_item change the current state without moving the reset baseline.
#Conditional items
Questionnaire does not contain a branching engine. The host can derive an
item’s disabled state from an earlier answer and synchronize it with
set_item_disabled. This keeps conditional policy in the page while the
Questionnaire continues to own ordering, focus, progress, validation, and
submission.
fn sync_advanced_item(
state: &Entity<QuestionnaireState>,
window: &mut Window,
cx: &mut App,
) {
let enabled = state.read(cx).answer("direction").is_some_and(|answer| {
answer
.choices()
.iter()
.any(|choice| choice.as_ref() == "delegation")
});
state.update(cx, |state, cx| {
let _ = state.set_item_disabled("advanced", !enabled, window, cx);
});
}
Call this helper from the host’s answer-change handling or from the UI action that changes the earlier answer. A disabled conditional item is excluded from progress, navigation, validation, focus, shortcuts, and submission.
#Keyboard shortcuts
Enable letter or number shortcuts on the state. Shortcuts apply only to the active item’s enabled choices. Repeated key events, text input, IME composition, and modified key presses are left untouched.
use gpui_kit::component::questionnaire::QuestionnaireShortcutMode;
let state = cx.new(|cx| {
QuestionnaireState::new(items, cx)
.expect("valid questionnaire schema")
.with_shortcuts(QuestionnaireShortcutMode::Letters)
});
Questionnaire handles radio movement according to the native single-choice interaction. Up and Down otherwise move through enabled choices and the freeform input in schema order; the input remains in that order when present. When a non-empty text input has focus, its normal text-editing behavior is preserved. Left and Right move between items only outside text inputs and single-choice radio controls; Right requires a confirmable current item.
Enter confirms a filled answer. Command/Ctrl+Enter confirms the current item.
An empty answer does not implicitly submit. Shortcut labels are assigned in
enabled-choice order (A–Z or 1–9), and disabled choices receive no label.
#Progress
QuestionnaireProgress follows the default presentation: “Question 2 of 4”.
The same snapshot can drive an existing indicator instead.
QuestionnaireProgress::new(&state);
let progress = state.read(cx).progress();
let percent = if progress.total() == 0 {
0.
} else {
progress.current() as f32 / progress.total() as f32 * 100.
};
Progress::new("questionnaire-progress").value(percent);
current and total count only the enabled items, and both move when the host
disables or re-enables a question. An indicator with one fixed label per step —
a Stepper, for example — has to derive its steps from the same enabled set,
or its labels and its selected step drift apart from the questionnaire.
#Sizes and theming
Questionnaire takes the scale for the whole questionnaire, and every part of
that questionnaire follows it — the root publishes the size under its state, so
the compound parts do not have to be told individually. A part that names its
own size keeps it.
use gpui_kit::component::{Sizable as _, Size};
Questionnaire::new(&state)
.with_size(Size::Small)
.child(QuestionnaireProgress::new(&state))
.child(
QuestionnaireItem::new(&state, "direction")
.child(QuestionnaireTitle::new(&state, "direction"))
.child(
QuestionnaireChoices::new(&state, "direction")
// Follows the root; pass `with_size` here only to differ.
.child(QuestionnaireChoice::new(&state, "direction", "delegation")),
),
);
The supported sizes are XSmall, Small, Medium (the default) and Large,
plus Size::Size(value) for a custom scale. Answer text matches the Checkbox
and Radio family’s label at the same size.
Spacing, typography, radius, border, input, primary, muted, destructive, and
focus-ring values all come from the active theme’s semantic tokens, so an
application changes the questionnaire’s shape by changing the theme. Use
Styled methods or StyleRefinement for local adjustments; local style
refinement is applied after the component defaults.
QuestionnaireChoiceDescription is the one part with no state of its own — it
is a plain text slot for a custom choice body — so it defaults to Medium and
takes with_size when a custom composition needs another scale.
#Card and Dialog composition
The questionnaire owns the question flow; the container owns its surface and its close or cancel behavior. Put the whole composition — progress, every item, and the actions — inside the container, so moving to the next question stays visible.
use gpui_kit::component::group_box::{GroupBox, GroupBoxVariants as _};
GroupBox::new()
.outline()
.title("Set up your workspace")
.child(questionnaire);
In a dialog, the footer carries the container’s own Cancel next to the
questionnaire’s actions, and the host closes the dialog when the questionnaire
reports a successful submit.
use gpui_kit::component::dialog::{
Dialog, DialogClose, DialogFooter, DialogHeader, DialogTitle,
};
use gpui_kit::component::{WindowExt as _, questionnaire::QuestionnaireEvent};
let dialog_state = state.clone();
cx.subscribe_in(
&dialog_state,
window,
|_, _, event: &QuestionnaireEvent, window, cx| {
if matches!(event, QuestionnaireEvent::Submit(_)) {
window.close_dialog(cx);
}
},
)
.detach();
Dialog::new(cx)
.trigger(Button::new("open-questionnaire").outline().label("Open questionnaire"))
.content(move |content, _, _| {
content
.child(DialogHeader::new().child(DialogTitle::new().child("Workspace setup")))
.child(
Questionnaire::new(&dialog_state)
// …progress and every item, as in Usage above
.child(
DialogFooter::new()
.child(DialogClose::new().child(
Button::new("cancel-questionnaire").outline().label("Cancel"),
))
.child(
QuestionnaireActions::new(&dialog_state)
.child(QuestionnairePrevious::new(&dialog_state))
.child(QuestionnaireNext::new(&dialog_state))
.child(QuestionnaireSubmit::new(&dialog_state)),
),
),
)
});
Cancel always closes. Submit closes only after the questionnaire has
validated every enabled item, and the same event hands the validated
QuestionnaireSubmission to application transport.
#Accessibility
Questionnaire uses the GPUI Form role for the root. QuestionnaireItem is
an accessible group with its item label and description. The definition’s
accessibility_label and description remain the semantic source for the
item and choice, even when a custom child replaces the visible fallback
content. QuestionnaireError is announced as an alert only while the item is
invalid. Choice parts preserve radio and checkbox semantics, progress exposes
current and total values, and navigation uses real buttons.
Inactive items and hidden actions are removed from keyboard navigation. On a successful transition focus moves to the new item; on validation failure focus moves to the selected or filled answer control, then to the first available control.
Always provide an accessible label for a freeform input with its definition’s
accessibility_label; a visible label or equivalent custom composition can
supplement it. The GPUI accessibility layer does not expose a direct
aria-invalid builder. Questionnaire still exposes invalid state through its
error alert, semantic group state, focus behavior, and destructive styling.
#Current scope
The questionnaire asks one question at a time: parts belonging to any question
other than the current one render nothing, so a single page of several
questions is not what this component builds. The schema is fixed at
construction — questions and choices cannot be inserted or reordered at
runtime, though any of them can be disabled — and validators run synchronously.
Persistence, transport, and submission side effects belong to the containing
page, which subscribes to QuestionnaireEvent.
#API reference
#Compound parts
- Questionnaire
- QuestionnaireProgress
- QuestionnaireItem
- QuestionnaireTitle
- QuestionnaireDescription
- QuestionnaireChoices
- QuestionnaireChoice
- QuestionnaireChoiceDescription
- QuestionnaireInput
- QuestionnaireError
- QuestionnaireActions
- QuestionnairePrevious
- QuestionnaireSkip
- QuestionnaireNext
- QuestionnaireSubmit
#State, answers, and events
- QuestionnaireState
- QuestionnaireItemDefinition
- QuestionnaireChoiceDefinition
- QuestionnaireInputDefinition
- QuestionnaireAnswer
- QuestionnaireAnswers
- QuestionnaireItemStatus
- QuestionnaireShortcutMode
- QuestionnaireProgressState
- QuestionnaireItemState
- QuestionnaireChoiceState
- QuestionnaireNavigationState
- QuestionnaireValidationContext
- QuestionnaireValidator
- QuestionnaireAnswerChange
- QuestionnaireSubmission
- QuestionnaireSubmissionItem
- QuestionnaireEvent
- QuestionnaireSchemaError
- Sizable