Questionnaire
Questionnaire 引导用户完成一组有序问题。它负责当前 item、答案状态、校验、
进度和导航。外层页面、GroupBox、Dialog 或 Sheet 负责关闭、取消、持久化、
传输以及应用特有的条件分支。
#引入
use gpui_kit::component::questionnaire::{
Questionnaire, QuestionnaireActions, QuestionnaireChoice,
QuestionnaireChoiceDescription, QuestionnaireChoices, QuestionnaireDescription,
QuestionnaireError, QuestionnaireInput, QuestionnaireItem, QuestionnaireNext,
QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, QuestionnaireState,
QuestionnaireSubmit, QuestionnaireTitle,
};
#用法
先创建一次 item 集合,并使用一个 QuestionnaireState entity 作为所有部件的
状态源。
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")
});
将集合中的每个 definition 都映射为组合部件。QuestionnaireItem 只渲染当前
item;如果组合中遗漏某个 item,导航到该 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)),
)
#组合结构
Questionnaire
├── QuestionnaireProgress
├── QuestionnaireItem
│ ├── QuestionnaireTitle
│ ├── QuestionnaireDescription
│ ├── QuestionnaireChoices
│ │ ├── QuestionnaireChoice
│ │ │ └── QuestionnaireChoiceDescription (custom child)
│ │ └── QuestionnaireInput
│ └── QuestionnaireError
└── QuestionnaireActions
├── QuestionnairePrevious
├── QuestionnaireSkip
├── QuestionnaireNext
└── QuestionnaireSubmit
所有部件都接受普通 GPUI 样式,并可以与现有的 Button、Input、Radio、
Checkbox、Progress、Stepper、GroupBox 和 Dialog 组合。将同一个 state
entity 传给每个部件。自定义部件应读取对应 state 并调用 state 方法处理用户操作,
不要创建第二份答案存储。
QuestionnaireChoice 默认提供 indicator、content 和 shortcut。加入 child 后,
它会替换 fallback label 与 description,同时保留选项激活、焦点、状态和可访问
行为。使用 QuestionnaireChoiceDescription::new() 为自定义 choice body 添加
辅助文字。下面这些 seam 只定制对应区域:
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 使用相同的 renderer 签名,并接收
QuestionnaireChoiceState;需要替换默认 Kbd 提示时使用它。renderer 会完整替换
对应区域,因此同一区域的 style seam 不再应用;请直接设置自定义 renderer 的样式。
状态快照提供 is_selected、is_disabled、is_invalid 和 shortcut,可用于自定义
渲染。
#选项
item 默认单选:激活某个选项后即有答案,Next 可以继续;with_multiple 则保留
所有已选项。答案 reader 按 schema 顺序返回结果,后续被禁用的 choice 会从
effective answer 中排除。
definition builder 承载初始快照:choice 可以初始选中,item、choice 和 input 都 可以初始禁用,单选 item 最多只能有一个默认选中项。
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 会拒绝重复的 item name、同一 item 内重复的 choice
value,以及单选 item 上的多个默认值。对未知 item 或 choice 调用 setter 返回
QuestionnaireSchemaError。
#自由输入
加入 QuestionnaireInputDefinition,允许用户输入固定选项之外的答案。请为输入
提供可访问名称;placeholder 不能替代 label。
只有空白的输入视为未回答。选择固定选项时会保留输入草稿,但只有自由输入成为 当前答案时才会提交它。多选 item 可以同时提交固定选项和非空自由输入。
#校验
必填状态校验已经内置。可以为 item 添加同步 validator,实现领域规则。validator
通过 QuestionnaireValidationContext 接收当前 item、当前答案和完整的 enabled
答案快照。Next 校验当前 item;Submit 校验全部 enabled item,并将焦点移到
第一个无效 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())
}
});
可选但未回答的 item 在显式跳过前仍然无效;Skipped 是明确有效的状态。disabled
item 和 disabled control 不参与校验。提交失败时会选中第一个无效 item,焦点优先
移到其中已填写的 input 或已选 choice,再退回第一个 enabled control。
外部 schema 或服务器响应应使用 external error。外部错误由宿主负责,并会一直 保留到宿主清除它。
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 会清除内部校验尝试和错误,但保留 owner 管理的 external error。
#导航与提交
QuestionnaireState 暴露当前 item、有序 item 状态和导航状态,可用于自定义操作
布局。
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 对 Previous、Next、Submit 和
is_confirmable 给出同样的判断;current_item 与 current_ix 定位当前 item。
默认操作布局在开头显示 Previous,在 item 之间显示 Next,当前 item 可选时
显示 Skip,最后显示 Submit。隐藏的操作不会渲染,也不会进入键盘导航。
disabled item 会从导航和进度总数中排除。item 有三种状态:Unanswered、
Answered 和 Skipped。
#跳过
可选 item 可以显示 QuestionnaireSkip。跳过是一个明确且有效的状态,会清除该
item 的答案并允许 Next 继续。必填 item 不允许跳过。重新进入 item 并选择答案
后,skipped 状态会被清除。跳过最后一个 enabled item 后,会在记录跳过状态后请求
提交。
#Event 与提交
订阅 QuestionnaireEvent,即可监听当前 item 变化、答案变化、完成和成功提交。
Completed 只在状态转入 complete 时发出;每次成功执行显式 submit 都会发出
Submit。
首次成功提交时,事件顺序为 Completed,随后是 Submit。
答案或 enabled 条件变化会清除 complete 状态,因此下次成功提交可以再次发出
Completed。
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();
detach 会让 callback 持续有效,直到订阅涉及的 entity 被销毁。如果宿主需要提前
取消监听,请改为保存返回的 Subscription。
提交结果按 item schema 顺序排列,并且只包含 enabled item。每个 item 包含 name、
Unanswered/Answered/Skipped 状态和 effective answer。它表示本地已校验的
提交请求;远程保存仍由宿主应用负责。
#状态控制
当页面需要控制当前 item,或需要在 state 创建后应用已保存答案时,使用静默 setter。 它们会按需更新 UI 和焦点,但不会发出用户交互事件。
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");
});
用户意图应使用 activate_choice、confirm_current、go_previous、go_next、
skip_current 和 submit。这些路径会发出相应的 QuestionnaireEvent。宿主也
可以使用 set_item_disabled 和 set_choice_disabled;禁用当前 item 后,焦点会
移动到下一个 enabled item;没有下一个时移动到前一个。
#重置
Reset 会恢复初始 choices 和 input 草稿,清除显式 skip、校验尝试和完成状态,回到 初始当前 item,并将焦点移到恢复后的当前 item。
state.update(cx, |state, cx| {
state.reset(window, cx);
});
External error 在 reset 后仍由 owner 管理。如果 reset 也应该移除服务器错误,请
使用 clear_external_error 显式清除。
reset 回到 schema 构造时的快照,因此「已保存的草稿」属于 definition:用
InputState::default_value、with_default_selected 和 with_current_item
建立这个基线。构造之后用 set_answer、set_input_value、set_current_item
写入的值只改变当前状态,不会移动 reset 的基线。
#条件 item
Questionnaire 不包含 branching engine。宿主可以根据前一个答案推导 item 的禁用
状态,并通过 set_item_disabled 同步。这让条件策略留在页面中,同时由
Questionnaire 继续负责顺序、焦点、进度、校验和提交。
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);
});
}
可以从宿主的 answer-change 处理,或改变前一个答案的 UI action 中调用这个 helper。 被禁用的条件 item 不参与进度、导航、校验、焦点、快捷键和提交。
#键盘快捷键
为 state 启用字母或数字快捷键。快捷键只作用于当前 item 的 enabled choices。 重复 key event、文本输入、IME 组合以及带修饰键的按键都会保持原有行为。
use gpui_kit::component::questionnaire::QuestionnaireShortcutMode;
let state = cx.new(|cx| {
QuestionnaireState::new(items, cx)
.expect("valid questionnaire schema")
.with_shortcuts(QuestionnaireShortcutMode::Letters)
});
Questionnaire 按原生单选交互处理 radio 的移动。其他场景下,Up/Down 会按 schema 顺序在 enabled choices 和自由输入之间移动;存在 input 时它也会包含在这个顺序中。 非空文本 input 获得焦点时保留正常文本编辑行为。只有焦点不在文本 input 或单选 radio 上时,Left/Right 才会在 item 之间移动;Right 要求当前 item 可确认。
Enter 确认已填写的答案。Command/Ctrl+Enter 确认当前 item。空答案不会隐式提交。
快捷键标签按 enabled choice 顺序分配(A–Z 或 1–9),disabled choice
不会分配标签。
#进度
QuestionnaireProgress 使用默认的 “Question 2 of 4” 样式。同一份快照也可以用来
驱动现有的指示器。
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 和 total 只统计启用的 item,宿主禁用或重新启用某一题时两者都会变化。
如果指示器为每一步固定一个标签(例如 Stepper),它的步骤必须从同一份启用集合
推导出来,否则标签和选中步骤会与问卷错位。
#尺寸与主题
Questionnaire 接受整份问卷的比例,该问卷的所有部件都会跟随 —— root 会把 size
记录在它的 state 上,因此组合部件不需要被逐个告知。部件自己声明的 size 优先。
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")
// 跟随 root;只有需要不同比例时才在这里写 with_size。
.child(QuestionnaireChoice::new(&state, "direction", "delegation")),
),
);
支持的尺寸为 XSmall、Small、Medium(默认)和 Large,也可以使用
Size::Size(value) 自定义比例。答案文字与同尺寸下 Checkbox、Radio 家族的 label
一致。
spacing、typography、radius、border、input、primary、muted、destructive 和
focus ring 全部取自当前主题的 semantic tokens,应用通过调整主题改变问卷的形状。
局部微调使用 Styled 方法或 StyleRefinement,实例样式在组件默认样式之后应用。
QuestionnaireChoiceDescription 是唯一没有自己 state 的部件 —— 它只是自定义
选项内容里的一个文本槽 —— 因此默认 Medium,需要其它比例时通过 with_size
指定。
#Card 和 Dialog 组合
问卷负责题目流程,容器负责自己的外观与关闭/取消行为。把完整组合 —— progress、 全部 item 和 actions —— 都放进容器,这样切换到下一题时仍然可见。
use gpui_kit::component::group_box::{GroupBox, GroupBoxVariants as _};
GroupBox::new()
.outline()
.title("Set up your workspace")
.child(questionnaire);
放进 Dialog 时,footer 里容器自己的 Cancel 与问卷的导航按钮并排,宿主在问卷报告
提交成功后关闭 Dialog。
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 和每个 item,同上面的「用法」
.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 始终关闭。Submit 只有在问卷校验通过全部启用 item 之后才关闭,同一个
event 也把校验后的 QuestionnaireSubmission 交给应用层传输。
#可访问性
Questionnaire 根部使用 GPUI 的 Form role。QuestionnaireItem 是带有 item label
和 description 的可访问分组。definition 中的 accessibility_label 与
description 始终是 item 和 choice 的语义来源;自定义 child 只替换可见的
fallback 内容,并保留 Questionnaire parts 提供的状态、role、焦点行为和语义。
QuestionnaireError 只有 item 无效时才会以 alert 形式播报。Choice 保留 radio 和
checkbox 语义,进度暴露当前值与总数,导航使用真实按钮。
非当前 item 和隐藏操作不会进入键盘导航。成功切换后,焦点移动到新的当前 item; 校验失败时,焦点优先移动到已选或已填写的答案控件,再退回第一个可用控件。
请始终为自由输入在 definition 中提供 accessibility_label;可见 label 或等价的
自定义组合可以补充它。GPUI accessibility layer 没有直接对应 aria-invalid 的
builder;Questionnaire 仍通过错误 alert、语义分组状态、焦点行为和 destructive
样式暴露无效状态。
#当前范围
问卷一次只呈现一道题:非当前题的部件不会渲染任何内容,因此它不适合做「一页多题」
的表单。schema 在构造时固定 —— 运行时不能插入或重排题目与选项,但可以禁用其中
任意一项 —— 校验器同步执行。持久化、传输和提交后的副作用属于外层页面,由它订阅
QuestionnaireEvent 处理。
#API 参考
#组合部件
- Questionnaire
- QuestionnaireProgress
- QuestionnaireItem
- QuestionnaireTitle
- QuestionnaireDescription
- QuestionnaireChoices
- QuestionnaireChoice
- QuestionnaireChoiceDescription
- QuestionnaireInput
- QuestionnaireError
- QuestionnaireActions
- QuestionnairePrevious
- QuestionnaireSkip
- QuestionnaireNext
- QuestionnaireSubmit
#状态、答案与事件
- QuestionnaireState
- QuestionnaireItemDefinition
- QuestionnaireChoiceDefinition
- QuestionnaireInputDefinition
- QuestionnaireAnswer
- QuestionnaireAnswers
- QuestionnaireItemStatus
- QuestionnaireShortcutMode
- QuestionnaireProgressState
- QuestionnaireItemState
- QuestionnaireChoiceState
- QuestionnaireNavigationState
- QuestionnaireValidationContext
- QuestionnaireValidator
- QuestionnaireAnswerChange
- QuestionnaireSubmission
- QuestionnaireSubmissionItem
- QuestionnaireEvent
- QuestionnaireSchemaError
- Sizable