From 078b993f50d0150f619be1aaee2e711ffb8c6c79 Mon Sep 17 00:00:00 2001 From: suxiaoshao <48886207+suxiaoshao@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:20:46 +0800 Subject: [PATCH 01/17] questionnaire: Add Questionnaire component --- crates/component/locales/ui.yml | 43 + crates/component/src/lib.rs | 2 + .../component/src/questionnaire/components.rs | 1746 +++++++++++++++++ crates/component/src/questionnaire/mod.rs | 11 + crates/component/src/questionnaire/state.rs | 1669 ++++++++++++++++ crates/component/src/questionnaire/types.rs | 672 +++++++ crates/story/src/gallery.rs | 1 + crates/story/src/stories/mod.rs | 2 + .../story/src/stories/questionnaire_story.rs | 749 +++++++ website/component/index.md | 1 + website/component/questionnaire.md | 454 +++++ website/zh-CN/component/index.md | 1 + website/zh-CN/component/questionnaire.md | 393 ++++ 13 files changed, 5744 insertions(+) create mode 100644 crates/component/src/questionnaire/components.rs create mode 100644 crates/component/src/questionnaire/mod.rs create mode 100644 crates/component/src/questionnaire/state.rs create mode 100644 crates/component/src/questionnaire/types.rs create mode 100644 crates/story/src/stories/questionnaire_story.rs create mode 100644 website/component/questionnaire.md create mode 100644 website/zh-CN/component/questionnaire.md diff --git a/crates/component/locales/ui.yml b/crates/component/locales/ui.yml index 3d9833fe59..fe8955c3d8 100644 --- a/crates/component/locales/ui.yml +++ b/crates/component/locales/ui.yml @@ -381,3 +381,46 @@ Chart: zh-CN: 收盘 zh-HK: 收市 zh-TW: 收盤 +Questionnaire: + progress: + en: Question %{current} of %{total} + zh-CN: 第 %{current} 题,共 %{total} 题 + zh-HK: 第 %{current} 題,共 %{total} 題 + zh-TW: 第 %{current} 題,共 %{total} 題 + it: Domanda %{current} di %{total} + previous: + en: Previous + zh-CN: 上一题 + zh-HK: 上一題 + zh-TW: 上一題 + it: Indietro + next: + en: Next + zh-CN: 下一题 + zh-HK: 下一題 + zh-TW: 下一題 + it: Avanti + skip: + en: Skip + zh-CN: 跳过 + zh-HK: 跳過 + zh-TW: 跳過 + it: Salta + submit: + en: Submit + zh-CN: 提交 + zh-HK: 提交 + zh-TW: 提交 + it: Invia + error.required: + en: Choose an answer to continue. + zh-CN: 请选择一个答案后继续。 + zh-HK: 請選擇一個答案後繼續。 + zh-TW: 請選擇一個答案後繼續。 + it: Scegli una risposta per continuare. + error.optional: + en: Choose an answer or skip this question. + zh-CN: 请选择一个答案,或跳过此题。 + zh-HK: 請選擇一個答案,或跳過此題。 + zh-TW: 請選擇一個答案,或跳過此題。 + it: Scegli una risposta o salta questa domanda. diff --git a/crates/component/src/lib.rs b/crates/component/src/lib.rs index c22e931d3a..1ae2abdde4 100644 --- a/crates/component/src/lib.rs +++ b/crates/component/src/lib.rs @@ -62,6 +62,7 @@ pub mod pagination; pub mod plot; pub mod popover; pub mod progress; +pub mod questionnaire; pub mod radio; pub mod rating; /// Backwards-compatible resizable component paths. @@ -144,6 +145,7 @@ pub fn init(cx: &mut App) { carousel::init(cx); notification::init(cx); popover::init(cx); + questionnaire::init(cx); menu::init(cx); table::init(cx); tooltip::init(cx); diff --git a/crates/component/src/questionnaire/components.rs b/crates/component/src/questionnaire/components.rs new file mode 100644 index 0000000000..b3aa3b5f49 --- /dev/null +++ b/crates/component/src/questionnaire/components.rs @@ -0,0 +1,1746 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, Entity, InteractiveElement, IntoElement, KeyDownEvent, + ParentElement, RenderOnce, Role, SharedString, StatefulInteractiveElement, StyleRefinement, + Styled, Window, div, prelude::FluentBuilder as _, svg, +}; +use gpui_base::{Checkbox, CheckboxState, Radio, RadioGroup}; +use rust_i18n::t; + +use crate::{ + ActiveTheme as _, IconName, Sizable, Size, StyledExt as _, ThemeStyled as _, + button::{Button, ButtonVariants as _}, + icon::IconNamed as _, + input::Input, + kbd::Kbd, +}; + +use super::{QuestionnaireChoiceState, QuestionnaireState}; + +type ChoiceRenderer = + Rc AnyElement + 'static>; + +#[derive(Clone, Copy)] +struct QuestionnaireMetrics { + root_gap: gpui::Pixels, + item_gap: gpui::Pixels, + choice_gap: gpui::Pixels, + content_gap: gpui::Pixels, + choice_padding_x: gpui::Pixels, + choice_padding_y: gpui::Pixels, + choice_min_height: gpui::Pixels, + indicator_size: gpui::Pixels, + indicator_mark_size: gpui::Pixels, +} + +impl QuestionnaireMetrics { + fn new(size: Size, cx: &App) -> Self { + let spacing = cx.theme().semantic_tokens().spacing; + match size { + Size::XSmall => Self { + root_gap: spacing.sm, + item_gap: spacing.sm, + choice_gap: spacing.xs, + content_gap: spacing.xxs, + choice_padding_x: spacing.sm, + choice_padding_y: spacing.xs, + choice_min_height: spacing.xl + spacing.xs, + indicator_size: spacing.md, + indicator_mark_size: spacing.xs, + }, + Size::Small => Self { + root_gap: spacing.md, + item_gap: spacing.md, + choice_gap: spacing.xs, + content_gap: spacing.xxs, + choice_padding_x: spacing.sm, + choice_padding_y: spacing.xs, + choice_min_height: spacing.xxl, + indicator_size: spacing.md + spacing.xxs, + indicator_mark_size: spacing.xs + spacing.xxs, + }, + Size::Large => Self { + root_gap: spacing.xl, + item_gap: spacing.xl, + choice_gap: spacing.md, + content_gap: spacing.xs, + choice_padding_x: spacing.lg, + choice_padding_y: spacing.md, + choice_min_height: spacing.xxl + spacing.lg, + indicator_size: spacing.lg + spacing.xxs, + indicator_mark_size: spacing.sm + spacing.xxs, + }, + Size::Size(value) => Self { + root_gap: value, + item_gap: value, + choice_gap: value * 0.5, + content_gap: value * 0.25, + choice_padding_x: value * 0.75, + choice_padding_y: value * 0.5, + choice_min_height: value * 2.75, + indicator_size: value, + indicator_mark_size: value * 0.5, + }, + Size::Medium => Self { + root_gap: spacing.lg, + item_gap: spacing.lg, + choice_gap: spacing.sm, + content_gap: spacing.xxs, + choice_padding_x: spacing.md, + choice_padding_y: spacing.sm, + choice_min_height: spacing.xxl + spacing.md, + indicator_size: spacing.lg, + indicator_mark_size: spacing.sm, + }, + } + } +} + +fn text_style(element: T, size: Size, cx: &App) -> T { + let typography = cx.theme().semantic_tokens().typography; + let token = match size { + Size::XSmall => typography.xs, + Size::Small => typography.sm, + Size::Medium => typography.sm, + Size::Large => typography.md, + Size::Size(value) => return element.text_size(value), + }; + element + .text_size(token.size) + .line_height(token.line_height) + .font_weight(token.weight) +} + +fn title_text_style(element: T, size: Size, cx: &App) -> T { + let typography = cx.theme().semantic_tokens().typography; + let token = match size { + Size::XSmall => typography.sm, + Size::Small => typography.sm, + Size::Medium => typography.md, + Size::Large => typography.lg, + Size::Size(value) => return element.text_size(value), + }; + element + .text_size(token.size) + .line_height(token.line_height) + .font_weight(gpui::FontWeight::MEDIUM) +} + +fn item_label(definition: &super::QuestionnaireItemDefinition) -> Option { + Some(definition.accessibility_label().clone()) +} + +fn item_description(definition: &super::QuestionnaireItemDefinition) -> Option { + definition.description().cloned() +} + +fn element_id(state: &Entity, suffix: impl std::fmt::Display) -> ElementId { + ElementId::Name(format!("questionnaire-{}-{suffix}", state.entity_id()).into()) +} + +/// The composable questionnaire root. It owns layout and keyboard routing while +/// [`QuestionnaireState`] remains the single source of behavioral state. +#[derive(IntoElement)] +pub struct Questionnaire { + state: Entity, + style: StyleRefinement, + size: Size, + children: Vec, +} + +impl Questionnaire { + pub fn new(state: &Entity) -> Self { + Self { + state: state.clone(), + style: StyleRefinement::default(), + size: Size::Medium, + children: Vec::new(), + } + } + + fn on_key_down( + state: &Entity, + event: &KeyDownEvent, + window: &mut Window, + cx: &mut App, + ) { + if window.default_prevented() + || event.is_held + || event.prefer_character_input + || event.keystroke.is_ime_in_progress() + { + return; + } + + let modifiers = event.keystroke.modifiers; + let key = event.keystroke.key.as_str(); + let input_focused = state.read(cx).is_current_input_focused(window); + let input_has_text = input_focused && state.read(cx).current_input_has_text(cx); + let single_radio_focused = { + let state = state.read(cx); + state + .current_item() + .and_then(|item| state.item_state(item)) + .is_some_and(|item| !item.is_multiple()) + && state.focused_current_choice(window).is_some() + }; + + let handled = + if key == "enter" && modifiers.secondary() && modifiers.number_of_modifiers() == 1 { + state.update(cx, |state, cx| state.confirm_current(window, cx)) + } else if modifiers.number_of_modifiers() != 0 { + false + } else if input_focused { + match key { + "enter" if Self::focused_answer_is_filled(state, window, cx) => { + state.update(cx, |state, cx| state.confirm_current(window, cx)) + } + "up" if !input_has_text => { + state.update(cx, |state, cx| state.focus_previous_answer(window, cx)) + } + "down" if !input_has_text => { + state.update(cx, |state, cx| state.focus_next_answer(window, cx)) + } + _ => false, + } + } else { + match key { + "up" if single_radio_focused => { + state.update(cx, |state, cx| state.move_current_radio(-1, window, cx)) + } + "down" if single_radio_focused => { + state.update(cx, |state, cx| state.move_current_radio(1, window, cx)) + } + "up" => state.update(cx, |state, cx| state.focus_previous_answer(window, cx)), + "down" => state.update(cx, |state, cx| state.focus_next_answer(window, cx)), + "left" if single_radio_focused => { + state.update(cx, |state, cx| state.move_current_radio(-1, window, cx)) + } + "right" if single_radio_focused => { + state.update(cx, |state, cx| state.move_current_radio(1, window, cx)) + } + "left" => state.update(cx, |state, cx| state.go_previous(window, cx)), + "right" if state.read(cx).navigation_state().is_confirmable() => { + state.update(cx, |state, cx| state.go_next(window, cx)) + } + "right" => false, + "enter" if Self::focused_answer_is_filled(state, window, cx) => { + state.update(cx, |state, cx| state.confirm_current(window, cx)) + } + "enter" => false, + _ => state.update(cx, |state, cx| state.activate_shortcut(key, window, cx)), + } + }; + + if handled { + window.prevent_default(); + } + } + + fn focused_answer_is_filled( + state: &Entity, + window: &Window, + cx: &App, + ) -> bool { + let state = state.read(cx); + let Some(item) = state.current_item() else { + return false; + }; + if state.is_current_input_focused(window) { + return state + .answer(item) + .is_some_and(|answer| answer.freeform().is_some()); + } + state + .focused_current_choice(window) + .and_then(|value| state.choice_state(item, value)) + .is_some_and(|choice| choice.is_selected()) + } +} + +impl Styled for Questionnaire { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl Sizable for Questionnaire { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +impl ParentElement for Questionnaire { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for Questionnaire { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let metrics = QuestionnaireMetrics::new(self.size, cx); + let focus_handle = self.state.read(cx).focus_handle().clone(); + let state = self.state.clone(); + let debug_selector = format!("questionnaire-{}-root", self.state.entity_id()); + + div() + .id(element_id(&self.state, "root")) + .debug_selector(move || debug_selector) + .role(Role::Form) + .key_context("Questionnaire") + .track_focus(&focus_handle) + .capture_key_down(move |event, window, cx| Self::on_key_down(&state, event, window, cx)) + .flex() + .flex_col() + .min_w_0() + .gap(metrics.root_gap) + .w_full() + .refine_style(&self.style) + .children(self.children) + } +} + +/// Textual progress matching shadcn/ui's base-nova default presentation. +#[derive(IntoElement)] +pub struct QuestionnaireProgress { + state: Entity, + style: StyleRefinement, + size: Size, + children: Vec, +} + +impl QuestionnaireProgress { + pub fn new(state: &Entity) -> Self { + Self { + state: state.clone(), + style: StyleRefinement::default(), + size: Size::Medium, + children: Vec::new(), + } + } +} + +impl Styled for QuestionnaireProgress { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl Sizable for QuestionnaireProgress { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +impl ParentElement for QuestionnaireProgress { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for QuestionnaireProgress { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let progress = self.state.read(cx).progress(); + let current = progress.current(); + let total = progress.total(); + let label: SharedString = + t!("Questionnaire.progress", current = current, total = total).into(); + let colors = cx.theme().semantic_tokens().colors; + let mono_font = cx.theme().semantic_tokens().typography.mono.clone(); + let has_children = !self.children.is_empty(); + + text_style( + div() + .id(element_id(&self.state, "progress")) + .role(Role::ProgressIndicator) + .aria_label(label.clone()) + .aria_min_numeric_value(0.) + .aria_max_numeric_value(total as f64) + .aria_numeric_value(current as f64) + .text_color(colors.muted_foreground), + self.size, + cx, + ) + .font_family(mono_font) + .font_weight(gpui::FontWeight::MEDIUM) + .refine_style(&self.style) + .when(!has_children, |this| this.child(label)) + .children(self.children) + } +} + +macro_rules! questionnaire_item_part { + ($name:ident, $fallback:ident, $style:ident, $color:ident) => { + #[derive(IntoElement)] + pub struct $name { + state: Entity, + item: SharedString, + style: StyleRefinement, + size: Size, + children: Vec, + } + + impl $name { + pub fn new(state: &Entity, item: impl Into) -> Self { + Self { + state: state.clone(), + item: item.into(), + style: StyleRefinement::default(), + size: Size::Medium, + children: Vec::new(), + } + } + } + + impl Styled for $name { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } + } + + impl Sizable for $name { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } + } + + impl ParentElement for $name { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } + } + + impl RenderOnce for $name { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let Some(definition) = self.state.read(cx).item_definition(&self.item) else { + return gpui::Empty.into_any_element(); + }; + let fallback = $fallback(definition); + let has_children = !self.children.is_empty(); + if !has_children && fallback.is_none() { + return gpui::Empty.into_any_element(); + } + let colors = cx.theme().semantic_tokens().colors; + $style(div().w_full().text_color(colors.$color), self.size, cx) + .refine_style(&self.style) + .when(!has_children, |this| { + this.when_some(fallback, |this, fallback| this.child(fallback)) + }) + .children(self.children) + .into_any_element() + } + } + }; +} + +questionnaire_item_part!(QuestionnaireTitle, item_label, title_text_style, foreground); +questionnaire_item_part!( + QuestionnaireDescription, + item_description, + text_style, + muted_foreground +); + +/// The active question group. Inactive or disabled items do not enter layout, +/// focus traversal, or the accessibility tree. +#[derive(IntoElement)] +pub struct QuestionnaireItem { + state: Entity, + item: SharedString, + style: StyleRefinement, + size: Size, + children: Vec, +} + +impl QuestionnaireItem { + pub fn new(state: &Entity, item: impl Into) -> Self { + Self { + state: state.clone(), + item: item.into(), + style: StyleRefinement::default(), + size: Size::Medium, + children: Vec::new(), + } + } +} + +impl Styled for QuestionnaireItem { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl Sizable for QuestionnaireItem { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +impl ParentElement for QuestionnaireItem { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for QuestionnaireItem { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let state = self.state.read(cx); + let active = state.current_item().is_some_and(|name| name == &self.item); + let Some(item_state) = state.item_state(&self.item) else { + return gpui::Empty.into_any_element(); + }; + if !active || item_state.is_disabled() { + return gpui::Empty.into_any_element(); + } + let Some(definition) = state.item_definition(&self.item) else { + return gpui::Empty.into_any_element(); + }; + let focus_handle = state.item_focus_handle(&self.item).cloned(); + let label = definition.accessibility_label().clone(); + let description = definition.description().cloned(); + let metrics = QuestionnaireMetrics::new(self.size, cx); + + div() + .id(element_id(&self.state, format!("item-{}", self.item))) + .role(Role::Group) + .aria_label(label) + .when_some(description, |this, description| { + this.aria_description(description) + }) + .when_some(focus_handle, |this, focus_handle| { + this.track_focus(&focus_handle.tab_index(-1).tab_stop(false)) + }) + .flex() + .flex_col() + .gap(metrics.item_gap) + .w_full() + .refine_style(&self.style) + .children(self.children) + .into_any_element() + } +} + +/// Container for an item's answer controls. +#[derive(IntoElement)] +pub struct QuestionnaireChoices { + state: Entity, + item: SharedString, + style: StyleRefinement, + size: Size, + children: Vec, +} + +impl QuestionnaireChoices { + pub fn new(state: &Entity, item: impl Into) -> Self { + Self { + state: state.clone(), + item: item.into(), + style: StyleRefinement::default(), + size: Size::Medium, + children: Vec::new(), + } + } +} + +impl Styled for QuestionnaireChoices { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl Sizable for QuestionnaireChoices { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +impl ParentElement for QuestionnaireChoices { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for QuestionnaireChoices { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let state = self.state.read(cx); + let active = state.current_item().is_some_and(|name| name == &self.item); + let Some(item) = state.item_state(&self.item) else { + return gpui::Empty.into_any_element(); + }; + if !active || item.is_disabled() { + return gpui::Empty.into_any_element(); + } + let metrics = QuestionnaireMetrics::new(self.size, cx); + + if item.is_multiple() { + div() + .id(element_id(&self.state, format!("choices-{}", self.item))) + .role(Role::Group) + .flex() + .flex_col() + .gap(metrics.choice_gap) + .w_full() + .refine_style(&self.style) + .children(self.children) + .into_any_element() + } else { + RadioGroup::new(element_id(&self.state, format!("choices-{}", self.item))) + .flex() + .flex_col() + .gap(metrics.choice_gap) + .w_full() + .refine_style(&self.style) + .children(self.children) + .into_any_element() + } + } +} + +/// A selectable base-nova choice card. +#[derive(IntoElement)] +pub struct QuestionnaireChoice { + state: Entity, + item: SharedString, + value: SharedString, + style: StyleRefinement, + indicator_style: StyleRefinement, + content_style: StyleRefinement, + shortcut_style: StyleRefinement, + size: Size, + children: Vec, + indicator_renderer: Option, + shortcut_renderer: Option, +} + +impl QuestionnaireChoice { + pub fn new( + state: &Entity, + item: impl Into, + value: impl Into, + ) -> Self { + Self { + state: state.clone(), + item: item.into(), + value: value.into(), + style: StyleRefinement::default(), + indicator_style: StyleRefinement::default(), + content_style: StyleRefinement::default(), + shortcut_style: StyleRefinement::default(), + size: Size::Medium, + children: Vec::new(), + indicator_renderer: None, + shortcut_renderer: None, + } + } + + pub fn indicator_style(mut self, style: StyleRefinement) -> Self { + self.indicator_style = style; + self + } + + pub fn content_style(mut self, style: StyleRefinement) -> Self { + self.content_style = style; + self + } + + pub fn shortcut_style(mut self, style: StyleRefinement) -> Self { + self.shortcut_style = style; + self + } + + pub fn render_indicator( + mut self, + renderer: impl Fn(&QuestionnaireChoiceState, &mut Window, &mut App) -> AnyElement + 'static, + ) -> Self { + self.indicator_renderer = Some(Rc::new(renderer)); + self + } + + pub fn render_shortcut( + mut self, + renderer: impl Fn(&QuestionnaireChoiceState, &mut Window, &mut App) -> AnyElement + 'static, + ) -> Self { + self.shortcut_renderer = Some(Rc::new(renderer)); + self + } +} + +impl Styled for QuestionnaireChoice { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl Sizable for QuestionnaireChoice { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +impl ParentElement for QuestionnaireChoice { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +#[allow(clippy::too_many_arguments)] +fn style_choice_card( + base: T, + indicator: AnyElement, + content: AnyElement, + shortcut: AnyElement, + metrics: QuestionnaireMetrics, + selected: bool, + disabled: bool, + invalid: bool, + focused: bool, + instance_style: &StyleRefinement, + window: &Window, + cx: &App, +) -> T +where + T: Styled + ParentElement + StatefulInteractiveElement + gpui::prelude::FluentBuilder, +{ + let tokens = cx.theme().semantic_tokens(); + base.flex() + .items_start() + .gap(metrics.choice_gap) + .w_full() + .min_h(metrics.choice_min_height) + .px(metrics.choice_padding_x) + .py(metrics.choice_padding_y) + .border_1() + .border_color(if invalid { + tokens.colors.destructive + } else if selected { + tokens.colors.primary.opacity(0.4) + } else { + tokens.colors.input + }) + .bg(if selected { + tokens.colors.muted + } else { + tokens.colors.background.opacity(0.) + }) + .rounded(tokens.radius.lg) + .when(!disabled, |this| { + this.hover(|style| style.bg(tokens.colors.muted.opacity(0.5))) + }) + .when(focused, |this| this.focus_ring_style(window, cx)) + .when(disabled, |this| this.opacity(0.5)) + .refine_style(instance_style) + .child(indicator) + .child(content) + .child(shortcut) +} + +impl RenderOnce for QuestionnaireChoice { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = self.state.read(cx); + let active = state.current_item().is_some_and(|name| name == &self.item); + let Some(choice_state) = state.choice_state(&self.item, &self.value) else { + return gpui::Empty.into_any_element(); + }; + if !active { + return gpui::Empty.into_any_element(); + } + let Some(item) = state.item_state(&self.item) else { + return gpui::Empty.into_any_element(); + }; + let Some(definition) = state.choice_definition(&self.item, &self.value) else { + return gpui::Empty.into_any_element(); + }; + let multiple = item.is_multiple(); + let label = definition.accessibility_label().clone(); + let description = definition.description().cloned(); + let position = state.item_definition(&self.item).and_then(|item| { + let enabled: Vec<_> = item + .choices() + .iter() + .filter(|choice| { + state + .choice_state(&self.item, choice.value()) + .is_some_and(|choice| !choice.is_disabled()) + }) + .collect(); + enabled + .iter() + .position(|choice| choice.value() == &self.value) + .map(|position| (position + 1, enabled.len())) + }); + let selected = choice_state.is_selected(); + let disabled = choice_state.is_disabled(); + let invalid = choice_state.is_invalid(); + let shortcut = choice_state.shortcut().cloned(); + let focus_handle = state.choice_focus_handle(&self.item, &self.value).cloned(); + let colors = cx.theme().semantic_tokens().colors; + let radius = cx.theme().semantic_tokens().radius; + let metrics = QuestionnaireMetrics::new(self.size, cx); + let focused = focus_handle + .as_ref() + .is_some_and(|focus_handle| focus_handle.is_focused(window)); + let has_children = !self.children.is_empty(); + + let default_indicator = || { + div() + .relative() + .flex() + .items_center() + .justify_center() + .flex_shrink_0() + .size(metrics.indicator_size) + .border_1() + .border_color(if selected { + colors.primary + } else { + colors.input + }) + .bg(if selected { + colors.primary + } else { + colors.background + }) + .when(multiple, |this| this.rounded(radius.sm)) + .when(!multiple, |this| this.rounded(radius.full)) + .refine_style(&self.indicator_style) + .when(selected && multiple, |this| { + this.child( + svg() + .size(metrics.indicator_mark_size) + .path(IconName::Check.path()) + .text_color(colors.primary_foreground), + ) + }) + .when(selected && !multiple, |this| { + this.child( + div() + .size(metrics.indicator_mark_size) + .rounded(radius.full) + .bg(colors.primary_foreground), + ) + }) + .into_any_element() + }; + + let indicator = self + .indicator_renderer + .as_ref() + .map(|renderer| renderer(&choice_state, window, cx)) + .unwrap_or_else(default_indicator); + + let content = div() + .flex() + .flex_1() + .flex_col() + .gap(metrics.content_gap) + .refine_style(&self.content_style) + .when(!has_children, |this| { + this.child(text_style( + div().text_color(colors.foreground).child(label.clone()), + self.size, + cx, + )) + .when_some(description.clone(), |this, description| { + this.child(text_style( + div().text_color(colors.muted_foreground).child(description), + self.size.smaller(), + cx, + )) + }) + }) + .children(self.children); + + let default_shortcut = || { + let Some(shortcut) = shortcut.clone() else { + return gpui::Empty.into_any_element(); + }; + let Ok(keystroke) = gpui::Keystroke::parse(&shortcut.to_lowercase()) else { + return gpui::Empty.into_any_element(); + }; + Kbd::new(keystroke) + .outline() + .bg(colors.background) + .border_color(colors.input) + .text_color(colors.muted_foreground) + .refine_style(&self.shortcut_style) + .into_any_element() + }; + let shortcut_element = self + .shortcut_renderer + .as_ref() + .map(|renderer| renderer(&choice_state, window, cx)) + .unwrap_or_else(default_shortcut); + + let id = element_id(&self.state, format!("choice-{}-{}", self.item, self.value)); + let instance_style = self.style.clone(); + let state = self.state.clone(); + let item_name = self.item.clone(); + let choice_value = self.value.clone(); + + if multiple { + let callback_state = state.clone(); + let callback_item = item_name.clone(); + let callback_value = choice_value.clone(); + let confirm_state = state.clone(); + let base = Checkbox::new(id) + .state(if selected { + CheckboxState::Checked + } else { + CheckboxState::Unchecked + }) + .disabled(disabled) + .accessibility_label(label) + .when_some(description.clone(), |this, description| { + this.aria_description(description) + }) + .when_some(position, |this, (position, total)| { + this.aria_position_in_set(position).aria_size_of_set(total) + }) + .when_some(focus_handle, |this, focus_handle| { + this.track_focus(&focus_handle) + }) + .capture_key_down(move |event, window, cx| { + if selected + && !window.default_prevented() + && !event.is_held + && event.keystroke.key == "enter" + && event.keystroke.modifiers.number_of_modifiers() == 0 + && confirm_state.update(cx, |state, cx| state.confirm_current(window, cx)) + { + window.prevent_default(); + } + }) + .on_change(move |_, _, window, cx| { + let _ = callback_state.update(cx, |state, cx| { + let result = state.activate_choice(&callback_item, &callback_value, cx); + state.focus_choice(&callback_item, &callback_value, window, cx); + result + }); + }); + style_choice_card( + base, + indicator, + content.into_any_element(), + shortcut_element, + metrics, + selected, + disabled, + invalid, + focused, + &instance_style, + window, + cx, + ) + .into_any_element() + } else { + let confirm_state = state.clone(); + let base = Radio::new(id) + .checked(selected) + .disabled(disabled) + .accessibility_label(label) + .when_some(description, |this, description| { + this.aria_description(description) + }) + .when_some(position, |this, (position, total)| { + this.set_position(position, total) + }) + .when_some(focus_handle, |this, focus_handle| { + this.track_focus(&focus_handle) + }) + .capture_key_down(move |event, window, cx| { + if selected + && !window.default_prevented() + && !event.is_held + && event.keystroke.key == "enter" + && event.keystroke.modifiers.number_of_modifiers() == 0 + && confirm_state.update(cx, |state, cx| state.confirm_current(window, cx)) + { + window.prevent_default(); + } + }) + .on_change(move |_, _, window, cx| { + let _ = state.update(cx, |state, cx| { + let result = state.activate_choice(&item_name, &choice_value, cx); + state.focus_choice(&item_name, &choice_value, window, cx); + result + }); + }); + style_choice_card( + base, + indicator, + content.into_any_element(), + shortcut_element, + metrics, + selected, + disabled, + invalid, + focused, + &instance_style, + window, + cx, + ) + .into_any_element() + } + } +} + +/// Secondary text for custom choice compositions. +#[derive(IntoElement)] +pub struct QuestionnaireChoiceDescription { + style: StyleRefinement, + size: Size, + children: Vec, +} + +impl QuestionnaireChoiceDescription { + pub fn new() -> Self { + Self { + style: StyleRefinement::default(), + size: Size::Medium, + children: Vec::new(), + } + } +} + +impl Default for QuestionnaireChoiceDescription { + fn default() -> Self { + Self::new() + } +} + +impl Styled for QuestionnaireChoiceDescription { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl Sizable for QuestionnaireChoiceDescription { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +impl ParentElement for QuestionnaireChoiceDescription { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for QuestionnaireChoiceDescription { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let colors = cx.theme().semantic_tokens().colors; + text_style( + div().text_color(colors.muted_foreground), + self.size.smaller(), + cx, + ) + .refine_style(&self.style) + .children(self.children) + } +} + +/// The optional freeform answer input for an item. +#[derive(IntoElement)] +pub struct QuestionnaireInput { + state: Entity, + item: SharedString, + style: StyleRefinement, + size: Size, +} + +impl QuestionnaireInput { + pub fn new(state: &Entity, item: impl Into) -> Self { + Self { + state: state.clone(), + item: item.into(), + style: StyleRefinement::default(), + size: Size::Medium, + } + } +} + +impl Styled for QuestionnaireInput { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl Sizable for QuestionnaireInput { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +impl RenderOnce for QuestionnaireInput { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let state = self.state.read(cx); + let active = state.current_item().is_some_and(|name| name == &self.item); + let Some(item_state) = state.item_state(&self.item) else { + return gpui::Empty.into_any_element(); + }; + let Some(definition) = state.item_definition(&self.item) else { + return gpui::Empty.into_any_element(); + }; + let Some(input_definition) = definition.input() else { + return gpui::Empty.into_any_element(); + }; + if !active { + return gpui::Empty.into_any_element(); + } + + Input::new(input_definition.state()) + .aria_label(input_definition.accessibility_label().clone()) + .disabled(item_state.is_disabled() || input_definition.is_disabled()) + .with_size(self.size) + .when(item_state.is_invalid(), |this| { + this.border_color(cx.theme().semantic_tokens().colors.destructive) + }) + .refine_style(&self.style) + .into_any_element() + } +} + +/// Validation error for an item. It only enters the tree while invalid. +#[derive(IntoElement)] +pub struct QuestionnaireError { + state: Entity, + item: SharedString, + style: StyleRefinement, + size: Size, + children: Vec, +} + +impl QuestionnaireError { + pub fn new(state: &Entity, item: impl Into) -> Self { + Self { + state: state.clone(), + item: item.into(), + style: StyleRefinement::default(), + size: Size::Medium, + children: Vec::new(), + } + } +} + +impl Styled for QuestionnaireError { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl Sizable for QuestionnaireError { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +impl ParentElement for QuestionnaireError { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +fn questionnaire_error_root(id: ElementId) -> gpui::Stateful { + div().id(id).role(Role::Alert) +} + +impl RenderOnce for QuestionnaireError { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let state = self.state.read(cx); + let Some(item) = state.item_state(&self.item) else { + return gpui::Empty.into_any_element(); + }; + let error = state.error(&self.item).cloned(); + if !item.is_invalid() || error.is_none() { + return gpui::Empty.into_any_element(); + } + let has_children = !self.children.is_empty(); + let colors = cx.theme().semantic_tokens().colors; + let spacing = cx.theme().semantic_tokens().spacing; + + text_style( + questionnaire_error_root(element_id(&self.state, format!("error-{}", self.item))) + .mt(spacing.sm) + .text_color(colors.destructive), + self.size, + cx, + ) + .refine_style(&self.style) + .when(!has_children, |this| { + this.when_some(error, |this, error| this.child(error)) + }) + .children(self.children) + .into_any_element() + } +} + +/// Layout part for questionnaire navigation actions. +#[derive(IntoElement)] +pub struct QuestionnaireActions { + state: Entity, + style: StyleRefinement, + size: Size, + children: Vec, +} + +impl QuestionnaireActions { + pub fn new(state: &Entity) -> Self { + Self { + state: state.clone(), + style: StyleRefinement::default(), + size: Size::Medium, + children: Vec::new(), + } + } +} + +impl Styled for QuestionnaireActions { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl Sizable for QuestionnaireActions { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +impl ParentElement for QuestionnaireActions { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for QuestionnaireActions { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let metrics = QuestionnaireMetrics::new(self.size, cx); + let debug_selector = format!("questionnaire-{}-actions", self.state.entity_id()); + div() + .id(element_id(&self.state, "actions")) + .debug_selector(move || debug_selector) + .flex() + .min_w_0() + .items_center() + .justify_end() + .gap(metrics.choice_gap) + .w_full() + .refine_style(&self.style) + .children(self.children) + } +} + +#[derive(Clone, Copy)] +enum QuestionnaireAction { + Previous, + Skip, + Next, + Submit, +} + +macro_rules! questionnaire_action_part { + ($name:ident, $action:ident, $translation:literal, $outline:expr, $primary:expr) => { + #[derive(IntoElement)] + pub struct $name { + state: Entity, + style: StyleRefinement, + size: Size, + children: Vec, + } + + impl $name { + pub fn new(state: &Entity) -> Self { + Self { + state: state.clone(), + style: StyleRefinement::default(), + size: Size::Medium, + children: Vec::new(), + } + } + } + + impl Styled for $name { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } + } + + impl Sizable for $name { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } + } + + impl ParentElement for $name { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } + } + + impl RenderOnce for $name { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let navigation = self.state.read(cx).navigation_state(); + let action = QuestionnaireAction::$action; + let visible = match action { + QuestionnaireAction::Previous => navigation.is_previous_visible(), + QuestionnaireAction::Skip => navigation.is_skip_visible(), + QuestionnaireAction::Next => navigation.is_next_visible(), + QuestionnaireAction::Submit => navigation.is_submit_visible(), + }; + if !visible { + return gpui::Empty.into_any_element(); + } + + let state = self.state.clone(); + let has_children = !self.children.is_empty(); + let debug_selector = format!( + "questionnaire-{}-{}", + self.state.entity_id(), + stringify!($action) + ); + let button = Button::new(element_id(&self.state, stringify!($action))) + .debug_selector(move || debug_selector) + .with_size(self.size) + .when($outline, |this| this.outline()) + .when($primary, |this| this.primary()) + .on_click(move |_, window, cx| { + state.update(cx, |state, cx| match action { + QuestionnaireAction::Previous => state.go_previous(window, cx), + QuestionnaireAction::Skip => state.skip_current(window, cx), + QuestionnaireAction::Next => state.go_next(window, cx), + QuestionnaireAction::Submit => state.submit(window, cx), + }); + }) + .refine_style(&self.style) + .when(!has_children, |this| this.label(t!($translation))) + .children(self.children); + + if matches!(action, QuestionnaireAction::Previous) { + div() + .flex() + .flex_1() + .min_w_0() + .justify_start() + .child(button.max_w_full()) + .into_any_element() + } else { + button.into_any_element() + } + } + } + }; +} + +questionnaire_action_part!( + QuestionnairePrevious, + Previous, + "Questionnaire.previous", + true, + false +); +questionnaire_action_part!(QuestionnaireSkip, Skip, "Questionnaire.skip", true, false); +questionnaire_action_part!(QuestionnaireNext, Next, "Questionnaire.next", false, true); +questionnaire_action_part!( + QuestionnaireSubmit, + Submit, + "Questionnaire.submit", + false, + true +); + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{ + AppContext as _, Context, Element as _, Focusable as _, Keystroke, Render, TestAppContext, + VisualTestContext, accesskit, px, + }; + + use super::super::{ + QuestionnaireChoiceDefinition, QuestionnaireInputDefinition, QuestionnaireItemDefinition, + QuestionnaireShortcutMode, + }; + + #[test] + fn compound_parts_support_builder_customization() { + let _ = QuestionnaireChoiceDescription::new() + .small() + .opacity(0.8) + .child("Description"); + } + + struct QuestionnaireHarness { + state: Entity, + } + + impl Render for QuestionnaireHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Questionnaire::new(&self.state) + .size(px(480.)) + .child( + QuestionnaireItem::new(&self.state, "choice") + .child( + QuestionnaireChoices::new(&self.state, "choice") + .child(QuestionnaireChoice::new(&self.state, "choice", "alpha")), + ) + .child(QuestionnaireInput::new(&self.state, "choice")), + ) + .child( + QuestionnaireItem::new(&self.state, "first") + .child( + QuestionnaireChoices::new(&self.state, "first") + .child(QuestionnaireChoice::new(&self.state, "first", "alpha")) + .child(QuestionnaireChoice::new(&self.state, "first", "beta")), + ) + .child(QuestionnaireInput::new(&self.state, "first")), + ) + .child( + QuestionnaireItem::new(&self.state, "second") + .child(QuestionnaireInput::new(&self.state, "second")), + ) + .child( + QuestionnaireActions::new(&self.state) + .child(QuestionnairePrevious::new(&self.state)) + .child(QuestionnaireSkip::new(&self.state)) + .child(QuestionnaireNext::new(&self.state)) + .child(QuestionnaireSubmit::new(&self.state)), + ) + } + } + + fn visual_harness( + cx: &mut TestAppContext, + items: Vec, + shortcuts: Option, + ) -> (&mut VisualTestContext, Entity) { + cx.update(crate::init); + let (view, cx) = cx.add_window_view(move |_, cx| { + let state = cx.new(|cx| { + let state = QuestionnaireState::new(items, cx).unwrap(); + match shortcuts { + Some(shortcuts) => state.with_shortcuts(shortcuts), + None => state, + } + }); + QuestionnaireHarness { state } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let state = cx.update(|_, cx| view.read(cx).state.clone()); + cx.update(|window, cx| { + let focus_handle = state.read(cx).focus_handle().clone(); + focus_handle.focus(window, cx); + }); + (cx, state) + } + + fn input_visual_harness( + cx: &mut TestAppContext, + multiple: bool, + ) -> (&mut VisualTestContext, Entity) { + cx.update(crate::init); + let (view, cx) = cx.add_window_view(move |window, cx| { + let input = cx.new(|cx| crate::input::InputState::new(window, cx)); + let state = cx.new(|cx| { + QuestionnaireState::new( + vec![ + QuestionnaireItemDefinition::new("first", "First") + .with_required(true) + .with_multiple(multiple) + .with_choices([ + QuestionnaireChoiceDefinition::new("alpha", "Alpha"), + QuestionnaireChoiceDefinition::new("beta", "Beta"), + ]) + .with_input(QuestionnaireInputDefinition::new(input, "Other")), + QuestionnaireItemDefinition::new("second", "Second"), + ], + cx, + ) + .unwrap() + }); + QuestionnaireHarness { state } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let state = cx.update(|_, cx| view.read(cx).state.clone()); + (cx, state) + } + + fn actions_visual_harness( + cx: &mut TestAppContext, + ) -> (&mut VisualTestContext, Entity) { + cx.update(crate::init); + let (view, cx) = cx.add_window_view(|_, cx| { + let state = cx.new(|cx| { + QuestionnaireState::new( + vec![ + QuestionnaireItemDefinition::new("first", "First"), + QuestionnaireItemDefinition::new("second", "Second"), + QuestionnaireItemDefinition::new("third", "Third"), + ], + cx, + ) + .unwrap() + .with_current_item("second") + .unwrap() + }); + QuestionnaireHarness { state } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let state = cx.update(|_, cx| view.read(cx).state.clone()); + (cx, state) + } + + fn focus_input(cx: &mut VisualTestContext, state: &Entity, item: &str) { + cx.update(|window, cx| { + let input = state.read(cx).input_state(item).unwrap(); + let focus_handle = input.read(cx).focus_handle(cx); + focus_handle.focus(window, cx); + }); + } + + fn simulate_key(cx: &mut VisualTestContext, key: &str, is_held: bool, simulate_ime: bool) { + let mut keystroke = Keystroke::parse(key).unwrap(); + if simulate_ime { + keystroke = keystroke.with_simulated_ime(); + } + cx.simulate_event(KeyDownEvent { + keystroke, + is_held, + prefer_character_input: false, + }); + } + + #[gpui::test] + fn progress_projects_numeric_accessibility(cx: &mut TestAppContext) { + cx.update(crate::init); + let window = cx.add_empty_window(); + window.update(|window, cx| { + let state = cx.new(|cx| { + QuestionnaireState::new( + vec![ + QuestionnaireItemDefinition::new("first", "First"), + QuestionnaireItemDefinition::new("second", "Second"), + ], + cx, + ) + .unwrap() + }); + let mut node = accesskit::Node::new(Role::ProgressIndicator); + QuestionnaireProgress::new(&state) + .render(window, cx) + .into_element() + .write_a11y_info(&mut node); + + assert_eq!(node.numeric_value(), Some(1.)); + assert_eq!(node.min_numeric_value(), Some(0.)); + assert_eq!(node.max_numeric_value(), Some(2.)); + + let root = Questionnaire::new(&state).render(window, cx).into_element(); + assert_eq!(root.a11y_role(), Some(Role::Form)); + }); + } + + #[gpui::test] + fn actions_stay_inside_questionnaire_width(cx: &mut TestAppContext) { + let (cx, state) = actions_visual_harness(cx); + let entity_id = state.entity_id(); + let root_id = Box::leak(format!("questionnaire-{entity_id}-root").into_boxed_str()); + let actions_id = Box::leak(format!("questionnaire-{entity_id}-actions").into_boxed_str()); + let previous_id = Box::leak(format!("questionnaire-{entity_id}-Previous").into_boxed_str()); + let skip_id = Box::leak(format!("questionnaire-{entity_id}-Skip").into_boxed_str()); + let next_id = Box::leak(format!("questionnaire-{entity_id}-Next").into_boxed_str()); + let root = cx.debug_bounds(root_id).expect("questionnaire rendered"); + let actions = cx.debug_bounds(actions_id).expect("actions rendered"); + let previous = cx.debug_bounds(previous_id).expect("previous rendered"); + let skip = cx.debug_bounds(skip_id).expect("skip rendered"); + let next = cx.debug_bounds(next_id).expect("next rendered"); + + assert!(actions.left() >= root.left()); + assert!(actions.right() <= root.right()); + assert!(previous.left() >= actions.left()); + assert!(previous.right() <= skip.left()); + assert!(skip.right() <= next.left()); + assert!(next.right() <= actions.right()); + } + + #[gpui::test] + fn shortcut_guards_held_keys_before_activation(cx: &mut TestAppContext) { + let (cx, state) = visual_harness( + cx, + vec![ + QuestionnaireItemDefinition::new("choice", "Choice") + .with_choice(QuestionnaireChoiceDefinition::new("alpha", "Alpha")), + QuestionnaireItemDefinition::new("second", "Second"), + ], + Some(QuestionnaireShortcutMode::Letters), + ); + + simulate_key(cx, "a", true, true); + simulate_key(cx, "a", false, false); + simulate_key(cx, "shift-a", false, true); + cx.update(|_, cx| assert!(state.read(cx).answer("choice").unwrap().is_empty())); + + simulate_key(cx, "a", false, true); + cx.update(|window, cx| { + assert_eq!( + state.read(cx).answer("choice").unwrap().choices(), + &[SharedString::from("alpha")] + ); + assert_eq!( + state + .read(cx) + .focused_current_choice(window) + .map(SharedString::as_ref), + Some("alpha") + ); + }); + simulate_key(cx, "enter", false, false); + cx.update(|_, cx| { + assert_eq!(state.read(cx).current_item().unwrap().as_ref(), "second"); + }); + } + + #[gpui::test] + fn root_keyboard_preserves_navigation_and_radio_semantics(cx: &mut TestAppContext) { + let (cx, state) = visual_harness( + cx, + vec![ + QuestionnaireItemDefinition::new("first", "First") + .with_required(true) + .with_choices([ + QuestionnaireChoiceDefinition::new("alpha", "Alpha"), + QuestionnaireChoiceDefinition::new("beta", "Beta"), + ]), + QuestionnaireItemDefinition::new("second", "Second"), + ], + None, + ); + + simulate_key(cx, "right", false, false); + cx.update(|_, cx| { + assert_eq!(state.read(cx).current_item().unwrap().as_ref(), "first"); + assert!(state.read(cx).answer("first").unwrap().is_empty()); + }); + + cx.update(|window, cx| { + let focus_handle = state + .read(cx) + .choice_focus_handle("first", "alpha") + .unwrap() + .clone(); + focus_handle.focus(window, cx); + }); + simulate_key(cx, "enter", false, false); + cx.update(|_, cx| { + assert_eq!(state.read(cx).current_item().unwrap().as_ref(), "first"); + assert!(state.read(cx).answer("first").unwrap().is_empty()); + }); + + simulate_key(cx, "right", false, false); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.update(|_, cx| { + assert_eq!( + state.read(cx).answer("first").unwrap().choices(), + &[SharedString::from("beta")] + ); + assert_eq!(state.read(cx).current_item().unwrap().as_ref(), "first"); + }); + + simulate_key(cx, "enter", false, false); + cx.update(|_, cx| { + assert_eq!(state.read(cx).current_item().unwrap().as_ref(), "second"); + }); + } + + #[gpui::test] + fn empty_input_enter_stays_put_and_arrows_move_to_answers(cx: &mut TestAppContext) { + let (cx, state) = input_visual_harness(cx, false); + focus_input(cx, &state, "first"); + + simulate_key(cx, "enter", false, false); + cx.update(|_, cx| { + assert_eq!(state.read(cx).current_item().unwrap().as_ref(), "first"); + assert!(state.read(cx).answer("first").unwrap().is_empty()); + assert!(state.read(cx).error("first").is_none()); + }); + + simulate_key(cx, "secondary-enter", false, false); + cx.update(|_, cx| { + assert_eq!(state.read(cx).current_item().unwrap().as_ref(), "first"); + assert!(state.read(cx).error("first").is_some()); + }); + + simulate_key(cx, "up", false, false); + cx.update(|window, cx| { + assert_eq!( + state + .read(cx) + .focused_current_choice(window) + .map(SharedString::as_ref), + Some("beta") + ); + }); + + cx.update(|window, cx| { + state.update(cx, |state, cx| { + state + .set_input_value("first", "Preserved draft", window, cx) + .unwrap(); + state.activate_choice("first", "alpha", cx).unwrap(); + }); + }); + focus_input(cx, &state, "first"); + simulate_key(cx, "enter", false, false); + cx.update(|_, cx| { + let answer = state.read(cx).answer("first").unwrap(); + assert_eq!(state.read(cx).current_item().unwrap().as_ref(), "first"); + assert_eq!(answer.choices(), &[SharedString::from("alpha")]); + assert!(answer.freeform().is_none()); + }); + } + + #[gpui::test] + fn filled_group_input_keeps_text_editing_directions(cx: &mut TestAppContext) { + let (cx, state) = input_visual_harness(cx, true); + cx.update(|window, cx| { + state + .update(cx, |state, cx| { + state.set_input_value("first", "Freeform answer", window, cx) + }) + .unwrap(); + }); + focus_input(cx, &state, "first"); + + simulate_key(cx, "down", false, false); + cx.update(|window, cx| { + let state = state.read(cx); + assert!(state.is_current_input_focused(window)); + assert_eq!( + state + .answer("first") + .unwrap() + .freeform() + .map(SharedString::as_ref), + Some("Freeform answer") + ); + assert!(state.answer("first").unwrap().choices().is_empty()); + }); + } + + #[test] + fn invalid_error_projects_alert_role() { + let error = questionnaire_error_root("questionnaire-error-test".into()).into_element(); + assert_eq!(error.a11y_role(), Some(Role::Alert)); + } +} diff --git a/crates/component/src/questionnaire/mod.rs b/crates/component/src/questionnaire/mod.rs new file mode 100644 index 0000000000..61c51a86ec --- /dev/null +++ b/crates/component/src/questionnaire/mod.rs @@ -0,0 +1,11 @@ +//! Composable questionnaire state, controls, navigation and validation. + +mod components; +mod state; +mod types; + +pub use components::*; +pub use state::*; +pub use types::*; + +pub(crate) fn init(_: &mut gpui::App) {} diff --git a/crates/component/src/questionnaire/state.rs b/crates/component/src/questionnaire/state.rs new file mode 100644 index 0000000000..648ceba5c2 --- /dev/null +++ b/crates/component/src/questionnaire/state.rs @@ -0,0 +1,1669 @@ +use std::collections::HashSet; + +use gpui::{ + App, Context, Entity, EventEmitter, FocusHandle, Focusable as _, SharedString, Subscription, + Window, +}; +use rust_i18n::t; + +use crate::input::{InputEvent, InputState}; + +use super::types::*; + +struct ItemRuntime { + disabled: bool, + choice_disabled: Vec, + input_disabled: bool, + answer: QuestionnaireAnswer, + initial_answer: QuestionnaireAnswer, + initial_input_value: Option, + skipped: bool, + validation_attempted: bool, + internal_error: Option, + external_error: Option, + focus_handle: FocusHandle, + choice_focus_handles: Vec, + input_focus_handle: Option, +} + +/// Owns questionnaire answers, validation, navigation and focus state. +pub struct QuestionnaireState { + items: Vec, + runtime: Vec, + current: Option, + initial_current: Option, + shortcut_mode: Option, + complete: bool, + focus_handle: FocusHandle, + _subscriptions: Vec, +} + +impl QuestionnaireState { + pub fn new( + items: Vec, + cx: &mut Context, + ) -> Result { + Self::validate_schema(&items)?; + + let mut runtime = Vec::with_capacity(items.len()); + let mut subscriptions = Vec::new(); + + for item in &items { + let mut answer = QuestionnaireAnswer::new().with_choices( + item.choices() + .iter() + .filter(|choice| choice.is_default_selected() && !choice.is_disabled()) + .map(|choice| choice.value().clone()), + ); + + let mut initial_input_value = None; + let mut input_focus_handle = None; + if let Some(input) = item.input() { + let state = input.state(); + state.update(cx, |state, cx| { + state.set_disabled(item.is_disabled() || input.is_disabled(), cx); + }); + + let value = state.read(cx).value(); + initial_input_value = Some(value.clone()); + input_focus_handle = Some(state.focus_handle(cx)); + if !value.trim().is_empty() && !input.is_disabled() { + if !item.is_multiple() { + answer = QuestionnaireAnswer::new(); + } + answer.freeform = Some(value); + } + + subscriptions.push(cx.subscribe(state, |this, input, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + this.on_input_change(&input, cx); + } + })); + } + + runtime.push(ItemRuntime { + disabled: item.is_disabled(), + choice_disabled: item + .choices() + .iter() + .map(QuestionnaireChoiceDefinition::is_disabled) + .collect(), + input_disabled: item + .input() + .is_none_or(QuestionnaireInputDefinition::is_disabled), + initial_answer: answer.clone(), + initial_input_value, + answer, + skipped: false, + validation_attempted: false, + internal_error: None, + external_error: None, + focus_handle: cx.focus_handle(), + choice_focus_handles: item.choices().iter().map(|_| cx.focus_handle()).collect(), + input_focus_handle, + }); + } + + let current = runtime.iter().position(|item| !item.disabled); + let initial_current = current.map(|ix| items[ix].name().clone()); + + Ok(Self { + items, + runtime, + current, + initial_current, + shortcut_mode: None, + complete: false, + focus_handle: cx.focus_handle(), + _subscriptions: subscriptions, + }) + } + + fn validate_schema( + items: &[QuestionnaireItemDefinition], + ) -> Result<(), QuestionnaireStateError> { + let mut item_names = HashSet::new(); + for item in items { + if !item_names.insert(item.name().to_string()) { + return Err(QuestionnaireStateError::DuplicateItem(item.name().clone())); + } + + let mut choices = HashSet::new(); + let mut defaults = 0; + for choice in item.choices() { + if !choices.insert(choice.value().to_string()) { + return Err(QuestionnaireStateError::DuplicateChoice { + item: item.name().clone(), + choice: choice.value().clone(), + }); + } + defaults += usize::from(choice.is_default_selected()); + } + if !item.is_multiple() && defaults > 1 { + return Err(QuestionnaireStateError::MultipleDefaultsForSingleItem( + item.name().clone(), + )); + } + } + Ok(()) + } + + pub fn with_current_item( + mut self, + name: impl Into, + ) -> Result { + let name = name.into(); + let ix = self.item_ix(&name)?; + if !self.runtime[ix].disabled { + self.current = Some(ix); + self.initial_current = Some(name); + } + Ok(self) + } + + pub fn with_shortcuts(mut self, mode: QuestionnaireShortcutMode) -> Self { + self.shortcut_mode = Some(mode); + self + } + + pub fn current_item(&self) -> Option<&SharedString> { + self.current.map(|ix| self.items[ix].name()) + } + + pub fn current_ix(&self) -> Option { + let current = self.current?; + self.enabled_indices().position(|ix| ix == current) + } + + pub fn total(&self) -> usize { + self.enabled_indices().count() + } + + pub fn progress(&self) -> QuestionnaireProgressState { + QuestionnaireProgressState::new(self.current_ix().map_or(0, |ix| ix + 1), self.total()) + } + + pub fn item_definition(&self, name: &str) -> Option<&QuestionnaireItemDefinition> { + self.items.iter().find(|item| item.name().as_ref() == name) + } + + pub fn choice_definition( + &self, + item: &str, + value: &str, + ) -> Option<&QuestionnaireChoiceDefinition> { + self.item_definition(item)? + .choices() + .iter() + .find(|choice| choice.value().as_ref() == value) + } + + pub fn item_state(&self, name: &str) -> Option { + let ix = self.item_ix_opt(name)?; + let definition = &self.items[ix]; + Some(QuestionnaireItemState::new( + definition.name().clone(), + self.status(ix), + definition.is_required(), + definition.is_multiple(), + self.runtime[ix].disabled, + self.error_at(ix).is_some(), + definition.input().is_some(), + )) + } + + pub fn choice_state(&self, item: &str, value: &str) -> Option { + let item_ix = self.item_ix_opt(item)?; + let choice_ix = self.choice_ix_opt(item_ix, value)?; + let runtime = &self.runtime[item_ix]; + let definition = &self.items[item_ix].choices()[choice_ix]; + Some(QuestionnaireChoiceState::new( + definition.value().clone(), + runtime.answer.choices.contains(definition.value()), + runtime.disabled || runtime.choice_disabled[choice_ix], + self.error_at(item_ix).is_some(), + self.shortcut_for_choice(item, value), + )) + } + + pub fn navigation_state(&self) -> QuestionnaireNavigationState { + let Some(ix) = self.current_ix() else { + return QuestionnaireNavigationState::default(); + }; + let total = self.total(); + let item_ix = self + .current + .expect("current item exists when enabled index exists"); + QuestionnaireNavigationState::new( + ix > 0, + ix + 1 < total, + !self.items[item_ix].is_required(), + ix + 1 == total, + self.status(item_ix) != QuestionnaireItemStatus::Unanswered, + ) + } + + pub fn answer(&self, name: &str) -> Option { + let ix = self.item_ix_opt(name)?; + Some(self.effective_answer(ix)) + } + + pub fn answers(&self) -> QuestionnaireAnswers { + QuestionnaireAnswers::from_entries( + self.enabled_indices() + .map(|ix| (self.items[ix].name().clone(), self.effective_answer(ix))) + .collect(), + ) + } + + pub fn error(&self, name: &str) -> Option<&SharedString> { + self.item_ix_opt(name).and_then(|ix| self.error_at(ix)) + } + + pub fn is_complete(&self) -> bool { + self.complete + } + + pub fn input_state(&self, name: &str) -> Option> { + self.item_definition(name)? + .input() + .map(|input| input.state().clone()) + } + + pub fn focus_handle(&self) -> &FocusHandle { + &self.focus_handle + } + + pub fn item_focus_handle(&self, name: &str) -> Option<&FocusHandle> { + self.item_ix_opt(name) + .map(|ix| &self.runtime[ix].focus_handle) + } + + pub fn choice_focus_handle(&self, item: &str, value: &str) -> Option<&FocusHandle> { + let item_ix = self.item_ix_opt(item)?; + let choice_ix = self.choice_ix_opt(item_ix, value)?; + Some(&self.runtime[item_ix].choice_focus_handles[choice_ix]) + } + + pub fn is_current_input_focused(&self, window: &Window) -> bool { + let Some(ix) = self.current else { return false }; + self.runtime[ix] + .input_focus_handle + .as_ref() + .is_some_and(|handle| handle.is_focused(window)) + } + + pub(crate) fn current_input_has_text(&self, cx: &App) -> bool { + let Some(item_ix) = self.current else { + return false; + }; + self.items[item_ix] + .input() + .is_some_and(|input| !input.state().read(cx).value().trim().is_empty()) + } + + pub fn focused_current_choice(&self, window: &Window) -> Option<&SharedString> { + let ix = self.current?; + self.runtime[ix] + .choice_focus_handles + .iter() + .position(|handle| handle.is_focused(window)) + .map(|choice_ix| self.items[ix].choices()[choice_ix].value()) + } + + pub fn shortcut_mode(&self) -> Option { + self.shortcut_mode + } + + pub fn shortcut_for_choice(&self, item: &str, value: &str) -> Option { + let mode = self.shortcut_mode?; + let item_ix = self.item_ix_opt(item)?; + let choice_ix = self.choice_ix_opt(item_ix, value)?; + if self.runtime[item_ix].disabled || self.runtime[item_ix].choice_disabled[choice_ix] { + return None; + } + let enabled_position = (0..=choice_ix) + .filter(|ix| !self.runtime[item_ix].choice_disabled[*ix]) + .count() + .checked_sub(1)?; + match mode { + QuestionnaireShortcutMode::Letters if enabled_position < 26 => { + Some(char::from(b'A' + enabled_position as u8).to_string().into()) + } + QuestionnaireShortcutMode::Numbers if enabled_position < 9 => { + Some((enabled_position + 1).to_string().into()) + } + _ => None, + } + } + + pub fn choice_for_shortcut(&self, item: &str, key: &str) -> Option<&SharedString> { + let item_ix = self.item_ix_opt(item)?; + self.items[item_ix].choices().iter().find_map(|choice| { + self.shortcut_for_choice(item, choice.value()) + .is_some_and(|shortcut| shortcut.as_ref().eq_ignore_ascii_case(key)) + .then_some(choice.value()) + }) + } + + pub fn activate_shortcut( + &mut self, + key: &str, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(item_ix) = self.current else { + return false; + }; + let item = self.items[item_ix].name().clone(); + let Some(choice) = self.choice_for_shortcut(&item, key).cloned() else { + return false; + }; + if self.activate_choice(&item, &choice, cx).is_err() { + return false; + } + self.focus_choice(&item, &choice, window, cx) + } + + pub fn set_current_item( + &mut self, + name: &str, + window: &mut Window, + cx: &mut Context, + ) -> Result<(), QuestionnaireStateError> { + let ix = self.item_ix(name)?; + if !self.runtime[ix].disabled { + self.current = Some(ix); + self.focus_current_item(window, cx); + cx.notify(); + } + Ok(()) + } + + pub fn set_answer( + &mut self, + item: &str, + mut answer: QuestionnaireAnswer, + window: &mut Window, + cx: &mut Context, + ) -> Result<(), QuestionnaireStateError> { + let item_ix = self.item_ix(item)?; + let before = self.effective_answer(item_ix); + let before_status = self.status(item_ix); + if answer + .freeform + .as_ref() + .is_some_and(|value| value.trim().is_empty()) + { + answer.freeform = None; + } + self.check_answer(item_ix, &answer)?; + answer.choices = self.items[item_ix] + .choices() + .iter() + .filter(|choice| answer.choices.contains(choice.value())) + .map(|choice| choice.value().clone()) + .collect(); + self.runtime[item_ix].answer = answer.clone(); + self.runtime[item_ix].skipped = false; + if let (Some(input), Some(value)) = + (self.items[item_ix].input(), answer.freeform().cloned()) + { + input + .state() + .update(cx, |input, cx| input.set_value(value, window, cx)); + } + if before != self.effective_answer(item_ix) || before_status != self.status(item_ix) { + self.answer_did_change(item_ix, false, cx); + } + Ok(()) + } + + pub fn set_input_value( + &mut self, + item: &str, + value: impl Into, + window: &mut Window, + cx: &mut Context, + ) -> Result<(), QuestionnaireStateError> { + let item_ix = self.item_ix(item)?; + let Some(input) = self.items[item_ix] + .input() + .map(|input| input.state().clone()) + else { + return Err(QuestionnaireStateError::AnswerDoesNotMatchItem( + self.items[item_ix].name().clone(), + )); + }; + input.update(cx, |input, cx| input.set_value(value, window, cx)); + self.sync_input_answer(item_ix, false, cx); + Ok(()) + } + + pub fn set_item_disabled( + &mut self, + name: &str, + disabled: bool, + window: &mut Window, + cx: &mut Context, + ) -> Result<(), QuestionnaireStateError> { + let ix = self.item_ix(name)?; + if self.runtime[ix].disabled == disabled { + return Ok(()); + } + self.runtime[ix].disabled = disabled; + if let Some(input) = self.items[ix].input() { + let input_disabled = disabled || self.runtime[ix].input_disabled; + input + .state() + .update(cx, |input, cx| input.set_disabled(input_disabled, cx)); + } + self.complete = false; + if self.current == Some(ix) && disabled { + let next = self + .enabled_indices() + .find(|candidate| *candidate > ix) + .or_else(|| { + self.enabled_indices() + .rev() + .find(|candidate| *candidate < ix) + }); + self.current = next; + self.focus_current_item(window, cx); + } else if self.current.is_none() && !disabled { + self.current = Some(ix); + self.focus_current_item(window, cx); + } + cx.notify(); + Ok(()) + } + + pub fn set_choice_disabled( + &mut self, + item: &str, + value: &str, + disabled: bool, + cx: &mut Context, + ) -> Result<(), QuestionnaireStateError> { + let item_ix = self.item_ix(item)?; + let choice_ix = self.choice_ix(item_ix, value)?; + if self.runtime[item_ix].choice_disabled[choice_ix] == disabled { + return Ok(()); + } + self.runtime[item_ix].choice_disabled[choice_ix] = disabled; + self.answer_did_change(item_ix, false, cx); + Ok(()) + } + + pub fn set_external_error( + &mut self, + item: &str, + error: impl Into, + cx: &mut Context, + ) -> Result<(), QuestionnaireStateError> { + let ix = self.item_ix(item)?; + self.runtime[ix].external_error = Some(error.into()); + self.complete = false; + cx.notify(); + Ok(()) + } + + pub fn clear_external_error( + &mut self, + item: &str, + cx: &mut Context, + ) -> Result<(), QuestionnaireStateError> { + let ix = self.item_ix(item)?; + self.runtime[ix].external_error = None; + cx.notify(); + Ok(()) + } + + pub fn reset(&mut self, window: &mut Window, cx: &mut Context) { + for ix in 0..self.items.len() { + let initial = self.runtime[ix].initial_answer.clone(); + self.runtime[ix].answer = initial; + self.runtime[ix].skipped = false; + self.runtime[ix].validation_attempted = false; + self.runtime[ix].internal_error = None; + if let Some(input) = self.items[ix].input() { + let value = self.runtime[ix] + .initial_input_value + .as_ref() + .cloned() + .unwrap_or_default(); + input + .state() + .update(cx, |input, cx| input.set_value(value, window, cx)); + } + } + self.complete = false; + self.current = self + .initial_current + .as_ref() + .and_then(|name| self.item_ix_opt(name)); + if self.current.is_some_and(|ix| self.runtime[ix].disabled) { + let next = self.enabled_indices().next(); + self.current = next; + } + self.focus_current_item(window, cx); + cx.notify(); + } + + pub fn activate_choice( + &mut self, + item: &str, + value: &str, + cx: &mut Context, + ) -> Result<(), QuestionnaireStateError> { + let item_ix = self.item_ix(item)?; + let choice_ix = self.choice_ix(item_ix, value)?; + if self.runtime[item_ix].disabled || self.runtime[item_ix].choice_disabled[choice_ix] { + return Ok(()); + } + let before = self.effective_answer(item_ix); + let before_status = self.status(item_ix); + + if self.items[item_ix].is_multiple() { + let selected = self.runtime[item_ix] + .answer + .choices + .iter() + .position(|choice| choice.as_ref() == value); + if let Some(ix) = selected { + self.runtime[item_ix].answer.choices.remove(ix); + } else { + self.runtime[item_ix].answer.choices.push(value.into()); + } + } else { + self.runtime[item_ix].answer.choices.clear(); + self.runtime[item_ix].answer.choices.push(value.into()); + self.runtime[item_ix].answer.freeform = None; + } + self.runtime[item_ix].skipped = false; + if before != self.effective_answer(item_ix) || before_status != self.status(item_ix) { + self.answer_did_change(item_ix, true, cx); + } + Ok(()) + } + + pub fn confirm_current(&mut self, window: &mut Window, cx: &mut Context) -> bool { + let Some(current_ix) = self.current_ix() else { + return false; + }; + if current_ix + 1 == self.total() { + self.submit(window, cx) + } else { + self.go_next(window, cx) + } + } + + pub fn go_previous(&mut self, window: &mut Window, cx: &mut Context) -> bool { + let Some(ix) = self.current_ix() else { + return false; + }; + let enabled: Vec<_> = self.enabled_indices().collect(); + if ix == 0 { + return false; + } + self.change_current(Some(enabled[ix - 1]), true, window, cx); + true + } + + pub fn go_next(&mut self, window: &mut Window, cx: &mut Context) -> bool { + let Some(current) = self.current else { + return false; + }; + if !self.validate_item(current) { + self.focus_invalid_item(self.items[current].name(), window, cx); + cx.notify(); + return false; + } + let Some(ix) = self.current_ix() else { + return false; + }; + let enabled: Vec<_> = self.enabled_indices().collect(); + if ix + 1 >= enabled.len() { + return false; + } + self.change_current(Some(enabled[ix + 1]), true, window, cx); + true + } + + pub fn skip_current(&mut self, window: &mut Window, cx: &mut Context) -> bool { + let Some(ix) = self.current else { return false }; + if self.items[ix].is_required() { + return false; + } + self.runtime[ix].answer = QuestionnaireAnswer::new(); + self.runtime[ix].skipped = true; + self.complete = false; + self.emit_answer_changed(ix, cx); + if self + .current_ix() + .is_some_and(|current| current + 1 == self.total()) + { + self.submit(window, cx) + } else { + self.go_next(window, cx) + } + } + + pub fn submit(&mut self, window: &mut Window, cx: &mut Context) -> bool { + let enabled: Vec<_> = self.enabled_indices().collect(); + let mut first_invalid = None; + for ix in enabled { + if !self.validate_item(ix) && first_invalid.is_none() { + first_invalid = Some(ix); + } + } + if let Some(ix) = first_invalid { + self.change_current(Some(ix), true, window, cx); + self.focus_invalid_item(self.items[ix].name(), window, cx); + cx.notify(); + return false; + } + + let submission = self.submission(); + if !self.complete { + self.complete = true; + cx.emit(QuestionnaireEvent::Completed(submission.clone())); + } + cx.emit(QuestionnaireEvent::Submit(submission)); + cx.notify(); + true + } + + pub fn focus_current_item(&self, window: &mut Window, cx: &mut App) -> bool { + let Some(ix) = self.current else { return false }; + self.runtime[ix].focus_handle.focus(window, cx); + true + } + + pub fn focus_invalid_item(&self, item: &str, window: &mut Window, cx: &mut App) -> bool { + let Some(item_ix) = self.item_ix_opt(item) else { + return false; + }; + if self.runtime[item_ix].answer.freeform().is_some() + && !self.runtime[item_ix].input_disabled + && let Some(focus_handle) = &self.runtime[item_ix].input_focus_handle + { + focus_handle.focus(window, cx); + return true; + } + for (choice_ix, choice) in self.items[item_ix].choices().iter().enumerate() { + if self.runtime[item_ix] + .answer + .choices + .contains(choice.value()) + && !self.runtime[item_ix].choice_disabled[choice_ix] + { + self.runtime[item_ix].choice_focus_handles[choice_ix].focus(window, cx); + return true; + } + } + if let Some(choice_ix) = self.runtime[item_ix] + .choice_disabled + .iter() + .position(|disabled| !disabled) + { + self.runtime[item_ix].choice_focus_handles[choice_ix].focus(window, cx); + return true; + } + if !self.runtime[item_ix].input_disabled + && let Some(focus_handle) = &self.runtime[item_ix].input_focus_handle + { + focus_handle.focus(window, cx); + return true; + } + self.runtime[item_ix].focus_handle.focus(window, cx); + true + } + + pub fn focus_choice(&self, item: &str, value: &str, window: &mut Window, cx: &mut App) -> bool { + let Some(item_ix) = self.item_ix_opt(item) else { + return false; + }; + let Some(choice_ix) = self.choice_ix_opt(item_ix, value) else { + return false; + }; + self.runtime[item_ix].choice_focus_handles[choice_ix].focus(window, cx); + true + } + + pub fn focus_input(&self, item: &str, window: &mut Window, cx: &mut App) -> bool { + let Some(item_ix) = self.item_ix_opt(item) else { + return false; + }; + let Some(focus_handle) = &self.runtime[item_ix].input_focus_handle else { + return false; + }; + focus_handle.focus(window, cx); + true + } + + pub fn focus_previous_answer(&mut self, window: &mut Window, cx: &mut Context) -> bool { + self.focus_adjacent_answer(-1, window, cx) + } + + pub fn focus_next_answer(&mut self, window: &mut Window, cx: &mut Context) -> bool { + self.focus_adjacent_answer(1, window, cx) + } + + pub fn move_current_radio( + &mut self, + direction: isize, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(item_ix) = self.current else { + return false; + }; + if self.items[item_ix].is_multiple() || self.is_current_input_focused(window) { + return false; + } + let enabled: Vec<_> = self.runtime[item_ix] + .choice_disabled + .iter() + .enumerate() + .filter_map(|(ix, disabled)| (!disabled).then_some(ix)) + .collect(); + if enabled.is_empty() { + return false; + } + let current = enabled + .iter() + .position(|ix| self.runtime[item_ix].choice_focus_handles[*ix].is_focused(window)) + .or_else(|| { + enabled.iter().position(|ix| { + self.runtime[item_ix] + .answer + .choices + .contains(self.items[item_ix].choices()[*ix].value()) + }) + }); + let target = match (current, direction.is_negative()) { + (Some(ix), true) => ix.checked_sub(1).unwrap_or(enabled.len() - 1), + (Some(ix), false) => (ix + 1) % enabled.len(), + (None, true) => enabled.len() - 1, + (None, false) => 0, + }; + let choice_ix = enabled[target]; + let item = self.items[item_ix].name().clone(); + let choice = self.items[item_ix].choices()[choice_ix].value().clone(); + let _ = self.activate_choice(&item, &choice, cx); + self.runtime[item_ix].choice_focus_handles[choice_ix].focus(window, cx); + true + } + + fn focus_adjacent_answer( + &mut self, + direction: isize, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(item_ix) = self.current else { + return false; + }; + if (self.is_current_input_focused(window) && self.current_input_has_text(cx)) + || (!self.items[item_ix].is_multiple() && self.focused_current_choice(window).is_some()) + { + return false; + } + + #[derive(Clone, Copy)] + enum Target { + Choice(usize), + Input, + } + let mut targets = Vec::new(); + for choice_ix in 0..self.items[item_ix].choices().len() { + if !self.runtime[item_ix].choice_disabled[choice_ix] { + targets.push(Target::Choice(choice_ix)); + } + } + if self.items[item_ix].input().is_some() && !self.runtime[item_ix].input_disabled { + targets.push(Target::Input); + } + if targets.is_empty() { + return false; + } + + let focused = targets.iter().position(|target| match target { + Target::Choice(ix) => { + self.runtime[item_ix].choice_focus_handles[*ix].is_focused(window) + } + Target::Input => self.is_current_input_focused(window), + }); + + if focused.is_none() && self.runtime[item_ix].focus_handle.is_focused(window) { + let filled: Vec<_> = targets + .iter() + .enumerate() + .filter_map(|(ix, target)| match target { + Target::Choice(choice_ix) + if self.runtime[item_ix] + .answer + .choices + .contains(self.items[item_ix].choices()[*choice_ix].value()) => + { + Some(ix) + } + Target::Input if self.runtime[item_ix].answer.freeform().is_some() => Some(ix), + _ => None, + }) + .collect(); + let filled_ix = if direction.is_negative() { + filled.last().copied() + } else { + filled.first().copied() + }; + if let Some(filled_ix) = filled_ix { + match targets[filled_ix] { + Target::Choice(choice_ix) => { + self.runtime[item_ix].choice_focus_handles[choice_ix].focus(window, cx); + } + Target::Input => { + if let Some(focus_handle) = &self.runtime[item_ix].input_focus_handle { + focus_handle.focus(window, cx); + } + } + } + return true; + } + } + + let target_ix = match (focused, direction.is_negative()) { + (Some(ix), true) => ix.checked_sub(1).unwrap_or(targets.len() - 1), + (Some(ix), false) => (ix + 1) % targets.len(), + (None, true) => targets.len() - 1, + (None, false) => 0, + }; + match targets[target_ix] { + Target::Choice(choice_ix) => { + if !self.items[item_ix].is_multiple() { + let item = self.items[item_ix].name().clone(); + let choice = self.items[item_ix].choices()[choice_ix].value().clone(); + let _ = self.activate_choice(&item, &choice, cx); + } + self.runtime[item_ix].choice_focus_handles[choice_ix].focus(window, cx); + } + Target::Input => { + if let Some(focus_handle) = &self.runtime[item_ix].input_focus_handle { + focus_handle.focus(window, cx); + } + } + } + true + } + + fn on_input_change(&mut self, input: &Entity, cx: &mut Context) { + if let Some(ix) = self.input_item_ix(input) { + self.sync_input_answer(ix, true, cx); + } + } + + fn sync_input_answer(&mut self, item_ix: usize, emit: bool, cx: &mut Context) { + if self.runtime[item_ix].disabled || self.runtime[item_ix].input_disabled { + return; + } + let before = self.effective_answer(item_ix); + let before_status = self.status(item_ix); + let Some(input) = self.items[item_ix].input() else { + return; + }; + let value = input.state().read(cx).value(); + if value.trim().is_empty() { + self.runtime[item_ix].answer.freeform = None; + } else { + if !self.items[item_ix].is_multiple() { + self.runtime[item_ix].answer.choices.clear(); + } + self.runtime[item_ix].answer.freeform = Some(value); + self.runtime[item_ix].skipped = false; + } + if before != self.effective_answer(item_ix) || before_status != self.status(item_ix) { + self.answer_did_change(item_ix, emit, cx); + } + } + + fn answer_did_change(&mut self, item_ix: usize, emit: bool, cx: &mut Context) { + if self.runtime[item_ix].validation_attempted { + self.validate_item(item_ix); + } else { + self.runtime[item_ix].internal_error = None; + } + self.complete = false; + if emit { + self.emit_answer_changed(item_ix, cx); + } + cx.notify(); + } + + fn emit_answer_changed(&self, item_ix: usize, cx: &mut Context) { + cx.emit(QuestionnaireEvent::AnswerChanged( + QuestionnaireAnswerChange::new( + self.items[item_ix].name().clone(), + self.effective_answer(item_ix), + self.status(item_ix), + ), + )); + } + + fn validate_item(&mut self, item_ix: usize) -> bool { + if self.runtime[item_ix].disabled || self.runtime[item_ix].skipped { + return true; + } + self.runtime[item_ix].validation_attempted = true; + let answer = self.effective_answer(item_ix); + let error = if answer.is_empty() { + Some(if self.items[item_ix].is_required() { + t!("Questionnaire.error.required").into() + } else { + t!("Questionnaire.error.optional").into() + }) + } else if let Some(validator) = self.items[item_ix].validator().cloned() { + validator(&QuestionnaireValidationContext::new( + self.items[item_ix].name().clone(), + answer, + self.answers(), + )) + .err() + } else { + None + }; + self.runtime[item_ix].internal_error = error; + self.error_at(item_ix).is_none() + } + + fn submission(&self) -> QuestionnaireSubmission { + QuestionnaireSubmission::new( + self.enabled_indices() + .map(|ix| { + QuestionnaireSubmissionItem::new( + self.items[ix].name().clone(), + self.status(ix), + self.effective_answer(ix), + ) + }) + .collect(), + ) + } + + fn status(&self, item_ix: usize) -> QuestionnaireItemStatus { + if self.runtime[item_ix].skipped { + QuestionnaireItemStatus::Skipped + } else if self.effective_answer(item_ix).is_empty() { + QuestionnaireItemStatus::Unanswered + } else { + QuestionnaireItemStatus::Answered + } + } + + fn effective_answer(&self, item_ix: usize) -> QuestionnaireAnswer { + if self.runtime[item_ix].disabled { + return QuestionnaireAnswer::new(); + } + let runtime = &self.runtime[item_ix]; + QuestionnaireAnswer { + choices: self.items[item_ix] + .choices() + .iter() + .filter(|choice| { + runtime.answer.choices.contains(choice.value()) + && self + .choice_ix_opt(item_ix, choice.value()) + .is_some_and(|ix| !runtime.choice_disabled[ix]) + }) + .map(|choice| choice.value().clone()) + .collect(), + freeform: (!runtime.input_disabled) + .then(|| runtime.answer.freeform.clone()) + .flatten(), + } + } + + fn error_at(&self, item_ix: usize) -> Option<&SharedString> { + if self.runtime[item_ix].skipped || self.runtime[item_ix].disabled { + return None; + } + self.runtime[item_ix].external_error.as_ref().or_else(|| { + self.runtime[item_ix] + .validation_attempted + .then_some(self.runtime[item_ix].internal_error.as_ref()) + .flatten() + }) + } + + fn check_answer( + &self, + item_ix: usize, + answer: &QuestionnaireAnswer, + ) -> Result<(), QuestionnaireStateError> { + let item = &self.items[item_ix]; + let sources = answer.choices.len() + usize::from(answer.freeform.is_some()); + if (!item.is_multiple() && sources > 1) + || (answer.freeform.is_some() && item.input().is_none()) + { + return Err(QuestionnaireStateError::AnswerDoesNotMatchItem( + item.name().clone(), + )); + } + for choice in &answer.choices { + let choice_ix = self.choice_ix(item_ix, choice)?; + if self.runtime[item_ix].choice_disabled[choice_ix] { + return Err(QuestionnaireStateError::AnswerDoesNotMatchItem( + item.name().clone(), + )); + } + } + Ok(()) + } + + fn change_current( + &mut self, + next: Option, + emit: bool, + window: &mut Window, + cx: &mut Context, + ) { + if self.current == next { + return; + } + let previous = self.current.map(|ix| self.items[ix].name().clone()); + self.current = next; + self.focus_current_item(window, cx); + if emit { + cx.emit(QuestionnaireEvent::CurrentItemChanged { + previous, + current: next.map(|ix| self.items[ix].name().clone()), + }); + } + cx.notify(); + } + + fn enabled_indices(&self) -> impl DoubleEndedIterator + '_ { + self.runtime + .iter() + .enumerate() + .filter_map(|(ix, runtime)| (!runtime.disabled).then_some(ix)) + } + + fn item_ix(&self, name: &str) -> Result { + self.item_ix_opt(name) + .ok_or_else(|| QuestionnaireStateError::UnknownItem(name.into())) + } + + fn item_ix_opt(&self, name: &str) -> Option { + self.items + .iter() + .position(|item| item.name().as_ref() == name) + } + + fn choice_ix(&self, item_ix: usize, value: &str) -> Result { + self.choice_ix_opt(item_ix, value) + .ok_or_else(|| QuestionnaireStateError::UnknownChoice { + item: self.items[item_ix].name().clone(), + choice: value.into(), + }) + } + + fn choice_ix_opt(&self, item_ix: usize, value: &str) -> Option { + self.items[item_ix] + .choices() + .iter() + .position(|choice| choice.value().as_ref() == value) + } + + fn input_item_ix(&self, input: &Entity) -> Option { + self.items.iter().position(|item| { + item.input() + .is_some_and(|definition| definition.state().entity_id() == input.entity_id()) + }) + } +} + +impl EventEmitter for QuestionnaireState {} + +#[cfg(test)] +mod tests { + use gpui::{ + AppContext as _, Context, Entity, IntoElement, Render, TestAppContext, VisualTestContext, + div, + }; + + use super::*; + + struct Harness { + state: Entity, + first_input: Entity, + second_input: Entity, + events: Vec<&'static str>, + _subscription: Subscription, + } + + impl Harness { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let first_input = cx.new(|cx| InputState::new(window, cx)); + let second_input = + cx.new(|cx| InputState::new(window, cx).default_value("initial draft")); + let items = vec![ + QuestionnaireItemDefinition::new("first", "First question") + .with_required(true) + .with_choices([ + QuestionnaireChoiceDefinition::new("a", "A"), + QuestionnaireChoiceDefinition::new("b", "B"), + ]) + .with_input(QuestionnaireInputDefinition::new( + first_input.clone(), + "Another answer", + )), + QuestionnaireItemDefinition::new("second", "Second question") + .with_multiple(true) + .with_choices([ + QuestionnaireChoiceDefinition::new("x", "X"), + QuestionnaireChoiceDefinition::new("y", "Y"), + ]) + .with_input(QuestionnaireInputDefinition::new( + second_input.clone(), + "Another answer", + )) + .with_validator(|context| { + (context.answer().freeform().map(SharedString::as_ref) == Some("valid")) + .then_some(()) + .ok_or_else(|| SharedString::from("Use the valid answer")) + }), + QuestionnaireItemDefinition::new("disabled", "Disabled").with_disabled(true), + ]; + let state = cx.new(|cx| { + QuestionnaireState::new(items, cx) + .unwrap() + .with_shortcuts(QuestionnaireShortcutMode::Letters) + }); + let subscription = cx.subscribe(&state, |this, _, event, _| { + this.events.push(match event { + QuestionnaireEvent::CurrentItemChanged { .. } => "current", + QuestionnaireEvent::AnswerChanged(_) => "answer", + QuestionnaireEvent::Completed(_) => "completed", + QuestionnaireEvent::Submit(_) => "submit", + }); + }); + Self { + state, + first_input, + second_input, + events: Vec::new(), + _subscription: subscription, + } + } + } + + impl Render for Harness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + + fn harness( + cx: &mut TestAppContext, + ) -> ( + Entity, + Entity, + Entity, + Entity, + &mut VisualTestContext, + ) { + cx.update(crate::init); + let (harness, cx) = cx.add_window_view(Harness::new); + let (state, first_input, second_input) = harness.read_with(cx, |harness, _| { + ( + harness.state.clone(), + harness.first_input.clone(), + harness.second_input.clone(), + ) + }); + (harness, state, first_input, second_input, cx) + } + + #[test] + fn schema_rejects_duplicate_names_and_invalid_single_defaults() { + let duplicate_items = vec![ + QuestionnaireItemDefinition::new("same", "One"), + QuestionnaireItemDefinition::new("same", "Two"), + ]; + assert_eq!( + QuestionnaireState::validate_schema(&duplicate_items), + Err(QuestionnaireStateError::DuplicateItem("same".into())) + ); + + let invalid_default = vec![ + QuestionnaireItemDefinition::new("single", "Single").with_choices([ + QuestionnaireChoiceDefinition::new("a", "A").with_default_selected(true), + QuestionnaireChoiceDefinition::new("b", "B").with_default_selected(true), + ]), + ]; + assert_eq!( + QuestionnaireState::validate_schema(&invalid_default), + Err(QuestionnaireStateError::MultipleDefaultsForSingleItem( + "single".into() + )) + ); + + let duplicate_choice = vec![ + QuestionnaireItemDefinition::new("item", "Item").with_choices([ + QuestionnaireChoiceDefinition::new("same", "One"), + QuestionnaireChoiceDefinition::new("same", "Two"), + ]), + ]; + assert_eq!( + QuestionnaireState::validate_schema(&duplicate_choice), + Err(QuestionnaireStateError::DuplicateChoice { + item: "item".into(), + choice: "same".into(), + }) + ); + } + + #[gpui::test] + fn validates_navigates_skips_and_emits_completion_before_submit(cx: &mut TestAppContext) { + let (harness, state, _, _, cx) = harness(cx); + + assert_eq!(cx.read(|cx| state.read(cx).progress().current()), 1); + assert_eq!(cx.read(|cx| state.read(cx).progress().total()), 2); + assert_eq!( + cx.read(|cx| state.read(cx).item_state("first").unwrap().status()), + QuestionnaireItemStatus::Unanswered + ); + assert!(!cx.update(|window, cx| state.update(cx, |state, cx| state.submit(window, cx)))); + assert!(cx.read(|cx| state.read(cx).error("first").is_some())); + assert_eq!( + cx.read(|cx| state.read(cx).error("second").unwrap().clone()), + "Use the valid answer" + ); + + cx.update(|window, cx| { + state.update(cx, |state, cx| { + state.set_input_value("first", "draft", window, cx).unwrap(); + }); + }); + assert!(cx.read(|cx| state.read(cx).error("first").is_none())); + cx.update(|window, cx| { + state.update(cx, |state, cx| { + state.set_input_value("first", "", window, cx).unwrap(); + }); + }); + assert!( + cx.read(|cx| state.read(cx).error("first").is_some()), + "once validation has been attempted, clearing an answer updates the error live" + ); + + state.update(cx, |state, cx| { + state.activate_choice("first", "a", cx).unwrap() + }); + assert!( + cx.update(|window, cx| { state.update(cx, |state, cx| state.go_next(window, cx)) }) + ); + assert_eq!( + cx.read(|cx| state.read(cx).current_item().unwrap().clone()), + "second" + ); + assert!( + cx.update(|window, cx| { + state.update(cx, |state, cx| state.skip_current(window, cx)) + }) + ); + + assert!(cx.read(|cx| state.read(cx).is_complete())); + assert_eq!( + cx.read(|cx| state.read(cx).item_state("second").unwrap().status()), + QuestionnaireItemStatus::Skipped + ); + assert_eq!( + cx.read(|cx| harness.read(cx).events.clone()), + vec!["answer", "current", "answer", "completed", "submit"] + ); + } + + #[gpui::test] + fn keeps_input_draft_separate_and_synchronizes_silent_setters_and_reset( + cx: &mut TestAppContext, + ) { + let (harness, state, first_input, second_input, cx) = harness(cx); + let initial_events = cx.read(|cx| harness.read(cx).events.len()); + + cx.update(|window, cx| { + state.update(cx, |state, cx| { + state + .set_answer( + "first", + QuestionnaireAnswer::new().with_freeform(" custom "), + window, + cx, + ) + .unwrap(); + }); + }); + assert_eq!( + cx.read(|cx| harness.read(cx).events.len()), + initial_events, + "programmatic setters are silent" + ); + assert_eq!(cx.read(|cx| first_input.read(cx).value()), " custom "); + assert_eq!( + cx.read(|cx| { + state + .read(cx) + .answer("first") + .unwrap() + .freeform() + .unwrap() + .clone() + }), + " custom " + ); + + cx.update(|window, cx| { + state.update(cx, |state, cx| { + state + .set_answer( + "first", + QuestionnaireAnswer::new().with_choices(["a"]), + window, + cx, + ) + .unwrap(); + }); + }); + assert_eq!(cx.read(|cx| first_input.read(cx).value()), " custom "); + assert!(cx.read(|cx| { state.read(cx).answer("first").unwrap().freeform().is_none() })); + assert_eq!( + cx.read(|cx| state.read(cx).answer("first").unwrap().choices()[0].clone()), + "a" + ); + + cx.update(|window, cx| { + first_input.update(cx, |input, cx| input.replace_all("", window, cx)); + }); + cx.run_until_parked(); + assert_eq!( + cx.read(|cx| harness.read(cx).events.len()), + initial_events, + "editing an unselected draft does not change the semantic answer" + ); + assert_eq!( + cx.read(|cx| state.read(cx).answer("first").unwrap().choices()[0].clone()), + "a" + ); + + state.update(cx, |state, cx| { + state.activate_choice("first", "b", cx).unwrap() + }); + + cx.update(|window, cx| { + state.update(cx, |state, cx| state.reset(window, cx)); + }); + assert_eq!(cx.read(|cx| first_input.read(cx).value()), ""); + assert_eq!(cx.read(|cx| second_input.read(cx).value()), "initial draft"); + assert!(cx.read(|cx| state.read(cx).answer("first").unwrap().is_empty())); + assert_eq!( + cx.read(|cx| { + state + .read(cx) + .answer("second") + .unwrap() + .freeform() + .unwrap() + .clone() + }), + "initial draft" + ); + assert_eq!( + cx.read(|cx| harness.read(cx).events.len()), + initial_events + 1 + ); + } + + #[gpui::test] + fn validates_all_items_and_returns_to_the_first_invalid_item(cx: &mut TestAppContext) { + let (_, state, _, _, cx) = harness(cx); + state.update(cx, |state, cx| { + state.activate_choice("first", "a", cx).unwrap() + }); + + assert!( + !cx.update(|window, cx| { state.update(cx, |state, cx| state.submit(window, cx)) }) + ); + assert_eq!( + cx.read(|cx| state.read(cx).current_item().unwrap().clone()), + "second" + ); + assert_eq!( + cx.read(|cx| state.read(cx).error("second").unwrap().clone()), + "Use the valid answer" + ); + + state.update(cx, |state, cx| { + state + .set_external_error("first", "Server rejected it", cx) + .unwrap() + }); + assert!( + !cx.update(|window, cx| { state.update(cx, |state, cx| state.submit(window, cx)) }) + ); + assert_eq!( + cx.read(|cx| state.read(cx).current_item().unwrap().clone()), + "first" + ); + + cx.update(|window, cx| { + state.update(cx, |state, cx| { + state.clear_external_error("first", cx).unwrap(); + state + .set_input_value("second", "valid", window, cx) + .unwrap(); + }); + }); + assert!(cx.update(|window, cx| { state.update(cx, |state, cx| state.submit(window, cx)) })); + } + + #[gpui::test] + fn preserves_schema_order_and_temporarily_excludes_disabled_answers(cx: &mut TestAppContext) { + let (_, state, _, _, cx) = harness(cx); + + cx.update(|window, cx| { + state.update(cx, |state, cx| { + state + .set_answer( + "second", + QuestionnaireAnswer::new() + .with_choices(["y", "x", "y"]) + .with_freeform("valid"), + window, + cx, + ) + .unwrap(); + }); + }); + assert_eq!( + cx.read(|cx| state.read(cx).answer("second").unwrap().choices().to_vec()), + vec![SharedString::from("x"), SharedString::from("y")] + ); + cx.update(|window, cx| { + state.update(cx, |state, cx| { + state.set_current_item("second", window, cx).unwrap(); + state.focus_current_item(window, cx); + assert!(state.focus_next_answer(window, cx)); + }); + }); + assert_eq!( + cx.update(|window, cx| state.read(cx).focused_current_choice(window).cloned()), + Some("x".into()), + "filled multiple choices are focused in schema order" + ); + assert_eq!( + cx.read(|cx| state.read(cx).answer("second").unwrap().choices().to_vec()), + vec![SharedString::from("x"), SharedString::from("y")], + "focusing a filled choice does not toggle it" + ); + + state.update(cx, |state, cx| { + state.set_choice_disabled("second", "x", true, cx).unwrap() + }); + assert_eq!( + cx.read(|cx| state.read(cx).answer("second").unwrap().choices().to_vec()), + vec![SharedString::from("y")] + ); + state.update(cx, |state, cx| { + state.set_choice_disabled("second", "x", false, cx).unwrap() + }); + assert_eq!( + cx.read(|cx| state.read(cx).answer("second").unwrap().choices().to_vec()), + vec![SharedString::from("x"), SharedString::from("y")] + ); + + let error = cx.update(|window, cx| { + state.update(cx, |state, cx| { + state.set_answer( + "second", + QuestionnaireAnswer::new().with_choices(["unknown"]), + window, + cx, + ) + }) + }); + assert_eq!( + error, + Err(QuestionnaireStateError::UnknownChoice { + item: "second".into(), + choice: "unknown".into(), + }) + ); + } + + #[gpui::test] + fn shortcuts_disabled_current_fallback_and_recompletion_are_deterministic( + cx: &mut TestAppContext, + ) { + let (harness, state, first_input, _, cx) = harness(cx); + cx.update(|window, cx| { + state.update(cx, |state, cx| { + state + .set_answer( + "first", + QuestionnaireAnswer::new().with_choices(["b"]), + window, + cx, + ) + .unwrap(); + state.focus_current_item(window, cx); + assert!(state.focus_next_answer(window, cx)); + }); + }); + assert_eq!( + cx.update(|window, cx| state.read(cx).focused_current_choice(window).cloned()), + Some("b".into()), + "the first move from the item group focuses the existing answer" + ); + assert_eq!( + cx.read(|cx| state.read(cx).answer("first").unwrap().choices()[0].clone()), + "b", + "focusing the filled radio does not replace the answer" + ); + cx.update(|window, cx| state.update(cx, |state, cx| state.reset(window, cx))); + + cx.update(|window, cx| { + state.update(cx, |state, cx| { + state.focus_input("first", window, cx); + assert!(!state.current_input_has_text(cx)); + assert!(state.focus_next_answer(window, cx)); + }); + }); + assert_eq!( + cx.read(|cx| state.read(cx).answer("first").unwrap().choices()[0].clone()), + "a", + "an empty focused input may move to and activate a radio" + ); + cx.update(|window, cx| state.update(cx, |state, cx| state.reset(window, cx))); + + cx.update(|window, cx| { + state.update(cx, |state, cx| { + state.set_input_value("first", "draft", window, cx).unwrap(); + state.focus_input("first", window, cx); + assert!(state.current_input_has_text(cx)); + assert!(!state.focus_next_answer(window, cx)); + }); + }); + assert!(cx.update(|window, cx| first_input.focus_handle(cx).is_focused(window))); + cx.update(|window, cx| state.update(cx, |state, cx| state.reset(window, cx))); + + assert!(cx.update(|window, cx| { + state.update(cx, |state, cx| { + state.focus_current_item(window, cx); + state.focus_next_answer(window, cx) + }) + })); + assert_eq!( + cx.read(|cx| state.read(cx).answer("first").unwrap().choices()[0].clone()), + "a", + "moving from the item group to a radio activates it" + ); + cx.update(|window, cx| state.update(cx, |state, cx| state.reset(window, cx))); + + assert_eq!( + cx.read(|cx| state.read(cx).shortcut_for_choice("first", "a")), + Some("A".into()) + ); + state.update(cx, |state, cx| { + state.set_choice_disabled("first", "a", true, cx).unwrap() + }); + assert_eq!( + cx.read(|cx| state.read(cx).shortcut_for_choice("first", "b")), + Some("A".into()) + ); + state.update(cx, |state, cx| { + state.set_choice_disabled("first", "a", false, cx).unwrap() + }); + cx.update(|window, cx| { + state.update(cx, |state, cx| { + assert!(state.activate_shortcut("a", window, cx)); + state.focus_choice("first", "a", window, cx); + assert!(state.move_current_radio(1, window, cx)); + assert!(state.go_next(window, cx)); + assert!(state.skip_current(window, cx)); + }); + }); + assert!(cx.read(|cx| state.read(cx).is_complete())); + + cx.update(|window, cx| { + state.update(cx, |state, cx| { + state + .set_input_value("second", "valid", window, cx) + .unwrap(); + state.activate_choice("second", "x", cx).unwrap(); + }); + }); + assert!(!cx.read(|cx| state.read(cx).is_complete())); + assert!(cx.update(|window, cx| { state.update(cx, |state, cx| state.submit(window, cx)) })); + let events = cx.read(|cx| harness.read(cx).events.clone()); + assert_eq!( + events.iter().filter(|event| **event == "completed").count(), + 2 + ); + + let before_disable = events.len(); + cx.update(|window, cx| { + state.update(cx, |state, cx| { + state.set_item_disabled("second", true, window, cx).unwrap(); + }); + }); + assert_eq!( + cx.read(|cx| state.read(cx).current_item().unwrap().clone()), + "first" + ); + assert_eq!( + cx.read(|cx| harness.read(cx).events.len()), + before_disable, + "programmatic disable and fallback are silent" + ); + } +} diff --git a/crates/component/src/questionnaire/types.rs b/crates/component/src/questionnaire/types.rs new file mode 100644 index 0000000000..d2e52ffbd4 --- /dev/null +++ b/crates/component/src/questionnaire/types.rs @@ -0,0 +1,672 @@ +use std::{error::Error, fmt, rc::Rc}; + +use gpui::{Entity, SharedString}; + +use crate::input::InputState; + +/// Validates one questionnaire item against the current questionnaire answers. +pub type QuestionnaireValidator = + Rc Result<(), SharedString> + 'static>; + +/// Describes one selectable answer. +#[derive(Clone, Debug)] +pub struct QuestionnaireChoiceDefinition { + value: SharedString, + accessibility_label: SharedString, + description: Option, + disabled: bool, + default_selected: bool, +} + +impl QuestionnaireChoiceDefinition { + pub fn new( + value: impl Into, + accessibility_label: impl Into, + ) -> Self { + Self { + value: value.into(), + accessibility_label: accessibility_label.into(), + description: None, + disabled: false, + default_selected: false, + } + } + + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + pub fn with_disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn with_default_selected(mut self, selected: bool) -> Self { + self.default_selected = selected; + self + } + + pub fn value(&self) -> &SharedString { + &self.value + } + + pub fn accessibility_label(&self) -> &SharedString { + &self.accessibility_label + } + + pub fn description(&self) -> Option<&SharedString> { + self.description.as_ref() + } + + pub fn is_disabled(&self) -> bool { + self.disabled + } + + pub fn is_default_selected(&self) -> bool { + self.default_selected + } +} + +/// Describes the optional freeform answer owned by an item. +#[derive(Clone, Debug)] +pub struct QuestionnaireInputDefinition { + state: Entity, + accessibility_label: SharedString, + disabled: bool, +} + +impl QuestionnaireInputDefinition { + pub fn new(state: Entity, accessibility_label: impl Into) -> Self { + Self { + state, + accessibility_label: accessibility_label.into(), + disabled: false, + } + } + + pub fn with_disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn state(&self) -> &Entity { + &self.state + } + + pub fn accessibility_label(&self) -> &SharedString { + &self.accessibility_label + } + + pub fn is_disabled(&self) -> bool { + self.disabled + } +} + +/// Describes one ordered questionnaire item. +#[derive(Clone)] +pub struct QuestionnaireItemDefinition { + name: SharedString, + accessibility_label: SharedString, + description: Option, + required: bool, + multiple: bool, + disabled: bool, + choices: Vec, + input: Option, + validator: Option, +} + +impl fmt::Debug for QuestionnaireItemDefinition { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("QuestionnaireItemDefinition") + .field("name", &self.name) + .field("accessibility_label", &self.accessibility_label) + .field("description", &self.description) + .field("required", &self.required) + .field("multiple", &self.multiple) + .field("disabled", &self.disabled) + .field("choices", &self.choices) + .field("input", &self.input) + .field("validator", &self.validator.as_ref().map(|_| "")) + .finish() + } +} + +impl QuestionnaireItemDefinition { + pub fn new( + name: impl Into, + accessibility_label: impl Into, + ) -> Self { + Self { + name: name.into(), + accessibility_label: accessibility_label.into(), + description: None, + required: false, + multiple: false, + disabled: false, + choices: Vec::new(), + input: None, + validator: None, + } + } + + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + pub fn with_required(mut self, required: bool) -> Self { + self.required = required; + self + } + + pub fn with_multiple(mut self, multiple: bool) -> Self { + self.multiple = multiple; + self + } + + pub fn with_disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn with_choices( + mut self, + choices: impl IntoIterator, + ) -> Self { + self.choices = choices.into_iter().collect(); + self + } + + pub fn with_choice(mut self, choice: QuestionnaireChoiceDefinition) -> Self { + self.choices.push(choice); + self + } + + pub fn with_input(mut self, input: QuestionnaireInputDefinition) -> Self { + self.input = Some(input); + self + } + + pub fn with_validator( + mut self, + validator: impl Fn(&QuestionnaireValidationContext) -> Result<(), SharedString> + 'static, + ) -> Self { + self.validator = Some(Rc::new(validator)); + self + } + + pub fn name(&self) -> &SharedString { + &self.name + } + + pub fn accessibility_label(&self) -> &SharedString { + &self.accessibility_label + } + + pub fn description(&self) -> Option<&SharedString> { + self.description.as_ref() + } + + pub fn is_required(&self) -> bool { + self.required + } + + pub fn is_multiple(&self) -> bool { + self.multiple + } + + pub fn is_disabled(&self) -> bool { + self.disabled + } + + pub fn choices(&self) -> &[QuestionnaireChoiceDefinition] { + &self.choices + } + + pub fn input(&self) -> Option<&QuestionnaireInputDefinition> { + self.input.as_ref() + } + + pub fn validator(&self) -> Option<&QuestionnaireValidator> { + self.validator.as_ref() + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum QuestionnaireItemStatus { + #[default] + Unanswered, + Answered, + Skipped, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum QuestionnaireShortcutMode { + Letters, + Numbers, +} + +/// A serializable-in-spirit answer snapshot. Input drafts are deliberately excluded. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct QuestionnaireAnswer { + pub(crate) choices: Vec, + pub(crate) freeform: Option, +} + +impl QuestionnaireAnswer { + pub fn new() -> Self { + Self::default() + } + + pub fn with_choices( + mut self, + choices: impl IntoIterator>, + ) -> Self { + self.choices.clear(); + for choice in choices.into_iter().map(Into::into) { + if !self.choices.contains(&choice) { + self.choices.push(choice); + } + } + self + } + + pub fn with_freeform(mut self, value: impl Into) -> Self { + let value = value.into(); + self.freeform = (!value.trim().is_empty()).then_some(value); + self + } + + pub fn choices(&self) -> &[SharedString] { + &self.choices + } + + pub fn freeform(&self) -> Option<&SharedString> { + self.freeform.as_ref() + } + + pub fn is_empty(&self) -> bool { + self.choices.is_empty() && self.freeform.is_none() + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct QuestionnaireAnswers { + entries: Vec<(SharedString, QuestionnaireAnswer)>, +} + +impl QuestionnaireAnswers { + pub(crate) fn from_entries(entries: Vec<(SharedString, QuestionnaireAnswer)>) -> Self { + Self { entries } + } + + pub fn get(&self, name: &str) -> Option<&QuestionnaireAnswer> { + self.entries + .iter() + .find_map(|(item, answer)| (item.as_ref() == name).then_some(answer)) + } + + pub fn iter(&self) -> impl Iterator { + self.entries.iter().map(|(name, answer)| (name, answer)) + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct QuestionnaireProgressState { + current: usize, + total: usize, +} + +impl QuestionnaireProgressState { + pub(crate) fn new(current: usize, total: usize) -> Self { + Self { current, total } + } + + pub fn current(&self) -> usize { + self.current + } + + pub fn total(&self) -> usize { + self.total + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct QuestionnaireItemState { + name: SharedString, + status: QuestionnaireItemStatus, + required: bool, + multiple: bool, + disabled: bool, + invalid: bool, + has_input: bool, +} + +impl QuestionnaireItemState { + pub(crate) fn new( + name: SharedString, + status: QuestionnaireItemStatus, + required: bool, + multiple: bool, + disabled: bool, + invalid: bool, + has_input: bool, + ) -> Self { + Self { + name, + status, + required, + multiple, + disabled, + invalid, + has_input, + } + } + + pub fn name(&self) -> &SharedString { + &self.name + } + + pub fn status(&self) -> QuestionnaireItemStatus { + self.status + } + + pub fn is_required(&self) -> bool { + self.required + } + + pub fn is_multiple(&self) -> bool { + self.multiple + } + + pub fn is_disabled(&self) -> bool { + self.disabled + } + + pub fn is_invalid(&self) -> bool { + self.invalid + } + + pub fn has_input(&self) -> bool { + self.has_input + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct QuestionnaireChoiceState { + value: SharedString, + selected: bool, + disabled: bool, + invalid: bool, + shortcut: Option, +} + +impl QuestionnaireChoiceState { + pub(crate) fn new( + value: SharedString, + selected: bool, + disabled: bool, + invalid: bool, + shortcut: Option, + ) -> Self { + Self { + value, + selected, + disabled, + invalid, + shortcut, + } + } + + pub fn value(&self) -> &SharedString { + &self.value + } + + pub fn is_selected(&self) -> bool { + self.selected + } + + pub fn is_disabled(&self) -> bool { + self.disabled + } + + pub fn is_invalid(&self) -> bool { + self.invalid + } + + pub fn shortcut(&self) -> Option<&SharedString> { + self.shortcut.as_ref() + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct QuestionnaireNavigationState { + previous_visible: bool, + next_visible: bool, + skip_visible: bool, + submit_visible: bool, + confirmable: bool, +} + +impl QuestionnaireNavigationState { + pub(crate) fn new( + previous_visible: bool, + next_visible: bool, + skip_visible: bool, + submit_visible: bool, + confirmable: bool, + ) -> Self { + Self { + previous_visible, + next_visible, + skip_visible, + submit_visible, + confirmable, + } + } + + pub fn is_previous_visible(&self) -> bool { + self.previous_visible + } + + pub fn is_next_visible(&self) -> bool { + self.next_visible + } + + pub fn is_skip_visible(&self) -> bool { + self.skip_visible + } + + pub fn is_submit_visible(&self) -> bool { + self.submit_visible + } + + pub fn is_confirmable(&self) -> bool { + self.confirmable + } +} + +/// Immutable validation input. Validators cannot mutate the questionnaire. +#[derive(Clone, Debug)] +pub struct QuestionnaireValidationContext { + item: SharedString, + answer: QuestionnaireAnswer, + answers: QuestionnaireAnswers, +} + +impl QuestionnaireValidationContext { + pub(crate) fn new( + item: SharedString, + answer: QuestionnaireAnswer, + answers: QuestionnaireAnswers, + ) -> Self { + Self { + item, + answer, + answers, + } + } + + pub fn item(&self) -> &SharedString { + &self.item + } + + pub fn answer(&self) -> &QuestionnaireAnswer { + &self.answer + } + + pub fn answers(&self) -> &QuestionnaireAnswers { + &self.answers + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct QuestionnaireAnswerChange { + item: SharedString, + answer: QuestionnaireAnswer, + status: QuestionnaireItemStatus, +} + +impl QuestionnaireAnswerChange { + pub(crate) fn new( + item: SharedString, + answer: QuestionnaireAnswer, + status: QuestionnaireItemStatus, + ) -> Self { + Self { + item, + answer, + status, + } + } + + pub fn item(&self) -> &SharedString { + &self.item + } + + pub fn answer(&self) -> &QuestionnaireAnswer { + &self.answer + } + + pub fn status(&self) -> QuestionnaireItemStatus { + self.status + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct QuestionnaireSubmissionItem { + name: SharedString, + status: QuestionnaireItemStatus, + answer: QuestionnaireAnswer, +} + +impl QuestionnaireSubmissionItem { + pub(crate) fn new( + name: SharedString, + status: QuestionnaireItemStatus, + answer: QuestionnaireAnswer, + ) -> Self { + Self { + name, + status, + answer, + } + } + + pub fn name(&self) -> &SharedString { + &self.name + } + + pub fn status(&self) -> QuestionnaireItemStatus { + self.status + } + + pub fn answer(&self) -> &QuestionnaireAnswer { + &self.answer + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct QuestionnaireSubmission { + items: Vec, +} + +impl QuestionnaireSubmission { + pub(crate) fn new(items: Vec) -> Self { + Self { items } + } + + pub fn items(&self) -> &[QuestionnaireSubmissionItem] { + &self.items + } + + pub fn answer(&self, name: &str) -> Option<&QuestionnaireAnswer> { + self.items + .iter() + .find_map(|item| (item.name.as_ref() == name).then_some(&item.answer)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum QuestionnaireEvent { + CurrentItemChanged { + previous: Option, + current: Option, + }, + AnswerChanged(QuestionnaireAnswerChange), + Completed(QuestionnaireSubmission), + Submit(QuestionnaireSubmission), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum QuestionnaireStateError { + DuplicateItem(SharedString), + DuplicateChoice { + item: SharedString, + choice: SharedString, + }, + MultipleDefaultsForSingleItem(SharedString), + UnknownItem(SharedString), + UnknownChoice { + item: SharedString, + choice: SharedString, + }, + AnswerDoesNotMatchItem(SharedString), +} + +impl fmt::Display for QuestionnaireStateError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DuplicateItem(item) => write!(formatter, "duplicate questionnaire item `{item}`"), + Self::DuplicateChoice { item, choice } => { + write!(formatter, "duplicate choice `{choice}` in item `{item}`") + } + Self::MultipleDefaultsForSingleItem(item) => write!( + formatter, + "single-choice item `{item}` has more than one default answer" + ), + Self::UnknownItem(item) => write!(formatter, "unknown questionnaire item `{item}`"), + Self::UnknownChoice { item, choice } => { + write!(formatter, "unknown choice `{choice}` in item `{item}`") + } + Self::AnswerDoesNotMatchItem(item) => { + write!(formatter, "answer does not match item `{item}`") + } + } + } +} + +impl Error for QuestionnaireStateError {} diff --git a/crates/story/src/gallery.rs b/crates/story/src/gallery.rs index cf1ccbe7b2..02ebd68434 100644 --- a/crates/story/src/gallery.rs +++ b/crates/story/src/gallery.rs @@ -111,6 +111,7 @@ impl Gallery { StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), + StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), diff --git a/crates/story/src/stories/mod.rs b/crates/story/src/stories/mod.rs index 6846b1af1e..e610a6990e 100644 --- a/crates/story/src/stories/mod.rs +++ b/crates/story/src/stories/mod.rs @@ -49,6 +49,7 @@ mod otp_input_story; mod pagination_story; mod popover_story; mod progress_story; +mod questionnaire_story; mod radio_story; mod rating_story; mod resizable_story; @@ -125,6 +126,7 @@ pub use otp_input_story::OtpInputStory; pub use pagination_story::PaginationStory; pub use popover_story::PopoverStory; pub use progress_story::ProgressStory; +pub use questionnaire_story::QuestionnaireStory; pub use radio_story::RadioStory; pub use rating_story::RatingStory; pub use resizable_story::ResizableStory; diff --git a/crates/story/src/stories/questionnaire_story.rs b/crates/story/src/stories/questionnaire_story.rs new file mode 100644 index 0000000000..1855e11069 --- /dev/null +++ b/crates/story/src/stories/questionnaire_story.rs @@ -0,0 +1,749 @@ +use gpui::{ + App, AppContext, Context, Entity, FocusHandle, Focusable, InteractiveElement, IntoElement, + ParentElement, Render, SharedString, Styled, Subscription, Window, div, px, +}; +use gpui_component::{ + ActiveTheme as _, Sizable, Size, StyledExt as _, + button::{Button, ButtonVariants as _}, + dialog::{Dialog, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogTitle}, + group_box::{GroupBox, GroupBoxVariants as _}, + h_flex, + input::InputState, + progress::Progress, + questionnaire::{ + Questionnaire, QuestionnaireActions, QuestionnaireChoice, QuestionnaireChoiceDefinition, + QuestionnaireChoiceDescription, QuestionnaireChoices, QuestionnaireDescription, + QuestionnaireError, QuestionnaireEvent, QuestionnaireInput, QuestionnaireInputDefinition, + QuestionnaireItem, QuestionnaireItemDefinition, QuestionnaireNext, QuestionnairePrevious, + QuestionnaireProgress, QuestionnaireShortcutMode, QuestionnaireSkip, QuestionnaireState, + QuestionnaireSubmission, QuestionnaireSubmit, QuestionnaireTitle, + }, + stepper::{Stepper, StepperItem}, + v_flex, +}; + +use crate::{ChangeStorySize, section, story_toolbar}; + +pub struct QuestionnaireStory { + focus_handle: FocusHandle, + size: Size, + state: Entity, + validation_state: Entity, + external_state: Entity, + control_state: Entity, + letters_state: Entity, + numbers_state: Entity, + edge_state: Entity, + dialog_state: Entity, + size_states: Vec<(Size, Entity)>, + event_log: Vec, + _subscriptions: Vec, +} + +impl super::Story for QuestionnaireStory { + fn title() -> &'static str { + "Questionnaire" + } + + fn description() -> &'static str { + "Composable multi-step questions with answers, validation, progress, and navigation." + } + + fn new_view(window: &mut Window, cx: &mut App) -> Entity { + Self::view(window, cx) + } +} + +impl QuestionnaireStory { + pub fn view(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| Self::new(window, cx)) + } + + fn input( + window: &mut Window, + cx: &mut Context, + placeholder: &'static str, + default_value: Option<&'static str>, + ) -> Entity { + cx.new(|cx| { + let input = InputState::new(window, cx).placeholder(placeholder); + if let Some(value) = default_value { + input.default_value(value) + } else { + input + } + }) + } + + fn state( + items: Vec, + cx: &mut Context, + ) -> Entity { + cx.new(|cx| { + QuestionnaireState::new(items, cx) + .expect("Questionnaire Story definitions must be valid") + }) + } + + fn shortcut_state( + items: Vec, + mode: QuestionnaireShortcutMode, + cx: &mut Context, + ) -> Entity { + cx.new(|cx| { + QuestionnaireState::new(items, cx) + .expect("Questionnaire Story definitions must be valid") + .with_shortcuts(mode) + }) + } + + fn single_item(name: &'static str, label: &'static str) -> Vec { + vec![QuestionnaireItemDefinition::new(name, label).with_choices([ + QuestionnaireChoiceDefinition::new("first", "First choice"), + QuestionnaireChoiceDefinition::new("second", "Second choice"), + QuestionnaireChoiceDefinition::new("third", "Third choice"), + ])] + } + + fn main_items(window: &mut Window, cx: &mut Context) -> Vec { + let direction_input = Self::input(window, cx, "Type another direction…", None); + let tools_input = Self::input(window, cx, "Add another tool…", Some("Terminal")); + + vec![ + QuestionnaireItemDefinition::new("direction", "What should we prototype next?") + .with_required(true) + .with_description("Choose one direction or write your own.") + .with_choices([ + QuestionnaireChoiceDefinition::new("delegation", "Delegation") + .with_description("Show how work moves to a specialist.") + .with_default_selected(true), + QuestionnaireChoiceDefinition::new("questions", "Question prompts") + .with_description("Show choices while the interface waits."), + QuestionnaireChoiceDefinition::new("both", "Both together"), + ]) + .with_input(QuestionnaireInputDefinition::new( + direction_input, + "Another direction", + )), + QuestionnaireItemDefinition::new("tools", "Which tools do you use?") + .with_multiple(true) + .with_description("Choose any that belong in the prototype.") + .with_choices([ + QuestionnaireChoiceDefinition::new("editor", "Editor"), + QuestionnaireChoiceDefinition::new("terminal", "Terminal"), + QuestionnaireChoiceDefinition::new("browser", "Browser").with_disabled(true), + ]) + .with_input(QuestionnaireInputDefinition::new( + tools_input, + "Another tool", + )), + QuestionnaireItemDefinition::new("tone", "What tone should the interface use?") + .with_description("This optional question can be intentionally skipped.") + .with_choices([ + QuestionnaireChoiceDefinition::new("direct", "Direct"), + QuestionnaireChoiceDefinition::new("warm", "Warm"), + ]), + QuestionnaireItemDefinition::new("advanced", "Advanced preferences") + .with_disabled(true) + .with_choices([QuestionnaireChoiceDefinition::new( + "enabled", + "Enable advanced options", + )]), + ] + } + + fn validation_items( + window: &mut Window, + cx: &mut Context, + ) -> Vec { + let handle_input = Self::input(window, cx, "At least three characters", None); + vec![ + QuestionnaireItemDefinition::new("handle", "Choose a public handle") + .with_required(true) + .with_description("The validator rejects short handles.") + .with_input(QuestionnaireInputDefinition::new( + handle_input, + "Public handle", + )) + .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()) + } + }), + QuestionnaireItemDefinition::new("summary", "How should we summarize it?") + .with_choices([ + QuestionnaireChoiceDefinition::new("short", "Short"), + QuestionnaireChoiceDefinition::new("detailed", "Detailed"), + ]), + ] + } + + fn item_view( + state: &Entity, + item: &'static str, + choices: impl IntoIterator, + size: Size, + ) -> QuestionnaireItem { + let result = QuestionnaireItem::new(state, item) + .with_size(size) + .child(QuestionnaireTitle::new(state, item).with_size(size)) + .child(QuestionnaireDescription::new(state, item).with_size(size)); + + let mut choice_parts = QuestionnaireChoices::new(state, item).with_size(size); + for value in choices { + let choice = QuestionnaireChoice::new(state, item, value).with_size(size); + choice_parts = choice_parts.child(choice); + } + + result + .child(choice_parts) + // This part renders Empty when the definition has no freeform input. + .child(QuestionnaireInput::new(state, item).with_size(size)) + .child(QuestionnaireError::new(state, item).with_size(size)) + } + + fn questionnaire_view( + state: &Entity, + size: Size, + items: &[(&'static str, &'static [&'static str])], + ) -> Questionnaire { + let mut questionnaire = Questionnaire::new(state) + .with_size(size) + .child(QuestionnaireProgress::new(state).with_size(size)); + for (name, choices) in items { + questionnaire = + questionnaire.child(Self::item_view(state, name, choices.iter().copied(), size)); + } + questionnaire.child( + QuestionnaireActions::new(state) + .with_size(size) + .child(QuestionnairePrevious::new(state).with_size(size)) + .child(QuestionnaireSkip::new(state).with_size(size)) + .child(QuestionnaireNext::new(state).with_size(size)) + .child(QuestionnaireSubmit::new(state).with_size(size)), + ) + } + + fn submission_summary(submission: &QuestionnaireSubmission) -> String { + submission + .items() + .iter() + .map(|item| format!("{}:{:?}={:?}", item.name(), item.status(), item.answer())) + .collect::>() + .join(" · ") + } + + fn new(window: &mut Window, cx: &mut Context) -> Self { + let main_state = Self::state(Self::main_items(window, cx), cx); + let validation_state = Self::state(Self::validation_items(window, cx), cx); + let external_state = Self::state( + vec![ + QuestionnaireItemDefinition::new("server", "Which workspace should we connect?") + .with_required(true) + .with_choices([ + QuestionnaireChoiceDefinition::new("personal", "Personal"), + QuestionnaireChoiceDefinition::new("team", "Team"), + ]), + ], + cx, + ); + external_state.update(cx, |state, cx| { + state + .set_external_error("server", "This workspace is not available.", cx) + .expect("external Story item exists"); + }); + + let control_items = vec![ + QuestionnaireItemDefinition::new("first", "Which screen comes first?").with_choices([ + QuestionnaireChoiceDefinition::new("first", "First choice"), + QuestionnaireChoiceDefinition::new("second", "Second choice"), + ]), + QuestionnaireItemDefinition::new("second", "Which screen comes next?").with_choices([ + QuestionnaireChoiceDefinition::new("first", "First choice"), + QuestionnaireChoiceDefinition::new("second", "Second choice"), + ]), + QuestionnaireItemDefinition::new("conditional", "Conditional preferences") + .with_choices([QuestionnaireChoiceDefinition::new("third", "Third choice")]), + ]; + let control_state = cx.new(|cx| { + QuestionnaireState::new(control_items, cx) + .expect("Questionnaire Story definitions must be valid") + .with_current_item("second") + .expect("controlled Story item exists") + }); + + let shortcut_items = Self::single_item("shortcut", "Choose an answer with a shortcut"); + let letters_state = Self::shortcut_state( + shortcut_items.clone(), + QuestionnaireShortcutMode::Letters, + cx, + ); + let numbers_state = + Self::shortcut_state(shortcut_items, QuestionnaireShortcutMode::Numbers, cx); + + let edge_state = Self::state( + vec![ + QuestionnaireItemDefinition::new("edge", "No description and disabled choice") + .with_required(true) + .with_choices([ + QuestionnaireChoiceDefinition::new("first", "Available choice"), + QuestionnaireChoiceDefinition::new("second", "Disabled choice") + .with_disabled(true), + ]), + ], + cx, + ); + let dialog_state = Self::state( + vec![ + QuestionnaireItemDefinition::new("dialog", "Which workspace should we open?") + .with_choices([ + QuestionnaireChoiceDefinition::new("first", "Personal"), + QuestionnaireChoiceDefinition::new("second", "Team"), + ]), + ], + cx, + ); + + let mut size_states = Vec::new(); + for size in [Size::XSmall, Size::Small, Size::Medium, Size::Large] { + let state = Self::state( + vec![ + QuestionnaireItemDefinition::new("size", "Choose a size").with_choices([ + QuestionnaireChoiceDefinition::new("first", "Example choice"), + ]), + ], + cx, + ); + size_states.push((size, state)); + } + + let subscriptions = + vec![ + cx.subscribe(&main_state, |this, _, event: &QuestionnaireEvent, cx| { + let message = match event { + QuestionnaireEvent::CurrentItemChanged { current, .. } => { + format!("Current item: {}", current.as_deref().unwrap_or("none")) + } + QuestionnaireEvent::AnswerChanged(change) => { + format!("Answer changed: {} ({:?})", change.item(), change.status()) + } + QuestionnaireEvent::Completed(submission) => { + format!("Completed: {}", Self::submission_summary(submission)) + } + QuestionnaireEvent::Submit(submission) => { + format!("Submitted: {}", Self::submission_summary(submission)) + } + _ => "Questionnaire event".to_string(), + }; + this.event_log.push(message.into()); + if this.event_log.len() > 4 { + this.event_log.remove(0); + } + cx.notify(); + }), + ]; + + Self { + focus_handle: cx.focus_handle(), + size: Size::Medium, + state: main_state, + validation_state, + external_state, + control_state, + letters_state, + numbers_state, + edge_state, + dialog_state, + size_states, + event_log: Vec::new(), + _subscriptions: subscriptions, + } + } +} + +impl Focusable for QuestionnaireStory { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for QuestionnaireStory { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let main = Self::questionnaire_view( + &self.state, + self.size, + &[ + ("direction", &["delegation", "questions", "both"]), + ("tools", &["editor", "terminal", "browser"]), + ("tone", &["direct", "warm"]), + ("advanced", &["enabled"]), + ], + ); + let validation = Self::questionnaire_view( + &self.validation_state, + self.size, + &[("handle", &[]), ("summary", &["short", "detailed"])], + ); + let external = Self::questionnaire_view( + &self.external_state, + self.size, + &[("server", &["personal", "team"])], + ); + let progress = self.state.read(cx).progress(); + let progress_value = if progress.total() == 0 { + 0. + } else { + progress.current() as f32 / progress.total() as f32 * 100. + }; + let current_step = progress.current().saturating_sub(1); + let event_log = self.event_log.clone(); + let state_snapshot = self.state.read(cx); + let navigation = state_snapshot.navigation_state(); + let navigation_summary = format!( + "Navigation: previous={} · next={} · skip={} · submit={} · can_confirm={}", + navigation.is_previous_visible(), + navigation.is_next_visible(), + navigation.is_skip_visible(), + navigation.is_submit_visible(), + navigation.is_confirmable(), + ); + let status_summary = ["direction", "tools", "tone", "advanced"] + .into_iter() + .filter_map(|name| { + state_snapshot + .item_state(name) + .map(|item| format!("{}: {:?}", name, item.status())) + }) + .collect::>() + .join(" · "); + let answer_summary = state_snapshot + .answers() + .iter() + .map(|(name, answer)| format!("{}={:?}", name, answer)) + .collect::>() + .join(" · "); + let advanced_disabled = state_snapshot + .item_state("advanced") + .is_some_and(|item| item.is_disabled()); + let letters = Self::questionnaire_view( + &self.letters_state, + self.size, + &[("shortcut", &["first", "second", "third"])], + ); + let numbers = Self::questionnaire_view( + &self.numbers_state, + self.size, + &[("shortcut", &["first", "second", "third"])], + ); + let edge = Self::questionnaire_view( + &self.edge_state, + self.size, + &[("edge", &["first", "second"])], + ); + + let control_state = self.control_state.clone(); + let custom_control = Questionnaire::new(&control_state) + .with_size(self.size) + .child(QuestionnaireProgress::new(&control_state).with_size(self.size)) + .child(Self::item_view( + &control_state, + "first", + ["first", "second"], + self.size, + )) + .child(Self::item_view( + &control_state, + "second", + ["first", "second"], + self.size, + )) + .child(Self::item_view( + &control_state, + "conditional", + ["third"], + self.size, + )) + .child( + QuestionnaireActions::new(&control_state) + .with_size(self.size) + .child( + Button::new("questionnaire-custom-previous") + .outline() + .label("Back") + .on_click({ + let state = control_state.clone(); + move |_, window, cx| { + state.update(cx, |state, cx| { + state.go_previous(window, cx); + }); + } + }), + ) + .child( + Button::new("questionnaire-custom-next") + .primary() + .label("Continue") + .on_click({ + let state = control_state.clone(); + move |_, window, cx| { + state.update(cx, |state, cx| { + state.go_next(window, cx); + }); + } + }), + ) + .child( + Button::new("questionnaire-custom-submit") + .primary() + .label("Finish") + .on_click(move |_, window, cx| { + control_state.update(cx, |state, cx| { + state.submit(window, cx); + }); + }), + ), + ); + let dialog_state = self.dialog_state.clone(); + let dialog = Dialog::new(cx) + .trigger( + Button::new("questionnaire-dialog-trigger") + .outline() + .label("Open Questionnaire Dialog"), + ) + .p_0() + .content(move |content, _, _| { + content + .child( + DialogHeader::new() + .p_4() + .child(DialogTitle::new().child("Workspace setup")) + .child(DialogDescription::new().child( + "The host owns dismissal while Questionnaire owns the flow.", + )), + ) + .child( + Self::questionnaire_view( + &dialog_state, + Size::Small, + &[("dialog", &["first", "second"])], + ) + .px_4(), + ) + .child( + DialogFooter::new().p_4().child( + DialogClose::new().child( + Button::new("questionnaire-dialog-close") + .outline() + .label("Cancel"), + ), + ), + ) + }); + let main_state = self.state.clone(); + let control_state_for_jump = self.control_state.clone(); + + v_flex() + .id("questionnaire-story") + .track_focus(&self.focus_handle) + .w_full() + .gap_4() + .on_action(cx.listener(|this, action: &ChangeStorySize, _, cx| { + this.size = action.0; + cx.notify(); + })) + .child(story_toolbar(self.size)) + .child( + section("Complete flow") + .description("Required single choice, multiple choice, freeform input, skip, disabled item, and submit events.") + .w(px(600.)) + .child(main), + ) + .child( + section("Validation and external errors") + .description("Next validates the active item; Submit returns to the first invalid item.") + .w(px(600.)) + .child(validation) + .child(external), + ) + .child( + section("Navigation state and reset") + .description("The host can inspect status, restore answers, disable items, and reset to defaults.") + .w(px(600.)) + .child( + Button::new("questionnaire-reset") + .outline() + .label("Reset complete flow") + .on_click(cx.listener(|this, _, window, cx| { + this.state.update(cx, |state, cx| state.reset(window, cx)); + })), + ) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(format!( + "Current: {} · enabled items: {} · complete: {}", + self.state + .read(cx) + .current_item() + .map(SharedString::as_ref) + .unwrap_or("none"), + self.state.read(cx).total(), + self.state.read(cx).is_complete() + )), + ) + .child(div().text_xs().text_color(cx.theme().muted_foreground).child(status_summary)) + .child(div().text_xs().text_color(cx.theme().muted_foreground).child(navigation_summary)) + .child(div().text_xs().text_color(cx.theme().muted_foreground).child(if answer_summary.is_empty() { + "Answers: none".to_string() + } else { + format!("Answers: {answer_summary}") + })) + .child( + Button::new("questionnaire-controlled-current") + .outline() + .label("Set controlled current to first") + .on_click(move |_, window, cx| { + control_state_for_jump.update(cx, |state, cx| { + let _ = state.set_current_item("first", window, cx); + }); + }), + ) + .child( + Button::new("questionnaire-conditional") + .outline() + .label(if advanced_disabled { + "Enable conditional item" + } else { + "Disable conditional item" + }) + .on_click(move |_, window, cx| { + main_state.update(cx, |state, cx| { + let disabled = state + .item_state("advanced") + .is_some_and(|item| item.is_disabled()); + let _ = state.set_item_disabled("advanced", !disabled, window, cx); + }); + }), + ) + .children(event_log.into_iter().map(|event| { + div().text_xs().text_color(cx.theme().muted_foreground).child(event) + })), + ) + .child( + section("Shortcuts") + .description("Letters and numbers are assigned only to enabled choices; the Kbd hints are part of the choice card.") + .w(px(600.)) + .child( + h_flex() + .w_full() + .gap_4() + .child(v_flex().flex_1().gap_2().child(div().font_medium().child("Letters")).child(letters)) + .child(v_flex().flex_1().gap_2().child(div().font_medium().child("Numbers")).child(numbers)), + ), + ) + .child( + section("Custom Progress and Stepper") + .description("Compose the state snapshot with existing progress components.") + .w(px(600.)) + .child( + v_flex() + .w_full() + .gap_2() + .child(Progress::new("questionnaire-progress-custom").value(progress_value)) + .child( + Stepper::new("questionnaire-stepper") + .w_full() + .selected_index(current_step) + .items({ + let mut items = vec![ + StepperItem::new().child("Direction"), + StepperItem::new().child("Tools"), + StepperItem::new().child("Tone"), + ]; + if !advanced_disabled { + items.push(StepperItem::new().child("Advanced")); + } + items + }), + ), + ), + ) + .child( + section("Controlled current, custom actions, and card composition") + .description("Container layout and content presentation remain host-owned.") + .w(px(600.)) + .child(custom_control) + .child( + GroupBox::new() + .outline() + .title("Workspace setup") + .child( + QuestionnaireChoice::new(&self.edge_state, "edge", "first") + .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( + v_flex() + .gap_1() + .child(div().font_medium().child("Available choice")) + .child(QuestionnaireChoiceDescription::new().child( + "A custom choice body using the same state.", + )), + ), + ), + ) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Place Questionnaire inside Dialog content when the host owns dismissal and cancellation."), + ), + ) + .child( + section("No description, disabled, invalid, and Dialog") + .description("The edge states are rendered with the same semantic parts; Dialog owns its close action.") + .w(px(600.)) + .child(edge) + .child(dialog), + ) + .child( + section("All sizes") + .description("Medium is the base-nova default; the same composition scales through all four Size values.") + .w(px(600.)) + .child( + h_flex() + .flex_wrap() + .gap_3() + .children(self.size_states.iter().map(|(size, state)| { + let label = match size { + Size::XSmall => "XSmall", + Size::Small => "Small", + Size::Medium => "Medium", + Size::Large => "Large", + Size::Size(_) => "Custom", + }; + v_flex() + .w(px(135.)) + .gap_2() + .child(div().font_medium().child(label)) + .child(Self::questionnaire_view(state, *size, &[("size", &["first"])])) + })), + ), + ) + } +} diff --git a/website/component/index.md b/website/component/index.md index 253b08b17f..631753570c 100644 --- a/website/component/index.md +++ b/website/component/index.md @@ -28,6 +28,7 @@ collapsed: false - [MessageScroller](message-scroller) - Tail-following virtualized message list - [Pagination](pagination) - Page navigation controls - [Progress](progress) - Progress bars +- [Questionnaire](questionnaire) - Composable multi-step questions and answers - [Radio](radio) - Single selection from multiple options - [Rating](rating) - Interactive star rating component - [Skeleton](skeleton) - Loading placeholders diff --git a/website/component/questionnaire.md b/website/component/questionnaire.md new file mode 100644 index 0000000000..81302f4549 --- /dev/null +++ b/website/component/questionnaire.md @@ -0,0 +1,454 @@ +--- +title: Questionnaire +description: A composable multi-step questionnaire with choice, freeform, validation, and navigation support. +--- + +# 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 + +```rust +use gpui_component::questionnaire::{ + Questionnaire, QuestionnaireActions, QuestionnaireChoice, + 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. + +```rust +use gpui_component::questionnaire::{ + QuestionnaireItemDefinition, QuestionnaireChoiceDefinition, + QuestionnaireInputDefinition, QuestionnaireState, QuestionnaireAnswer, + QuestionnaireEvent, +}; +use gpui_component::input::InputState; + +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 the same collection into the compound parts. The parts are intentionally +small, so an application can replace a title, choice body, progress indicator, +or action without taking ownership of questionnaire state. + +```rust +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( + QuestionnaireActions::new(&state) + .child(QuestionnairePrevious::new(&state)) + .child(QuestionnaireSkip::new(&state)) + .child(QuestionnaireNext::new(&state)) + .child(QuestionnaireSubmit::new(&state)), + ) +``` + +`Questionnaire` renders the active item. The other items remain in the ordered +schema and are available to navigation and final validation. + +## Composition + +```text +Questionnaire +├── QuestionnaireProgress +├── QuestionnaireItem +│ ├── QuestionnaireTitle +│ ├── QuestionnaireDescription +│ ├── QuestionnaireChoices +│ │ ├── QuestionnaireChoice +│ │ └── 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. A custom part should read the corresponding state and call +the state methods for user actions; it should not duplicate answer state. + +## Single selection + +An item uses single selection by default. Activating a choice answers the item +and makes `Next` available. A single-choice item may also provide a freeform +input. The fixed choice and freeform answer are mutually exclusive, while the +input draft remains available when the user changes their mind. + +```rust +let plan_input = cx.new(|cx| InputState::new(window, cx)); +let item = QuestionnaireItemDefinition::new("plan", "Which plan fits your team?") + .with_choices([ + QuestionnaireChoiceDefinition::new("plus", "Plus"), + QuestionnaireChoiceDefinition::new("pro", "Pro"), + ]) + .with_input(QuestionnaireInputDefinition::new(plan_input, "Another plan")); +``` + +## Multiple selection + +Set `multiple` on an item when more than one fixed answer is valid. A non-empty +freeform input can be included with the selected fixed choices. + +```rust +let tools_input = cx.new(|cx| InputState::new(window, cx)); +let item = 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_input(QuestionnaireInputDefinition::new(tools_input, "Something else")); +``` + +The answer reader preserves schema order. Disabled choices are excluded from +answers even if a previously restored answer contains their value. + +## 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. + +```rust +let feedback_input = cx.new(|cx| { + InputState::new(window, cx).placeholder("Tell us what would help…") +}); +let item = QuestionnaireItemDefinition::new("feedback", "What should we improve?") + .with_input(QuestionnaireInputDefinition::new( + feedback_input, + "Your suggestion", + )); +``` + +Whitespace-only input is unanswered. The input draft is kept when a fixed +choice is selected, but it is submitted only when it is the active freeform +answer. + +## Explicit skip + +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. + +```rust +let optional = QuestionnaireItemDefinition::new("tone", "What tone should we use?") + .with_required(false) + .with_choices([ + QuestionnaireChoiceDefinition::new("direct", "Direct"), + QuestionnaireChoiceDefinition::new("warm", "Warm"), + ]); +``` + +## Navigation and status + +`QuestionnaireState` exposes the current item, ordered item states, and +navigation state for custom action layouts. + +```rust +let current = state.read(cx).current_item(); +let progress = state.read(cx).progress(); +let status = state + .read(cx) + .item_state("direction") + .map(|item| item.status()); +let navigation = state.read(cx).navigation_state(); +let can_confirm = navigation.is_confirmable(); +let show_previous = navigation.is_previous_visible(); +let show_next = navigation.is_next_visible(); +let show_skip = navigation.is_skip_visible(); +let show_submit = navigation.is_submit_visible(); + +state.update(cx, |state, cx| { + state.go_previous(window, cx); + state.go_next(window, cx); +}); +``` + +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 inert and do not enter keyboard navigation. Disabled items +are removed from the navigation and progress totals. + +## Validation + +Required status validation is built in. Add a synchronous validator to an item +for domain-specific checks. `Next` validates the current item; `Submit` +validates all enabled items and focuses the first invalid item. + +```rust +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 application can show an external schema or server error with +`set_external_error`, then clear it after the owner has corrected the data. +Reset restores defaults and clears internal validation state while leaving +owner-managed external errors under application control. + +```rust +state.update(cx, |state, cx| { + state + .set_external_error("handle", "This handle is already taken.", cx) + .expect("known questionnaire item"); +}); +``` + +## Controlled state, resume, and reset + +Use the state readers and silent setters when a page owns the active item or +restores a saved draft. Silent setters update the UI without emitting user +interaction events. + +```rust +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.reset(window, cx); +}); +``` + +Set disabled state when an earlier answer makes an item irrelevant. Disabled +items do not count toward progress, validation, focus, or submission. + +```rust +state.update(cx, |state, cx| { + state + .set_item_disabled("advanced", true, window, cx) + .expect("known questionnaire item"); +}); +``` + +## 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. + +```rust +use gpui_component::questionnaire::QuestionnaireShortcutMode; + +let state = cx.new(|cx| { + QuestionnaireState::new(items, cx) + .expect("valid questionnaire schema") + .with_shortcuts(QuestionnaireShortcutMode::Letters) +}); +``` + +Questionnaire handles all four arrow directions inside a single-choice radio +group, moving focus and selecting the next enabled choice. Up and Down otherwise +move between checkbox, choice, and freeform controls in schema order; Left and +Right move between items only when focus is not in a text input or radio control. +Enter confirms a filled answer, and Command/Ctrl+Enter confirms the current +item. Empty answers do not implicitly submit the questionnaire. + +## Progress and custom rendering + +`QuestionnaireProgress` follows the docs default presentation: “Question 2 of +4”. Its state can also be used to compose a custom indicator from the existing +`Progress` or `Stepper` components. + +```rust +QuestionnaireProgress::new(&state) + .with_size(Size::Small) + +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) + +Stepper::new("questionnaire-steps") + .selected_index(progress.current().saturating_sub(1)) +``` + +## Sizes and theming + +Questionnaire parts implement the same `Sizable` contract as the rest of the +library. `Medium` is the default and follows the shadcn/ui `base-nova` docs +appearance. + +```rust +use gpui_component::{Sizable as _, Size}; + +Questionnaire::new(&state).with_size(Size::Small) +Questionnaire::new(&state).with_size(Size::Medium) +Questionnaire::new(&state).with_size(Size::Large) +``` + +The default skin derives spacing, typography, radius, border, input, primary, +muted, destructive, and focus-ring values from the active theme's semantic +tokens. Use `Styled` methods or `StyleRefinement` for local adjustments; no +Questionnaire-specific color constants are required. + +## Card and Dialog composition + +The questionnaire owns its question flow. A card or dialog owns its container +layout and close/cancel behavior. + +```rust +GroupBox::new() + .outline() + .title("Set up your workspace") + .child(Questionnaire::new(&state)) +``` + +For a dialog, create the Questionnaire inside the existing Dialog content and +let the host handle dismissal. The Questionnaire `Submit` event is the place +to hand a validated `QuestionnaireSubmission` to application transport. + +## 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. + +```rust +cx.subscribe(&state, |_, _, event, _| match event { + QuestionnaireEvent::CurrentItemChanged { current, .. } => { + println!("Current item: {:?}", current); + } + QuestionnaireEvent::AnswerChanged(change) => { + println!("Changed: {:?}", change.item()); + } + QuestionnaireEvent::Completed(submission) + | QuestionnaireEvent::Submit(submission) => { + println!("Answers: {:?}", submission.items()); + } + _ => {} +}); +``` + +The submission is ordered by the item schema and contains only enabled items. +It represents a validated local submission request; saving it remotely remains +the host application's responsibility. + +## 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. Custom children control visible presentation and keep the state, +roles, focus behavior, and semantics supplied by the Questionnaire parts. +`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. + +## API reference + +- [Questionnaire] +- [QuestionnaireState] +- [QuestionnaireItemDefinition] +- [QuestionnaireChoiceDefinition] +- [QuestionnaireInputDefinition] +- [QuestionnaireProgress] +- [QuestionnaireItem] +- [QuestionnaireChoice] +- [QuestionnaireInput] +- [QuestionnaireActions] +- [QuestionnaireEvent] +- [QuestionnaireSubmission] +- [Sizable] + +[Questionnaire]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.Questionnaire.html +[QuestionnaireState]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireState.html +[QuestionnaireItemDefinition]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireItemDefinition.html +[QuestionnaireChoiceDefinition]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireChoiceDefinition.html +[QuestionnaireInputDefinition]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireInputDefinition.html +[QuestionnaireProgress]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireProgress.html +[QuestionnaireItem]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireItem.html +[QuestionnaireChoice]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireChoice.html +[QuestionnaireInput]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireInput.html +[QuestionnaireActions]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireActions.html +[QuestionnaireEvent]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/enum.QuestionnaireEvent.html +[QuestionnaireSubmission]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireSubmission.html +[Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html diff --git a/website/zh-CN/component/index.md b/website/zh-CN/component/index.md index 0cc737b3e8..f5b8cb98ac 100644 --- a/website/zh-CN/component/index.md +++ b/website/zh-CN/component/index.md @@ -36,6 +36,7 @@ collapsed: false - [DatePicker](date-picker) - 日期选择器 - [OtpInput](otp-input) - 一次性验证码输入 - [ColorPicker](color-picker) - 颜色选择器 +- [Questionnaire](questionnaire) - 可组合的多步骤问卷与答案 - [Form](form) - 表单容器与布局 ## 布局与高级组件 diff --git a/website/zh-CN/component/questionnaire.md b/website/zh-CN/component/questionnaire.md new file mode 100644 index 0000000000..adcca5fffb --- /dev/null +++ b/website/zh-CN/component/questionnaire.md @@ -0,0 +1,393 @@ +--- +title: Questionnaire +description: 支持单选、多选、自由输入、校验和导航的可组合多步骤问卷。 +--- + +# Questionnaire + +`Questionnaire` 引导用户完成一组有序问题。它负责当前 item、答案状态、校验、进度和导航。外层页面、`GroupBox`、`Dialog` 或 `Sheet` 负责关闭、取消、持久化、传输以及应用特有的条件分支。 + +## 引入 + +```rust +use gpui_component::questionnaire::{ + Questionnaire, QuestionnaireActions, QuestionnaireChoice, + QuestionnaireChoices, QuestionnaireDescription, QuestionnaireError, + QuestionnaireInput, QuestionnaireItem, QuestionnaireNext, + QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, + QuestionnaireState, QuestionnaireSubmit, QuestionnaireTitle, +}; +``` + +## 用法 + +先创建一次 item 集合,并使用一个 `QuestionnaireState` entity 作为所有部件的状态源。 + +```rust +use gpui_component::questionnaire::{ + QuestionnaireItemDefinition, QuestionnaireChoiceDefinition, + QuestionnaireInputDefinition, QuestionnaireState, QuestionnaireAnswer, + QuestionnaireEvent, +}; +use gpui_component::input::InputState; + +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") +}); +``` + +将同一个集合映射为组合部件。每个部件都保持足够小,应用可以替换标题、选项内容、进度指示器或操作按钮,同时继续使用 Questionnaire 的状态。 + +```rust +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( + QuestionnaireActions::new(&state) + .child(QuestionnairePrevious::new(&state)) + .child(QuestionnaireSkip::new(&state)) + .child(QuestionnaireNext::new(&state)) + .child(QuestionnaireSubmit::new(&state)), + ) +``` + +`Questionnaire` 只渲染当前 item;其他 item 仍保留在有序 schema 中,并参与导航和最终校验。 + +## 组合结构 + +```text +Questionnaire +├── QuestionnaireProgress +├── QuestionnaireItem +│ ├── QuestionnaireTitle +│ ├── QuestionnaireDescription +│ ├── QuestionnaireChoices +│ │ ├── QuestionnaireChoice +│ │ └── QuestionnaireInput +│ └── QuestionnaireError +└── QuestionnaireActions + ├── QuestionnairePrevious + ├── QuestionnaireSkip + ├── QuestionnaireNext + └── QuestionnaireSubmit +``` + +所有部件都接受普通 GPUI 样式,并可以与现有的 `Button`、`Input`、`Radio`、`Checkbox`、`Progress`、`Stepper`、`GroupBox` 和 `Dialog` 组合。自定义部件应读取对应 state 并调用 state 方法处理用户操作,不要复制答案状态。 + +## 单选 + +item 默认使用单选模式。激活某个选项后 item 即有答案,`Next` 可以继续。单选 item 也可以提供自由输入;固定选项和自由答案互斥,但用户切换选择时会保留输入草稿。 + +```rust +let plan_input = cx.new(|cx| InputState::new(window, cx)); +let item = QuestionnaireItemDefinition::new("plan", "Which plan fits your team?") + .with_choices([ + QuestionnaireChoiceDefinition::new("plus", "Plus"), + QuestionnaireChoiceDefinition::new("pro", "Pro"), + ]) + .with_input(QuestionnaireInputDefinition::new(plan_input, "Another plan")); +``` + +## 多选 + +当一个 item 可以接受多个固定答案时设置 `multiple`。 + +```rust +let tools_input = cx.new(|cx| InputState::new(window, cx)); +let item = 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_input(QuestionnaireInputDefinition::new(tools_input, "Something else")); +``` + +多选 item 可以同时提交多个固定选项和非空自由输入。答案读取器按 schema 顺序返回结果。即使恢复的数据包含 disabled choice,其值也不会进入答案。 + +## 自由输入 + +加入 `QuestionnaireInputDefinition`,允许用户输入固定选项之外的答案。请为输入提供可访问名称;placeholder 不能替代 label。 + +```rust +let feedback_input = cx.new(|cx| { + InputState::new(window, cx).placeholder("Tell us what would help…") +}); +let item = QuestionnaireItemDefinition::new("feedback", "What should we improve?") + .with_input(QuestionnaireInputDefinition::new( + feedback_input, + "Your suggestion", + )); +``` + +只有空白的输入视为未回答。选择固定选项时会保留输入草稿,但只有自由输入成为当前答案时才会提交它。 + +## 显式跳过 + +可选 item 可以显示 `QuestionnaireSkip`。跳过是一个明确且有效的状态,会清除该 item 的答案并允许 `Next` 继续。必填 item 不允许跳过。重新进入 item 并选择答案后,skipped 状态会被清除。 + +```rust +let optional = QuestionnaireItemDefinition::new("tone", "What tone should we use?") + .with_required(false) + .with_choices([ + QuestionnaireChoiceDefinition::new("direct", "Direct"), + QuestionnaireChoiceDefinition::new("warm", "Warm"), + ]); +``` + +## 导航与状态 + +`QuestionnaireState` 暴露当前 item、有序 item 状态和导航状态,可用于自定义操作布局。 + +```rust +let current = state.read(cx).current_item(); +let progress = state.read(cx).progress(); +let status = state + .read(cx) + .item_state("direction") + .map(|item| item.status()); +let navigation = state.read(cx).navigation_state(); +let can_confirm = navigation.is_confirmable(); +let show_previous = navigation.is_previous_visible(); +let show_next = navigation.is_next_visible(); +let show_skip = navigation.is_skip_visible(); +let show_submit = navigation.is_submit_visible(); + +state.update(cx, |state, cx| { + state.go_previous(window, cx); + state.go_next(window, cx); +}); +``` + +默认操作布局在开头显示 `Previous`,在 item 之间显示 `Next`,当前 item 可选时显示 `Skip`,最后显示 `Submit`。隐藏的操作不会进入键盘导航。disabled item 会从导航和进度总数中排除。 + +## 校验 + +必填状态校验已经内置。可以为 item 添加同步 validator,实现领域规则。`Next` 校验当前 item;`Submit` 校验全部 enabled item,并将焦点移到第一个无效 item。 + +```rust +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()) + } + }); +``` + +应用可以使用 `set_external_error` 显示外部 schema 或服务器错误,在数据修正后由 owner 清除。Reset 会恢复默认值并清除内部校验状态;owner 管理的 external error 仍由应用控制。 + +```rust +state.update(cx, |state, cx| { + state + .set_external_error("handle", "This handle is already taken.", cx) + .expect("known questionnaire item"); +}); +``` + +## 受控状态、恢复与重置 + +当页面需要控制当前 item 或恢复已保存草稿时,使用 state reader 和静默 setter。静默 setter 会更新 UI,但不会发出用户交互事件。 + +```rust +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.reset(window, cx); +}); +``` + +如果前面的答案使某个 item 不适用,可以设置 disabled。disabled item 不计入进度、校验、焦点或提交。 + +```rust +state.update(cx, |state, cx| { + state + .set_item_disabled("advanced", true, window, cx) + .expect("known questionnaire item"); +}); +``` + +## 键盘快捷键 + +为 state 启用字母或数字快捷键。快捷键只作用于当前 item 的 enabled choices。重复 key event、文本输入、IME 组合以及带修饰键的按键都会保持原有行为。 + +```rust +use gpui_component::questionnaire::QuestionnaireShortcutMode; + +let state = cx.new(|cx| { + QuestionnaireState::new(items, cx) + .expect("valid questionnaire schema") + .with_shortcuts(QuestionnaireShortcutMode::Letters) +}); +``` + +Questionnaire 在单选 radio group 内处理四个方向键,将焦点移到下一个 enabled choice 并选中它。Up/Down 在其他场景下按 schema 顺序在 checkbox、choice 和自由输入控件之间移动;只有焦点不在文本输入或 radio 控件上时,Left/Right 才会在 item 之间移动。Enter 确认已填写的答案,Command/Ctrl+Enter 确认当前 item。空答案不会隐式提交问卷。 + +## 进度和自定义渲染 + +`QuestionnaireProgress` 使用 docs 默认的 “Question 2 of 4” 样式。也可以读取 progress state,使用现有 `Progress` 或 `Stepper` 组合自定义指示器。 + +```rust +QuestionnaireProgress::new(&state) + .with_size(Size::Small) + +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) + +Stepper::new("questionnaire-steps") + .selected_index(progress.current().saturating_sub(1)) +``` + +## 尺寸与主题 + +Questionnaire 部件实现与其他组件相同的 `Sizable` 契约。默认尺寸为 `Medium`,并遵循 shadcn/ui `base-nova` docs 外观。 + +```rust +use gpui_component::{Sizable as _, Size}; + +Questionnaire::new(&state).with_size(Size::Small) +Questionnaire::new(&state).with_size(Size::Medium) +Questionnaire::new(&state).with_size(Size::Large) +``` + +默认皮肤从当前主题的 semantic tokens 派生 spacing、typography、radius、border、input、primary、muted、destructive 和 focus-ring。局部调整可以使用 `Styled` 方法或 `StyleRefinement`,不需要增加 Questionnaire 专属颜色常量。 + +## Card 和 Dialog 组合 + +Questionnaire 负责问题流程;卡片或 dialog 负责容器布局以及关闭、取消行为。 + +```rust +GroupBox::new() + .outline() + .title("Set up your workspace") + .child(Questionnaire::new(&state)) +``` + +对于 dialog,可以在现有 Dialog 内容中创建 Questionnaire,并由宿主处理关闭。Questionnaire 的 `Submit` event 适合把已校验的 `QuestionnaireSubmission` 交给应用传输层。 + +## Event 与提交 + +订阅 `QuestionnaireEvent`,即可监听当前 item 变化、答案变化、完成和成功提交。`Completed` 只在状态首次转为 complete 时发出;每次成功执行显式 submit 都会发出 `Submit`。 + +```rust +cx.subscribe(&state, |_, _, event, _| match event { + QuestionnaireEvent::CurrentItemChanged { current, .. } => { + println!("Current item: {:?}", current); + } + QuestionnaireEvent::AnswerChanged(change) => { + println!("Changed: {:?}", change.item()); + } + QuestionnaireEvent::Completed(submission) + | QuestionnaireEvent::Submit(submission) => { + println!("Answers: {:?}", submission.items()); + } + _ => {} +}); +``` + +提交结果按 item schema 顺序排列,并且只包含 enabled item。它表示本地已校验的提交请求;远程保存仍由宿主应用负责。 + +## 可访问性 + +`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 样式暴露无效状态。 + +## API 参考 + +- [Questionnaire] +- [QuestionnaireState] +- [QuestionnaireItemDefinition] +- [QuestionnaireChoiceDefinition] +- [QuestionnaireInputDefinition] +- [QuestionnaireProgress] +- [QuestionnaireItem] +- [QuestionnaireChoice] +- [QuestionnaireInput] +- [QuestionnaireActions] +- [QuestionnaireEvent] +- [QuestionnaireSubmission] +- [Sizable] + +[Questionnaire]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.Questionnaire.html +[QuestionnaireState]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireState.html +[QuestionnaireItemDefinition]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireItemDefinition.html +[QuestionnaireChoiceDefinition]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireChoiceDefinition.html +[QuestionnaireInputDefinition]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireInputDefinition.html +[QuestionnaireProgress]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireProgress.html +[QuestionnaireItem]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireItem.html +[QuestionnaireChoice]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireChoice.html +[QuestionnaireInput]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireInput.html +[QuestionnaireActions]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireActions.html +[QuestionnaireEvent]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/enum.QuestionnaireEvent.html +[QuestionnaireSubmission]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireSubmission.html +[Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html From bff84f3001da47815260c8caaba0535d11dc1fa6 Mon Sep 17 00:00:00 2001 From: suxiaoshao <48886207+suxiaoshao@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:52:27 +0800 Subject: [PATCH 02/17] questionnaire: Refine interactions and base-nova styling --- .../component/src/questionnaire/components.rs | 358 ++++++++++++++---- crates/component/src/questionnaire/state.rs | 18 +- .../story/src/stories/questionnaire_story.rs | 14 +- 3 files changed, 304 insertions(+), 86 deletions(-) diff --git a/crates/component/src/questionnaire/components.rs b/crates/component/src/questionnaire/components.rs index b3aa3b5f49..ff259ac43b 100644 --- a/crates/component/src/questionnaire/components.rs +++ b/crates/component/src/questionnaire/components.rs @@ -30,13 +30,22 @@ struct QuestionnaireMetrics { choice_padding_x: gpui::Pixels, choice_padding_y: gpui::Pixels, choice_min_height: gpui::Pixels, + choice_radius: gpui::Pixels, + input_padding_y: gpui::Pixels, + input_radius: gpui::Pixels, indicator_size: gpui::Pixels, indicator_mark_size: gpui::Pixels, + indicator_check_size: gpui::Pixels, + shortcut_size: gpui::Pixels, + shortcut_text_size: gpui::Pixels, + shortcut_radius: gpui::Pixels, } impl QuestionnaireMetrics { fn new(size: Size, cx: &App) -> Self { - let spacing = cx.theme().semantic_tokens().spacing; + let tokens = cx.theme().semantic_tokens(); + let spacing = tokens.spacing; + let radius = tokens.radius; match size { Size::XSmall => Self { root_gap: spacing.sm, @@ -46,8 +55,15 @@ impl QuestionnaireMetrics { choice_padding_x: spacing.sm, choice_padding_y: spacing.xs, choice_min_height: spacing.xl + spacing.xs, + choice_radius: radius.md, + input_padding_y: gpui::Pixels::ZERO, + input_radius: radius.md, indicator_size: spacing.md, indicator_mark_size: spacing.xs, + indicator_check_size: spacing.sm, + shortcut_size: spacing.lg, + shortcut_text_size: spacing.sm, + shortcut_radius: radius.sm, }, Size::Small => Self { root_gap: spacing.md, @@ -57,8 +73,15 @@ impl QuestionnaireMetrics { choice_padding_x: spacing.sm, choice_padding_y: spacing.xs, choice_min_height: spacing.xxl, + choice_radius: radius.lg, + input_padding_y: spacing.xxs, + input_radius: radius.lg, indicator_size: spacing.md + spacing.xxs, indicator_mark_size: spacing.xs + spacing.xxs, + indicator_check_size: spacing.sm + spacing.xxs, + shortcut_size: spacing.lg + spacing.xxs, + shortcut_text_size: spacing.sm + spacing.xxs * 0.5, + shortcut_radius: radius.md, }, Size::Large => Self { root_gap: spacing.xl, @@ -68,30 +91,51 @@ impl QuestionnaireMetrics { choice_padding_x: spacing.lg, choice_padding_y: spacing.md, choice_min_height: spacing.xxl + spacing.lg, + choice_radius: radius.xl, + input_padding_y: spacing.sm, + input_radius: radius.xl, indicator_size: spacing.lg + spacing.xxs, indicator_mark_size: spacing.sm + spacing.xxs, + indicator_check_size: spacing.lg, + shortcut_size: spacing.xl, + shortcut_text_size: spacing.md, + shortcut_radius: radius.xl, }, Size::Size(value) => Self { root_gap: value, item_gap: value, - choice_gap: value * 0.5, + choice_gap: value * 0.625, content_gap: value * 0.25, choice_padding_x: value * 0.75, - choice_padding_y: value * 0.5, + choice_padding_y: value * 0.625, choice_min_height: value * 2.75, + choice_radius: (radius.lg + radius.xl) * 0.5, + input_padding_y: value * 0.25, + input_radius: (radius.lg + radius.xl) * 0.5, indicator_size: value, indicator_mark_size: value * 0.5, + indicator_check_size: value * 0.875, + shortcut_size: value * 1.25, + shortcut_text_size: value * 0.625, + shortcut_radius: radius.lg, }, Size::Medium => Self { root_gap: spacing.lg, item_gap: spacing.lg, - choice_gap: spacing.sm, + choice_gap: spacing.sm + spacing.xxs, content_gap: spacing.xxs, choice_padding_x: spacing.md, - choice_padding_y: spacing.sm, + choice_padding_y: spacing.sm + spacing.xxs, choice_min_height: spacing.xxl + spacing.md, + choice_radius: (radius.lg + radius.xl) * 0.5, + input_padding_y: spacing.xs, + input_radius: (radius.lg + radius.xl) * 0.5, indicator_size: spacing.lg, indicator_mark_size: spacing.sm, + indicator_check_size: spacing.md + spacing.xxs, + shortcut_size: spacing.lg + spacing.xs, + shortcut_text_size: spacing.sm + spacing.xxs, + shortcut_radius: radius.lg, }, } } @@ -112,6 +156,30 @@ fn text_style(element: T, size: Size, cx: &App) -> T { .font_weight(token.weight) } +fn progress_text_style(element: T, size: Size, cx: &App) -> T { + let typography = cx.theme().semantic_tokens().typography; + let token = match size { + Size::XSmall | Size::Small | Size::Medium => typography.xs, + Size::Large => typography.sm, + Size::Size(value) => { + return element.text_size(value * 0.75).line_height(value); + } + }; + + element + .text_size(token.size) + .line_height(token.line_height) + .font_weight(token.weight) +} + +fn description_text_style(element: T, size: Size, cx: &App) -> T { + let metrics = QuestionnaireMetrics::new(size, cx); + + // A native fieldset excludes its legend from the flex gap before the + // description. Recreate that base-nova relationship for GPUI's group. + text_style(element, size, cx).mt(-metrics.item_gap) +} + fn title_text_style(element: T, size: Size, cx: &App) -> T { let typography = cx.theme().semantic_tokens().typography; let token = match size { @@ -186,52 +254,58 @@ impl Questionnaire { && state.focused_current_choice(window).is_some() }; - let handled = - if key == "enter" && modifiers.secondary() && modifiers.number_of_modifiers() == 1 { - state.update(cx, |state, cx| state.confirm_current(window, cx)) - } else if modifiers.number_of_modifiers() != 0 { - false - } else if input_focused { - match key { - "enter" if Self::focused_answer_is_filled(state, window, cx) => { - state.update(cx, |state, cx| state.confirm_current(window, cx)) - } - "up" if !input_has_text => { - state.update(cx, |state, cx| state.focus_previous_answer(window, cx)) - } - "down" if !input_has_text => { - state.update(cx, |state, cx| state.focus_next_answer(window, cx)) - } - _ => false, + let handled = if key == "enter" + && modifiers.secondary() + && modifiers.number_of_modifiers() == 1 + { + state.update(cx, |state, cx| state.confirm_current(window, cx)) + } else if modifiers.number_of_modifiers() != 0 { + false + } else if input_focused { + match key { + "enter" if Self::focused_answer_is_filled(state, window, cx) => { + state.update(cx, |state, cx| state.confirm_current(window, cx)) } - } else { - match key { - "up" if single_radio_focused => { - state.update(cx, |state, cx| state.move_current_radio(-1, window, cx)) - } - "down" if single_radio_focused => { - state.update(cx, |state, cx| state.move_current_radio(1, window, cx)) - } - "up" => state.update(cx, |state, cx| state.focus_previous_answer(window, cx)), - "down" => state.update(cx, |state, cx| state.focus_next_answer(window, cx)), - "left" if single_radio_focused => { - state.update(cx, |state, cx| state.move_current_radio(-1, window, cx)) - } - "right" if single_radio_focused => { - state.update(cx, |state, cx| state.move_current_radio(1, window, cx)) - } - "left" => state.update(cx, |state, cx| state.go_previous(window, cx)), - "right" if state.read(cx).navigation_state().is_confirmable() => { - state.update(cx, |state, cx| state.go_next(window, cx)) - } - "right" => false, - "enter" if Self::focused_answer_is_filled(state, window, cx) => { - state.update(cx, |state, cx| state.confirm_current(window, cx)) - } - "enter" => false, - _ => state.update(cx, |state, cx| state.activate_shortcut(key, window, cx)), + "up" if !input_has_text => { + state.update(cx, |state, cx| state.focus_previous_answer(window, cx)) } - }; + "down" if !input_has_text => { + state.update(cx, |state, cx| state.focus_next_answer(window, cx)) + } + _ => false, + } + } else { + match key { + "up" => { + state.update(cx, |state, cx| state.focus_previous_answer(window, cx)) + || (single_radio_focused + && state + .update(cx, |state, cx| state.move_current_radio(-1, window, cx))) + } + "down" => { + state.update(cx, |state, cx| state.focus_next_answer(window, cx)) + || (single_radio_focused + && state + .update(cx, |state, cx| state.move_current_radio(1, window, cx))) + } + "left" if single_radio_focused => { + state.update(cx, |state, cx| state.move_current_radio(-1, window, cx)) + } + "right" if single_radio_focused => { + state.update(cx, |state, cx| state.move_current_radio(1, window, cx)) + } + "left" => state.update(cx, |state, cx| state.go_previous(window, cx)), + "right" if state.read(cx).navigation_state().is_confirmable() => { + state.update(cx, |state, cx| state.go_next(window, cx)) + } + "right" => false, + "enter" if Self::focused_answer_is_filled(state, window, cx) => { + state.update(cx, |state, cx| state.confirm_current(window, cx)) + } + "enter" => false, + _ => state.update(cx, |state, cx| state.activate_shortcut(key, window, cx)), + } + }; if handled { window.prevent_default(); @@ -349,10 +423,9 @@ impl RenderOnce for QuestionnaireProgress { let label: SharedString = t!("Questionnaire.progress", current = current, total = total).into(); let colors = cx.theme().semantic_tokens().colors; - let mono_font = cx.theme().semantic_tokens().typography.mono.clone(); let has_children = !self.children.is_empty(); - text_style( + progress_text_style( div() .id(element_id(&self.state, "progress")) .role(Role::ProgressIndicator) @@ -364,7 +437,6 @@ impl RenderOnce for QuestionnaireProgress { self.size, cx, ) - .font_family(mono_font) .font_weight(gpui::FontWeight::MEDIUM) .refine_style(&self.style) .when(!has_children, |this| this.child(label)) @@ -441,7 +513,7 @@ questionnaire_item_part!(QuestionnaireTitle, item_label, title_text_style, foreg questionnaire_item_part!( QuestionnaireDescription, item_description, - text_style, + description_text_style, muted_foreground ); @@ -729,7 +801,7 @@ where } else { tokens.colors.background.opacity(0.) }) - .rounded(tokens.radius.lg) + .rounded(metrics.choice_radius) .when(!disabled, |this| { this.hover(|style| style.bg(tokens.colors.muted.opacity(0.5))) }) @@ -782,7 +854,9 @@ impl RenderOnce for QuestionnaireChoice { let focus_handle = state.choice_focus_handle(&self.item, &self.value).cloned(); let colors = cx.theme().semantic_tokens().colors; let radius = cx.theme().semantic_tokens().radius; + let mono_font = cx.theme().semantic_tokens().typography.mono.clone(); let metrics = QuestionnaireMetrics::new(self.size, cx); + let answer_alignment_offset = metrics.content_gap; let focused = focus_handle .as_ref() .is_some_and(|focus_handle| focus_handle.is_focused(window)); @@ -809,11 +883,12 @@ impl RenderOnce for QuestionnaireChoice { }) .when(multiple, |this| this.rounded(radius.sm)) .when(!multiple, |this| this.rounded(radius.full)) + .mt(answer_alignment_offset) .refine_style(&self.indicator_style) .when(selected && multiple, |this| { this.child( svg() - .size(metrics.indicator_mark_size) + .size(metrics.indicator_check_size) .path(IconName::Check.path()) .text_color(colors.primary_foreground), ) @@ -866,9 +941,19 @@ impl RenderOnce for QuestionnaireChoice { }; Kbd::new(keystroke) .outline() + .flex() + .items_center() + .justify_center() + .size(metrics.shortcut_size) + .p_0() .bg(colors.background) .border_color(colors.input) .text_color(colors.muted_foreground) + .font_family(mono_font.clone()) + .text_size(metrics.shortcut_text_size) + .font_weight(gpui::FontWeight::MEDIUM) + .rounded(metrics.shortcut_radius) + .mt(answer_alignment_offset) .refine_style(&self.shortcut_style) .into_any_element() }; @@ -1096,11 +1181,14 @@ impl RenderOnce for QuestionnaireInput { if !active { return gpui::Empty.into_any_element(); } + let metrics = QuestionnaireMetrics::new(self.size, cx); Input::new(input_definition.state()) .aria_label(input_definition.accessibility_label().clone()) .disabled(item_state.is_disabled() || input_definition.is_disabled()) .with_size(self.size) + .py(metrics.input_padding_y) + .rounded(metrics.input_radius) .when(item_state.is_invalid(), |this| { this.border_color(cx.theme().semantic_tokens().colors.destructive) }) @@ -1233,7 +1321,7 @@ impl RenderOnce for QuestionnaireActions { .flex() .min_w_0() .items_center() - .justify_end() + .justify_start() .gap(metrics.choice_gap) .w_full() .refine_style(&self.style) @@ -1303,6 +1391,13 @@ macro_rules! questionnaire_action_part { return gpui::Empty.into_any_element(); } + let anchors_trailing_actions = match action { + QuestionnaireAction::Skip => true, + QuestionnaireAction::Next | QuestionnaireAction::Submit => { + !navigation.is_skip_visible() + } + QuestionnaireAction::Previous => false, + }; let state = self.state.clone(); let has_children = !self.children.is_empty(); let debug_selector = format!( @@ -1310,11 +1405,12 @@ macro_rules! questionnaire_action_part { self.state.entity_id(), stringify!($action) ); - let button = Button::new(element_id(&self.state, stringify!($action))) + Button::new(element_id(&self.state, stringify!($action))) .debug_selector(move || debug_selector) .with_size(self.size) .when($outline, |this| this.outline()) .when($primary, |this| this.primary()) + .when(anchors_trailing_actions, |this| this.ml_auto()) .on_click(move |_, window, cx| { state.update(cx, |state, cx| match action { QuestionnaireAction::Previous => state.go_previous(window, cx), @@ -1325,19 +1421,8 @@ macro_rules! questionnaire_action_part { }) .refine_style(&self.style) .when(!has_children, |this| this.label(t!($translation))) - .children(self.children); - - if matches!(action, QuestionnaireAction::Previous) { - div() - .flex() - .flex_1() - .min_w_0() - .justify_start() - .child(button.max_w_full()) - .into_any_element() - } else { - button.into_any_element() - } + .children(self.children) + .into_any_element() } } }; @@ -1383,6 +1468,7 @@ mod tests { struct QuestionnaireHarness { state: Entity, + override_skip_margin: bool, } impl Render for QuestionnaireHarness { @@ -1413,7 +1499,10 @@ mod tests { .child( QuestionnaireActions::new(&self.state) .child(QuestionnairePrevious::new(&self.state)) - .child(QuestionnaireSkip::new(&self.state)) + .child( + QuestionnaireSkip::new(&self.state) + .when(self.override_skip_margin, |this| this.ml(px(0.))), + ) .child(QuestionnaireNext::new(&self.state)) .child(QuestionnaireSubmit::new(&self.state)), ) @@ -1434,7 +1523,10 @@ mod tests { None => state, } }); - QuestionnaireHarness { state } + QuestionnaireHarness { + state, + override_skip_margin: false, + } }); cx.update(|window, cx| window.draw(cx).clear(cx)); let state = cx.update(|_, cx| view.read(cx).state.clone()); @@ -1469,7 +1561,10 @@ mod tests { ) .unwrap() }); - QuestionnaireHarness { state } + QuestionnaireHarness { + state, + override_skip_margin: false, + } }); cx.update(|window, cx| window.draw(cx).clear(cx)); let state = cx.update(|_, cx| view.read(cx).state.clone()); @@ -1478,15 +1573,16 @@ mod tests { fn actions_visual_harness( cx: &mut TestAppContext, + override_skip_margin: bool, ) -> (&mut VisualTestContext, Entity) { cx.update(crate::init); let (view, cx) = cx.add_window_view(|_, cx| { let state = cx.new(|cx| { QuestionnaireState::new( vec![ - QuestionnaireItemDefinition::new("first", "First"), + QuestionnaireItemDefinition::new("first", "First").with_required(true), QuestionnaireItemDefinition::new("second", "Second"), - QuestionnaireItemDefinition::new("third", "Third"), + QuestionnaireItemDefinition::new("third", "Third").with_required(true), ], cx, ) @@ -1494,7 +1590,10 @@ mod tests { .with_current_item("second") .unwrap() }); - QuestionnaireHarness { state } + QuestionnaireHarness { + state, + override_skip_margin, + } }); cx.update(|window, cx| window.draw(cx).clear(cx)); let state = cx.update(|_, cx| view.read(cx).state.clone()); @@ -1553,13 +1652,14 @@ mod tests { #[gpui::test] fn actions_stay_inside_questionnaire_width(cx: &mut TestAppContext) { - let (cx, state) = actions_visual_harness(cx); + let (cx, state) = actions_visual_harness(cx, false); let entity_id = state.entity_id(); let root_id = Box::leak(format!("questionnaire-{entity_id}-root").into_boxed_str()); let actions_id = Box::leak(format!("questionnaire-{entity_id}-actions").into_boxed_str()); let previous_id = Box::leak(format!("questionnaire-{entity_id}-Previous").into_boxed_str()); let skip_id = Box::leak(format!("questionnaire-{entity_id}-Skip").into_boxed_str()); let next_id = Box::leak(format!("questionnaire-{entity_id}-Next").into_boxed_str()); + let submit_id = Box::leak(format!("questionnaire-{entity_id}-Submit").into_boxed_str()); let root = cx.debug_bounds(root_id).expect("questionnaire rendered"); let actions = cx.debug_bounds(actions_id).expect("actions rendered"); let previous = cx.debug_bounds(previous_id).expect("previous rendered"); @@ -1568,10 +1668,55 @@ mod tests { assert!(actions.left() >= root.left()); assert!(actions.right() <= root.right()); - assert!(previous.left() >= actions.left()); + assert_eq!(previous.left(), actions.left()); + assert!(previous.right() <= skip.left()); + assert!(skip.right() <= next.left()); + assert_eq!(next.right(), actions.right()); + + cx.update(|window, cx| { + state + .update(cx, |state, cx| state.set_current_item("first", window, cx)) + .unwrap(); + window.draw(cx).clear(cx); + }); + let first_actions = cx.debug_bounds(actions_id).expect("first actions rendered"); + let first_next = cx.debug_bounds(next_id).expect("first next rendered"); + assert!(first_next.left() >= first_actions.left()); + assert_eq!(first_next.right(), first_actions.right()); + + cx.update(|window, cx| { + state + .update(cx, |state, cx| state.set_current_item("third", window, cx)) + .unwrap(); + window.draw(cx).clear(cx); + }); + let last_actions = cx.debug_bounds(actions_id).expect("last actions rendered"); + let last_previous = cx + .debug_bounds(previous_id) + .expect("last previous rendered"); + let submit = cx.debug_bounds(submit_id).expect("submit rendered"); + assert_eq!(last_previous.left(), last_actions.left()); + assert!(last_previous.right() <= submit.left()); + assert_eq!(submit.right(), last_actions.right()); + } + + #[gpui::test] + fn action_instance_style_overrides_default_trailing_anchor(cx: &mut TestAppContext) { + let (cx, state) = actions_visual_harness(cx, true); + let entity_id = state.entity_id(); + let actions_id = Box::leak(format!("questionnaire-{entity_id}-actions").into_boxed_str()); + let previous_id = Box::leak(format!("questionnaire-{entity_id}-Previous").into_boxed_str()); + let skip_id = Box::leak(format!("questionnaire-{entity_id}-Skip").into_boxed_str()); + let next_id = Box::leak(format!("questionnaire-{entity_id}-Next").into_boxed_str()); + let actions = cx.debug_bounds(actions_id).expect("actions rendered"); + let previous = cx.debug_bounds(previous_id).expect("previous rendered"); + let skip = cx.debug_bounds(skip_id).expect("skip rendered"); + let next = cx.debug_bounds(next_id).expect("next rendered"); + + assert_eq!(previous.left(), actions.left()); assert!(previous.right() <= skip.left()); assert!(skip.right() <= next.left()); - assert!(next.right() <= actions.right()); + assert!(next.right() < actions.right()); } #[gpui::test] @@ -1681,6 +1826,7 @@ mod tests { assert!(state.read(cx).error("first").is_some()); }); + focus_input(cx, &state, "first"); simulate_key(cx, "up", false, false); cx.update(|window, cx| { assert_eq!( @@ -1692,6 +1838,45 @@ mod tests { ); }); + simulate_key(cx, "down", false, false); + cx.update(|window, cx| { + assert!(state.read(cx).is_current_input_focused(window)); + assert_eq!( + state.read(cx).answer("first").unwrap().choices(), + &[SharedString::from("beta")] + ); + }); + + simulate_key(cx, "down", false, false); + cx.update(|window, cx| { + assert_eq!( + state + .read(cx) + .focused_current_choice(window) + .map(SharedString::as_ref), + Some("alpha") + ); + assert_eq!( + state.read(cx).answer("first").unwrap().choices(), + &[SharedString::from("alpha")] + ); + }); + + simulate_key(cx, "down", false, false); + cx.update(|window, cx| { + assert_eq!( + state + .read(cx) + .focused_current_choice(window) + .map(SharedString::as_ref), + Some("beta") + ); + assert_eq!( + state.read(cx).answer("first").unwrap().choices(), + &[SharedString::from("beta")] + ); + }); + cx.update(|window, cx| { state.update(cx, |state, cx| { state @@ -1736,6 +1921,21 @@ mod tests { ); assert!(state.answer("first").unwrap().choices().is_empty()); }); + + simulate_key(cx, "up", false, false); + cx.update(|window, cx| { + let state = state.read(cx); + assert!(state.is_current_input_focused(window)); + assert_eq!( + state + .answer("first") + .unwrap() + .freeform() + .map(SharedString::as_ref), + Some("Freeform answer") + ); + assert!(state.answer("first").unwrap().choices().is_empty()); + }); } #[test] diff --git a/crates/component/src/questionnaire/state.rs b/crates/component/src/questionnaire/state.rs index 648ceba5c2..f45aa052e6 100644 --- a/crates/component/src/questionnaire/state.rs +++ b/crates/component/src/questionnaire/state.rs @@ -805,9 +805,7 @@ impl QuestionnaireState { let Some(item_ix) = self.current else { return false; }; - if (self.is_current_input_focused(window) && self.current_input_has_text(cx)) - || (!self.items[item_ix].is_multiple() && self.focused_current_choice(window).is_some()) - { + if self.is_current_input_focused(window) && self.current_input_has_text(cx) { return false; } @@ -879,6 +877,20 @@ impl QuestionnaireState { (None, true) => targets.len() - 1, (None, false) => 0, }; + + // GPUI's Radio primitive intentionally leaves group arrow behavior to + // its owner. Let the root emulate native radio movement only when both + // adjacent answers are radios; crossing the boundary to an Input stays + // in this schema-ordered answer sequence. + if !self.items[item_ix].is_multiple() + && focused.is_some_and(|focused_ix| { + matches!(targets[focused_ix], Target::Choice(_)) + && matches!(targets[target_ix], Target::Choice(_)) + }) + { + return false; + } + match targets[target_ix] { Target::Choice(choice_ix) => { if !self.items[item_ix].is_multiple() { diff --git a/crates/story/src/stories/questionnaire_story.rs b/crates/story/src/stories/questionnaire_story.rs index 1855e11069..6dd73fd7aa 100644 --- a/crates/story/src/stories/questionnaire_story.rs +++ b/crates/story/src/stories/questionnaire_story.rs @@ -201,10 +201,12 @@ impl QuestionnaireStory { choice_parts = choice_parts.child(choice); } + // Keep the freeform answer in the same answer group as fixed choices, + // matching shadcn/ui's Questionnaire composition and spacing. + choice_parts = choice_parts.child(QuestionnaireInput::new(state, item).with_size(size)); + result .child(choice_parts) - // This part renders Empty when the definition has no freeform input. - .child(QuestionnaireInput::new(state, item).with_size(size)) .child(QuestionnaireError::new(state, item).with_size(size)) } @@ -240,7 +242,11 @@ impl QuestionnaireStory { } fn new(window: &mut Window, cx: &mut Context) -> Self { - let main_state = Self::state(Self::main_items(window, cx), cx); + let main_state = Self::shortcut_state( + Self::main_items(window, cx), + QuestionnaireShortcutMode::Letters, + cx, + ); let validation_state = Self::state(Self::validation_items(window, cx), cx); let external_state = Self::state( vec![ @@ -561,7 +567,7 @@ impl Render for QuestionnaireStory { .child( section("Complete flow") .description("Required single choice, multiple choice, freeform input, skip, disabled item, and submit events.") - .w(px(600.)) + .w(px(448.)) .child(main), ) .child( From c42fcc86a9401b7a0c7d71603fac365192fcec07 Mon Sep 17 00:00:00 2001 From: suxiaoshao <48886207+suxiaoshao@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:02:38 +0800 Subject: [PATCH 03/17] questionnaire: Complete Story and documentation --- .../story/src/stories/questionnaire_story.rs | 882 ++++++++++++++---- website/component/questionnaire.md | 572 ++++++++++-- website/zh-CN/component/questionnaire.md | 572 ++++++++++-- 3 files changed, 1679 insertions(+), 347 deletions(-) diff --git a/crates/story/src/stories/questionnaire_story.rs b/crates/story/src/stories/questionnaire_story.rs index 6dd73fd7aa..c3879ace51 100644 --- a/crates/story/src/stories/questionnaire_story.rs +++ b/crates/story/src/stories/questionnaire_story.rs @@ -1,22 +1,25 @@ use gpui::{ App, AppContext, Context, Entity, FocusHandle, Focusable, InteractiveElement, IntoElement, - ParentElement, Render, SharedString, Styled, Subscription, Window, div, px, + ParentElement, Render, SharedString, StyleRefinement, Styled, Subscription, Window, div, + prelude::FluentBuilder as _, px, }; use gpui_component::{ - ActiveTheme as _, Sizable, Size, StyledExt as _, + ActiveTheme as _, Sizable, Size, StyledExt as _, WindowExt as _, button::{Button, ButtonVariants as _}, dialog::{Dialog, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogTitle}, group_box::{GroupBox, GroupBoxVariants as _}, h_flex, input::InputState, + kbd::Kbd, progress::Progress, questionnaire::{ - Questionnaire, QuestionnaireActions, QuestionnaireChoice, QuestionnaireChoiceDefinition, - QuestionnaireChoiceDescription, QuestionnaireChoices, QuestionnaireDescription, - QuestionnaireError, QuestionnaireEvent, QuestionnaireInput, QuestionnaireInputDefinition, - QuestionnaireItem, QuestionnaireItemDefinition, QuestionnaireNext, QuestionnairePrevious, - QuestionnaireProgress, QuestionnaireShortcutMode, QuestionnaireSkip, QuestionnaireState, - QuestionnaireSubmission, QuestionnaireSubmit, QuestionnaireTitle, + Questionnaire, QuestionnaireActions, QuestionnaireAnswer, QuestionnaireChoice, + QuestionnaireChoiceDefinition, QuestionnaireChoiceDescription, QuestionnaireChoices, + QuestionnaireDescription, QuestionnaireError, QuestionnaireEvent, QuestionnaireInput, + QuestionnaireInputDefinition, QuestionnaireItem, QuestionnaireItemDefinition, + QuestionnaireNext, QuestionnairePrevious, QuestionnaireProgress, QuestionnaireShortcutMode, + QuestionnaireSkip, QuestionnaireState, QuestionnaireSubmission, QuestionnaireSubmit, + QuestionnaireTitle, }, stepper::{Stepper, StepperItem}, v_flex, @@ -31,12 +34,16 @@ pub struct QuestionnaireStory { validation_state: Entity, external_state: Entity, control_state: Entity, + resume_state: Entity, letters_state: Entity, numbers_state: Entity, + card_state: Entity, + custom_choice_state: Entity, edge_state: Entity, dialog_state: Entity, size_states: Vec<(Size, Entity)>, event_log: Vec, + keyboard_event_log: Vec, _subscriptions: Vec, } @@ -105,6 +112,30 @@ impl QuestionnaireStory { ])] } + fn keyboard_items( + window: &mut Window, + cx: &mut Context, + ) -> Vec { + let input = Self::input(window, cx, "Type without triggering A, B, or C…", None); + vec![ + QuestionnaireItemDefinition::new("shortcut", "Choose an answer or type your own") + .with_description( + "Use Up/Down to include the freeform input in the answer focus order.", + ) + .with_choices([ + QuestionnaireChoiceDefinition::new("first", "First choice"), + QuestionnaireChoiceDefinition::new("second", "Second choice"), + QuestionnaireChoiceDefinition::new("third", "Third choice"), + ]) + .with_input(QuestionnaireInputDefinition::new(input, "Custom answer")), + QuestionnaireItemDefinition::new("keyboard_review", "Confirm the keyboard result") + .with_choices([ + QuestionnaireChoiceDefinition::new("keep", "Keep it"), + QuestionnaireChoiceDefinition::new("change", "Change it"), + ]), + ] + } + fn main_items(window: &mut Window, cx: &mut Context) -> Vec { let direction_input = Self::input(window, cx, "Type another direction…", None); let tools_input = Self::input(window, cx, "Add another tool…", Some("Terminal")); @@ -241,6 +272,68 @@ impl QuestionnaireStory { .join(" · ") } + fn on_control_event( + &mut self, + state: &Entity, + event: &QuestionnaireEvent, + window: &mut Window, + cx: &mut Context, + ) { + let QuestionnaireEvent::AnswerChanged(change) = event else { + return; + }; + if change.item() != "runtime" { + return; + } + + let cloud = state + .read(cx) + .answer("runtime") + .is_some_and(|answer| answer.choices().iter().any(|value| value == "cloud")); + let environment_disabled = state + .read(cx) + .item_state("environment") + .is_some_and(|item| item.is_disabled()); + let should_disable_environment = !cloud; + if environment_disabled != should_disable_environment { + state.update(cx, |state, cx| { + state + .set_item_disabled("environment", should_disable_environment, window, cx) + .expect("conditional Story item exists"); + }); + } + } + + fn on_keyboard_event( + &mut self, + state: &Entity, + event: &QuestionnaireEvent, + _: &mut Window, + cx: &mut Context, + ) { + let mode = if state == &self.letters_state { + "Letters" + } else { + "Numbers" + }; + let message = match event { + QuestionnaireEvent::CurrentItemChanged { current, .. } => { + format!("{mode}: current={}", current.as_deref().unwrap_or("none")) + } + QuestionnaireEvent::AnswerChanged(change) => { + format!("{mode}: answer={:?}", change.answer()) + } + QuestionnaireEvent::Completed(_) => format!("{mode}: completed"), + QuestionnaireEvent::Submit(_) => format!("{mode}: submitted"), + _ => return, + }; + self.keyboard_event_log.push(message.into()); + if self.keyboard_event_log.len() > 4 { + self.keyboard_event_log.remove(0); + } + cx.notify(); + } + fn new(window: &mut Window, cx: &mut Context) -> Self { let main_state = Self::shortcut_state( Self::main_items(window, cx), @@ -266,32 +359,96 @@ impl QuestionnaireStory { }); let control_items = vec![ - QuestionnaireItemDefinition::new("first", "Which screen comes first?").with_choices([ - QuestionnaireChoiceDefinition::new("first", "First choice"), - QuestionnaireChoiceDefinition::new("second", "Second choice"), - ]), - QuestionnaireItemDefinition::new("second", "Which screen comes next?").with_choices([ - QuestionnaireChoiceDefinition::new("first", "First choice"), - QuestionnaireChoiceDefinition::new("second", "Second choice"), - ]), - QuestionnaireItemDefinition::new("conditional", "Conditional preferences") - .with_choices([QuestionnaireChoiceDefinition::new("third", "Third choice")]), + QuestionnaireItemDefinition::new("runtime", "Where will this workflow run?") + .with_required(true) + .with_description("Selecting Cloud enables the Environment question.") + .with_choices([ + QuestionnaireChoiceDefinition::new("local", "Local") + .with_default_selected(true), + QuestionnaireChoiceDefinition::new("cloud", "Cloud"), + ]), + QuestionnaireItemDefinition::new("delivery", "How should updates be delivered?") + .with_choices([ + QuestionnaireChoiceDefinition::new("guided", "Guided"), + QuestionnaireChoiceDefinition::new("automatic", "Automatic"), + ]), + QuestionnaireItemDefinition::new("environment", "Which cloud environment?") + .with_disabled(true) + .with_choices([ + QuestionnaireChoiceDefinition::new("staging", "Staging"), + QuestionnaireChoiceDefinition::new("production", "Production"), + ]), ]; - let control_state = cx.new(|cx| { - QuestionnaireState::new(control_items, cx) - .expect("Questionnaire Story definitions must be valid") - .with_current_item("second") - .expect("controlled Story item exists") - }); + let control_state = Self::state(control_items, cx); + + let resume_scope_input = Self::input(window, cx, "Saved alternative workspace…", None); + let resume_tools_input = Self::input(window, cx, "Another saved tool…", None); + let resume_state = Self::state( + vec![ + QuestionnaireItemDefinition::new("resume_scope", "Which workspace should resume?") + .with_choices([ + QuestionnaireChoiceDefinition::new("personal", "Personal") + .with_default_selected(true), + QuestionnaireChoiceDefinition::new("team", "Team"), + ]) + .with_input(QuestionnaireInputDefinition::new( + resume_scope_input, + "Alternative workspace", + )), + QuestionnaireItemDefinition::new("resume_tools", "Which tools were restored?") + .with_multiple(true) + .with_choices([ + QuestionnaireChoiceDefinition::new("editor", "Editor") + .with_default_selected(true), + QuestionnaireChoiceDefinition::new("terminal", "Terminal"), + QuestionnaireChoiceDefinition::new("browser", "Browser"), + ]) + .with_input(QuestionnaireInputDefinition::new( + resume_tools_input, + "Another restored tool", + )), + ], + cx, + ); - let shortcut_items = Self::single_item("shortcut", "Choose an answer with a shortcut"); let letters_state = Self::shortcut_state( - shortcut_items.clone(), + Self::keyboard_items(window, cx), + QuestionnaireShortcutMode::Letters, + cx, + ); + let numbers_state = Self::shortcut_state( + Self::single_item("shortcut", "Choose an answer with a number"), + QuestionnaireShortcutMode::Numbers, + cx, + ); + + let card_state = Self::state( + vec![ + QuestionnaireItemDefinition::new("card_scope", "Who can use this workspace?") + .with_required(true) + .with_choices([ + QuestionnaireChoiceDefinition::new("team", "Team members"), + QuestionnaireChoiceDefinition::new("everyone", "Everyone"), + ]), + QuestionnaireItemDefinition::new("card_updates", "Send setup updates?") + .with_choices([ + QuestionnaireChoiceDefinition::new("yes", "Yes"), + QuestionnaireChoiceDefinition::new("no", "No"), + ]), + ], + cx, + ); + let custom_choice_state = Self::shortcut_state( + vec![ + QuestionnaireItemDefinition::new("custom", "Choose a presentation").with_choices([ + QuestionnaireChoiceDefinition::new("compact", "Compact") + .with_description("Use a custom indicator and composed description."), + QuestionnaireChoiceDefinition::new("comfortable", "Comfortable"), + ]), + ], QuestionnaireShortcutMode::Letters, cx, ); - let numbers_state = - Self::shortcut_state(shortcut_items, QuestionnaireShortcutMode::Numbers, cx); let edge_state = Self::state( vec![ @@ -308,10 +465,20 @@ impl QuestionnaireStory { let dialog_state = Self::state( vec![ QuestionnaireItemDefinition::new("dialog", "Which workspace should we open?") + .with_required(true) .with_choices([ QuestionnaireChoiceDefinition::new("first", "Personal"), QuestionnaireChoiceDefinition::new("second", "Team"), ]), + QuestionnaireItemDefinition::new( + "dialog_verification", + "How should we verify the setup?", + ) + .with_required(true) + .with_choices([ + QuestionnaireChoiceDefinition::new("targeted", "Targeted checks"), + QuestionnaireChoiceDefinition::new("full", "Full verification"), + ]), ], cx, ); @@ -329,31 +496,42 @@ impl QuestionnaireStory { size_states.push((size, state)); } - let subscriptions = - vec![ - cx.subscribe(&main_state, |this, _, event: &QuestionnaireEvent, cx| { - let message = match event { - QuestionnaireEvent::CurrentItemChanged { current, .. } => { - format!("Current item: {}", current.as_deref().unwrap_or("none")) - } - QuestionnaireEvent::AnswerChanged(change) => { - format!("Answer changed: {} ({:?})", change.item(), change.status()) - } - QuestionnaireEvent::Completed(submission) => { - format!("Completed: {}", Self::submission_summary(submission)) - } - QuestionnaireEvent::Submit(submission) => { - format!("Submitted: {}", Self::submission_summary(submission)) - } - _ => "Questionnaire event".to_string(), - }; - this.event_log.push(message.into()); - if this.event_log.len() > 4 { - this.event_log.remove(0); + let subscriptions = vec![ + cx.subscribe(&main_state, |this, _, event: &QuestionnaireEvent, cx| { + let message = match event { + QuestionnaireEvent::CurrentItemChanged { current, .. } => { + format!("Current item: {}", current.as_deref().unwrap_or("none")) } - cx.notify(); - }), - ]; + QuestionnaireEvent::AnswerChanged(change) => { + format!("Answer changed: {} ({:?})", change.item(), change.status()) + } + QuestionnaireEvent::Completed(submission) => { + format!("Completed: {}", Self::submission_summary(submission)) + } + QuestionnaireEvent::Submit(submission) => { + format!("Submitted: {}", Self::submission_summary(submission)) + } + _ => "Questionnaire event".to_string(), + }; + this.event_log.push(message.into()); + if this.event_log.len() > 4 { + this.event_log.remove(0); + } + cx.notify(); + }), + cx.subscribe_in(&control_state, window, Self::on_control_event), + cx.subscribe_in(&letters_state, window, Self::on_keyboard_event), + cx.subscribe_in(&numbers_state, window, Self::on_keyboard_event), + cx.subscribe_in( + &dialog_state, + window, + |_, _, event: &QuestionnaireEvent, window, cx| { + if matches!(event, QuestionnaireEvent::Submit(_)) { + window.close_dialog(cx); + } + }, + ), + ]; Self { focus_handle: cx.focus_handle(), @@ -362,12 +540,16 @@ impl QuestionnaireStory { validation_state, external_state, control_state, + resume_state, letters_state, numbers_state, + card_state, + custom_choice_state, edge_state, dialog_state, size_states, event_log: Vec::new(), + keyboard_event_log: Vec::new(), _subscriptions: subscriptions, } } @@ -380,7 +562,7 @@ impl Focusable for QuestionnaireStory { } impl Render for QuestionnaireStory { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let main = Self::questionnaire_view( &self.state, self.size, @@ -440,7 +622,10 @@ impl Render for QuestionnaireStory { let letters = Self::questionnaire_view( &self.letters_state, self.size, - &[("shortcut", &["first", "second", "third"])], + &[ + ("shortcut", &["first", "second", "third"]), + ("keyboard_review", &["keep", "change"]), + ], ); let numbers = Self::questionnaire_view( &self.numbers_state, @@ -453,69 +638,242 @@ impl Render for QuestionnaireStory { &[("edge", &["first", "second"])], ); + let letters_snapshot = self.letters_state.read(cx); + let keyboard_focus = if letters_snapshot.is_current_input_focused(window) { + "freeform input".to_string() + } else if let Some(choice) = letters_snapshot.focused_current_choice(window) { + format!("choice {choice}") + } else { + "item group or none".to_string() + }; + let letters_answer = letters_snapshot.answer("shortcut").unwrap_or_default(); + let letters_draft = letters_snapshot + .input_state("shortcut") + .map(|input| input.read(cx).value()) + .unwrap_or_default(); + let keyboard_event_log = self.keyboard_event_log.clone(); + + let resume = Self::questionnaire_view( + &self.resume_state, + self.size, + &[ + ("resume_scope", &["personal", "team"]), + ("resume_tools", &["editor", "terminal", "browser"]), + ], + ); + let resume_snapshot = self.resume_state.read(cx); + let resume_summary = format!( + "Current: {} · scope={:?} · tools={:?}", + resume_snapshot + .current_item() + .map(SharedString::as_ref) + .unwrap_or("none"), + resume_snapshot.answer("resume_scope").unwrap_or_default(), + resume_snapshot.answer("resume_tools").unwrap_or_default(), + ); + let resume_scope_draft = resume_snapshot + .input_state("resume_scope") + .map(|input| input.read(cx).value()) + .unwrap_or_default(); + + let external_error = self + .external_state + .read(cx) + .error("server") + .map(ToString::to_string) + .unwrap_or_else(|| "none".to_string()); + let control_state = self.control_state.clone(); + let control_navigation = control_state.read(cx).navigation_state(); + let control_previous_visible = control_navigation.is_previous_visible(); + let control_skip_visible = control_navigation.is_skip_visible(); + let control_next_visible = control_navigation.is_next_visible(); + let control_submit_visible = control_navigation.is_submit_visible(); + let environment_enabled = control_state + .read(cx) + .item_state("environment") + .is_some_and(|item| !item.is_disabled()); let custom_control = Questionnaire::new(&control_state) .with_size(self.size) .child(QuestionnaireProgress::new(&control_state).with_size(self.size)) .child(Self::item_view( &control_state, - "first", - ["first", "second"], + "runtime", + ["local", "cloud"], self.size, )) .child(Self::item_view( &control_state, - "second", - ["first", "second"], + "delivery", + ["guided", "automatic"], self.size, )) .child(Self::item_view( &control_state, - "conditional", - ["third"], + "environment", + ["staging", "production"], self.size, )) .child( QuestionnaireActions::new(&control_state) + .with_size(self.size) + .when(control_previous_visible, |actions| { + actions.child( + Button::new("questionnaire-custom-previous") + .outline() + .with_size(self.size) + .label("Back") + .on_click({ + let state = control_state.clone(); + move |_, window, cx| { + state.update(cx, |state, cx| { + state.go_previous(window, cx); + }); + } + }), + ) + }) + .when(control_skip_visible, |actions| { + actions.child( + Button::new("questionnaire-custom-skip") + .outline() + .with_size(self.size) + .ml_auto() + .label("Not now") + .on_click({ + let state = control_state.clone(); + move |_, window, cx| { + state.update(cx, |state, cx| { + state.skip_current(window, cx); + }); + } + }), + ) + }) + .when(control_next_visible, |actions| { + actions.child( + Button::new("questionnaire-custom-next") + .primary() + .with_size(self.size) + .when(!control_skip_visible, |button| button.ml_auto()) + .label("Continue") + .on_click({ + let state = control_state.clone(); + move |_, window, cx| { + state.update(cx, |state, cx| { + state.go_next(window, cx); + }); + } + }), + ) + }) + .when(control_submit_visible, |actions| { + actions.child( + Button::new("questionnaire-custom-submit") + .primary() + .with_size(self.size) + .when(!control_skip_visible, |button| button.ml_auto()) + .label("Finish") + .on_click({ + let state = control_state.clone(); + move |_, window, cx| { + state.update(cx, |state, cx| { + state.submit(window, cx); + }); + } + }), + ) + }), + ); + + let card = + GroupBox::new() + .outline() + .title("Workspace access") + .child(Self::questionnaire_view( + &self.card_state, + self.size, + &[ + ("card_scope", &["team", "everyone"]), + ("card_updates", &["yes", "no"]), + ], + )); + + let custom_choice_state = self.custom_choice_state.clone(); + let custom_choice = Questionnaire::new(&custom_choice_state) + .with_size(self.size) + .child( + QuestionnaireItem::new(&custom_choice_state, "custom") .with_size(self.size) .child( - Button::new("questionnaire-custom-previous") - .outline() - .label("Back") - .on_click({ - let state = control_state.clone(); - move |_, window, cx| { - state.update(cx, |state, cx| { - state.go_previous(window, cx); - }); - } - }), + QuestionnaireTitle::new(&custom_choice_state, "custom") + .with_size(self.size), ) .child( - Button::new("questionnaire-custom-next") - .primary() - .label("Continue") - .on_click({ - let state = control_state.clone(); - move |_, window, cx| { - state.update(cx, |state, cx| { - state.go_next(window, cx); - }); - } - }), + QuestionnaireChoices::new(&custom_choice_state, "custom") + .with_size(self.size) + .child( + QuestionnaireChoice::new(&custom_choice_state, "custom", "compact") + .with_size(self.size) + .content_style(StyleRefinement::default().gap_2()) + .render_indicator(|choice, _, cx| { + div() + .size_4() + .rounded_full() + .bg(if choice.is_selected() { + cx.theme().primary + } else { + cx.theme().muted + }) + .into_any_element() + }) + .render_shortcut(|choice, _, _| { + let Some(shortcut) = choice.shortcut() else { + return div().into_any_element(); + }; + let Ok(keystroke) = + gpui::Keystroke::parse(&shortcut.to_lowercase()) + else { + return div().into_any_element(); + }; + Kbd::new(keystroke).outline().into_any_element() + }) + .child( + v_flex() + .gap_1() + .child(div().font_medium().child("Compact")) + .child( + QuestionnaireChoiceDescription::new() + .with_size(self.size) + .child( + "A custom indicator and composed description.", + ), + ), + ), + ) + .child( + QuestionnaireChoice::new( + &custom_choice_state, + "custom", + "comfortable", + ) + .with_size(self.size) + .indicator_style(StyleRefinement::default().opacity(0.65)) + .shortcut_style(StyleRefinement::default().opacity(0.65)), + ), ) .child( - Button::new("questionnaire-custom-submit") - .primary() - .label("Finish") - .on_click(move |_, window, cx| { - control_state.update(cx, |state, cx| { - state.submit(window, cx); - }); - }), + QuestionnaireError::new(&custom_choice_state, "custom") + .with_size(self.size), ), + ) + .child( + QuestionnaireActions::new(&custom_choice_state) + .with_size(self.size) + .child(QuestionnaireSubmit::new(&custom_choice_state).with_size(self.size)), ); - let dialog_state = self.dialog_state.clone(); + + let dialog_content_state = self.dialog_state.clone(); let dialog = Dialog::new(cx) .trigger( Button::new("questionnaire-dialog-trigger") @@ -530,29 +888,67 @@ impl Render for QuestionnaireStory { .p_4() .child(DialogTitle::new().child("Workspace setup")) .child(DialogDescription::new().child( - "The host owns dismissal while Questionnaire owns the flow.", + "Questionnaire validates the answer; the Dialog host owns close and cancel.", )), ) .child( - Self::questionnaire_view( - &dialog_state, - Size::Small, - &[("dialog", &["first", "second"])], - ) - .px_4(), - ) - .child( - DialogFooter::new().p_4().child( - DialogClose::new().child( - Button::new("questionnaire-dialog-close") - .outline() - .label("Cancel"), - ), - ), + Questionnaire::new(&dialog_content_state) + .with_size(Size::Small) + .child( + QuestionnaireProgress::new(&dialog_content_state) + .with_size(Size::Small), + ) + .child(Self::item_view( + &dialog_content_state, + "dialog", + ["first", "second"], + Size::Small, + )) + .child( + Self::item_view( + &dialog_content_state, + "dialog_verification", + ["targeted", "full"], + Size::Small, + ), + ) + .child( + DialogFooter::new() + .child( + DialogClose::new().child( + Button::new("questionnaire-dialog-cancel") + .outline() + .with_size(Size::Small) + .label("Cancel"), + ), + ) + .child( + QuestionnaireActions::new(&dialog_content_state) + .with_size(Size::Small) + .child( + QuestionnairePrevious::new(&dialog_content_state) + .with_size(Size::Small), + ) + .child( + QuestionnaireNext::new(&dialog_content_state) + .with_size(Size::Small), + ) + .child( + QuestionnaireSubmit::new(&dialog_content_state) + .with_size(Size::Small), + ), + ), + ) + .px_4() + .pb_4(), ) }); - let main_state = self.state.clone(); let control_state_for_jump = self.control_state.clone(); + let resume_state_for_restore = self.resume_state.clone(); + let resume_state_for_reset = self.resume_state.clone(); + let external_state_for_error = self.external_state.clone(); + let external_state_for_clear = self.external_state.clone(); + let external_state_for_fix = self.external_state.clone(); v_flex() .id("questionnaire-story") @@ -572,14 +968,76 @@ impl Render for QuestionnaireStory { ) .child( section("Validation and external errors") - .description("Next validates the active item; Submit returns to the first invalid item.") + .description("Next validates the active item; external errors remain host-owned until cleared or fixed.") .w(px(600.)) + .child(div().font_medium().child("Internal validation")) .child(validation) - .child(external), + .child(div().font_medium().child("External error lifecycle")) + .child(external) + .child( + h_flex() + .gap_2() + .child( + Button::new("questionnaire-external-reapply") + .outline() + .label("Apply server error") + .on_click(move |_, _, cx| { + external_state_for_error.update(cx, |state, cx| { + state + .set_external_error( + "server", + "This workspace is not available.", + cx, + ) + .expect("external Story item exists"); + }); + }), + ) + .child( + Button::new("questionnaire-external-clear") + .outline() + .label("Clear error") + .on_click(move |_, _, cx| { + external_state_for_clear.update(cx, |state, cx| { + state + .clear_external_error("server", cx) + .expect("external Story item exists"); + }); + }), + ) + .child( + Button::new("questionnaire-external-fix-submit") + .primary() + .label("Fix and submit again") + .on_click(move |_, window, cx| { + external_state_for_fix.update(cx, |state, cx| { + state + .set_answer( + "server", + QuestionnaireAnswer::new() + .with_choices(["team"]), + window, + cx, + ) + .expect("external Story answer is valid"); + state + .clear_external_error("server", cx) + .expect("external Story item exists"); + state.submit(window, cx); + }); + }), + ), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!("External error: {external_error}")), + ), ) .child( - section("Navigation state and reset") - .description("The host can inspect status, restore answers, disable items, and reset to defaults.") + section("Navigation state") + .description("The host can inspect current item, answers, status, available actions, and events.") .w(px(600.)) .child( Button::new("questionnaire-reset") @@ -611,48 +1069,119 @@ impl Render for QuestionnaireStory { } else { format!("Answers: {answer_summary}") })) + .children(event_log.into_iter().map(|event| { + div().text_xs().text_color(cx.theme().muted_foreground).child(event) + })), + ) + .child( + section("Resume and reset") + .description("Restore current item, single and multiple answers, freeform values, and an unselected single-choice draft.") + .w(px(600.)) .child( - Button::new("questionnaire-controlled-current") - .outline() - .label("Set controlled current to first") - .on_click(move |_, window, cx| { - control_state_for_jump.update(cx, |state, cx| { - let _ = state.set_current_item("first", window, cx); - }); - }), + h_flex() + .gap_2() + .child( + Button::new("questionnaire-resume") + .primary() + .label("Restore saved response") + .on_click(move |_, window, cx| { + resume_state_for_restore.update(cx, |state, cx| { + state + .set_input_value( + "resume_scope", + "Saved private workspace", + window, + cx, + ) + .expect("resume Story input exists"); + state + .set_answer( + "resume_scope", + QuestionnaireAnswer::new() + .with_choices(["team"]), + window, + cx, + ) + .expect("resume Story answer is valid"); + state + .set_answer( + "resume_tools", + QuestionnaireAnswer::new() + .with_choices(["editor", "terminal"]) + .with_freeform("CLI"), + window, + cx, + ) + .expect("resume Story answer is valid"); + state + .set_current_item("resume_tools", window, cx) + .expect("resume Story item exists"); + }); + }), + ) + .child( + Button::new("questionnaire-resume-reset") + .outline() + .label("Reset to defaults") + .on_click(move |_, window, cx| { + resume_state_for_reset.update(cx, |state, cx| { + state.reset(window, cx); + }); + }), + ), ) + .child(resume) .child( - Button::new("questionnaire-conditional") - .outline() - .label(if advanced_disabled { - "Enable conditional item" - } else { - "Disable conditional item" - }) - .on_click(move |_, window, cx| { - main_state.update(cx, |state, cx| { - let disabled = state - .item_state("advanced") - .is_some_and(|item| item.is_disabled()); - let _ = state.set_item_disabled("advanced", !disabled, window, cx); - }); - }), + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(resume_summary), ) - .children(event_log.into_iter().map(|event| { - div().text_xs().text_color(cx.theme().muted_foreground).child(event) - })), + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!( + "Single-choice input draft: {:?} (kept when Team is selected)", + resume_scope_draft + )), + ), ) .child( - section("Shortcuts") - .description("Letters and numbers are assigned only to enabled choices; the Kbd hints are part of the choice card.") + section("Shortcuts and keyboard") + .description("The fixture exposes focus, answers, drafts, and events while testing the full keyboard contract.") .w(px(600.)) + .child( + v_flex() + .gap_1() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Up/Down: move through choices and the freeform input; text editing keeps native arrow behavior.") + .child("Left/Right: switch items outside text input and radio focus; Right requires an answer.") + .child("Enter: confirm a filled answer. Command/Ctrl+Enter: confirm the current item.") + .child("A–Z or 1–9: activate enabled choices. While input is focused, typed characters edit the draft and are not intercepted."), + ) .child( h_flex() .w_full() .gap_4() .child(v_flex().flex_1().gap_2().child(div().font_medium().child("Letters")).child(letters)) .child(v_flex().flex_1().gap_2().child(div().font_medium().child("Numbers")).child(numbers)), - ), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!( + "Letters focus: {keyboard_focus} · answer={letters_answer:?} · draft={letters_draft:?}" + )), + ) + .children(keyboard_event_log.into_iter().map(|event| { + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(event) + })), ) .child( section("Custom Progress and Stepper") @@ -682,47 +1211,52 @@ impl Render for QuestionnaireStory { ), ) .child( - section("Controlled current, custom actions, and card composition") - .description("Container layout and content presentation remain host-owned.") + section("Controlled current, conditional items, and custom actions") + .description("The host synchronizes Environment from the Runtime answer; custom actions use NavigationState visibility.") .w(px(600.)) - .child(custom_control) .child( - GroupBox::new() - .outline() - .title("Workspace setup") + h_flex() + .gap_2() .child( - QuestionnaireChoice::new(&self.edge_state, "edge", "first") - .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( - v_flex() - .gap_1() - .child(div().font_medium().child("Available choice")) - .child(QuestionnaireChoiceDescription::new().child( - "A custom choice body using the same state.", - )), - ), + Button::new("questionnaire-controlled-current") + .outline() + .label("Jump to runtime question") + .on_click(move |_, window, cx| { + control_state_for_jump.update(cx, |state, cx| { + state + .set_current_item("runtime", window, cx) + .expect("controlled Story item exists"); + }); + }), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(if environment_enabled { + "Environment enabled: Runtime is Cloud" + } else { + "Environment disabled: choose Cloud on Runtime" + }), ), ) - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child("Place Questionnaire inside Dialog content when the host owns dismissal and cancellation."), - ), + .child(custom_control) + ) + .child( + section("Card-like composition") + .description("GroupBox owns the card surface while a complete Questionnaire keeps progress, items, and actions together.") + .w(px(600.)) + .child(card), + ) + .child( + section("Custom choice composition") + .description("Customize indicator, content, shortcut renderers, and style seams while preserving Questionnaire state and behavior.") + .w(px(600.)) + .child(custom_choice), ) .child( section("No description, disabled, invalid, and Dialog") - .description("The edge states are rendered with the same semantic parts; Dialog owns its close action.") + .description("Dialog Cancel always closes; the host closes after Questionnaire emits a successful Submit.") .w(px(600.)) .child(edge) .child(dialog), diff --git a/website/component/questionnaire.md b/website/component/questionnaire.md index 81302f4549..6e9a7cefb9 100644 --- a/website/component/questionnaire.md +++ b/website/component/questionnaire.md @@ -15,10 +15,10 @@ cancelling, persistence, transport, and application-specific branching. ```rust use gpui_component::questionnaire::{ Questionnaire, QuestionnaireActions, QuestionnaireChoice, - QuestionnaireChoices, QuestionnaireDescription, QuestionnaireError, - QuestionnaireInput, QuestionnaireItem, QuestionnaireNext, - QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, - QuestionnaireState, QuestionnaireSubmit, QuestionnaireTitle, + QuestionnaireChoiceDescription, QuestionnaireChoices, QuestionnaireDescription, + QuestionnaireError, QuestionnaireInput, QuestionnaireItem, QuestionnaireNext, + QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, QuestionnaireState, + QuestionnaireSubmit, QuestionnaireTitle, }; ``` @@ -28,12 +28,11 @@ Create the item collection once and use one `QuestionnaireState` entity as the source of truth for all parts. ```rust +use gpui_component::input::InputState; use gpui_component::questionnaire::{ - QuestionnaireItemDefinition, QuestionnaireChoiceDefinition, - QuestionnaireInputDefinition, QuestionnaireState, QuestionnaireAnswer, - QuestionnaireEvent, + QuestionnaireChoiceDefinition, QuestionnaireInputDefinition, + QuestionnaireItemDefinition, QuestionnaireState, }; -use gpui_component::input::InputState; let direction_input = cx.new(|cx| { InputState::new(window, cx).placeholder("Type another answer…") @@ -67,9 +66,9 @@ let state = cx.new(|cx| { }); ``` -Map the same collection into the compound parts. The parts are intentionally -small, so an application can replace a title, choice body, progress indicator, -or action without taking ownership of questionnaire state. +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. ```rust Questionnaire::new(&state) @@ -87,6 +86,17 @@ Questionnaire::new(&state) ) .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)) @@ -96,9 +106,6 @@ Questionnaire::new(&state) ) ``` -`Questionnaire` renders the active item. The other items remain in the ordered -schema and are available to navigation and final validation. - ## Composition ```text @@ -109,6 +116,7 @@ Questionnaire │ ├── QuestionnaireDescription │ ├── QuestionnaireChoices │ │ ├── QuestionnaireChoice +│ │ │ └── QuestionnaireChoiceDescription (custom child) │ │ └── QuestionnaireInput │ └── QuestionnaireError └── QuestionnaireActions @@ -120,8 +128,55 @@ Questionnaire Every part accepts ordinary GPUI styling and can be composed with existing `Button`, `Input`, `Radio`, `Checkbox`, `Progress`, `Stepper`, `GroupBox`, and -`Dialog` elements. A custom part should read the corresponding state and call -the state methods for user actions; it should not duplicate answer state. +`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: + +```rust +use gpui::{IntoElement as _, ParentElement as _, StyleRefinement, Styled as _, div}; +use gpui_component::{ActiveTheme as _, StyledExt as _}; +use gpui_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. ## Single selection @@ -157,8 +212,8 @@ let item = QuestionnaireItemDefinition::new("tools", "Which tools do you use?") .with_input(QuestionnaireInputDefinition::new(tools_input, "Something else")); ``` -The answer reader preserves schema order. Disabled choices are excluded from -answers even if a previously restored answer contains their value. +The answer reader preserves schema order. If a selected choice is disabled +later, it is excluded from the effective answer. ## Freeform answer @@ -178,15 +233,17 @@ let item = QuestionnaireItemDefinition::new("feedback", "What should we improve? ``` Whitespace-only input is unanswered. The input draft is kept when a fixed -choice is selected, but it is submitted only when it is the active freeform -answer. +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. ## Explicit skip 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. +skipped state. Skipping the final enabled item requests submission after the +skip has been recorded. ```rust let optional = QuestionnaireItemDefinition::new("tone", "What tone should we use?") @@ -197,6 +254,48 @@ let optional = QuestionnaireItemDefinition::new("tone", "What tone should we use ]); ``` +## Defaults and disabled controls + +Use definition builders for the initial snapshot. A choice can start selected, +an item or choice can start disabled, and an input can start disabled. A +single-choice item may contain at most one default selected choice. + +```rust +let saved_input = cx.new(|cx| InputState::new(window, cx).default_value("Saved draft")); +let item = QuestionnaireItemDefinition::new("workspace", "Which workspaces?") + .with_multiple(true) + .with_choices([ + QuestionnaireChoiceDefinition::new("personal", "Personal") + .with_default_selected(true), + QuestionnaireChoiceDefinition::new("team", "Team") + .with_disabled(true), + ]) + .with_input( + QuestionnaireInputDefinition::new(saved_input, "Another workspace") + .with_disabled(false), + ); +let disabled_item = QuestionnaireItemDefinition::new( + "advanced", + "Advanced preferences", +) + .with_disabled(true); +let disabled_input = cx.new(|cx| InputState::new(window, cx)); +let disabled_input_definition = QuestionnaireInputDefinition::new( + disabled_input, + "Disabled answer", +) + .with_disabled(true); +``` + +`with_default_selected` belongs to `QuestionnaireChoiceDefinition`; an item +uses `with_disabled`, and an input uses +`QuestionnaireInputDefinition::with_disabled`. For an initially disabled item, +use `QuestionnaireItemDefinition::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 `QuestionnaireStateError`. + ## Navigation and status `QuestionnaireState` exposes the current item, ordered item states, and @@ -204,6 +303,7 @@ navigation state for custom action layouts. ```rust let current = state.read(cx).current_item(); +let current_ix = state.read(cx).current_ix(); let progress = state.read(cx).progress(); let status = state .read(cx) @@ -215,22 +315,20 @@ let show_previous = navigation.is_previous_visible(); let show_next = navigation.is_next_visible(); let show_skip = navigation.is_skip_visible(); let show_submit = navigation.is_submit_visible(); - -state.update(cx, |state, cx| { - state.go_previous(window, cx); - state.go_next(window, cx); -}); ``` 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 inert and do not enter keyboard navigation. Disabled items -are removed from the navigation and progress totals. +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`. ## Validation Required status validation is built in. Add a synchronous validator to an item -for domain-specific checks. `Next` validates the current item; `Submit` +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. ```rust @@ -249,10 +347,14 @@ let item = QuestionnaireItemDefinition::new("handle", "Choose a handle") }); ``` -An application can show an external schema or server error with -`set_external_error`, then clear it after the owner has corrected the data. -Reset restores defaults and clears internal validation state while leaving -owner-managed external errors under application control. +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. ```rust state.update(cx, |state, cx| { @@ -260,40 +362,132 @@ state.update(cx, |state, cx| { .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"); +}); ``` -## Controlled state, resume, and reset +`reset` clears internal validation attempts and errors, but preserves +owner-managed external errors. Questionnaire semantic validation and +synchronous validators are supported; native HTML constraint validation is +not part of this GPUI component. -Use the state readers and silent setters when a page owns the active item or -restores a saved draft. Silent setters update the UI without emitting user -interaction events. +## Controlled 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. ```rust +use gpui_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.reset(window, cx); + 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"); }); ``` -Set disabled state when an earlier answer makes an item irrelevant. Disabled -items do not count toward progress, validation, focus, or submission. +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. + +## Resume + +To make `reset` return to a saved draft, establish the saved draft as the +initial snapshot before constructing `QuestionnaireState`. Use +`InputState::default_value`, `with_default_selected`, and +`with_current_item` for the input, choice, and current-item baselines. + +```rust +let saved_input = cx.new(|cx| { + InputState::new(window, cx).default_value("Saved description") +}); +let saved_items = vec![ + QuestionnaireItemDefinition::new("plan", "Which plan?") + .with_choices([ + QuestionnaireChoiceDefinition::new("plus", "Plus") + .with_default_selected(true), + QuestionnaireChoiceDefinition::new("pro", "Pro"), + ]), + QuestionnaireItemDefinition::new("detail", "How much detail?") + .with_input(QuestionnaireInputDefinition::new(saved_input, "More detail")), +]; +let state = cx.new(|cx| { + QuestionnaireState::new(saved_items, cx) + .expect("valid saved questionnaire") + .with_current_item("detail") + .expect("known enabled questionnaire item") +}); +``` + +If the saved values arrive after construction, apply +`set_answer`, `set_input_value`, and `set_current_item` instead. Those setters +change the current state; they do not replace the reset baseline. + +## 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. ```rust state.update(cx, |state, cx| { - state - .set_item_disabled("advanced", true, window, cx) - .expect("known questionnaire item"); + 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`. + +## 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. + +```rust +fn sync_advanced_item( + state: &Entity, + 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 @@ -310,22 +504,26 @@ let state = cx.new(|cx| { }); ``` -Questionnaire handles all four arrow directions inside a single-choice radio -group, moving focus and selecting the next enabled choice. Up and Down otherwise -move between checkbox, choice, and freeform controls in schema order; Left and -Right move between items only when focus is not in a text input or radio control. -Enter confirms a filled answer, and Command/Ctrl+Enter confirms the current -item. Empty answers do not implicitly submit the questionnaire. +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 and custom rendering -`QuestionnaireProgress` follows the docs default presentation: “Question 2 of -4”. Its state can also be used to compose a custom indicator from the existing +`QuestionnaireProgress` follows the default presentation: “Question 2 of 4”. +Its state can also be used to compose a custom indicator from the existing `Progress` or `Stepper` components. ```rust QuestionnaireProgress::new(&state) - .with_size(Size::Small) + .with_size(Size::Small); let progress = state.read(cx).progress(); let percent = if progress.total() == 0 { @@ -333,72 +531,208 @@ let percent = if progress.total() == 0 { } else { progress.current() as f32 / progress.total() as f32 * 100. }; -Progress::new("questionnaire-progress").value(percent) +Progress::new("questionnaire-progress") + .value(percent); Stepper::new("questionnaire-steps") - .selected_index(progress.current().saturating_sub(1)) + .selected_index(progress.current().saturating_sub(1)); ``` ## Sizes and theming Questionnaire parts implement the same `Sizable` contract as the rest of the library. `Medium` is the default and follows the shadcn/ui `base-nova` docs -appearance. +appearance. The supported named sizes are `XSmall`, `Small`, `Medium`, and +`Large`; `Size::Size(value)` is available for a custom scale. + +Compound parts do not inherit the root's size automatically. Pass the same +size to the root, progress, item, title, description, choices, choice, choice +description, input, error, actions, and navigation parts that should share one +scale. ```rust use gpui_component::{Sizable as _, Size}; -Questionnaire::new(&state).with_size(Size::Small) -Questionnaire::new(&state).with_size(Size::Medium) -Questionnaire::new(&state).with_size(Size::Large) +let size = Size::Small; +Questionnaire::new(&state) + .with_size(size) + .child(QuestionnaireProgress::new(&state).with_size(size)) + .child( + QuestionnaireItem::new(&state, "direction") + .with_size(size) + .child(QuestionnaireTitle::new(&state, "direction").with_size(size)) + .child(QuestionnaireDescription::new(&state, "direction").with_size(size)) + .child( + QuestionnaireChoices::new(&state, "direction") + .with_size(size) + .child(QuestionnaireChoice::new(&state, "direction", "delegation").with_size(size)) + .child(QuestionnaireInput::new(&state, "direction").with_size(size)), + ) + .child(QuestionnaireError::new(&state, "direction").with_size(size)), + ) + .child( + QuestionnaireActions::new(&state) + .with_size(size) + .child(QuestionnairePrevious::new(&state).with_size(size)) + .child(QuestionnaireSkip::new(&state).with_size(size)) + .child(QuestionnaireNext::new(&state).with_size(size)) + .child(QuestionnaireSubmit::new(&state).with_size(size)), + ); ``` The default skin derives spacing, typography, radius, border, input, primary, muted, destructive, and focus-ring values from the active theme's semantic -tokens. Use `Styled` methods or `StyleRefinement` for local adjustments; no -Questionnaire-specific color constants are required. +tokens. Use `Styled` methods or `StyleRefinement` for local adjustments; local +style refinement is applied after the component defaults. ## Card and Dialog composition -The questionnaire owns its question flow. A card or dialog owns its container -layout and close/cancel behavior. +The questionnaire owns the complete question flow. A card or dialog owns its +container layout and close/cancel behavior. Both examples below include every +item in the collection, so moving to the second question remains visible. ```rust +use gpui::{Entity, IntoElement, ParentElement as _}; +use gpui_component::{ + button::{Button, ButtonVariants as _}, + dialog::{Dialog, DialogClose, DialogFooter, DialogHeader, DialogTitle}, + group_box::{GroupBox, GroupBoxVariants as _}, +}; + +fn questionnaire_content( + state: &Entity, + actions: impl IntoElement, +) -> Questionnaire { + 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(actions) +} + GroupBox::new() .outline() .title("Set up your workspace") - .child(Questionnaire::new(&state)) + .child(questionnaire_content( + &state, + QuestionnaireActions::new(&state) + .child(QuestionnairePrevious::new(&state)) + .child(QuestionnaireSkip::new(&state)) + .child(QuestionnaireNext::new(&state)) + .child(QuestionnaireSubmit::new(&state)), + )); ``` -For a dialog, create the Questionnaire inside the existing Dialog content and -let the host handle dismissal. The Questionnaire `Submit` event is the place -to hand a validated `QuestionnaireSubmission` to application transport. +For a dialog, put the same complete composition inside the dialog content and +let the host handle dismissal and cancellation. + +```rust +use gpui_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_content( + &dialog_state, + 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)), + ), + )) + }); +``` + +The host subscription closes the Dialog only after a successful `Submit`. +The same event is the place to hand a validated `QuestionnaireSubmission` to +application transport. Persistence and network success remain outside +Questionnaire. ## 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. ```rust +use gpui_component::questionnaire::QuestionnaireEvent; + cx.subscribe(&state, |_, _, event, _| match event { QuestionnaireEvent::CurrentItemChanged { current, .. } => { println!("Current item: {:?}", current); } QuestionnaireEvent::AnswerChanged(change) => { - println!("Changed: {:?}", change.item()); + 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. -It represents a validated local submission request; saving it remotely remains -the host application's responsibility. +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. ## Accessibility @@ -406,11 +740,9 @@ the host application's responsibility. 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. Custom children control visible presentation and keep the state, -roles, focus behavior, and semantics supplied by the Questionnaire parts. -`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. +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 @@ -423,32 +755,90 @@ 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 + +This GPUI port covers state, navigation, validation, focus, accessibility, +compound rendering, and local submission events. The following web-specific or +future behaviors are not currently provided: SSR/hydration collection +diagnostics, `FormData`, native HTML validation, DOM mutation registration, +async validation, runtime insertion/reordering of definitions, and built-in +animation or persistence/transport. + ## API reference +### Compound parts + - [Questionnaire] -- [QuestionnaireState] -- [QuestionnaireItemDefinition] -- [QuestionnaireChoiceDefinition] -- [QuestionnaireInputDefinition] - [QuestionnaireProgress] - [QuestionnaireItem] +- [QuestionnaireTitle] +- [QuestionnaireDescription] +- [QuestionnaireChoices] - [QuestionnaireChoice] +- [QuestionnaireChoiceDescription] - [QuestionnaireInput] +- [QuestionnaireError] - [QuestionnaireActions] -- [QuestionnaireEvent] +- [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] +- [QuestionnaireStateError] - [Sizable] [Questionnaire]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.Questionnaire.html -[QuestionnaireState]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireState.html -[QuestionnaireItemDefinition]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireItemDefinition.html -[QuestionnaireChoiceDefinition]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireChoiceDefinition.html -[QuestionnaireInputDefinition]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireInputDefinition.html [QuestionnaireProgress]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireProgress.html [QuestionnaireItem]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireItem.html +[QuestionnaireTitle]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireTitle.html +[QuestionnaireDescription]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireDescription.html +[QuestionnaireChoices]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireChoices.html [QuestionnaireChoice]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireChoice.html +[QuestionnaireChoiceDescription]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireChoiceDescription.html [QuestionnaireInput]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireInput.html +[QuestionnaireError]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireError.html [QuestionnaireActions]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireActions.html -[QuestionnaireEvent]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/enum.QuestionnaireEvent.html +[QuestionnairePrevious]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnairePrevious.html +[QuestionnaireSkip]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireSkip.html +[QuestionnaireNext]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireNext.html +[QuestionnaireSubmit]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireSubmit.html +[QuestionnaireState]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireState.html +[QuestionnaireItemDefinition]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireItemDefinition.html +[QuestionnaireChoiceDefinition]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireChoiceDefinition.html +[QuestionnaireInputDefinition]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireInputDefinition.html +[QuestionnaireAnswer]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireAnswer.html +[QuestionnaireAnswers]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireAnswers.html +[QuestionnaireItemStatus]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/enum.QuestionnaireItemStatus.html +[QuestionnaireShortcutMode]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/enum.QuestionnaireShortcutMode.html +[QuestionnaireProgressState]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireProgressState.html +[QuestionnaireItemState]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireItemState.html +[QuestionnaireChoiceState]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireChoiceState.html +[QuestionnaireNavigationState]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireNavigationState.html +[QuestionnaireValidationContext]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireValidationContext.html +[QuestionnaireValidator]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/type.QuestionnaireValidator.html +[QuestionnaireAnswerChange]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireAnswerChange.html [QuestionnaireSubmission]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireSubmission.html +[QuestionnaireSubmissionItem]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireSubmissionItem.html +[QuestionnaireEvent]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/enum.QuestionnaireEvent.html +[QuestionnaireStateError]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/enum.QuestionnaireStateError.html [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html diff --git a/website/zh-CN/component/questionnaire.md b/website/zh-CN/component/questionnaire.md index adcca5fffb..d51f2cdfd7 100644 --- a/website/zh-CN/component/questionnaire.md +++ b/website/zh-CN/component/questionnaire.md @@ -5,31 +5,33 @@ description: 支持单选、多选、自由输入、校验和导航的可组合 # Questionnaire -`Questionnaire` 引导用户完成一组有序问题。它负责当前 item、答案状态、校验、进度和导航。外层页面、`GroupBox`、`Dialog` 或 `Sheet` 负责关闭、取消、持久化、传输以及应用特有的条件分支。 +`Questionnaire` 引导用户完成一组有序问题。它负责当前 item、答案状态、校验、 +进度和导航。外层页面、`GroupBox`、`Dialog` 或 `Sheet` 负责关闭、取消、持久化、 +传输以及应用特有的条件分支。 ## 引入 ```rust use gpui_component::questionnaire::{ Questionnaire, QuestionnaireActions, QuestionnaireChoice, - QuestionnaireChoices, QuestionnaireDescription, QuestionnaireError, - QuestionnaireInput, QuestionnaireItem, QuestionnaireNext, - QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, - QuestionnaireState, QuestionnaireSubmit, QuestionnaireTitle, + QuestionnaireChoiceDescription, QuestionnaireChoices, QuestionnaireDescription, + QuestionnaireError, QuestionnaireInput, QuestionnaireItem, QuestionnaireNext, + QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, QuestionnaireState, + QuestionnaireSubmit, QuestionnaireTitle, }; ``` ## 用法 -先创建一次 item 集合,并使用一个 `QuestionnaireState` entity 作为所有部件的状态源。 +先创建一次 item 集合,并使用一个 `QuestionnaireState` entity 作为所有部件的 +状态源。 ```rust +use gpui_component::input::InputState; use gpui_component::questionnaire::{ - QuestionnaireItemDefinition, QuestionnaireChoiceDefinition, - QuestionnaireInputDefinition, QuestionnaireState, QuestionnaireAnswer, - QuestionnaireEvent, + QuestionnaireChoiceDefinition, QuestionnaireInputDefinition, + QuestionnaireItemDefinition, QuestionnaireState, }; -use gpui_component::input::InputState; let direction_input = cx.new(|cx| { InputState::new(window, cx).placeholder("Type another answer…") @@ -63,7 +65,8 @@ let state = cx.new(|cx| { }); ``` -将同一个集合映射为组合部件。每个部件都保持足够小,应用可以替换标题、选项内容、进度指示器或操作按钮,同时继续使用 Questionnaire 的状态。 +将集合中的每个 definition 都映射为组合部件。`QuestionnaireItem` 只渲染当前 +item;如果组合中遗漏某个 item,导航到该 item 时界面会为空。 ```rust Questionnaire::new(&state) @@ -81,6 +84,17 @@ Questionnaire::new(&state) ) .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)) @@ -90,8 +104,6 @@ Questionnaire::new(&state) ) ``` -`Questionnaire` 只渲染当前 item;其他 item 仍保留在有序 schema 中,并参与导航和最终校验。 - ## 组合结构 ```text @@ -102,6 +114,7 @@ Questionnaire │ ├── QuestionnaireDescription │ ├── QuestionnaireChoices │ │ ├── QuestionnaireChoice +│ │ │ └── QuestionnaireChoiceDescription (custom child) │ │ └── QuestionnaireInput │ └── QuestionnaireError └── QuestionnaireActions @@ -111,11 +124,60 @@ Questionnaire └── QuestionnaireSubmit ``` -所有部件都接受普通 GPUI 样式,并可以与现有的 `Button`、`Input`、`Radio`、`Checkbox`、`Progress`、`Stepper`、`GroupBox` 和 `Dialog` 组合。自定义部件应读取对应 state 并调用 state 方法处理用户操作,不要复制答案状态。 +所有部件都接受普通 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 只定制对应区域: + +```rust +use gpui::{IntoElement as _, ParentElement as _, StyleRefinement, Styled as _, div}; +use gpui_component::{ActiveTheme as _, StyledExt as _}; +use gpui_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 默认使用单选模式。激活某个选项后 item 即有答案,`Next` 可以继续。单选 item 也可以提供自由输入;固定选项和自由答案互斥,但用户切换选择时会保留输入草稿。 +item 默认使用单选模式。激活某个选项后 item 即有答案,`Next` 可以继续。单选 +item 也可以提供自由输入;固定选项和自由答案互斥,但用户切换选择时会保留输入 +草稿。 ```rust let plan_input = cx.new(|cx| InputState::new(window, cx)); @@ -129,7 +191,8 @@ let item = QuestionnaireItemDefinition::new("plan", "Which plan fits your team?" ## 多选 -当一个 item 可以接受多个固定答案时设置 `multiple`。 +当一个 item 可以接受多个固定答案时设置 `multiple`。非空自由输入可以和已选 +固定选项一起提交。 ```rust let tools_input = cx.new(|cx| InputState::new(window, cx)); @@ -143,11 +206,13 @@ let item = QuestionnaireItemDefinition::new("tools", "Which tools do you use?") .with_input(QuestionnaireInputDefinition::new(tools_input, "Something else")); ``` -多选 item 可以同时提交多个固定选项和非空自由输入。答案读取器按 schema 顺序返回结果。即使恢复的数据包含 disabled choice,其值也不会进入答案。 +答案 reader 按 schema 顺序保留结果。如果已选 choice 后续被禁用,它会从 effective +answer 中排除。 ## 自由输入 -加入 `QuestionnaireInputDefinition`,允许用户输入固定选项之外的答案。请为输入提供可访问名称;placeholder 不能替代 label。 +加入 `QuestionnaireInputDefinition`,允许用户输入固定选项之外的答案。请为输入 +提供可访问名称;placeholder 不能替代 label。 ```rust let feedback_input = cx.new(|cx| { @@ -160,11 +225,15 @@ let item = QuestionnaireItemDefinition::new("feedback", "What should we improve? )); ``` -只有空白的输入视为未回答。选择固定选项时会保留输入草稿,但只有自由输入成为当前答案时才会提交它。 +只有空白的输入视为未回答。选择固定选项时会保留输入草稿,但只有自由输入成为 +当前答案时才会提交它。多选 item 可以同时提交固定选项和非空自由输入。 ## 显式跳过 -可选 item 可以显示 `QuestionnaireSkip`。跳过是一个明确且有效的状态,会清除该 item 的答案并允许 `Next` 继续。必填 item 不允许跳过。重新进入 item 并选择答案后,skipped 状态会被清除。 +可选 item 可以显示 `QuestionnaireSkip`。跳过是一个明确且有效的状态,会清除该 +item 的答案并允许 `Next` 继续。必填 item 不允许跳过。重新进入 item 并选择答案 +后,skipped 状态会被清除。跳过最后一个 enabled item 后,会在记录跳过状态后请求 +提交。 ```rust let optional = QuestionnaireItemDefinition::new("tone", "What tone should we use?") @@ -175,12 +244,54 @@ let optional = QuestionnaireItemDefinition::new("tone", "What tone should we use ]); ``` +## 默认值与禁用控件 + +使用 definition builder 设置初始快照。choice 可以初始选中,item 或 choice 可以 +初始禁用,input 也可以初始禁用。单选 item 最多只能有一个默认选中的 choice。 + +```rust +let saved_input = cx.new(|cx| InputState::new(window, cx).default_value("Saved draft")); +let item = QuestionnaireItemDefinition::new("workspace", "Which workspaces?") + .with_multiple(true) + .with_choices([ + QuestionnaireChoiceDefinition::new("personal", "Personal") + .with_default_selected(true), + QuestionnaireChoiceDefinition::new("team", "Team") + .with_disabled(true), + ]) + .with_input( + QuestionnaireInputDefinition::new(saved_input, "Another workspace") + .with_disabled(false), + ); +let disabled_item = QuestionnaireItemDefinition::new( + "advanced", + "Advanced preferences", +) + .with_disabled(true); +let disabled_input = cx.new(|cx| InputState::new(window, cx)); +let disabled_input_definition = QuestionnaireInputDefinition::new( + disabled_input, + "Disabled answer", +) + .with_disabled(true); +``` + +`with_default_selected` 属于 `QuestionnaireChoiceDefinition`;item 使用 +`with_disabled`,input 使用 `QuestionnaireInputDefinition::with_disabled`。如果要 +让 item 初始禁用,使用 `QuestionnaireItemDefinition::with_disabled(true)`。 + +`QuestionnaireState::new` 会拒绝重复 item name、同一 item 中重复的 choice value, +以及单选 item 的多个默认值。针对未知 item 或 choice 的 setter 会返回 +`QuestionnaireStateError`。 + ## 导航与状态 -`QuestionnaireState` 暴露当前 item、有序 item 状态和导航状态,可用于自定义操作布局。 +`QuestionnaireState` 暴露当前 item、有序 item 状态和导航状态,可用于自定义操作 +布局。 ```rust let current = state.read(cx).current_item(); +let current_ix = state.read(cx).current_ix(); let progress = state.read(cx).progress(); let status = state .read(cx) @@ -192,18 +303,19 @@ let show_previous = navigation.is_previous_visible(); let show_next = navigation.is_next_visible(); let show_skip = navigation.is_skip_visible(); let show_submit = navigation.is_submit_visible(); - -state.update(cx, |state, cx| { - state.go_previous(window, cx); - state.go_next(window, cx); -}); ``` -默认操作布局在开头显示 `Previous`,在 item 之间显示 `Next`,当前 item 可选时显示 `Skip`,最后显示 `Submit`。隐藏的操作不会进入键盘导航。disabled item 会从导航和进度总数中排除。 +默认操作布局在开头显示 `Previous`,在 item 之间显示 `Next`,当前 item 可选时 +显示 `Skip`,最后显示 `Submit`。隐藏的操作不会渲染,也不会进入键盘导航。 +disabled item 会从导航和进度总数中排除。item 有三种状态:`Unanswered`、 +`Answered` 和 `Skipped`。 ## 校验 -必填状态校验已经内置。可以为 item 添加同步 validator,实现领域规则。`Next` 校验当前 item;`Submit` 校验全部 enabled item,并将焦点移到第一个无效 item。 +必填状态校验已经内置。可以为 item 添加同步 validator,实现领域规则。validator +通过 `QuestionnaireValidationContext` 接收当前 item、当前答案和完整的 enabled +答案快照。`Next` 校验当前 item;`Submit` 校验全部 enabled item,并将焦点移到 +第一个无效 item。 ```rust let item = QuestionnaireItemDefinition::new("handle", "Choose a handle") @@ -221,7 +333,12 @@ let item = QuestionnaireItemDefinition::new("handle", "Choose a handle") }); ``` -应用可以使用 `set_external_error` 显示外部 schema 或服务器错误,在数据修正后由 owner 清除。Reset 会恢复默认值并清除内部校验状态;owner 管理的 external error 仍由应用控制。 +可选但未回答的 item 在显式跳过前仍然无效;`Skipped` 是明确有效的状态。disabled +item 和 disabled control 不参与校验。提交失败时会选中第一个无效 item,焦点优先 +移到其中已填写的 input 或已选 choice,再退回第一个 enabled control。 + +外部 schema 或服务器响应应使用 external error。外部错误由宿主负责,并会一直 +保留到宿主清除它。 ```rust state.update(cx, |state, cx| { @@ -229,40 +346,127 @@ state.update(cx, |state, cx| { .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。组件支持 +Questionnaire 语义校验和同步 validator;原生 HTML constraint validation 不属于此 +GPUI 组件。 -当页面需要控制当前 item 或恢复已保存草稿时,使用 state reader 和静默 setter。静默 setter 会更新 UI,但不会发出用户交互事件。 +## 受控状态 + +当页面需要控制当前 item,或需要在 state 创建后应用已保存答案时,使用静默 setter。 +它们会按需更新 UI 和焦点,但不会发出用户交互事件。 ```rust +use gpui_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.reset(window, cx); + 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"); }); ``` -如果前面的答案使某个 item 不适用,可以设置 disabled。disabled item 不计入进度、校验、焦点或提交。 +用户意图应使用 `activate_choice`、`confirm_current`、`go_previous`、`go_next`、 +`skip_current` 和 `submit`。这些路径会发出相应的 `QuestionnaireEvent`。宿主也 +可以使用 `set_item_disabled` 和 `set_choice_disabled`;禁用当前 item 后,焦点会 +移动到下一个 enabled item;没有下一个时移动到前一个。 + +## 恢复 + +如果希望 `reset` 回到保存的草稿,应在构造 `QuestionnaireState` 之前建立保存的 +草稿作为初始快照。使用 `InputState::default_value`、`with_default_selected` 和 +`with_current_item`,分别设置 input、choice 和当前 item 的初始基线。 + +```rust +let saved_input = cx.new(|cx| { + InputState::new(window, cx).default_value("Saved description") +}); +let saved_items = vec![ + QuestionnaireItemDefinition::new("plan", "Which plan?") + .with_choices([ + QuestionnaireChoiceDefinition::new("plus", "Plus") + .with_default_selected(true), + QuestionnaireChoiceDefinition::new("pro", "Pro"), + ]), + QuestionnaireItemDefinition::new("detail", "How much detail?") + .with_input(QuestionnaireInputDefinition::new(saved_input, "More detail")), +]; +let state = cx.new(|cx| { + QuestionnaireState::new(saved_items, cx) + .expect("valid saved questionnaire") + .with_current_item("detail") + .expect("known enabled questionnaire item") +}); +``` + +如果保存值在构造之后才到达,则使用 `set_answer`、`set_input_value` 和 +`set_current_item`。这些 setter 只改变当前状态,不会替换 reset 基线。 + +## 重置 + +Reset 会恢复初始 choices 和 input 草稿,清除显式 skip、校验尝试和完成状态,回到 +初始当前 item,并将焦点移到恢复后的当前 item。 ```rust state.update(cx, |state, cx| { - state - .set_item_disabled("advanced", true, window, cx) - .expect("known questionnaire item"); + state.reset(window, cx); }); ``` +External error 在 reset 后仍由 owner 管理。如果 reset 也应该移除服务器错误,请 +使用 `clear_external_error` 显式清除。 + +## 条件 item + +Questionnaire 不包含 branching engine。宿主可以根据前一个答案推导 item 的禁用 +状态,并通过 `set_item_disabled` 同步。这让条件策略留在页面中,同时由 +Questionnaire 继续负责顺序、焦点、进度、校验和提交。 + +```rust +fn sync_advanced_item( + state: &Entity, + 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 组合以及带修饰键的按键都会保持原有行为。 +为 state 启用字母或数字快捷键。快捷键只作用于当前 item 的 enabled choices。 +重复 key event、文本输入、IME 组合以及带修饰键的按键都会保持原有行为。 ```rust use gpui_component::questionnaire::QuestionnaireShortcutMode; @@ -274,15 +478,23 @@ let state = cx.new(|cx| { }); ``` -Questionnaire 在单选 radio group 内处理四个方向键,将焦点移到下一个 enabled choice 并选中它。Up/Down 在其他场景下按 schema 顺序在 checkbox、choice 和自由输入控件之间移动;只有焦点不在文本输入或 radio 控件上时,Left/Right 才会在 item 之间移动。Enter 确认已填写的答案,Command/Ctrl+Enter 确认当前 item。空答案不会隐式提交问卷。 +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` 使用 docs 默认的 “Question 2 of 4” 样式。也可以读取 progress state,使用现有 `Progress` 或 `Stepper` 组合自定义指示器。 +`QuestionnaireProgress` 使用默认的 “Question 2 of 4” 样式。也可以读取 progress +state,使用现有 `Progress` 或 `Stepper` 组合自定义指示器。 ```rust QuestionnaireProgress::new(&state) - .with_size(Size::Small) + .with_size(Size::Small); let progress = state.read(cx).progress(); let percent = if progress.total() == 0 { @@ -290,104 +502,300 @@ let percent = if progress.total() == 0 { } else { progress.current() as f32 / progress.total() as f32 * 100. }; -Progress::new("questionnaire-progress").value(percent) +Progress::new("questionnaire-progress") + .value(percent); Stepper::new("questionnaire-steps") - .selected_index(progress.current().saturating_sub(1)) + .selected_index(progress.current().saturating_sub(1)); ``` ## 尺寸与主题 -Questionnaire 部件实现与其他组件相同的 `Sizable` 契约。默认尺寸为 `Medium`,并遵循 shadcn/ui `base-nova` docs 外观。 +Questionnaire 部件实现与其他组件相同的 `Sizable` 契约。默认尺寸为 `Medium`,并 +遵循 shadcn/ui `base-nova` docs 外观。支持的命名尺寸为 `XSmall`、`Small`、 +`Medium` 和 `Large`;也可以使用 `Size::Size(value)` 自定义比例。 + +组合部件不会自动继承 root 的 size。需要保持同一比例时,应将相同 size 传给 root、 +progress、item、title、description、choices、choice、choice description、input、 +error、actions 和 navigation 部件。 ```rust use gpui_component::{Sizable as _, Size}; -Questionnaire::new(&state).with_size(Size::Small) -Questionnaire::new(&state).with_size(Size::Medium) -Questionnaire::new(&state).with_size(Size::Large) +let size = Size::Small; +Questionnaire::new(&state) + .with_size(size) + .child(QuestionnaireProgress::new(&state).with_size(size)) + .child( + QuestionnaireItem::new(&state, "direction") + .with_size(size) + .child(QuestionnaireTitle::new(&state, "direction").with_size(size)) + .child(QuestionnaireDescription::new(&state, "direction").with_size(size)) + .child( + QuestionnaireChoices::new(&state, "direction") + .with_size(size) + .child(QuestionnaireChoice::new(&state, "direction", "delegation").with_size(size)) + .child(QuestionnaireInput::new(&state, "direction").with_size(size)), + ) + .child(QuestionnaireError::new(&state, "direction").with_size(size)), + ) + .child( + QuestionnaireActions::new(&state) + .with_size(size) + .child(QuestionnairePrevious::new(&state).with_size(size)) + .child(QuestionnaireSkip::new(&state).with_size(size)) + .child(QuestionnaireNext::new(&state).with_size(size)) + .child(QuestionnaireSubmit::new(&state).with_size(size)), + ); ``` -默认皮肤从当前主题的 semantic tokens 派生 spacing、typography、radius、border、input、primary、muted、destructive 和 focus-ring。局部调整可以使用 `Styled` 方法或 `StyleRefinement`,不需要增加 Questionnaire 专属颜色常量。 +默认皮肤从当前主题的 semantic tokens 派生 spacing、typography、radius、border、 +input、primary、muted、destructive 和 focus-ring。局部调整可以使用 `Styled` 方法 +或 `StyleRefinement`;局部 style refinement 会在组件默认值之后应用。 ## Card 和 Dialog 组合 -Questionnaire 负责问题流程;卡片或 dialog 负责容器布局以及关闭、取消行为。 +Questionnaire 负责完整的问题流程;卡片或 dialog 负责容器布局以及关闭、取消行为。 +下面两个示例都包含集合中的每个 item,导航到第二个问题时仍会正常显示。 ```rust +use gpui::{Entity, IntoElement, ParentElement as _}; +use gpui_component::{ + button::{Button, ButtonVariants as _}, + dialog::{Dialog, DialogClose, DialogFooter, DialogHeader, DialogTitle}, + group_box::{GroupBox, GroupBoxVariants as _}, +}; + +fn questionnaire_content( + state: &Entity, + actions: impl IntoElement, +) -> Questionnaire { + 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(actions) +} + GroupBox::new() .outline() .title("Set up your workspace") - .child(Questionnaire::new(&state)) + .child(questionnaire_content( + &state, + QuestionnaireActions::new(&state) + .child(QuestionnairePrevious::new(&state)) + .child(QuestionnaireSkip::new(&state)) + .child(QuestionnaireNext::new(&state)) + .child(QuestionnaireSubmit::new(&state)), + )); ``` -对于 dialog,可以在现有 Dialog 内容中创建 Questionnaire,并由宿主处理关闭。Questionnaire 的 `Submit` event 适合把已校验的 `QuestionnaireSubmission` 交给应用传输层。 +对于 dialog,将同一个完整组合放在 dialog content 中,并由宿主处理关闭和取消。 + +```rust +use gpui_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_content( + &dialog_state, + 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)), + ), + )) + }); +``` + +宿主 subscription 只在成功的 `Submit` 之后关闭 Dialog。同一个 event 也适合将 +已校验的 `QuestionnaireSubmission` 交给应用传输层。持久化和网络成功仍由 +Questionnaire 外部负责。 ## Event 与提交 -订阅 `QuestionnaireEvent`,即可监听当前 item 变化、答案变化、完成和成功提交。`Completed` 只在状态首次转为 complete 时发出;每次成功执行显式 submit 都会发出 `Submit`。 +订阅 `QuestionnaireEvent`,即可监听当前 item 变化、答案变化、完成和成功提交。 +`Completed` 只在状态转入 complete 时发出;每次成功执行显式 submit 都会发出 +`Submit`。 +首次成功提交时,事件顺序为 `Completed`,随后是 `Submit`。 +答案或 enabled 条件变化会清除 complete 状态,因此下次成功提交可以再次发出 +`Completed`。 ```rust +use gpui_component::questionnaire::QuestionnaireEvent; + cx.subscribe(&state, |_, _, event, _| match event { QuestionnaireEvent::CurrentItemChanged { current, .. } => { println!("Current item: {:?}", current); } QuestionnaireEvent::AnswerChanged(change) => { - println!("Changed: {:?}", change.item()); + println!("Changed: {:?} ({:?})", change.item(), change.status()); } QuestionnaireEvent::Completed(submission) | QuestionnaireEvent::Submit(submission) => { println!("Answers: {:?}", submission.items()); } _ => {} -}); +}) +.detach(); ``` -提交结果按 item schema 顺序排列,并且只包含 enabled item。它表示本地已校验的提交请求;远程保存仍由宿主应用负责。 +`detach` 会让 callback 持续有效,直到订阅涉及的 entity 被销毁。如果宿主需要提前 +取消监听,请改为保存返回的 `Subscription`。 + +提交结果按 item schema 顺序排列,并且只包含 enabled item。每个 item 包含 name、 +`Unanswered`/`Answered`/`Skipped` 状态和 effective answer。它表示本地已校验的 +提交请求;远程保存仍由宿主应用负责。 ## 可访问性 -`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 语义,进度暴露当前值与总数, -导航使用真实按钮。 +`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; +校验失败时,焦点优先移动到已选或已填写的答案控件,再退回第一个可用控件。 -非当前 item 和隐藏操作不会进入键盘导航。成功切换后,焦点移动到新的当前 item;校验失败时,焦点优先移动到已选或已填写的答案控件,再退回第一个可用控件。 +请始终为自由输入在 definition 中提供 `accessibility_label`;可见 label 或等价的 +自定义组合可以补充它。GPUI accessibility layer 没有直接对应 `aria-invalid` 的 +builder;Questionnaire 仍通过错误 alert、语义分组状态、焦点行为和 destructive +样式暴露无效状态。 -请始终为自由输入在 definition 中提供 `accessibility_label`;可见 label 或 -等价的自定义组合可以补充它。GPUI accessibility layer 没有直接对应 -`aria-invalid` 的 builder;Questionnaire 仍通过错误 alert、语义分组状态、焦点 -行为和 destructive 样式暴露无效状态。 +## 当前范围 + +此 GPUI port 覆盖状态、导航、校验、焦点、可访问性、组合渲染和本地提交事件。以下 +Web 专属或未来行为暂不提供:SSR/hydration collection diagnostics、`FormData`、 +原生 HTML 校验、DOM mutation registration、异步校验、definition 的运行时插入/重排, +以及内置动画或持久化/传输。 ## API 参考 +### 组合部件 + - [Questionnaire] -- [QuestionnaireState] -- [QuestionnaireItemDefinition] -- [QuestionnaireChoiceDefinition] -- [QuestionnaireInputDefinition] - [QuestionnaireProgress] - [QuestionnaireItem] +- [QuestionnaireTitle] +- [QuestionnaireDescription] +- [QuestionnaireChoices] - [QuestionnaireChoice] +- [QuestionnaireChoiceDescription] - [QuestionnaireInput] +- [QuestionnaireError] - [QuestionnaireActions] -- [QuestionnaireEvent] +- [QuestionnairePrevious] +- [QuestionnaireSkip] +- [QuestionnaireNext] +- [QuestionnaireSubmit] + +### 状态、答案与事件 + +- [QuestionnaireState] +- [QuestionnaireItemDefinition] +- [QuestionnaireChoiceDefinition] +- [QuestionnaireInputDefinition] +- [QuestionnaireAnswer] +- [QuestionnaireAnswers] +- [QuestionnaireItemStatus] +- [QuestionnaireShortcutMode] +- [QuestionnaireProgressState] +- [QuestionnaireItemState] +- [QuestionnaireChoiceState] +- [QuestionnaireNavigationState] +- [QuestionnaireValidationContext] +- [QuestionnaireValidator] +- [QuestionnaireAnswerChange] - [QuestionnaireSubmission] +- [QuestionnaireSubmissionItem] +- [QuestionnaireEvent] +- [QuestionnaireStateError] - [Sizable] [Questionnaire]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.Questionnaire.html -[QuestionnaireState]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireState.html -[QuestionnaireItemDefinition]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireItemDefinition.html -[QuestionnaireChoiceDefinition]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireChoiceDefinition.html -[QuestionnaireInputDefinition]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireInputDefinition.html [QuestionnaireProgress]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireProgress.html [QuestionnaireItem]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireItem.html +[QuestionnaireTitle]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireTitle.html +[QuestionnaireDescription]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireDescription.html +[QuestionnaireChoices]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireChoices.html [QuestionnaireChoice]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireChoice.html +[QuestionnaireChoiceDescription]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireChoiceDescription.html [QuestionnaireInput]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireInput.html +[QuestionnaireError]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireError.html [QuestionnaireActions]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireActions.html -[QuestionnaireEvent]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/enum.QuestionnaireEvent.html +[QuestionnairePrevious]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnairePrevious.html +[QuestionnaireSkip]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireSkip.html +[QuestionnaireNext]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireNext.html +[QuestionnaireSubmit]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireSubmit.html +[QuestionnaireState]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireState.html +[QuestionnaireItemDefinition]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireItemDefinition.html +[QuestionnaireChoiceDefinition]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireChoiceDefinition.html +[QuestionnaireInputDefinition]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireInputDefinition.html +[QuestionnaireAnswer]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireAnswer.html +[QuestionnaireAnswers]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireAnswers.html +[QuestionnaireItemStatus]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/enum.QuestionnaireItemStatus.html +[QuestionnaireShortcutMode]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/enum.QuestionnaireShortcutMode.html +[QuestionnaireProgressState]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireProgressState.html +[QuestionnaireItemState]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireItemState.html +[QuestionnaireChoiceState]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireChoiceState.html +[QuestionnaireNavigationState]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireNavigationState.html +[QuestionnaireValidationContext]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireValidationContext.html +[QuestionnaireValidator]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/type.QuestionnaireValidator.html +[QuestionnaireAnswerChange]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireAnswerChange.html [QuestionnaireSubmission]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireSubmission.html +[QuestionnaireSubmissionItem]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireSubmissionItem.html +[QuestionnaireEvent]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/enum.QuestionnaireEvent.html +[QuestionnaireStateError]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/enum.QuestionnaireStateError.html [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html From d7f5777b8c263d76c432805d40e5ac9c7e8d1a22 Mon Sep 17 00:00:00 2001 From: suxiaoshao <48886207+suxiaoshao@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:50:15 +0800 Subject: [PATCH 04/17] questionnaire: Allow Italian localization spelling --- _typos.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/_typos.toml b/_typos.toml index 61c1bde92e..02bfe51bc0 100644 --- a/_typos.toml +++ b/_typos.toml @@ -6,6 +6,8 @@ extend-ignore-re = ['\\u\{[0-9A-Fa-f]+\}'] astroid = "astroid" # Correct Italian singular of "command". comando = "comando" +# Correct Italian infinitive for "to continue". +continuare = "continuare" # A deliberately misspelled manifest key: `crates/shell/src/plugin.rs` refuses # it by name, and the test that proves it has to contain the misspelling. capabilites = "capabilites" From 93d4674e8602c375ddac7f62dc223cebe27fa1ba Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Sat, 19 Sep 2026 10:02:08 +0800 Subject: [PATCH 05/17] questionnaire: Follow the `gpui-kit` crate and docs layout Co-Authored-By: Claude Opus 5 (1M context) --- crates/component/src/questionnaire/state.rs | 1 + .../story/src/stories/questionnaire_story.rs | 11 +++----- website/component/questionnaire.md | 26 +++++++++---------- website/zh-CN/component/questionnaire.md | 26 +++++++++---------- 4 files changed, 31 insertions(+), 33 deletions(-) diff --git a/crates/component/src/questionnaire/state.rs b/crates/component/src/questionnaire/state.rs index f45aa052e6..e61eef4d7c 100644 --- a/crates/component/src/questionnaire/state.rs +++ b/crates/component/src/questionnaire/state.rs @@ -435,6 +435,7 @@ impl QuestionnaireState { self.items[item_ix].name().clone(), )); }; + let value: SharedString = value.into(); input.update(cx, |input, cx| input.set_value(value, window, cx)); self.sync_input_answer(item_ix, false, cx); Ok(()) diff --git a/crates/story/src/stories/questionnaire_story.rs b/crates/story/src/stories/questionnaire_story.rs index c3879ace51..a1ea4ebe2b 100644 --- a/crates/story/src/stories/questionnaire_story.rs +++ b/crates/story/src/stories/questionnaire_story.rs @@ -1,9 +1,4 @@ -use gpui::{ - App, AppContext, Context, Entity, FocusHandle, Focusable, InteractiveElement, IntoElement, - ParentElement, Render, SharedString, StyleRefinement, Styled, Subscription, Window, div, - prelude::FluentBuilder as _, px, -}; -use gpui_component::{ +use gpui_kit::component::{ ActiveTheme as _, Sizable, Size, StyledExt as _, WindowExt as _, button::{Button, ButtonVariants as _}, dialog::{Dialog, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogTitle}, @@ -24,6 +19,8 @@ use gpui_component::{ stepper::{Stepper, StepperItem}, v_flex, }; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::*; use crate::{ChangeStorySize, section, story_toolbar}; @@ -832,7 +829,7 @@ impl Render for QuestionnaireStory { return div().into_any_element(); }; let Ok(keystroke) = - gpui::Keystroke::parse(&shortcut.to_lowercase()) + Keystroke::parse(&shortcut.to_lowercase()) else { return div().into_any_element(); }; diff --git a/website/component/questionnaire.md b/website/component/questionnaire.md index 6e9a7cefb9..91686875a1 100644 --- a/website/component/questionnaire.md +++ b/website/component/questionnaire.md @@ -13,7 +13,7 @@ cancelling, persistence, transport, and application-specific branching. ## Import ```rust -use gpui_component::questionnaire::{ +use gpui_kit::component::questionnaire::{ Questionnaire, QuestionnaireActions, QuestionnaireChoice, QuestionnaireChoiceDescription, QuestionnaireChoices, QuestionnaireDescription, QuestionnaireError, QuestionnaireInput, QuestionnaireItem, QuestionnaireNext, @@ -28,8 +28,8 @@ Create the item collection once and use one `QuestionnaireState` entity as the source of truth for all parts. ```rust -use gpui_component::input::InputState; -use gpui_component::questionnaire::{ +use gpui_kit::component::input::InputState; +use gpui_kit::component::questionnaire::{ QuestionnaireChoiceDefinition, QuestionnaireInputDefinition, QuestionnaireItemDefinition, QuestionnaireState, }; @@ -139,9 +139,9 @@ choice activation, focus, state, and accessibility behavior. Use body. The following seams customize only the corresponding region: ```rust -use gpui::{IntoElement as _, ParentElement as _, StyleRefinement, Styled as _, div}; -use gpui_component::{ActiveTheme as _, StyledExt as _}; -use gpui_component::questionnaire::{ +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, }; @@ -383,7 +383,7 @@ creation, use the silent setters. They update the UI and focus as needed but do not emit user-interaction events. ```rust -use gpui_component::questionnaire::QuestionnaireAnswer; +use gpui_kit::component::questionnaire::QuestionnaireAnswer; state.update(cx, |state, cx| { state @@ -495,7 +495,7 @@ active item's enabled choices. Repeated key events, text input, IME composition, and modified key presses are left untouched. ```rust -use gpui_component::questionnaire::QuestionnaireShortcutMode; +use gpui_kit::component::questionnaire::QuestionnaireShortcutMode; let state = cx.new(|cx| { QuestionnaireState::new(items, cx) @@ -551,7 +551,7 @@ description, input, error, actions, and navigation parts that should share one scale. ```rust -use gpui_component::{Sizable as _, Size}; +use gpui_kit::component::{Sizable as _, Size}; let size = Size::Small; Questionnaire::new(&state) @@ -592,8 +592,8 @@ container layout and close/cancel behavior. Both examples below include every item in the collection, so moving to the second question remains visible. ```rust -use gpui::{Entity, IntoElement, ParentElement as _}; -use gpui_component::{ +use gpui_kit::{Entity, IntoElement, ParentElement as _}; +use gpui_kit::component::{ button::{Button, ButtonVariants as _}, dialog::{Dialog, DialogClose, DialogFooter, DialogHeader, DialogTitle}, group_box::{GroupBox, GroupBoxVariants as _}, @@ -649,7 +649,7 @@ For a dialog, put the same complete composition inside the dialog content and let the host handle dismissal and cancellation. ```rust -use gpui_component::{WindowExt as _, questionnaire::QuestionnaireEvent}; +use gpui_kit::component::{WindowExt as _, questionnaire::QuestionnaireEvent}; let dialog_state = state.clone(); cx.subscribe_in( @@ -707,7 +707,7 @@ Changing answers or enabled conditions clears completion, so the next successful submit can emit `Completed` again. ```rust -use gpui_component::questionnaire::QuestionnaireEvent; +use gpui_kit::component::questionnaire::QuestionnaireEvent; cx.subscribe(&state, |_, _, event, _| match event { QuestionnaireEvent::CurrentItemChanged { current, .. } => { diff --git a/website/zh-CN/component/questionnaire.md b/website/zh-CN/component/questionnaire.md index d51f2cdfd7..3bc54683bf 100644 --- a/website/zh-CN/component/questionnaire.md +++ b/website/zh-CN/component/questionnaire.md @@ -12,7 +12,7 @@ description: 支持单选、多选、自由输入、校验和导航的可组合 ## 引入 ```rust -use gpui_component::questionnaire::{ +use gpui_kit::component::questionnaire::{ Questionnaire, QuestionnaireActions, QuestionnaireChoice, QuestionnaireChoiceDescription, QuestionnaireChoices, QuestionnaireDescription, QuestionnaireError, QuestionnaireInput, QuestionnaireItem, QuestionnaireNext, @@ -27,8 +27,8 @@ use gpui_component::questionnaire::{ 状态源。 ```rust -use gpui_component::input::InputState; -use gpui_component::questionnaire::{ +use gpui_kit::component::input::InputState; +use gpui_kit::component::questionnaire::{ QuestionnaireChoiceDefinition, QuestionnaireInputDefinition, QuestionnaireItemDefinition, QuestionnaireState, }; @@ -135,9 +135,9 @@ entity 传给每个部件。自定义部件应读取对应 state 并调用 state 辅助文字。下面这些 seam 只定制对应区域: ```rust -use gpui::{IntoElement as _, ParentElement as _, StyleRefinement, Styled as _, div}; -use gpui_component::{ActiveTheme as _, StyledExt as _}; -use gpui_component::questionnaire::{ +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, }; @@ -365,7 +365,7 @@ GPUI 组件。 它们会按需更新 UI 和焦点,但不会发出用户交互事件。 ```rust -use gpui_component::questionnaire::QuestionnaireAnswer; +use gpui_kit::component::questionnaire::QuestionnaireAnswer; state.update(cx, |state, cx| { state @@ -469,7 +469,7 @@ fn sync_advanced_item( 重复 key event、文本输入、IME 组合以及带修饰键的按键都会保持原有行为。 ```rust -use gpui_component::questionnaire::QuestionnaireShortcutMode; +use gpui_kit::component::questionnaire::QuestionnaireShortcutMode; let state = cx.new(|cx| { QuestionnaireState::new(items, cx) @@ -520,7 +520,7 @@ progress、item、title、description、choices、choice、choice description、 error、actions 和 navigation 部件。 ```rust -use gpui_component::{Sizable as _, Size}; +use gpui_kit::component::{Sizable as _, Size}; let size = Size::Small; Questionnaire::new(&state) @@ -559,8 +559,8 @@ Questionnaire 负责完整的问题流程;卡片或 dialog 负责容器布局 下面两个示例都包含集合中的每个 item,导航到第二个问题时仍会正常显示。 ```rust -use gpui::{Entity, IntoElement, ParentElement as _}; -use gpui_component::{ +use gpui_kit::{Entity, IntoElement, ParentElement as _}; +use gpui_kit::component::{ button::{Button, ButtonVariants as _}, dialog::{Dialog, DialogClose, DialogFooter, DialogHeader, DialogTitle}, group_box::{GroupBox, GroupBoxVariants as _}, @@ -615,7 +615,7 @@ GroupBox::new() 对于 dialog,将同一个完整组合放在 dialog content 中,并由宿主处理关闭和取消。 ```rust -use gpui_component::{WindowExt as _, questionnaire::QuestionnaireEvent}; +use gpui_kit::component::{WindowExt as _, questionnaire::QuestionnaireEvent}; let dialog_state = state.clone(); cx.subscribe_in( @@ -672,7 +672,7 @@ Questionnaire 外部负责。 `Completed`。 ```rust -use gpui_component::questionnaire::QuestionnaireEvent; +use gpui_kit::component::questionnaire::QuestionnaireEvent; cx.subscribe(&state, |_, _, event, _| match event { QuestionnaireEvent::CurrentItemChanged { current, .. } => { From f913b44962e3b8a13c0e475b0b87ce54e0385151 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Sat, 19 Sep 2026 10:26:47 +0800 Subject: [PATCH 06/17] questionnaire: Align the skin with the ReUI `base-nova` questionnaire Co-Authored-By: Claude Opus 5 (1M context) --- .../component/src/questionnaire/components.rs | 74 ++++++++++--------- .../story/src/stories/questionnaire_story.rs | 4 +- website/component/questionnaire.md | 2 +- website/zh-CN/component/questionnaire.md | 4 +- 4 files changed, 44 insertions(+), 40 deletions(-) diff --git a/crates/component/src/questionnaire/components.rs b/crates/component/src/questionnaire/components.rs index ff259ac43b..bf0fcc4d55 100644 --- a/crates/component/src/questionnaire/components.rs +++ b/crates/component/src/questionnaire/components.rs @@ -25,14 +25,13 @@ type ChoiceRenderer = struct QuestionnaireMetrics { root_gap: gpui::Pixels, item_gap: gpui::Pixels, + choices_gap: gpui::Pixels, choice_gap: gpui::Pixels, content_gap: gpui::Pixels, choice_padding_x: gpui::Pixels, choice_padding_y: gpui::Pixels, choice_min_height: gpui::Pixels, choice_radius: gpui::Pixels, - input_padding_y: gpui::Pixels, - input_radius: gpui::Pixels, indicator_size: gpui::Pixels, indicator_mark_size: gpui::Pixels, indicator_check_size: gpui::Pixels, @@ -50,14 +49,13 @@ impl QuestionnaireMetrics { Size::XSmall => Self { root_gap: spacing.sm, item_gap: spacing.sm, + choices_gap: spacing.xs, choice_gap: spacing.xs, content_gap: spacing.xxs, choice_padding_x: spacing.sm, choice_padding_y: spacing.xs, choice_min_height: spacing.xl + spacing.xs, choice_radius: radius.md, - input_padding_y: gpui::Pixels::ZERO, - input_radius: radius.md, indicator_size: spacing.md, indicator_mark_size: spacing.xs, indicator_check_size: spacing.sm, @@ -68,14 +66,13 @@ impl QuestionnaireMetrics { Size::Small => Self { root_gap: spacing.md, item_gap: spacing.md, - choice_gap: spacing.xs, + choices_gap: spacing.xs, + choice_gap: spacing.xs + spacing.xxs, content_gap: spacing.xxs, choice_padding_x: spacing.sm, choice_padding_y: spacing.xs, choice_min_height: spacing.xxl, choice_radius: radius.lg, - input_padding_y: spacing.xxs, - input_radius: radius.lg, indicator_size: spacing.md + spacing.xxs, indicator_mark_size: spacing.xs + spacing.xxs, indicator_check_size: spacing.sm + spacing.xxs, @@ -86,56 +83,53 @@ impl QuestionnaireMetrics { Size::Large => Self { root_gap: spacing.xl, item_gap: spacing.xl, + choices_gap: spacing.sm + spacing.xxs, choice_gap: spacing.md, content_gap: spacing.xs, choice_padding_x: spacing.lg, choice_padding_y: spacing.md, choice_min_height: spacing.xxl + spacing.lg, choice_radius: radius.xl, - input_padding_y: spacing.sm, - input_radius: radius.xl, indicator_size: spacing.lg + spacing.xxs, indicator_mark_size: spacing.sm + spacing.xxs, indicator_check_size: spacing.lg, shortcut_size: spacing.xl, shortcut_text_size: spacing.md, - shortcut_radius: radius.xl, + shortcut_radius: radius.lg, }, Size::Size(value) => Self { root_gap: value, item_gap: value, + choices_gap: value * 0.5, choice_gap: value * 0.625, content_gap: value * 0.25, choice_padding_x: value * 0.75, choice_padding_y: value * 0.625, choice_min_height: value * 2.75, - choice_radius: (radius.lg + radius.xl) * 0.5, - input_padding_y: value * 0.25, - input_radius: (radius.lg + radius.xl) * 0.5, + choice_radius: radius.lg, indicator_size: value, indicator_mark_size: value * 0.5, indicator_check_size: value * 0.875, shortcut_size: value * 1.25, shortcut_text_size: value * 0.625, - shortcut_radius: radius.lg, + shortcut_radius: radius.md, }, Size::Medium => Self { root_gap: spacing.lg, item_gap: spacing.lg, + choices_gap: spacing.sm, choice_gap: spacing.sm + spacing.xxs, content_gap: spacing.xxs, choice_padding_x: spacing.md, choice_padding_y: spacing.sm + spacing.xxs, choice_min_height: spacing.xxl + spacing.md, - choice_radius: (radius.lg + radius.xl) * 0.5, - input_padding_y: spacing.xs, - input_radius: (radius.lg + radius.xl) * 0.5, + choice_radius: radius.lg, indicator_size: spacing.lg, indicator_mark_size: spacing.sm, indicator_check_size: spacing.md + spacing.xxs, shortcut_size: spacing.lg + spacing.xs, shortcut_text_size: spacing.sm + spacing.xxs, - shortcut_radius: radius.lg, + shortcut_radius: radius.md, }, } } @@ -173,11 +167,7 @@ fn progress_text_style(element: T, size: Size, cx: &App) -> T { } fn description_text_style(element: T, size: Size, cx: &App) -> T { - let metrics = QuestionnaireMetrics::new(size, cx); - - // A native fieldset excludes its legend from the flex gap before the - // description. Recreate that base-nova relationship for GPUI's group. - text_style(element, size, cx).mt(-metrics.item_gap) + text_style(element, size, cx) } fn title_text_style(element: T, size: Size, cx: &App) -> T { @@ -376,7 +366,7 @@ impl RenderOnce for Questionnaire { } } -/// Textual progress matching shadcn/ui's base-nova default presentation. +/// Textual progress, following the ReUI `base-nova` questionnaire skin. #[derive(IntoElement)] pub struct QuestionnaireProgress { state: Entity, @@ -445,7 +435,7 @@ impl RenderOnce for QuestionnaireProgress { } macro_rules! questionnaire_item_part { - ($name:ident, $fallback:ident, $style:ident, $color:ident) => { + ($name:ident, $fallback:ident, $style:ident, $color:ident, $closes_item_gap:expr) => { #[derive(IntoElement)] pub struct $name { state: Entity, @@ -497,7 +487,14 @@ macro_rules! questionnaire_item_part { return gpui::Empty.into_any_element(); } let colors = cx.theme().semantic_tokens().colors; + // The item stacks its parts on one gap. A title with no + // description of its own closes the gap the description would + // have filled, so answers never crowd the question. + let closes_item_gap = $closes_item_gap && item_description(definition).is_none(); $style(div().w_full().text_color(colors.$color), self.size, cx) + .when(closes_item_gap, |this| { + this.mb(QuestionnaireMetrics::new(self.size, cx).item_gap) + }) .refine_style(&self.style) .when(!has_children, |this| { this.when_some(fallback, |this, fallback| this.child(fallback)) @@ -509,12 +506,19 @@ macro_rules! questionnaire_item_part { }; } -questionnaire_item_part!(QuestionnaireTitle, item_label, title_text_style, foreground); +questionnaire_item_part!( + QuestionnaireTitle, + item_label, + title_text_style, + foreground, + true +); questionnaire_item_part!( QuestionnaireDescription, item_description, description_text_style, - muted_foreground + muted_foreground, + false ); /// The active question group. Inactive or disabled items do not enter layout, @@ -656,7 +660,7 @@ impl RenderOnce for QuestionnaireChoices { .role(Role::Group) .flex() .flex_col() - .gap(metrics.choice_gap) + .gap(metrics.choices_gap) .w_full() .refine_style(&self.style) .children(self.children) @@ -665,7 +669,7 @@ impl RenderOnce for QuestionnaireChoices { RadioGroup::new(element_id(&self.state, format!("choices-{}", self.item))) .flex() .flex_col() - .gap(metrics.choice_gap) + .gap(metrics.choices_gap) .w_full() .refine_style(&self.style) .children(self.children) @@ -674,7 +678,7 @@ impl RenderOnce for QuestionnaireChoices { } } -/// A selectable base-nova choice card. +/// A selectable choice card, following the ReUI `base-nova` questionnaire skin. #[derive(IntoElement)] pub struct QuestionnaireChoice { state: Entity, @@ -798,6 +802,8 @@ where }) .bg(if selected { tokens.colors.muted + } else if cx.theme().is_dark() { + tokens.colors.input.opacity(0.2) } else { tokens.colors.background.opacity(0.) }) @@ -854,6 +860,7 @@ impl RenderOnce for QuestionnaireChoice { let focus_handle = state.choice_focus_handle(&self.item, &self.value).cloned(); let colors = cx.theme().semantic_tokens().colors; let radius = cx.theme().semantic_tokens().radius; + let indicator_background = cx.theme().input_background(); let mono_font = cx.theme().semantic_tokens().typography.mono.clone(); let metrics = QuestionnaireMetrics::new(self.size, cx); let answer_alignment_offset = metrics.content_gap; @@ -879,7 +886,7 @@ impl RenderOnce for QuestionnaireChoice { .bg(if selected { colors.primary } else { - colors.background + indicator_background }) .when(multiple, |this| this.rounded(radius.sm)) .when(!multiple, |this| this.rounded(radius.full)) @@ -1181,14 +1188,11 @@ impl RenderOnce for QuestionnaireInput { if !active { return gpui::Empty.into_any_element(); } - let metrics = QuestionnaireMetrics::new(self.size, cx); Input::new(input_definition.state()) .aria_label(input_definition.accessibility_label().clone()) .disabled(item_state.is_disabled() || input_definition.is_disabled()) .with_size(self.size) - .py(metrics.input_padding_y) - .rounded(metrics.input_radius) .when(item_state.is_invalid(), |this| { this.border_color(cx.theme().semantic_tokens().colors.destructive) }) @@ -1322,7 +1326,7 @@ impl RenderOnce for QuestionnaireActions { .min_w_0() .items_center() .justify_start() - .gap(metrics.choice_gap) + .gap(metrics.choices_gap) .w_full() .refine_style(&self.style) .children(self.children) diff --git a/crates/story/src/stories/questionnaire_story.rs b/crates/story/src/stories/questionnaire_story.rs index a1ea4ebe2b..6280e66092 100644 --- a/crates/story/src/stories/questionnaire_story.rs +++ b/crates/story/src/stories/questionnaire_story.rs @@ -230,7 +230,7 @@ impl QuestionnaireStory { } // Keep the freeform answer in the same answer group as fixed choices, - // matching shadcn/ui's Questionnaire composition and spacing. + // matching the ReUI questionnaire composition and spacing. choice_parts = choice_parts.child(QuestionnaireInput::new(state, item).with_size(size)); result @@ -1260,7 +1260,7 @@ impl Render for QuestionnaireStory { ) .child( section("All sizes") - .description("Medium is the base-nova default; the same composition scales through all four Size values.") + .description("Medium matches the ReUI skin; the same composition scales through all four Size values.") .w(px(600.)) .child( h_flex() diff --git a/website/component/questionnaire.md b/website/component/questionnaire.md index 91686875a1..c742eca4f9 100644 --- a/website/component/questionnaire.md +++ b/website/component/questionnaire.md @@ -541,7 +541,7 @@ Stepper::new("questionnaire-steps") ## Sizes and theming Questionnaire parts implement the same `Sizable` contract as the rest of the -library. `Medium` is the default and follows the shadcn/ui `base-nova` docs +library. `Medium` is the default and follows the ReUI `base-nova` questionnaire appearance. The supported named sizes are `XSmall`, `Small`, `Medium`, and `Large`; `Size::Size(value)` is available for a custom scale. diff --git a/website/zh-CN/component/questionnaire.md b/website/zh-CN/component/questionnaire.md index 3bc54683bf..076c24c44a 100644 --- a/website/zh-CN/component/questionnaire.md +++ b/website/zh-CN/component/questionnaire.md @@ -511,8 +511,8 @@ Stepper::new("questionnaire-steps") ## 尺寸与主题 -Questionnaire 部件实现与其他组件相同的 `Sizable` 契约。默认尺寸为 `Medium`,并 -遵循 shadcn/ui `base-nova` docs 外观。支持的命名尺寸为 `XSmall`、`Small`、 +Questionnaire 部件实现与其他组件相同的 `Sizable` 契约。默认尺寸为 `Medium`,其 +外观对齐 ReUI 的 `base-nova` questionnaire。支持的命名尺寸为 `XSmall`、`Small`、 `Medium` 和 `Large`;也可以使用 `Size::Size(value)` 自定义比例。 组合部件不会自动继承 root 的 size。需要保持同一比例时,应将相同 size 传给 root、 From 20d32f9a1e6f55a5a5feccf15f6c8fa2a08d3350 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Sat, 19 Sep 2026 12:26:08 +0800 Subject: [PATCH 07/17] questionnaire: Give the skin one scale and tighten its seams Co-Authored-By: Claude Opus 5 (1M context) --- _typos.toml | 2 - crates/component/locales/ui.yml | 7 - .../component/src/questionnaire/components.rs | 324 +++++------------- crates/component/src/questionnaire/state.rs | 52 +-- crates/component/src/questionnaire/types.rs | 6 +- .../story/src/stories/questionnaire_story.rs | 71 +--- website/component/questionnaire.md | 73 ++-- website/zh-CN/component/questionnaire.md | 61 +--- 8 files changed, 178 insertions(+), 418 deletions(-) diff --git a/_typos.toml b/_typos.toml index 02bfe51bc0..61c1bde92e 100644 --- a/_typos.toml +++ b/_typos.toml @@ -6,8 +6,6 @@ extend-ignore-re = ['\\u\{[0-9A-Fa-f]+\}'] astroid = "astroid" # Correct Italian singular of "command". comando = "comando" -# Correct Italian infinitive for "to continue". -continuare = "continuare" # A deliberately misspelled manifest key: `crates/shell/src/plugin.rs` refuses # it by name, and the test that proves it has to contain the misspelling. capabilites = "capabilites" diff --git a/crates/component/locales/ui.yml b/crates/component/locales/ui.yml index fe8955c3d8..53fb77d067 100644 --- a/crates/component/locales/ui.yml +++ b/crates/component/locales/ui.yml @@ -387,40 +387,33 @@ Questionnaire: zh-CN: 第 %{current} 题,共 %{total} 题 zh-HK: 第 %{current} 題,共 %{total} 題 zh-TW: 第 %{current} 題,共 %{total} 題 - it: Domanda %{current} di %{total} previous: en: Previous zh-CN: 上一题 zh-HK: 上一題 zh-TW: 上一題 - it: Indietro next: en: Next zh-CN: 下一题 zh-HK: 下一題 zh-TW: 下一題 - it: Avanti skip: en: Skip zh-CN: 跳过 zh-HK: 跳過 zh-TW: 跳過 - it: Salta submit: en: Submit zh-CN: 提交 zh-HK: 提交 zh-TW: 提交 - it: Invia error.required: en: Choose an answer to continue. zh-CN: 请选择一个答案后继续。 zh-HK: 請選擇一個答案後繼續。 zh-TW: 請選擇一個答案後繼續。 - it: Scegli una risposta per continuare. error.optional: en: Choose an answer or skip this question. zh-CN: 请选择一个答案,或跳过此题。 zh-HK: 請選擇一個答案,或跳過此題。 zh-TW: 請選擇一個答案,或跳過此題。 - it: Scegli una risposta o salta questa domanda. diff --git a/crates/component/src/questionnaire/components.rs b/crates/component/src/questionnaire/components.rs index bf0fcc4d55..6f5ec78f3a 100644 --- a/crates/component/src/questionnaire/components.rs +++ b/crates/component/src/questionnaire/components.rs @@ -21,6 +21,9 @@ use super::{QuestionnaireChoiceState, QuestionnaireState}; type ChoiceRenderer = Rc AnyElement + 'static>; +/// The questionnaire skin's fixed geometry, following the ReUI `base-nova` +/// questionnaire. Sizing is a theme concern here: the numbers come from the +/// semantic spacing and radius tokens rather than a per-part size scale. #[derive(Clone, Copy)] struct QuestionnaireMetrics { root_gap: gpui::Pixels, @@ -41,148 +44,59 @@ struct QuestionnaireMetrics { } impl QuestionnaireMetrics { - fn new(size: Size, cx: &App) -> Self { + fn new(cx: &App) -> Self { let tokens = cx.theme().semantic_tokens(); let spacing = tokens.spacing; let radius = tokens.radius; - match size { - Size::XSmall => Self { - root_gap: spacing.sm, - item_gap: spacing.sm, - choices_gap: spacing.xs, - choice_gap: spacing.xs, - content_gap: spacing.xxs, - choice_padding_x: spacing.sm, - choice_padding_y: spacing.xs, - choice_min_height: spacing.xl + spacing.xs, - choice_radius: radius.md, - indicator_size: spacing.md, - indicator_mark_size: spacing.xs, - indicator_check_size: spacing.sm, - shortcut_size: spacing.lg, - shortcut_text_size: spacing.sm, - shortcut_radius: radius.sm, - }, - Size::Small => Self { - root_gap: spacing.md, - item_gap: spacing.md, - choices_gap: spacing.xs, - choice_gap: spacing.xs + spacing.xxs, - content_gap: spacing.xxs, - choice_padding_x: spacing.sm, - choice_padding_y: spacing.xs, - choice_min_height: spacing.xxl, - choice_radius: radius.lg, - indicator_size: spacing.md + spacing.xxs, - indicator_mark_size: spacing.xs + spacing.xxs, - indicator_check_size: spacing.sm + spacing.xxs, - shortcut_size: spacing.lg + spacing.xxs, - shortcut_text_size: spacing.sm + spacing.xxs * 0.5, - shortcut_radius: radius.md, - }, - Size::Large => Self { - root_gap: spacing.xl, - item_gap: spacing.xl, - choices_gap: spacing.sm + spacing.xxs, - choice_gap: spacing.md, - content_gap: spacing.xs, - choice_padding_x: spacing.lg, - choice_padding_y: spacing.md, - choice_min_height: spacing.xxl + spacing.lg, - choice_radius: radius.xl, - indicator_size: spacing.lg + spacing.xxs, - indicator_mark_size: spacing.sm + spacing.xxs, - indicator_check_size: spacing.lg, - shortcut_size: spacing.xl, - shortcut_text_size: spacing.md, - shortcut_radius: radius.lg, - }, - Size::Size(value) => Self { - root_gap: value, - item_gap: value, - choices_gap: value * 0.5, - choice_gap: value * 0.625, - content_gap: value * 0.25, - choice_padding_x: value * 0.75, - choice_padding_y: value * 0.625, - choice_min_height: value * 2.75, - choice_radius: radius.lg, - indicator_size: value, - indicator_mark_size: value * 0.5, - indicator_check_size: value * 0.875, - shortcut_size: value * 1.25, - shortcut_text_size: value * 0.625, - shortcut_radius: radius.md, - }, - Size::Medium => Self { - root_gap: spacing.lg, - item_gap: spacing.lg, - choices_gap: spacing.sm, - choice_gap: spacing.sm + spacing.xxs, - content_gap: spacing.xxs, - choice_padding_x: spacing.md, - choice_padding_y: spacing.sm + spacing.xxs, - choice_min_height: spacing.xxl + spacing.md, - choice_radius: radius.lg, - indicator_size: spacing.lg, - indicator_mark_size: spacing.sm, - indicator_check_size: spacing.md + spacing.xxs, - shortcut_size: spacing.lg + spacing.xs, - shortcut_text_size: spacing.sm + spacing.xxs, - shortcut_radius: radius.md, - }, + Self { + root_gap: spacing.lg, + item_gap: spacing.lg, + choices_gap: spacing.sm, + choice_gap: spacing.sm + spacing.xxs, + content_gap: spacing.xxs, + choice_padding_x: spacing.md, + choice_padding_y: spacing.sm + spacing.xxs, + choice_min_height: spacing.xxl + spacing.md, + choice_radius: radius.lg, + indicator_size: spacing.lg, + indicator_mark_size: spacing.sm, + indicator_check_size: spacing.md + spacing.xxs, + shortcut_size: spacing.lg + spacing.xs, + shortcut_text_size: spacing.sm + spacing.xxs, + shortcut_radius: radius.md, } } } -fn text_style(element: T, size: Size, cx: &App) -> T { - let typography = cx.theme().semantic_tokens().typography; - let token = match size { - Size::XSmall => typography.xs, - Size::Small => typography.sm, - Size::Medium => typography.sm, - Size::Large => typography.md, - Size::Size(value) => return element.text_size(value), - }; - element - .text_size(token.size) - .line_height(token.line_height) - .font_weight(token.weight) +/// Answer text matches the Checkbox and Radio family's medium label. +fn text_style(element: T, cx: &App) -> T { + apply_text_token(element, cx.theme().semantic_tokens().typography.md) } -fn progress_text_style(element: T, size: Size, cx: &App) -> T { - let typography = cx.theme().semantic_tokens().typography; - let token = match size { - Size::XSmall | Size::Small | Size::Medium => typography.xs, - Size::Large => typography.sm, - Size::Size(value) => { - return element.text_size(value * 0.75).line_height(value); - } - }; +/// Secondary text sits one step below the answer text. +fn secondary_text_style(element: T, cx: &App) -> T { + apply_text_token(element, cx.theme().semantic_tokens().typography.sm) +} - element - .text_size(token.size) - .line_height(token.line_height) - .font_weight(token.weight) +fn progress_text_style(element: T, cx: &App) -> T { + apply_text_token(element, cx.theme().semantic_tokens().typography.xs) + .font_weight(gpui::FontWeight::MEDIUM) } -fn description_text_style(element: T, size: Size, cx: &App) -> T { - text_style(element, size, cx) +fn description_text_style(element: T, cx: &App) -> T { + secondary_text_style(element, cx) } -fn title_text_style(element: T, size: Size, cx: &App) -> T { - let typography = cx.theme().semantic_tokens().typography; - let token = match size { - Size::XSmall => typography.sm, - Size::Small => typography.sm, - Size::Medium => typography.md, - Size::Large => typography.lg, - Size::Size(value) => return element.text_size(value), - }; +fn title_text_style(element: T, cx: &App) -> T { + apply_text_token(element, cx.theme().semantic_tokens().typography.lg) + .font_weight(gpui::FontWeight::MEDIUM) +} + +fn apply_text_token(element: T, token: gpui_base::TextStyleToken) -> T { element .text_size(token.size) .line_height(token.line_height) - .font_weight(gpui::FontWeight::MEDIUM) + .font_weight(token.weight) } fn item_label(definition: &super::QuestionnaireItemDefinition) -> Option { @@ -193,6 +107,32 @@ fn item_description(definition: &super::QuestionnaireItemDefinition) -> Option, suffix: impl std::fmt::Display) -> ElementId { ElementId::Name(format!("questionnaire-{}-{suffix}", state.entity_id()).into()) } @@ -203,7 +143,6 @@ fn element_id(state: &Entity, suffix: impl std::fmt::Display pub struct Questionnaire { state: Entity, style: StyleRefinement, - size: Size, children: Vec, } @@ -212,7 +151,6 @@ impl Questionnaire { Self { state: state.clone(), style: StyleRefinement::default(), - size: Size::Medium, children: Vec::new(), } } @@ -329,13 +267,6 @@ impl Styled for Questionnaire { } } -impl Sizable for Questionnaire { - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); - self - } -} - impl ParentElement for Questionnaire { fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements); @@ -344,7 +275,7 @@ impl ParentElement for Questionnaire { impl RenderOnce for Questionnaire { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let metrics = QuestionnaireMetrics::new(self.size, cx); + let metrics = QuestionnaireMetrics::new(cx); let focus_handle = self.state.read(cx).focus_handle().clone(); let state = self.state.clone(); let debug_selector = format!("questionnaire-{}-root", self.state.entity_id()); @@ -371,7 +302,6 @@ impl RenderOnce for Questionnaire { pub struct QuestionnaireProgress { state: Entity, style: StyleRefinement, - size: Size, children: Vec, } @@ -380,7 +310,6 @@ impl QuestionnaireProgress { Self { state: state.clone(), style: StyleRefinement::default(), - size: Size::Medium, children: Vec::new(), } } @@ -392,13 +321,6 @@ impl Styled for QuestionnaireProgress { } } -impl Sizable for QuestionnaireProgress { - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); - self - } -} - impl ParentElement for QuestionnaireProgress { fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements); @@ -424,10 +346,8 @@ impl RenderOnce for QuestionnaireProgress { .aria_max_numeric_value(total as f64) .aria_numeric_value(current as f64) .text_color(colors.muted_foreground), - self.size, cx, ) - .font_weight(gpui::FontWeight::MEDIUM) .refine_style(&self.style) .when(!has_children, |this| this.child(label)) .children(self.children) @@ -441,7 +361,6 @@ macro_rules! questionnaire_item_part { state: Entity, item: SharedString, style: StyleRefinement, - size: Size, children: Vec, } @@ -451,7 +370,6 @@ macro_rules! questionnaire_item_part { state: state.clone(), item: item.into(), style: StyleRefinement::default(), - size: Size::Medium, children: Vec::new(), } } @@ -463,13 +381,6 @@ macro_rules! questionnaire_item_part { } } - impl Sizable for $name { - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); - self - } - } - impl ParentElement for $name { fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements); @@ -479,6 +390,7 @@ macro_rules! questionnaire_item_part { impl RenderOnce for $name { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let Some(definition) = self.state.read(cx).item_definition(&self.item) else { + report_unknown_item(&self.item); return gpui::Empty.into_any_element(); }; let fallback = $fallback(definition); @@ -491,9 +403,9 @@ macro_rules! questionnaire_item_part { // description of its own closes the gap the description would // have filled, so answers never crowd the question. let closes_item_gap = $closes_item_gap && item_description(definition).is_none(); - $style(div().w_full().text_color(colors.$color), self.size, cx) + $style(div().w_full().text_color(colors.$color), cx) .when(closes_item_gap, |this| { - this.mb(QuestionnaireMetrics::new(self.size, cx).item_gap) + this.mb(QuestionnaireMetrics::new(cx).item_gap) }) .refine_style(&self.style) .when(!has_children, |this| { @@ -528,7 +440,6 @@ pub struct QuestionnaireItem { state: Entity, item: SharedString, style: StyleRefinement, - size: Size, children: Vec, } @@ -538,7 +449,6 @@ impl QuestionnaireItem { state: state.clone(), item: item.into(), style: StyleRefinement::default(), - size: Size::Medium, children: Vec::new(), } } @@ -550,13 +460,6 @@ impl Styled for QuestionnaireItem { } } -impl Sizable for QuestionnaireItem { - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); - self - } -} - impl ParentElement for QuestionnaireItem { fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements); @@ -568,6 +471,7 @@ impl RenderOnce for QuestionnaireItem { let state = self.state.read(cx); let active = state.current_item().is_some_and(|name| name == &self.item); let Some(item_state) = state.item_state(&self.item) else { + report_unknown_item(&self.item); return gpui::Empty.into_any_element(); }; if !active || item_state.is_disabled() { @@ -579,7 +483,7 @@ impl RenderOnce for QuestionnaireItem { let focus_handle = state.item_focus_handle(&self.item).cloned(); let label = definition.accessibility_label().clone(); let description = definition.description().cloned(); - let metrics = QuestionnaireMetrics::new(self.size, cx); + let metrics = QuestionnaireMetrics::new(cx); div() .id(element_id(&self.state, format!("item-{}", self.item))) @@ -607,7 +511,6 @@ pub struct QuestionnaireChoices { state: Entity, item: SharedString, style: StyleRefinement, - size: Size, children: Vec, } @@ -617,7 +520,6 @@ impl QuestionnaireChoices { state: state.clone(), item: item.into(), style: StyleRefinement::default(), - size: Size::Medium, children: Vec::new(), } } @@ -629,13 +531,6 @@ impl Styled for QuestionnaireChoices { } } -impl Sizable for QuestionnaireChoices { - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); - self - } -} - impl ParentElement for QuestionnaireChoices { fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements); @@ -647,12 +542,13 @@ impl RenderOnce for QuestionnaireChoices { let state = self.state.read(cx); let active = state.current_item().is_some_and(|name| name == &self.item); let Some(item) = state.item_state(&self.item) else { + report_unknown_item(&self.item); return gpui::Empty.into_any_element(); }; if !active || item.is_disabled() { return gpui::Empty.into_any_element(); } - let metrics = QuestionnaireMetrics::new(self.size, cx); + let metrics = QuestionnaireMetrics::new(cx); if item.is_multiple() { div() @@ -688,7 +584,6 @@ pub struct QuestionnaireChoice { indicator_style: StyleRefinement, content_style: StyleRefinement, shortcut_style: StyleRefinement, - size: Size, children: Vec, indicator_renderer: Option, shortcut_renderer: Option, @@ -708,7 +603,6 @@ impl QuestionnaireChoice { indicator_style: StyleRefinement::default(), content_style: StyleRefinement::default(), shortcut_style: StyleRefinement::default(), - size: Size::Medium, children: Vec::new(), indicator_renderer: None, shortcut_renderer: None, @@ -753,13 +647,6 @@ impl Styled for QuestionnaireChoice { } } -impl Sizable for QuestionnaireChoice { - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); - self - } -} - impl ParentElement for QuestionnaireChoice { fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements); @@ -824,12 +711,18 @@ impl RenderOnce for QuestionnaireChoice { let state = self.state.read(cx); let active = state.current_item().is_some_and(|name| name == &self.item); let Some(choice_state) = state.choice_state(&self.item, &self.value) else { + if state.item_state(&self.item).is_none() { + report_unknown_item(&self.item); + } else { + report_unknown_choice(&self.item, &self.value); + } return gpui::Empty.into_any_element(); }; if !active { return gpui::Empty.into_any_element(); } let Some(item) = state.item_state(&self.item) else { + report_unknown_item(&self.item); return gpui::Empty.into_any_element(); }; let Some(definition) = state.choice_definition(&self.item, &self.value) else { @@ -862,7 +755,7 @@ impl RenderOnce for QuestionnaireChoice { let radius = cx.theme().semantic_tokens().radius; let indicator_background = cx.theme().input_background(); let mono_font = cx.theme().semantic_tokens().typography.mono.clone(); - let metrics = QuestionnaireMetrics::new(self.size, cx); + let metrics = QuestionnaireMetrics::new(cx); let answer_alignment_offset = metrics.content_gap; let focused = focus_handle .as_ref() @@ -926,13 +819,11 @@ impl RenderOnce for QuestionnaireChoice { .when(!has_children, |this| { this.child(text_style( div().text_color(colors.foreground).child(label.clone()), - self.size, cx, )) .when_some(description.clone(), |this, description| { - this.child(text_style( + this.child(secondary_text_style( div().text_color(colors.muted_foreground).child(description), - self.size.smaller(), cx, )) }) @@ -1087,7 +978,6 @@ impl RenderOnce for QuestionnaireChoice { #[derive(IntoElement)] pub struct QuestionnaireChoiceDescription { style: StyleRefinement, - size: Size, children: Vec, } @@ -1095,7 +985,6 @@ impl QuestionnaireChoiceDescription { pub fn new() -> Self { Self { style: StyleRefinement::default(), - size: Size::Medium, children: Vec::new(), } } @@ -1113,13 +1002,6 @@ impl Styled for QuestionnaireChoiceDescription { } } -impl Sizable for QuestionnaireChoiceDescription { - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); - self - } -} - impl ParentElement for QuestionnaireChoiceDescription { fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements); @@ -1129,13 +1011,9 @@ impl ParentElement for QuestionnaireChoiceDescription { impl RenderOnce for QuestionnaireChoiceDescription { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let colors = cx.theme().semantic_tokens().colors; - text_style( - div().text_color(colors.muted_foreground), - self.size.smaller(), - cx, - ) - .refine_style(&self.style) - .children(self.children) + secondary_text_style(div().text_color(colors.muted_foreground), cx) + .refine_style(&self.style) + .children(self.children) } } @@ -1145,7 +1023,6 @@ pub struct QuestionnaireInput { state: Entity, item: SharedString, style: StyleRefinement, - size: Size, } impl QuestionnaireInput { @@ -1154,7 +1031,6 @@ impl QuestionnaireInput { state: state.clone(), item: item.into(), style: StyleRefinement::default(), - size: Size::Medium, } } } @@ -1165,18 +1041,12 @@ impl Styled for QuestionnaireInput { } } -impl Sizable for QuestionnaireInput { - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); - self - } -} - impl RenderOnce for QuestionnaireInput { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let state = self.state.read(cx); let active = state.current_item().is_some_and(|name| name == &self.item); let Some(item_state) = state.item_state(&self.item) else { + report_unknown_item(&self.item); return gpui::Empty.into_any_element(); }; let Some(definition) = state.item_definition(&self.item) else { @@ -1192,7 +1062,6 @@ impl RenderOnce for QuestionnaireInput { Input::new(input_definition.state()) .aria_label(input_definition.accessibility_label().clone()) .disabled(item_state.is_disabled() || input_definition.is_disabled()) - .with_size(self.size) .when(item_state.is_invalid(), |this| { this.border_color(cx.theme().semantic_tokens().colors.destructive) }) @@ -1207,7 +1076,6 @@ pub struct QuestionnaireError { state: Entity, item: SharedString, style: StyleRefinement, - size: Size, children: Vec, } @@ -1217,7 +1085,6 @@ impl QuestionnaireError { state: state.clone(), item: item.into(), style: StyleRefinement::default(), - size: Size::Medium, children: Vec::new(), } } @@ -1229,13 +1096,6 @@ impl Styled for QuestionnaireError { } } -impl Sizable for QuestionnaireError { - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); - self - } -} - impl ParentElement for QuestionnaireError { fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements); @@ -1250,6 +1110,7 @@ impl RenderOnce for QuestionnaireError { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let state = self.state.read(cx); let Some(item) = state.item_state(&self.item) else { + report_unknown_item(&self.item); return gpui::Empty.into_any_element(); }; let error = state.error(&self.item).cloned(); @@ -1264,7 +1125,6 @@ impl RenderOnce for QuestionnaireError { questionnaire_error_root(element_id(&self.state, format!("error-{}", self.item))) .mt(spacing.sm) .text_color(colors.destructive), - self.size, cx, ) .refine_style(&self.style) @@ -1281,7 +1141,6 @@ impl RenderOnce for QuestionnaireError { pub struct QuestionnaireActions { state: Entity, style: StyleRefinement, - size: Size, children: Vec, } @@ -1290,7 +1149,6 @@ impl QuestionnaireActions { Self { state: state.clone(), style: StyleRefinement::default(), - size: Size::Medium, children: Vec::new(), } } @@ -1302,13 +1160,6 @@ impl Styled for QuestionnaireActions { } } -impl Sizable for QuestionnaireActions { - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); - self - } -} - impl ParentElement for QuestionnaireActions { fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements); @@ -1317,7 +1168,7 @@ impl ParentElement for QuestionnaireActions { impl RenderOnce for QuestionnaireActions { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let metrics = QuestionnaireMetrics::new(self.size, cx); + let metrics = QuestionnaireMetrics::new(cx); let debug_selector = format!("questionnaire-{}-actions", self.state.entity_id()); div() .id(element_id(&self.state, "actions")) @@ -1465,7 +1316,6 @@ mod tests { #[test] fn compound_parts_support_builder_customization() { let _ = QuestionnaireChoiceDescription::new() - .small() .opacity(0.8) .child("Description"); } diff --git a/crates/component/src/questionnaire/state.rs b/crates/component/src/questionnaire/state.rs index e61eef4d7c..13ed343378 100644 --- a/crates/component/src/questionnaire/state.rs +++ b/crates/component/src/questionnaire/state.rs @@ -42,7 +42,7 @@ impl QuestionnaireState { pub fn new( items: Vec, cx: &mut Context, - ) -> Result { + ) -> Result { Self::validate_schema(&items)?; let mut runtime = Vec::with_capacity(items.len()); @@ -121,18 +121,18 @@ impl QuestionnaireState { fn validate_schema( items: &[QuestionnaireItemDefinition], - ) -> Result<(), QuestionnaireStateError> { + ) -> Result<(), QuestionnaireSchemaError> { let mut item_names = HashSet::new(); for item in items { if !item_names.insert(item.name().to_string()) { - return Err(QuestionnaireStateError::DuplicateItem(item.name().clone())); + return Err(QuestionnaireSchemaError::DuplicateItem(item.name().clone())); } let mut choices = HashSet::new(); let mut defaults = 0; for choice in item.choices() { if !choices.insert(choice.value().to_string()) { - return Err(QuestionnaireStateError::DuplicateChoice { + return Err(QuestionnaireSchemaError::DuplicateChoice { item: item.name().clone(), choice: choice.value().clone(), }); @@ -140,7 +140,7 @@ impl QuestionnaireState { defaults += usize::from(choice.is_default_selected()); } if !item.is_multiple() && defaults > 1 { - return Err(QuestionnaireStateError::MultipleDefaultsForSingleItem( + return Err(QuestionnaireSchemaError::MultipleDefaultsForSingleItem( item.name().clone(), )); } @@ -151,7 +151,7 @@ impl QuestionnaireState { pub fn with_current_item( mut self, name: impl Into, - ) -> Result { + ) -> Result { let name = name.into(); let ix = self.item_ix(&name)?; if !self.runtime[ix].disabled { @@ -370,7 +370,7 @@ impl QuestionnaireState { name: &str, window: &mut Window, cx: &mut Context, - ) -> Result<(), QuestionnaireStateError> { + ) -> Result<(), QuestionnaireSchemaError> { let ix = self.item_ix(name)?; if !self.runtime[ix].disabled { self.current = Some(ix); @@ -386,7 +386,7 @@ impl QuestionnaireState { mut answer: QuestionnaireAnswer, window: &mut Window, cx: &mut Context, - ) -> Result<(), QuestionnaireStateError> { + ) -> Result<(), QuestionnaireSchemaError> { let item_ix = self.item_ix(item)?; let before = self.effective_answer(item_ix); let before_status = self.status(item_ix); @@ -425,13 +425,13 @@ impl QuestionnaireState { value: impl Into, window: &mut Window, cx: &mut Context, - ) -> Result<(), QuestionnaireStateError> { + ) -> Result<(), QuestionnaireSchemaError> { let item_ix = self.item_ix(item)?; let Some(input) = self.items[item_ix] .input() .map(|input| input.state().clone()) else { - return Err(QuestionnaireStateError::AnswerDoesNotMatchItem( + return Err(QuestionnaireSchemaError::AnswerDoesNotMatchItem( self.items[item_ix].name().clone(), )); }; @@ -447,7 +447,7 @@ impl QuestionnaireState { disabled: bool, window: &mut Window, cx: &mut Context, - ) -> Result<(), QuestionnaireStateError> { + ) -> Result<(), QuestionnaireSchemaError> { let ix = self.item_ix(name)?; if self.runtime[ix].disabled == disabled { return Ok(()); @@ -485,7 +485,7 @@ impl QuestionnaireState { value: &str, disabled: bool, cx: &mut Context, - ) -> Result<(), QuestionnaireStateError> { + ) -> Result<(), QuestionnaireSchemaError> { let item_ix = self.item_ix(item)?; let choice_ix = self.choice_ix(item_ix, value)?; if self.runtime[item_ix].choice_disabled[choice_ix] == disabled { @@ -501,7 +501,7 @@ impl QuestionnaireState { item: &str, error: impl Into, cx: &mut Context, - ) -> Result<(), QuestionnaireStateError> { + ) -> Result<(), QuestionnaireSchemaError> { let ix = self.item_ix(item)?; self.runtime[ix].external_error = Some(error.into()); self.complete = false; @@ -513,7 +513,7 @@ impl QuestionnaireState { &mut self, item: &str, cx: &mut Context, - ) -> Result<(), QuestionnaireStateError> { + ) -> Result<(), QuestionnaireSchemaError> { let ix = self.item_ix(item)?; self.runtime[ix].external_error = None; cx.notify(); @@ -556,7 +556,7 @@ impl QuestionnaireState { item: &str, value: &str, cx: &mut Context, - ) -> Result<(), QuestionnaireStateError> { + ) -> Result<(), QuestionnaireSchemaError> { let item_ix = self.item_ix(item)?; let choice_ix = self.choice_ix(item_ix, value)?; if self.runtime[item_ix].disabled || self.runtime[item_ix].choice_disabled[choice_ix] { @@ -1052,20 +1052,20 @@ impl QuestionnaireState { &self, item_ix: usize, answer: &QuestionnaireAnswer, - ) -> Result<(), QuestionnaireStateError> { + ) -> Result<(), QuestionnaireSchemaError> { let item = &self.items[item_ix]; let sources = answer.choices.len() + usize::from(answer.freeform.is_some()); if (!item.is_multiple() && sources > 1) || (answer.freeform.is_some() && item.input().is_none()) { - return Err(QuestionnaireStateError::AnswerDoesNotMatchItem( + return Err(QuestionnaireSchemaError::AnswerDoesNotMatchItem( item.name().clone(), )); } for choice in &answer.choices { let choice_ix = self.choice_ix(item_ix, choice)?; if self.runtime[item_ix].choice_disabled[choice_ix] { - return Err(QuestionnaireStateError::AnswerDoesNotMatchItem( + return Err(QuestionnaireSchemaError::AnswerDoesNotMatchItem( item.name().clone(), )); } @@ -1102,9 +1102,9 @@ impl QuestionnaireState { .filter_map(|(ix, runtime)| (!runtime.disabled).then_some(ix)) } - fn item_ix(&self, name: &str) -> Result { + fn item_ix(&self, name: &str) -> Result { self.item_ix_opt(name) - .ok_or_else(|| QuestionnaireStateError::UnknownItem(name.into())) + .ok_or_else(|| QuestionnaireSchemaError::UnknownItem(name.into())) } fn item_ix_opt(&self, name: &str) -> Option { @@ -1113,9 +1113,9 @@ impl QuestionnaireState { .position(|item| item.name().as_ref() == name) } - fn choice_ix(&self, item_ix: usize, value: &str) -> Result { + fn choice_ix(&self, item_ix: usize, value: &str) -> Result { self.choice_ix_opt(item_ix, value) - .ok_or_else(|| QuestionnaireStateError::UnknownChoice { + .ok_or_else(|| QuestionnaireSchemaError::UnknownChoice { item: self.items[item_ix].name().clone(), choice: value.into(), }) @@ -1246,7 +1246,7 @@ mod tests { ]; assert_eq!( QuestionnaireState::validate_schema(&duplicate_items), - Err(QuestionnaireStateError::DuplicateItem("same".into())) + Err(QuestionnaireSchemaError::DuplicateItem("same".into())) ); let invalid_default = vec![ @@ -1257,7 +1257,7 @@ mod tests { ]; assert_eq!( QuestionnaireState::validate_schema(&invalid_default), - Err(QuestionnaireStateError::MultipleDefaultsForSingleItem( + Err(QuestionnaireSchemaError::MultipleDefaultsForSingleItem( "single".into() )) ); @@ -1270,7 +1270,7 @@ mod tests { ]; assert_eq!( QuestionnaireState::validate_schema(&duplicate_choice), - Err(QuestionnaireStateError::DuplicateChoice { + Err(QuestionnaireSchemaError::DuplicateChoice { item: "item".into(), choice: "same".into(), }) @@ -1546,7 +1546,7 @@ mod tests { }); assert_eq!( error, - Err(QuestionnaireStateError::UnknownChoice { + Err(QuestionnaireSchemaError::UnknownChoice { item: "second".into(), choice: "unknown".into(), }) diff --git a/crates/component/src/questionnaire/types.rs b/crates/component/src/questionnaire/types.rs index d2e52ffbd4..d9994b8e6d 100644 --- a/crates/component/src/questionnaire/types.rs +++ b/crates/component/src/questionnaire/types.rs @@ -632,7 +632,7 @@ pub enum QuestionnaireEvent { #[derive(Clone, Debug, PartialEq, Eq)] #[non_exhaustive] -pub enum QuestionnaireStateError { +pub enum QuestionnaireSchemaError { DuplicateItem(SharedString), DuplicateChoice { item: SharedString, @@ -647,7 +647,7 @@ pub enum QuestionnaireStateError { AnswerDoesNotMatchItem(SharedString), } -impl fmt::Display for QuestionnaireStateError { +impl fmt::Display for QuestionnaireSchemaError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::DuplicateItem(item) => write!(formatter, "duplicate questionnaire item `{item}`"), @@ -669,4 +669,4 @@ impl fmt::Display for QuestionnaireStateError { } } -impl Error for QuestionnaireStateError {} +impl Error for QuestionnaireSchemaError {} diff --git a/crates/story/src/stories/questionnaire_story.rs b/crates/story/src/stories/questionnaire_story.rs index 6280e66092..ce6bb6f586 100644 --- a/crates/story/src/stories/questionnaire_story.rs +++ b/crates/story/src/stories/questionnaire_story.rs @@ -216,26 +216,24 @@ impl QuestionnaireStory { state: &Entity, item: &'static str, choices: impl IntoIterator, - size: Size, ) -> QuestionnaireItem { let result = QuestionnaireItem::new(state, item) - .with_size(size) - .child(QuestionnaireTitle::new(state, item).with_size(size)) - .child(QuestionnaireDescription::new(state, item).with_size(size)); + .child(QuestionnaireTitle::new(state, item)) + .child(QuestionnaireDescription::new(state, item)); - let mut choice_parts = QuestionnaireChoices::new(state, item).with_size(size); + let mut choice_parts = QuestionnaireChoices::new(state, item); for value in choices { - let choice = QuestionnaireChoice::new(state, item, value).with_size(size); + let choice = QuestionnaireChoice::new(state, item, value); choice_parts = choice_parts.child(choice); } // Keep the freeform answer in the same answer group as fixed choices, // matching the ReUI questionnaire composition and spacing. - choice_parts = choice_parts.child(QuestionnaireInput::new(state, item).with_size(size)); + choice_parts = choice_parts.child(QuestionnaireInput::new(state, item)); result .child(choice_parts) - .child(QuestionnaireError::new(state, item).with_size(size)) + .child(QuestionnaireError::new(state, item)) } fn questionnaire_view( @@ -243,16 +241,13 @@ impl QuestionnaireStory { size: Size, items: &[(&'static str, &'static [&'static str])], ) -> Questionnaire { - let mut questionnaire = Questionnaire::new(state) - .with_size(size) - .child(QuestionnaireProgress::new(state).with_size(size)); + let mut questionnaire = Questionnaire::new(state).child(QuestionnaireProgress::new(state)); for (name, choices) in items { questionnaire = - questionnaire.child(Self::item_view(state, name, choices.iter().copied(), size)); + questionnaire.child(Self::item_view(state, name, choices.iter().copied())); } questionnaire.child( QuestionnaireActions::new(state) - .with_size(size) .child(QuestionnairePrevious::new(state).with_size(size)) .child(QuestionnaireSkip::new(state).with_size(size)) .child(QuestionnaireNext::new(state).with_size(size)) @@ -691,29 +686,24 @@ impl Render for QuestionnaireStory { .item_state("environment") .is_some_and(|item| !item.is_disabled()); let custom_control = Questionnaire::new(&control_state) - .with_size(self.size) - .child(QuestionnaireProgress::new(&control_state).with_size(self.size)) + .child(QuestionnaireProgress::new(&control_state)) .child(Self::item_view( &control_state, "runtime", ["local", "cloud"], - self.size, )) .child(Self::item_view( &control_state, "delivery", ["guided", "automatic"], - self.size, )) .child(Self::item_view( &control_state, "environment", ["staging", "production"], - self.size, )) .child( QuestionnaireActions::new(&control_state) - .with_size(self.size) .when(control_previous_visible, |actions| { actions.child( Button::new("questionnaire-custom-previous") @@ -798,20 +788,13 @@ impl Render for QuestionnaireStory { let custom_choice_state = self.custom_choice_state.clone(); let custom_choice = Questionnaire::new(&custom_choice_state) - .with_size(self.size) .child( QuestionnaireItem::new(&custom_choice_state, "custom") - .with_size(self.size) - .child( - QuestionnaireTitle::new(&custom_choice_state, "custom") - .with_size(self.size), - ) + .child(QuestionnaireTitle::new(&custom_choice_state, "custom")) .child( QuestionnaireChoices::new(&custom_choice_state, "custom") - .with_size(self.size) .child( QuestionnaireChoice::new(&custom_choice_state, "custom", "compact") - .with_size(self.size) .content_style(StyleRefinement::default().gap_2()) .render_indicator(|choice, _, cx| { div() @@ -839,13 +822,9 @@ impl Render for QuestionnaireStory { v_flex() .gap_1() .child(div().font_medium().child("Compact")) - .child( - QuestionnaireChoiceDescription::new() - .with_size(self.size) - .child( - "A custom indicator and composed description.", - ), - ), + .child(QuestionnaireChoiceDescription::new().child( + "A custom indicator and composed description.", + )), ), ) .child( @@ -854,19 +833,14 @@ impl Render for QuestionnaireStory { "custom", "comfortable", ) - .with_size(self.size) .indicator_style(StyleRefinement::default().opacity(0.65)) .shortcut_style(StyleRefinement::default().opacity(0.65)), ), ) - .child( - QuestionnaireError::new(&custom_choice_state, "custom") - .with_size(self.size), - ), + .child(QuestionnaireError::new(&custom_choice_state, "custom")), ) .child( QuestionnaireActions::new(&custom_choice_state) - .with_size(self.size) .child(QuestionnaireSubmit::new(&custom_choice_state).with_size(self.size)), ); @@ -890,24 +864,18 @@ impl Render for QuestionnaireStory { ) .child( Questionnaire::new(&dialog_content_state) - .with_size(Size::Small) .child( - QuestionnaireProgress::new(&dialog_content_state) - .with_size(Size::Small), + QuestionnaireProgress::new(&dialog_content_state), ) .child(Self::item_view( &dialog_content_state, "dialog", - ["first", "second"], - Size::Small, - )) + ["first", "second"],)) .child( Self::item_view( &dialog_content_state, "dialog_verification", - ["targeted", "full"], - Size::Small, - ), + ["targeted", "full"],), ) .child( DialogFooter::new() @@ -921,7 +889,6 @@ impl Render for QuestionnaireStory { ) .child( QuestionnaireActions::new(&dialog_content_state) - .with_size(Size::Small) .child( QuestionnairePrevious::new(&dialog_content_state) .with_size(Size::Small), @@ -1259,8 +1226,8 @@ impl Render for QuestionnaireStory { .child(dialog), ) .child( - section("All sizes") - .description("Medium matches the ReUI skin; the same composition scales through all four Size values.") + section("Navigation button sizes") + .description("The questionnaire skin has one fixed scale; Sizable only reaches the navigation buttons.") .w(px(600.)) .child( h_flex() diff --git a/website/component/questionnaire.md b/website/component/questionnaire.md index c742eca4f9..1c91cdf780 100644 --- a/website/component/questionnaire.md +++ b/website/component/questionnaire.md @@ -294,7 +294,7 @@ use `QuestionnaireItemDefinition::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 `QuestionnaireStateError`. +for unknown items or choices return `QuestionnaireSchemaError`. ## Navigation and status @@ -522,8 +522,7 @@ Its state can also be used to compose a custom indicator from the existing `Progress` or `Stepper` components. ```rust -QuestionnaireProgress::new(&state) - .with_size(Size::Small); +QuestionnaireProgress::new(&state); let progress = state.read(cx).progress(); let percent = if progress.total() == 0 { @@ -540,50 +539,27 @@ Stepper::new("questionnaire-steps") ## Sizes and theming -Questionnaire parts implement the same `Sizable` contract as the rest of the -library. `Medium` is the default and follows the ReUI `base-nova` questionnaire -appearance. The supported named sizes are `XSmall`, `Small`, `Medium`, and -`Large`; `Size::Size(value)` is available for a custom scale. +The questionnaire skin has one scale. 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 +density and shape by changing the theme rather than by passing a size to every +part. Answer text matches the Checkbox and Radio family's medium label, which +makes a choice card slightly taller than the upstream skin's; the card keeps a +minimum height so a short answer still reads as a full row. -Compound parts do not inherit the root's size automatically. Pass the same -size to the root, progress, item, title, description, choices, choice, choice -description, input, error, actions, and navigation parts that should share one -scale. +`Sizable` reaches only the navigation buttons, which pass the size through to +`Button`: ```rust use gpui_kit::component::{Sizable as _, Size}; -let size = Size::Small; -Questionnaire::new(&state) - .with_size(size) - .child(QuestionnaireProgress::new(&state).with_size(size)) - .child( - QuestionnaireItem::new(&state, "direction") - .with_size(size) - .child(QuestionnaireTitle::new(&state, "direction").with_size(size)) - .child(QuestionnaireDescription::new(&state, "direction").with_size(size)) - .child( - QuestionnaireChoices::new(&state, "direction") - .with_size(size) - .child(QuestionnaireChoice::new(&state, "direction", "delegation").with_size(size)) - .child(QuestionnaireInput::new(&state, "direction").with_size(size)), - ) - .child(QuestionnaireError::new(&state, "direction").with_size(size)), - ) - .child( - QuestionnaireActions::new(&state) - .with_size(size) - .child(QuestionnairePrevious::new(&state).with_size(size)) - .child(QuestionnaireSkip::new(&state).with_size(size)) - .child(QuestionnaireNext::new(&state).with_size(size)) - .child(QuestionnaireSubmit::new(&state).with_size(size)), - ); +QuestionnaireActions::new(&state) + .child(QuestionnairePrevious::new(&state).with_size(Size::Small)) + .child(QuestionnaireNext::new(&state).with_size(Size::Small)); ``` -The default skin derives spacing, typography, radius, border, input, primary, -muted, destructive, and focus-ring values from the active theme's semantic -tokens. Use `Styled` methods or `StyleRefinement` for local adjustments; local -style refinement is applied after the component defaults. +Use `Styled` methods or `StyleRefinement` for local adjustments; local style +refinement is applied after the component defaults. ## Card and Dialog composition @@ -757,12 +733,13 @@ error alert, semantic group state, focus behavior, and destructive styling. ## Current scope -This GPUI port covers state, navigation, validation, focus, accessibility, -compound rendering, and local submission events. The following web-specific or -future behaviors are not currently provided: SSR/hydration collection -diagnostics, `FormData`, native HTML validation, DOM mutation registration, -async validation, runtime insertion/reordering of definitions, and built-in -animation or persistence/transport. +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 @@ -804,7 +781,7 @@ animation or persistence/transport. - [QuestionnaireSubmission] - [QuestionnaireSubmissionItem] - [QuestionnaireEvent] -- [QuestionnaireStateError] +- [QuestionnaireSchemaError] - [Sizable] [Questionnaire]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.Questionnaire.html @@ -840,5 +817,5 @@ animation or persistence/transport. [QuestionnaireSubmission]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireSubmission.html [QuestionnaireSubmissionItem]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireSubmissionItem.html [QuestionnaireEvent]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/enum.QuestionnaireEvent.html -[QuestionnaireStateError]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/enum.QuestionnaireStateError.html +[QuestionnaireSchemaError]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/enum.QuestionnaireSchemaError.html [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html diff --git a/website/zh-CN/component/questionnaire.md b/website/zh-CN/component/questionnaire.md index 076c24c44a..192666b1c4 100644 --- a/website/zh-CN/component/questionnaire.md +++ b/website/zh-CN/component/questionnaire.md @@ -282,7 +282,7 @@ let disabled_input_definition = QuestionnaireInputDefinition::new( `QuestionnaireState::new` 会拒绝重复 item name、同一 item 中重复的 choice value, 以及单选 item 的多个默认值。针对未知 item 或 choice 的 setter 会返回 -`QuestionnaireStateError`。 +`QuestionnaireSchemaError`。 ## 导航与状态 @@ -493,8 +493,7 @@ Enter 确认已填写的答案。Command/Ctrl+Enter 确认当前 item。空答 state,使用现有 `Progress` 或 `Stepper` 组合自定义指示器。 ```rust -QuestionnaireProgress::new(&state) - .with_size(Size::Small); +QuestionnaireProgress::new(&state); let progress = state.read(cx).progress(); let percent = if progress.total() == 0 { @@ -511,47 +510,23 @@ Stepper::new("questionnaire-steps") ## 尺寸与主题 -Questionnaire 部件实现与其他组件相同的 `Sizable` 契约。默认尺寸为 `Medium`,其 -外观对齐 ReUI 的 `base-nova` questionnaire。支持的命名尺寸为 `XSmall`、`Small`、 -`Medium` 和 `Large`;也可以使用 `Size::Size(value)` 自定义比例。 +Questionnaire 皮肤只有一套比例。spacing、typography、radius、border、input、 +primary、muted、destructive 和 focus ring 全部取自当前主题的 semantic tokens, +应用通过调整主题来改变问卷的密度与形状,而不是给每个部件传 size。答案文字与 +Checkbox、Radio 家族的 medium label 一致,因此选项卡片会比上游皮肤略高;卡片仍 +保留最小高度,使内容很短的选项也是完整的一行。 -组合部件不会自动继承 root 的 size。需要保持同一比例时,应将相同 size 传给 root、 -progress、item、title、description、choices、choice、choice description、input、 -error、actions 和 navigation 部件。 +`Sizable` 只作用于导航按钮,它们会把 size 透传给 `Button`: ```rust use gpui_kit::component::{Sizable as _, Size}; -let size = Size::Small; -Questionnaire::new(&state) - .with_size(size) - .child(QuestionnaireProgress::new(&state).with_size(size)) - .child( - QuestionnaireItem::new(&state, "direction") - .with_size(size) - .child(QuestionnaireTitle::new(&state, "direction").with_size(size)) - .child(QuestionnaireDescription::new(&state, "direction").with_size(size)) - .child( - QuestionnaireChoices::new(&state, "direction") - .with_size(size) - .child(QuestionnaireChoice::new(&state, "direction", "delegation").with_size(size)) - .child(QuestionnaireInput::new(&state, "direction").with_size(size)), - ) - .child(QuestionnaireError::new(&state, "direction").with_size(size)), - ) - .child( - QuestionnaireActions::new(&state) - .with_size(size) - .child(QuestionnairePrevious::new(&state).with_size(size)) - .child(QuestionnaireSkip::new(&state).with_size(size)) - .child(QuestionnaireNext::new(&state).with_size(size)) - .child(QuestionnaireSubmit::new(&state).with_size(size)), - ); +QuestionnaireActions::new(&state) + .child(QuestionnairePrevious::new(&state).with_size(Size::Small)) + .child(QuestionnaireNext::new(&state).with_size(Size::Small)); ``` -默认皮肤从当前主题的 semantic tokens 派生 spacing、typography、radius、border、 -input、primary、muted、destructive 和 focus-ring。局部调整可以使用 `Styled` 方法 -或 `StyleRefinement`;局部 style refinement 会在组件默认值之后应用。 +局部微调使用 `Styled` 方法或 `StyleRefinement`,实例样式在组件默认样式之后应用。 ## Card 和 Dialog 组合 @@ -716,10 +691,10 @@ builder;Questionnaire 仍通过错误 alert、语义分组状态、焦点行 ## 当前范围 -此 GPUI port 覆盖状态、导航、校验、焦点、可访问性、组合渲染和本地提交事件。以下 -Web 专属或未来行为暂不提供:SSR/hydration collection diagnostics、`FormData`、 -原生 HTML 校验、DOM mutation registration、异步校验、definition 的运行时插入/重排, -以及内置动画或持久化/传输。 +问卷一次只呈现一道题:非当前题的部件不会渲染任何内容,因此它不适合做「一页多题」 +的表单。schema 在构造时固定 —— 运行时不能插入或重排题目与选项,但可以禁用其中 +任意一项 —— 校验器同步执行。持久化、传输和提交后的副作用属于外层页面,由它订阅 +`QuestionnaireEvent` 处理。 ## API 参考 @@ -761,7 +736,7 @@ Web 专属或未来行为暂不提供:SSR/hydration collection diagnostics、` - [QuestionnaireSubmission] - [QuestionnaireSubmissionItem] - [QuestionnaireEvent] -- [QuestionnaireStateError] +- [QuestionnaireSchemaError] - [Sizable] [Questionnaire]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.Questionnaire.html @@ -797,5 +772,5 @@ Web 专属或未来行为暂不提供:SSR/hydration collection diagnostics、` [QuestionnaireSubmission]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireSubmission.html [QuestionnaireSubmissionItem]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireSubmissionItem.html [QuestionnaireEvent]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/enum.QuestionnaireEvent.html -[QuestionnaireStateError]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/enum.QuestionnaireStateError.html +[QuestionnaireSchemaError]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/enum.QuestionnaireSchemaError.html [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html From e423921291eff5f5e6c8693c1b7b4dbeb7ff8890 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Sat, 19 Sep 2026 12:36:54 +0800 Subject: [PATCH 08/17] questionnaire: Trim the Story and documentation to the examples that earn their place Co-Authored-By: Claude Opus 5 (1M context) --- .../story/src/stories/questionnaire_story.rs | 204 +-------- website/component/questionnaire.md | 412 ++++++------------ website/zh-CN/component/questionnaire.md | 388 ++++++----------- 3 files changed, 264 insertions(+), 740 deletions(-) diff --git a/crates/story/src/stories/questionnaire_story.rs b/crates/story/src/stories/questionnaire_story.rs index ce6bb6f586..f026225495 100644 --- a/crates/story/src/stories/questionnaire_story.rs +++ b/crates/story/src/stories/questionnaire_story.rs @@ -31,14 +31,11 @@ pub struct QuestionnaireStory { validation_state: Entity, external_state: Entity, control_state: Entity, - resume_state: Entity, letters_state: Entity, numbers_state: Entity, card_state: Entity, custom_choice_state: Entity, - edge_state: Entity, dialog_state: Entity, - size_states: Vec<(Size, Entity)>, event_log: Vec, keyboard_event_log: Vec, _subscriptions: Vec, @@ -373,36 +370,6 @@ impl QuestionnaireStory { ]; let control_state = Self::state(control_items, cx); - let resume_scope_input = Self::input(window, cx, "Saved alternative workspace…", None); - let resume_tools_input = Self::input(window, cx, "Another saved tool…", None); - let resume_state = Self::state( - vec![ - QuestionnaireItemDefinition::new("resume_scope", "Which workspace should resume?") - .with_choices([ - QuestionnaireChoiceDefinition::new("personal", "Personal") - .with_default_selected(true), - QuestionnaireChoiceDefinition::new("team", "Team"), - ]) - .with_input(QuestionnaireInputDefinition::new( - resume_scope_input, - "Alternative workspace", - )), - QuestionnaireItemDefinition::new("resume_tools", "Which tools were restored?") - .with_multiple(true) - .with_choices([ - QuestionnaireChoiceDefinition::new("editor", "Editor") - .with_default_selected(true), - QuestionnaireChoiceDefinition::new("terminal", "Terminal"), - QuestionnaireChoiceDefinition::new("browser", "Browser"), - ]) - .with_input(QuestionnaireInputDefinition::new( - resume_tools_input, - "Another restored tool", - )), - ], - cx, - ); - let letters_state = Self::shortcut_state( Self::keyboard_items(window, cx), QuestionnaireShortcutMode::Letters, @@ -442,18 +409,6 @@ impl QuestionnaireStory { cx, ); - let edge_state = Self::state( - vec![ - QuestionnaireItemDefinition::new("edge", "No description and disabled choice") - .with_required(true) - .with_choices([ - QuestionnaireChoiceDefinition::new("first", "Available choice"), - QuestionnaireChoiceDefinition::new("second", "Disabled choice") - .with_disabled(true), - ]), - ], - cx, - ); let dialog_state = Self::state( vec![ QuestionnaireItemDefinition::new("dialog", "Which workspace should we open?") @@ -475,19 +430,6 @@ impl QuestionnaireStory { cx, ); - let mut size_states = Vec::new(); - for size in [Size::XSmall, Size::Small, Size::Medium, Size::Large] { - let state = Self::state( - vec![ - QuestionnaireItemDefinition::new("size", "Choose a size").with_choices([ - QuestionnaireChoiceDefinition::new("first", "Example choice"), - ]), - ], - cx, - ); - size_states.push((size, state)); - } - let subscriptions = vec![ cx.subscribe(&main_state, |this, _, event: &QuestionnaireEvent, cx| { let message = match event { @@ -532,14 +474,11 @@ impl QuestionnaireStory { validation_state, external_state, control_state, - resume_state, letters_state, numbers_state, card_state, custom_choice_state, - edge_state, dialog_state, - size_states, event_log: Vec::new(), keyboard_event_log: Vec::new(), _subscriptions: subscriptions, @@ -624,12 +563,6 @@ impl Render for QuestionnaireStory { self.size, &[("shortcut", &["first", "second", "third"])], ); - let edge = Self::questionnaire_view( - &self.edge_state, - self.size, - &[("edge", &["first", "second"])], - ); - let letters_snapshot = self.letters_state.read(cx); let keyboard_focus = if letters_snapshot.is_current_input_focused(window) { "freeform input".to_string() @@ -645,29 +578,6 @@ impl Render for QuestionnaireStory { .unwrap_or_default(); let keyboard_event_log = self.keyboard_event_log.clone(); - let resume = Self::questionnaire_view( - &self.resume_state, - self.size, - &[ - ("resume_scope", &["personal", "team"]), - ("resume_tools", &["editor", "terminal", "browser"]), - ], - ); - let resume_snapshot = self.resume_state.read(cx); - let resume_summary = format!( - "Current: {} · scope={:?} · tools={:?}", - resume_snapshot - .current_item() - .map(SharedString::as_ref) - .unwrap_or("none"), - resume_snapshot.answer("resume_scope").unwrap_or_default(), - resume_snapshot.answer("resume_tools").unwrap_or_default(), - ); - let resume_scope_draft = resume_snapshot - .input_state("resume_scope") - .map(|input| input.read(cx).value()) - .unwrap_or_default(); - let external_error = self .external_state .read(cx) @@ -908,8 +818,6 @@ impl Render for QuestionnaireStory { ) }); let control_state_for_jump = self.control_state.clone(); - let resume_state_for_restore = self.resume_state.clone(); - let resume_state_for_reset = self.resume_state.clone(); let external_state_for_error = self.external_state.clone(); let external_state_for_clear = self.external_state.clone(); let external_state_for_fix = self.external_state.clone(); @@ -1037,80 +945,6 @@ impl Render for QuestionnaireStory { div().text_xs().text_color(cx.theme().muted_foreground).child(event) })), ) - .child( - section("Resume and reset") - .description("Restore current item, single and multiple answers, freeform values, and an unselected single-choice draft.") - .w(px(600.)) - .child( - h_flex() - .gap_2() - .child( - Button::new("questionnaire-resume") - .primary() - .label("Restore saved response") - .on_click(move |_, window, cx| { - resume_state_for_restore.update(cx, |state, cx| { - state - .set_input_value( - "resume_scope", - "Saved private workspace", - window, - cx, - ) - .expect("resume Story input exists"); - state - .set_answer( - "resume_scope", - QuestionnaireAnswer::new() - .with_choices(["team"]), - window, - cx, - ) - .expect("resume Story answer is valid"); - state - .set_answer( - "resume_tools", - QuestionnaireAnswer::new() - .with_choices(["editor", "terminal"]) - .with_freeform("CLI"), - window, - cx, - ) - .expect("resume Story answer is valid"); - state - .set_current_item("resume_tools", window, cx) - .expect("resume Story item exists"); - }); - }), - ) - .child( - Button::new("questionnaire-resume-reset") - .outline() - .label("Reset to defaults") - .on_click(move |_, window, cx| { - resume_state_for_reset.update(cx, |state, cx| { - state.reset(window, cx); - }); - }), - ), - ) - .child(resume) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(resume_summary), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(format!( - "Single-choice input draft: {:?} (kept when Team is selected)", - resume_scope_draft - )), - ), - ) .child( section("Shortcuts and keyboard") .description("The fixture exposes focus, answers, drafts, and events while testing the full keyboard contract.") @@ -1207,10 +1041,11 @@ impl Render for QuestionnaireStory { .child(custom_control) ) .child( - section("Card-like composition") - .description("GroupBox owns the card surface while a complete Questionnaire keeps progress, items, and actions together.") + section("Card and Dialog composition") + .description("GroupBox or Dialog owns the surface and its close behavior; the questionnaire keeps progress, items, and actions together.") .w(px(600.)) - .child(card), + .child(card) + .child(dialog), ) .child( section("Custom choice composition") @@ -1218,36 +1053,5 @@ impl Render for QuestionnaireStory { .w(px(600.)) .child(custom_choice), ) - .child( - section("No description, disabled, invalid, and Dialog") - .description("Dialog Cancel always closes; the host closes after Questionnaire emits a successful Submit.") - .w(px(600.)) - .child(edge) - .child(dialog), - ) - .child( - section("Navigation button sizes") - .description("The questionnaire skin has one fixed scale; Sizable only reaches the navigation buttons.") - .w(px(600.)) - .child( - h_flex() - .flex_wrap() - .gap_3() - .children(self.size_states.iter().map(|(size, state)| { - let label = match size { - Size::XSmall => "XSmall", - Size::Small => "Small", - Size::Medium => "Medium", - Size::Large => "Large", - Size::Size(_) => "Custom", - }; - v_flex() - .w(px(135.)) - .gap_2() - .child(div().font_medium().child(label)) - .child(Self::questionnaire_view(state, *size, &[("size", &["first"])])) - })), - ), - ) } } diff --git a/website/component/questionnaire.md b/website/component/questionnaire.md index 1c91cdf780..0a58085304 100644 --- a/website/component/questionnaire.md +++ b/website/component/questionnaire.md @@ -178,42 +178,41 @@ 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. -## Single selection +## Choices -An item uses single selection by default. Activating a choice answers the item -and makes `Next` available. A single-choice item may also provide a freeform -input. The fixed choice and freeform answer are mutually exclusive, while the -input draft remains available when the user changes their mind. +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. -```rust -let plan_input = cx.new(|cx| InputState::new(window, cx)); -let item = QuestionnaireItemDefinition::new("plan", "Which plan fits your team?") - .with_choices([ - QuestionnaireChoiceDefinition::new("plus", "Plus"), - QuestionnaireChoiceDefinition::new("pro", "Pro"), - ]) - .with_input(QuestionnaireInputDefinition::new(plan_input, "Another plan")); -``` - -## Multiple selection - -Set `multiple` on an item when more than one fixed answer is valid. A non-empty -freeform input can be included with the selected fixed choices. +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. ```rust let tools_input = cx.new(|cx| InputState::new(window, cx)); -let item = 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_input(QuestionnaireInputDefinition::new(tools_input, "Something else")); +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), +]; ``` -The answer reader preserves schema order. If a selected choice is disabled -later, it is excluded from the effective answer. +`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 @@ -221,108 +220,11 @@ 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. -```rust -let feedback_input = cx.new(|cx| { - InputState::new(window, cx).placeholder("Tell us what would help…") -}); -let item = QuestionnaireItemDefinition::new("feedback", "What should we improve?") - .with_input(QuestionnaireInputDefinition::new( - feedback_input, - "Your suggestion", - )); -``` - 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. -## Explicit skip - -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. - -```rust -let optional = QuestionnaireItemDefinition::new("tone", "What tone should we use?") - .with_required(false) - .with_choices([ - QuestionnaireChoiceDefinition::new("direct", "Direct"), - QuestionnaireChoiceDefinition::new("warm", "Warm"), - ]); -``` - -## Defaults and disabled controls - -Use definition builders for the initial snapshot. A choice can start selected, -an item or choice can start disabled, and an input can start disabled. A -single-choice item may contain at most one default selected choice. - -```rust -let saved_input = cx.new(|cx| InputState::new(window, cx).default_value("Saved draft")); -let item = QuestionnaireItemDefinition::new("workspace", "Which workspaces?") - .with_multiple(true) - .with_choices([ - QuestionnaireChoiceDefinition::new("personal", "Personal") - .with_default_selected(true), - QuestionnaireChoiceDefinition::new("team", "Team") - .with_disabled(true), - ]) - .with_input( - QuestionnaireInputDefinition::new(saved_input, "Another workspace") - .with_disabled(false), - ); -let disabled_item = QuestionnaireItemDefinition::new( - "advanced", - "Advanced preferences", -) - .with_disabled(true); -let disabled_input = cx.new(|cx| InputState::new(window, cx)); -let disabled_input_definition = QuestionnaireInputDefinition::new( - disabled_input, - "Disabled answer", -) - .with_disabled(true); -``` - -`with_default_selected` belongs to `QuestionnaireChoiceDefinition`; an item -uses `with_disabled`, and an input uses -`QuestionnaireInputDefinition::with_disabled`. For an initially disabled item, -use `QuestionnaireItemDefinition::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`. - -## Navigation and status - -`QuestionnaireState` exposes the current item, ordered item states, and -navigation state for custom action layouts. - -```rust -let current = state.read(cx).current_item(); -let current_ix = state.read(cx).current_ix(); -let progress = state.read(cx).progress(); -let status = state - .read(cx) - .item_state("direction") - .map(|item| item.status()); -let navigation = state.read(cx).navigation_state(); -let can_confirm = navigation.is_confirmable(); -let show_previous = navigation.is_previous_visible(); -let show_next = navigation.is_next_visible(); -let show_skip = navigation.is_skip_visible(); -let show_submit = navigation.is_submit_visible(); -``` - -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`. - ## Validation Required status validation is built in. Add a synchronous validator to an item @@ -376,7 +278,75 @@ owner-managed external errors. Questionnaire semantic validation and synchronous validators are supported; native HTML constraint validation is not part of this GPUI component. -## Controlled state +## Navigation and submission + +`QuestionnaireState` exposes the current item, ordered item states, and +navigation state for custom action layouts. + +```rust +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. + +```rust +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 @@ -409,40 +379,7 @@ Use `activate_choice`, `confirm_current`, `go_previous`, `go_next`, `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. -## Resume - -To make `reset` return to a saved draft, establish the saved draft as the -initial snapshot before constructing `QuestionnaireState`. Use -`InputState::default_value`, `with_default_selected`, and -`with_current_item` for the input, choice, and current-item baselines. - -```rust -let saved_input = cx.new(|cx| { - InputState::new(window, cx).default_value("Saved description") -}); -let saved_items = vec![ - QuestionnaireItemDefinition::new("plan", "Which plan?") - .with_choices([ - QuestionnaireChoiceDefinition::new("plus", "Plus") - .with_default_selected(true), - QuestionnaireChoiceDefinition::new("pro", "Pro"), - ]), - QuestionnaireItemDefinition::new("detail", "How much detail?") - .with_input(QuestionnaireInputDefinition::new(saved_input, "More detail")), -]; -let state = cx.new(|cx| { - QuestionnaireState::new(saved_items, cx) - .expect("valid saved questionnaire") - .with_current_item("detail") - .expect("known enabled questionnaire item") -}); -``` - -If the saved values arrive after construction, apply -`set_answer`, `set_input_value`, and `set_current_item` instead. Those setters -change the current state; they do not replace the reset baseline. - -## Reset +### Reset Reset restores the initial choices and input drafts, clears intentional skips, validation attempts, and completion, and returns to the initial current item. @@ -457,7 +394,13 @@ state.update(cx, |state, cx| { External errors remain owner-managed across reset. If a reset should also remove a server error, clear it explicitly with `clear_external_error`. -## Conditional items +`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 @@ -515,7 +458,7 @@ 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 and custom rendering +## Progress `QuestionnaireProgress` follows the default presentation: “Question 2 of 4”. Its state can also be used to compose a custom indicator from the existing @@ -563,68 +506,28 @@ refinement is applied after the component defaults. ## Card and Dialog composition -The questionnaire owns the complete question flow. A card or dialog owns its -container layout and close/cancel behavior. Both examples below include every -item in the collection, so moving to the second question remains visible. +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. ```rust -use gpui_kit::{Entity, IntoElement, ParentElement as _}; -use gpui_kit::component::{ - button::{Button, ButtonVariants as _}, - dialog::{Dialog, DialogClose, DialogFooter, DialogHeader, DialogTitle}, - group_box::{GroupBox, GroupBoxVariants as _}, -}; - -fn questionnaire_content( - state: &Entity, - actions: impl IntoElement, -) -> Questionnaire { - 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(actions) -} +use gpui_kit::component::group_box::{GroupBox, GroupBoxVariants as _}; GroupBox::new() .outline() .title("Set up your workspace") - .child(questionnaire_content( - &state, - QuestionnaireActions::new(&state) - .child(QuestionnairePrevious::new(&state)) - .child(QuestionnaireSkip::new(&state)) - .child(QuestionnaireNext::new(&state)) - .child(QuestionnaireSubmit::new(&state)), - )); + .child(questionnaire); ``` -For a dialog, put the same complete composition inside the dialog content and -let the host handle dismissal and cancellation. +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. ```rust +use gpui_kit::component::dialog::{ + Dialog, DialogClose, DialogFooter, DialogHeader, DialogTitle, +}; use gpui_kit::component::{WindowExt as _, questionnaire::QuestionnaireEvent}; let dialog_state = state.clone(); @@ -640,75 +543,32 @@ cx.subscribe_in( .detach(); Dialog::new(cx) - .trigger( - Button::new("open-questionnaire") - .outline() - .label("Open questionnaire"), - ) + .trigger(Button::new("open-questionnaire").outline().label("Open questionnaire")) .content(move |content, _, _| { content .child(DialogHeader::new().child(DialogTitle::new().child("Workspace setup"))) - .child(questionnaire_content( - &dialog_state, - DialogFooter::new() - .child( - DialogClose::new().child( - Button::new("cancel-questionnaire") - .outline() - .label("Cancel"), - ), - ) + .child( + Questionnaire::new(&dialog_state) + // …progress and every item, as in Usage above .child( - QuestionnaireActions::new(&dialog_state) - .child(QuestionnairePrevious::new(&dialog_state)) - .child(QuestionnaireNext::new(&dialog_state)) - .child(QuestionnaireSubmit::new(&dialog_state)), + 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)), + ), ), - )) + ) }); ``` -The host subscription closes the Dialog only after a successful `Submit`. -The same event is the place to hand a validated `QuestionnaireSubmission` to -application transport. Persistence and network success remain outside -Questionnaire. - -## 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. - -```rust -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. +`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 diff --git a/website/zh-CN/component/questionnaire.md b/website/zh-CN/component/questionnaire.md index 192666b1c4..7543298a48 100644 --- a/website/zh-CN/component/questionnaire.md +++ b/website/zh-CN/component/questionnaire.md @@ -173,143 +173,48 @@ let _rendered_choice = QuestionnaireChoice::new(&state, "direction", "delegation 状态快照提供 `is_selected`、`is_disabled`、`is_invalid` 和 `shortcut`,可用于自定义 渲染。 -## 单选 +## 选项 -item 默认使用单选模式。激活某个选项后 item 即有答案,`Next` 可以继续。单选 -item 也可以提供自由输入;固定选项和自由答案互斥,但用户切换选择时会保留输入 -草稿。 +item 默认单选:激活某个选项后即有答案,`Next` 可以继续;`with_multiple` 则保留 +所有已选项。答案 reader 按 schema 顺序返回结果,后续被禁用的 choice 会从 +effective answer 中排除。 -```rust -let plan_input = cx.new(|cx| InputState::new(window, cx)); -let item = QuestionnaireItemDefinition::new("plan", "Which plan fits your team?") - .with_choices([ - QuestionnaireChoiceDefinition::new("plus", "Plus"), - QuestionnaireChoiceDefinition::new("pro", "Pro"), - ]) - .with_input(QuestionnaireInputDefinition::new(plan_input, "Another plan")); -``` - -## 多选 - -当一个 item 可以接受多个固定答案时设置 `multiple`。非空自由输入可以和已选 -固定选项一起提交。 +definition builder 承载初始快照:choice 可以初始选中,item、choice 和 input 都 +可以初始禁用,单选 item 最多只能有一个默认选中项。 ```rust let tools_input = cx.new(|cx| InputState::new(window, cx)); -let item = 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_input(QuestionnaireInputDefinition::new(tools_input, "Something else")); +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), +]; ``` -答案 reader 按 schema 顺序保留结果。如果已选 choice 后续被禁用,它会从 effective -answer 中排除。 +`QuestionnaireState::new` 会拒绝重复的 item name、同一 item 内重复的 choice +value,以及单选 item 上的多个默认值。对未知 item 或 choice 调用 setter 返回 +`QuestionnaireSchemaError`。 ## 自由输入 加入 `QuestionnaireInputDefinition`,允许用户输入固定选项之外的答案。请为输入 提供可访问名称;placeholder 不能替代 label。 -```rust -let feedback_input = cx.new(|cx| { - InputState::new(window, cx).placeholder("Tell us what would help…") -}); -let item = QuestionnaireItemDefinition::new("feedback", "What should we improve?") - .with_input(QuestionnaireInputDefinition::new( - feedback_input, - "Your suggestion", - )); -``` - 只有空白的输入视为未回答。选择固定选项时会保留输入草稿,但只有自由输入成为 当前答案时才会提交它。多选 item 可以同时提交固定选项和非空自由输入。 -## 显式跳过 - -可选 item 可以显示 `QuestionnaireSkip`。跳过是一个明确且有效的状态,会清除该 -item 的答案并允许 `Next` 继续。必填 item 不允许跳过。重新进入 item 并选择答案 -后,skipped 状态会被清除。跳过最后一个 enabled item 后,会在记录跳过状态后请求 -提交。 - -```rust -let optional = QuestionnaireItemDefinition::new("tone", "What tone should we use?") - .with_required(false) - .with_choices([ - QuestionnaireChoiceDefinition::new("direct", "Direct"), - QuestionnaireChoiceDefinition::new("warm", "Warm"), - ]); -``` - -## 默认值与禁用控件 - -使用 definition builder 设置初始快照。choice 可以初始选中,item 或 choice 可以 -初始禁用,input 也可以初始禁用。单选 item 最多只能有一个默认选中的 choice。 - -```rust -let saved_input = cx.new(|cx| InputState::new(window, cx).default_value("Saved draft")); -let item = QuestionnaireItemDefinition::new("workspace", "Which workspaces?") - .with_multiple(true) - .with_choices([ - QuestionnaireChoiceDefinition::new("personal", "Personal") - .with_default_selected(true), - QuestionnaireChoiceDefinition::new("team", "Team") - .with_disabled(true), - ]) - .with_input( - QuestionnaireInputDefinition::new(saved_input, "Another workspace") - .with_disabled(false), - ); -let disabled_item = QuestionnaireItemDefinition::new( - "advanced", - "Advanced preferences", -) - .with_disabled(true); -let disabled_input = cx.new(|cx| InputState::new(window, cx)); -let disabled_input_definition = QuestionnaireInputDefinition::new( - disabled_input, - "Disabled answer", -) - .with_disabled(true); -``` - -`with_default_selected` 属于 `QuestionnaireChoiceDefinition`;item 使用 -`with_disabled`,input 使用 `QuestionnaireInputDefinition::with_disabled`。如果要 -让 item 初始禁用,使用 `QuestionnaireItemDefinition::with_disabled(true)`。 - -`QuestionnaireState::new` 会拒绝重复 item name、同一 item 中重复的 choice value, -以及单选 item 的多个默认值。针对未知 item 或 choice 的 setter 会返回 -`QuestionnaireSchemaError`。 - -## 导航与状态 - -`QuestionnaireState` 暴露当前 item、有序 item 状态和导航状态,可用于自定义操作 -布局。 - -```rust -let current = state.read(cx).current_item(); -let current_ix = state.read(cx).current_ix(); -let progress = state.read(cx).progress(); -let status = state - .read(cx) - .item_state("direction") - .map(|item| item.status()); -let navigation = state.read(cx).navigation_state(); -let can_confirm = navigation.is_confirmable(); -let show_previous = navigation.is_previous_visible(); -let show_next = navigation.is_next_visible(); -let show_skip = navigation.is_skip_visible(); -let show_submit = navigation.is_submit_visible(); -``` - -默认操作布局在开头显示 `Previous`,在 item 之间显示 `Next`,当前 item 可选时 -显示 `Skip`,最后显示 `Submit`。隐藏的操作不会渲染,也不会进入键盘导航。 -disabled item 会从导航和进度总数中排除。item 有三种状态:`Unanswered`、 -`Answered` 和 `Skipped`。 - ## 校验 必填状态校验已经内置。可以为 item 添加同步 validator,实现领域规则。validator @@ -359,7 +264,70 @@ state.update(cx, |state, cx| { Questionnaire 语义校验和同步 validator;原生 HTML constraint validation 不属于此 GPUI 组件。 -## 受控状态 +## 导航与提交 + +`QuestionnaireState` 暴露当前 item、有序 item 状态和导航状态,可用于自定义操作 +布局。 + +```rust +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`。 + +```rust +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 和焦点,但不会发出用户交互事件。 @@ -390,38 +358,7 @@ state.update(cx, |state, cx| { 可以使用 `set_item_disabled` 和 `set_choice_disabled`;禁用当前 item 后,焦点会 移动到下一个 enabled item;没有下一个时移动到前一个。 -## 恢复 - -如果希望 `reset` 回到保存的草稿,应在构造 `QuestionnaireState` 之前建立保存的 -草稿作为初始快照。使用 `InputState::default_value`、`with_default_selected` 和 -`with_current_item`,分别设置 input、choice 和当前 item 的初始基线。 - -```rust -let saved_input = cx.new(|cx| { - InputState::new(window, cx).default_value("Saved description") -}); -let saved_items = vec![ - QuestionnaireItemDefinition::new("plan", "Which plan?") - .with_choices([ - QuestionnaireChoiceDefinition::new("plus", "Plus") - .with_default_selected(true), - QuestionnaireChoiceDefinition::new("pro", "Pro"), - ]), - QuestionnaireItemDefinition::new("detail", "How much detail?") - .with_input(QuestionnaireInputDefinition::new(saved_input, "More detail")), -]; -let state = cx.new(|cx| { - QuestionnaireState::new(saved_items, cx) - .expect("valid saved questionnaire") - .with_current_item("detail") - .expect("known enabled questionnaire item") -}); -``` - -如果保存值在构造之后才到达,则使用 `set_answer`、`set_input_value` 和 -`set_current_item`。这些 setter 只改变当前状态,不会替换 reset 基线。 - -## 重置 +### 重置 Reset 会恢复初始 choices 和 input 草稿,清除显式 skip、校验尝试和完成状态,回到 初始当前 item,并将焦点移到恢复后的当前 item。 @@ -435,7 +372,12 @@ state.update(cx, |state, cx| { External error 在 reset 后仍由 owner 管理。如果 reset 也应该移除服务器错误,请 使用 `clear_external_error` 显式清除。 -## 条件 item +`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` 同步。这让条件策略留在页面中,同时由 @@ -487,7 +429,7 @@ Enter 确认已填写的答案。Command/Ctrl+Enter 确认当前 item。空答 快捷键标签按 enabled choice 顺序分配(`A`–`Z` 或 `1`–`9`),disabled choice 不会分配标签。 -## 进度和自定义渲染 +## 进度 `QuestionnaireProgress` 使用默认的 “Question 2 of 4” 样式。也可以读取 progress state,使用现有 `Progress` 或 `Stepper` 组合自定义指示器。 @@ -530,66 +472,25 @@ QuestionnaireActions::new(&state) ## Card 和 Dialog 组合 -Questionnaire 负责完整的问题流程;卡片或 dialog 负责容器布局以及关闭、取消行为。 -下面两个示例都包含集合中的每个 item,导航到第二个问题时仍会正常显示。 +问卷负责题目流程,容器负责自己的外观与关闭/取消行为。把完整组合 —— progress、 +全部 item 和 actions —— 都放进容器,这样切换到下一题时仍然可见。 ```rust -use gpui_kit::{Entity, IntoElement, ParentElement as _}; -use gpui_kit::component::{ - button::{Button, ButtonVariants as _}, - dialog::{Dialog, DialogClose, DialogFooter, DialogHeader, DialogTitle}, - group_box::{GroupBox, GroupBoxVariants as _}, -}; - -fn questionnaire_content( - state: &Entity, - actions: impl IntoElement, -) -> Questionnaire { - 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(actions) -} +use gpui_kit::component::group_box::{GroupBox, GroupBoxVariants as _}; GroupBox::new() .outline() .title("Set up your workspace") - .child(questionnaire_content( - &state, - QuestionnaireActions::new(&state) - .child(QuestionnairePrevious::new(&state)) - .child(QuestionnaireSkip::new(&state)) - .child(QuestionnaireNext::new(&state)) - .child(QuestionnaireSubmit::new(&state)), - )); + .child(questionnaire); ``` -对于 dialog,将同一个完整组合放在 dialog content 中,并由宿主处理关闭和取消。 +放进 Dialog 时,footer 里容器自己的 `Cancel` 与问卷的导航按钮并排,宿主在问卷报告 +提交成功后关闭 Dialog。 ```rust +use gpui_kit::component::dialog::{ + Dialog, DialogClose, DialogFooter, DialogHeader, DialogTitle, +}; use gpui_kit::component::{WindowExt as _, questionnaire::QuestionnaireEvent}; let dialog_state = state.clone(); @@ -605,72 +506,31 @@ cx.subscribe_in( .detach(); Dialog::new(cx) - .trigger( - Button::new("open-questionnaire") - .outline() - .label("Open questionnaire"), - ) + .trigger(Button::new("open-questionnaire").outline().label("Open questionnaire")) .content(move |content, _, _| { content .child(DialogHeader::new().child(DialogTitle::new().child("Workspace setup"))) - .child(questionnaire_content( - &dialog_state, - DialogFooter::new() - .child( - DialogClose::new().child( - Button::new("cancel-questionnaire") - .outline() - .label("Cancel"), - ), - ) + .child( + Questionnaire::new(&dialog_state) + // …progress 和每个 item,同上面的「用法」 .child( - QuestionnaireActions::new(&dialog_state) - .child(QuestionnairePrevious::new(&dialog_state)) - .child(QuestionnaireNext::new(&dialog_state)) - .child(QuestionnaireSubmit::new(&dialog_state)), + 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)), + ), ), - )) + ) }); ``` -宿主 subscription 只在成功的 `Submit` 之后关闭 Dialog。同一个 event 也适合将 -已校验的 `QuestionnaireSubmission` 交给应用传输层。持久化和网络成功仍由 -Questionnaire 外部负责。 - -## Event 与提交 - -订阅 `QuestionnaireEvent`,即可监听当前 item 变化、答案变化、完成和成功提交。 -`Completed` 只在状态转入 complete 时发出;每次成功执行显式 submit 都会发出 -`Submit`。 -首次成功提交时,事件顺序为 `Completed`,随后是 `Submit`。 -答案或 enabled 条件变化会清除 complete 状态,因此下次成功提交可以再次发出 -`Completed`。 - -```rust -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。它表示本地已校验的 -提交请求;远程保存仍由宿主应用负责。 +`Cancel` 始终关闭。`Submit` 只有在问卷校验通过全部启用 item 之后才关闭,同一个 +event 也把校验后的 `QuestionnaireSubmission` 交给应用层传输。 ## 可访问性 From c089a2e90fa4d5fa3090fcbb2ee3f4b47ec18b95 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Sat, 19 Sep 2026 12:43:48 +0800 Subject: [PATCH 09/17] questionnaire: Move the state machine into `gpui-base` Co-Authored-By: Claude Opus 5 (1M context) --- crates/base/src/lib.rs | 1 + crates/base/src/questionnaire/mod.rs | 10 +++++++ .../src/questionnaire/state.rs | 29 ++++++++++--------- .../src/questionnaire/types.rs | 26 +++++++++++++++++ .../component/src/questionnaire/components.rs | 26 +++++++++++++---- crates/component/src/questionnaire/mod.rs | 19 ++++++++---- .../story/src/stories/questionnaire_story.rs | 2 +- docs/ARCHITECTURE.md | 4 +-- 8 files changed, 91 insertions(+), 26 deletions(-) create mode 100644 crates/base/src/questionnaire/mod.rs rename crates/{component => base}/src/questionnaire/state.rs (98%) rename crates/{component => base}/src/questionnaire/types.rs (95%) diff --git a/crates/base/src/lib.rs b/crates/base/src/lib.rs index 2fe2559bac..03a490ba31 100644 --- a/crates/base/src/lib.rs +++ b/crates/base/src/lib.rs @@ -46,6 +46,7 @@ mod popover; mod popup; mod positioner; mod progress; +pub mod questionnaire; mod radio; mod radio_group; mod reduce_motion; diff --git a/crates/base/src/questionnaire/mod.rs b/crates/base/src/questionnaire/mod.rs new file mode 100644 index 0000000000..b9a46c56aa --- /dev/null +++ b/crates/base/src/questionnaire/mod.rs @@ -0,0 +1,10 @@ +//! Questionnaire behavior: answers, validation, navigation, focus and shortcuts. +//! +//! The state machine lives here so an application can replace the visual +//! language without reimplementing it. `gpui-component` owns the skin. + +mod state; +mod types; + +pub use state::*; +pub use types::*; diff --git a/crates/component/src/questionnaire/state.rs b/crates/base/src/questionnaire/state.rs similarity index 98% rename from crates/component/src/questionnaire/state.rs rename to crates/base/src/questionnaire/state.rs index 13ed343378..eb6b7059e4 100644 --- a/crates/component/src/questionnaire/state.rs +++ b/crates/base/src/questionnaire/state.rs @@ -1,12 +1,10 @@ use std::collections::HashSet; +use crate::input::{InputEvent, InputState}; use gpui::{ App, Context, Entity, EventEmitter, FocusHandle, Focusable as _, SharedString, Subscription, Window, }; -use rust_i18n::t; - -use crate::input::{InputEvent, InputState}; use super::types::*; @@ -19,8 +17,8 @@ struct ItemRuntime { initial_input_value: Option, skipped: bool, validation_attempted: bool, - internal_error: Option, - external_error: Option, + internal_error: Option, + external_error: Option, focus_handle: FocusHandle, choice_focus_handles: Vec, input_focus_handle: Option, @@ -256,7 +254,9 @@ impl QuestionnaireState { ) } - pub fn error(&self, name: &str) -> Option<&SharedString> { + /// The active validation failure for an item, if any. Base reports the + /// reason; the presentation layer turns a built-in reason into text. + pub fn error(&self, name: &str) -> Option<&QuestionnaireValidationError> { self.item_ix_opt(name).and_then(|ix| self.error_at(ix)) } @@ -293,7 +293,9 @@ impl QuestionnaireState { .is_some_and(|handle| handle.is_focused(window)) } - pub(crate) fn current_input_has_text(&self, cx: &App) -> bool { + /// Whether the active item's freeform input currently holds text. The skin + /// needs it to decide whether an arrow key moves focus or edits the draft. + pub fn current_input_has_text(&self, cx: &App) -> bool { let Some(item_ix) = self.current else { return false; }; @@ -503,7 +505,7 @@ impl QuestionnaireState { cx: &mut Context, ) -> Result<(), QuestionnaireSchemaError> { let ix = self.item_ix(item)?; - self.runtime[ix].external_error = Some(error.into()); + self.runtime[ix].external_error = Some(QuestionnaireValidationError::Message(error.into())); self.complete = false; cx.notify(); Ok(()) @@ -971,9 +973,9 @@ impl QuestionnaireState { let answer = self.effective_answer(item_ix); let error = if answer.is_empty() { Some(if self.items[item_ix].is_required() { - t!("Questionnaire.error.required").into() + QuestionnaireValidationError::Required } else { - t!("Questionnaire.error.optional").into() + QuestionnaireValidationError::Unanswered }) } else if let Some(validator) = self.items[item_ix].validator().cloned() { validator(&QuestionnaireValidationContext::new( @@ -982,6 +984,7 @@ impl QuestionnaireState { self.answers(), )) .err() + .map(QuestionnaireValidationError::Message) } else { None }; @@ -1036,7 +1039,7 @@ impl QuestionnaireState { } } - fn error_at(&self, item_ix: usize) -> Option<&SharedString> { + fn error_at(&self, item_ix: usize) -> Option<&QuestionnaireValidationError> { if self.runtime[item_ix].skipped || self.runtime[item_ix].disabled { return None; } @@ -1291,7 +1294,7 @@ mod tests { assert!(cx.read(|cx| state.read(cx).error("first").is_some())); assert_eq!( cx.read(|cx| state.read(cx).error("second").unwrap().clone()), - "Use the valid answer" + QuestionnaireValidationError::Message("Use the valid answer".into()) ); cx.update(|window, cx| { @@ -1452,7 +1455,7 @@ mod tests { ); assert_eq!( cx.read(|cx| state.read(cx).error("second").unwrap().clone()), - "Use the valid answer" + QuestionnaireValidationError::Message("Use the valid answer".into()) ); state.update(cx, |state, cx| { diff --git a/crates/component/src/questionnaire/types.rs b/crates/base/src/questionnaire/types.rs similarity index 95% rename from crates/component/src/questionnaire/types.rs rename to crates/base/src/questionnaire/types.rs index d9994b8e6d..a8b2d76b07 100644 --- a/crates/component/src/questionnaire/types.rs +++ b/crates/base/src/questionnaire/types.rs @@ -630,6 +630,32 @@ pub enum QuestionnaireEvent { Submit(QuestionnaireSubmission), } +/// Why an item currently fails validation. +/// +/// `Required` and `Unanswered` carry no text: base does not own product copy, +/// so the presentation layer supplies the localized sentence. `Message` is the +/// text a validator or the host already wrote. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum QuestionnaireValidationError { + /// A required item has no answer. + Required, + /// An optional item has no answer and has not been skipped. + Unanswered, + /// A validator or the host supplied this text. + Message(SharedString), +} + +impl QuestionnaireValidationError { + /// The text the host or a validator wrote, if this is not a built-in reason. + pub fn message(&self) -> Option<&SharedString> { + match self { + Self::Message(message) => Some(message), + _ => None, + } + } +} + #[derive(Clone, Debug, PartialEq, Eq)] #[non_exhaustive] pub enum QuestionnaireSchemaError { diff --git a/crates/component/src/questionnaire/components.rs b/crates/component/src/questionnaire/components.rs index 6f5ec78f3a..7528cceea3 100644 --- a/crates/component/src/questionnaire/components.rs +++ b/crates/component/src/questionnaire/components.rs @@ -16,7 +16,9 @@ use crate::{ kbd::Kbd, }; -use super::{QuestionnaireChoiceState, QuestionnaireState}; +use gpui_base::questionnaire::{ + QuestionnaireChoiceState, QuestionnaireState, QuestionnaireValidationError, +}; type ChoiceRenderer = Rc AnyElement + 'static>; @@ -99,11 +101,15 @@ fn apply_text_token(element: T, token: gpui_base::TextStyleToken) -> .font_weight(token.weight) } -fn item_label(definition: &super::QuestionnaireItemDefinition) -> Option { +fn item_label( + definition: &gpui_base::questionnaire::QuestionnaireItemDefinition, +) -> Option { Some(definition.accessibility_label().clone()) } -fn item_description(definition: &super::QuestionnaireItemDefinition) -> Option { +fn item_description( + definition: &gpui_base::questionnaire::QuestionnaireItemDefinition, +) -> Option { definition.description().cloned() } @@ -1106,6 +1112,16 @@ fn questionnaire_error_root(id: ElementId) -> gpui::Stateful { div().id(id).role(Role::Alert) } +/// Base reports why an item failed; the skin owns the sentence a person reads. +fn error_text(error: &QuestionnaireValidationError) -> SharedString { + match error { + QuestionnaireValidationError::Required => t!("Questionnaire.error.required").into(), + QuestionnaireValidationError::Unanswered => t!("Questionnaire.error.optional").into(), + QuestionnaireValidationError::Message(message) => message.clone(), + _ => SharedString::default(), + } +} + impl RenderOnce for QuestionnaireError { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let state = self.state.read(cx); @@ -1129,7 +1145,7 @@ impl RenderOnce for QuestionnaireError { ) .refine_style(&self.style) .when(!has_children, |this| { - this.when_some(error, |this, error| this.child(error)) + this.when_some(error, |this, error| this.child(error_text(&error))) }) .children(self.children) .into_any_element() @@ -1308,7 +1324,7 @@ mod tests { VisualTestContext, accesskit, px, }; - use super::super::{ + use gpui_base::questionnaire::{ QuestionnaireChoiceDefinition, QuestionnaireInputDefinition, QuestionnaireItemDefinition, QuestionnaireShortcutMode, }; diff --git a/crates/component/src/questionnaire/mod.rs b/crates/component/src/questionnaire/mod.rs index 61c51a86ec..7640fda1c6 100644 --- a/crates/component/src/questionnaire/mod.rs +++ b/crates/component/src/questionnaire/mod.rs @@ -1,11 +1,20 @@ -//! Composable questionnaire state, controls, navigation and validation. +//! Composable questionnaire controls. +//! +//! The behavior — answers, validation, navigation, focus and shortcuts — lives +//! in [`gpui_base::questionnaire`]; this module is its skin. The public path +//! stays `gpui_component::questionnaire::*` for both halves. mod components; -mod state; -mod types; pub use components::*; -pub use state::*; -pub use types::*; +pub use gpui_base::questionnaire::{ + QuestionnaireAnswer, QuestionnaireAnswerChange, QuestionnaireAnswers, + QuestionnaireChoiceDefinition, QuestionnaireChoiceState, QuestionnaireEvent, + QuestionnaireInputDefinition, QuestionnaireItemDefinition, QuestionnaireItemState, + QuestionnaireItemStatus, QuestionnaireNavigationState, QuestionnaireProgressState, + QuestionnaireSchemaError, QuestionnaireShortcutMode, QuestionnaireState, + QuestionnaireSubmission, QuestionnaireSubmissionItem, QuestionnaireValidationContext, + QuestionnaireValidationError, QuestionnaireValidator, +}; pub(crate) fn init(_: &mut gpui::App) {} diff --git a/crates/story/src/stories/questionnaire_story.rs b/crates/story/src/stories/questionnaire_story.rs index f026225495..d0db8a9ab2 100644 --- a/crates/story/src/stories/questionnaire_story.rs +++ b/crates/story/src/stories/questionnaire_story.rs @@ -582,7 +582,7 @@ impl Render for QuestionnaireStory { .external_state .read(cx) .error("server") - .map(ToString::to_string) + .and_then(|error| error.message().map(ToString::to_string)) .unwrap_or_else(|| "none".to_string()); let control_state = self.control_state.clone(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 784249dfa0..283667fb04 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -102,8 +102,8 @@ semantic seams. Base does not walk arbitrary descendant trees to discover them. ### 3. Stateful systems Examples include InputState, TextareaState, EditorState, CalendarState, TreeState, SliderState, -ResizableState, OtpState, ColorPickerState, ToastManager, ToastStackState, NavStackState, DockArea, -and TabGroup. +ResizableState, OtpState, ColorPickerState, QuestionnaireState, ToastManager, ToastStackState, +NavStackState, DockArea, and TabGroup. These modules retain data because their behavior spans frames or requires measurement, subscriptions, history, focus, or incremental updates. State is From 28ff033e4208504d16e9c94ca7390a1ca0f6722e Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Sat, 19 Sep 2026 12:49:13 +0800 Subject: [PATCH 10/17] questionnaire: Move the keyboard contract and answer controls into `gpui-base` Co-Authored-By: Claude Opus 5 (1M context) --- crates/base/src/questionnaire/control.rs | 121 ++++++++++ crates/base/src/questionnaire/keyboard.rs | 104 ++++++++ crates/base/src/questionnaire/mod.rs | 4 + crates/base/src/questionnaire/state.rs | 18 ++ .../component/src/questionnaire/components.rs | 227 ++---------------- 5 files changed, 268 insertions(+), 206 deletions(-) create mode 100644 crates/base/src/questionnaire/control.rs create mode 100644 crates/base/src/questionnaire/keyboard.rs diff --git a/crates/base/src/questionnaire/control.rs b/crates/base/src/questionnaire/control.rs new file mode 100644 index 0000000000..20f22257ac --- /dev/null +++ b/crates/base/src/questionnaire/control.rs @@ -0,0 +1,121 @@ +use gpui::{ + ElementId, Entity, InteractiveElement as _, SharedString, StatefulInteractiveElement as _, + prelude::FluentBuilder as _, +}; + +use super::QuestionnaireState; +use crate::{Checkbox, CheckboxState, Radio}; + +/// The answer control for one choice, wired to the questionnaire's behavior. +/// +/// A multiple-answer item hands back a `Checkbox` and a single-answer item a +/// `Radio`, each already carrying its checked state, disabled state, +/// accessibility name, position in set, focus handle, confirm key and change +/// handler. The skin decides what the control looks like and what it contains. +// The value is handed straight to the caller's `match` and dropped into an +// element; boxing either variant would buy an allocation on every choice to +// even out a difference that never outlives one render. +#[allow(clippy::large_enum_variant)] +pub enum QuestionnaireChoiceControl { + Checkbox(Checkbox), + Radio(Radio), +} + +impl QuestionnaireChoiceControl { + /// Returns `None` when the item or the choice is not part of the schema. + pub fn new( + state: &Entity, + item: impl Into, + value: impl Into, + id: impl Into, + cx: &gpui::App, + ) -> Option { + let item = item.into(); + let value = value.into(); + let snapshot = state.read(cx); + let choice = snapshot.choice_state(&item, &value)?; + let multiple = snapshot.item_state(&item)?.is_multiple(); + let definition = snapshot.choice_definition(&item, &value)?; + let label = definition.accessibility_label().clone(); + let description = definition.description().cloned(); + let position = snapshot.choice_position(&item, &value); + let focus_handle = snapshot.choice_focus_handle(&item, &value).cloned(); + let selected = choice.is_selected(); + let disabled = choice.is_disabled(); + + // Enter confirms an answer that is already selected; an unselected + // control keeps Enter for activation. + let confirm_state = state.clone(); + let confirm = + move |event: &gpui::KeyDownEvent, window: &mut gpui::Window, cx: &mut gpui::App| { + if selected + && !window.default_prevented() + && !event.is_held + && event.keystroke.key == "enter" + && event.keystroke.modifiers.number_of_modifiers() == 0 + && confirm_state.update(cx, |state, cx| state.confirm_current(window, cx)) + { + window.prevent_default(); + } + }; + + let id = id.into(); + Some(if multiple { + let change_state = state.clone(); + let change_item = item.clone(); + let change_value = value.clone(); + Self::Checkbox( + Checkbox::new(id) + .state(if selected { + CheckboxState::Checked + } else { + CheckboxState::Unchecked + }) + .disabled(disabled) + .accessibility_label(label) + .when_some(description, |this, description| { + this.aria_description(description) + }) + .when_some(position, |this, (position, total)| { + this.aria_position_in_set(position).aria_size_of_set(total) + }) + .when_some(focus_handle, |this, focus_handle| { + this.track_focus(&focus_handle) + }) + .capture_key_down(confirm) + .on_change(move |_, _, window, cx| { + let _ = change_state.update(cx, |state, cx| { + let result = state.activate_choice(&change_item, &change_value, cx); + state.focus_choice(&change_item, &change_value, window, cx); + result + }); + }), + ) + } else { + let change_state = state.clone(); + Self::Radio( + Radio::new(id) + .checked(selected) + .disabled(disabled) + .accessibility_label(label) + .when_some(description, |this, description| { + this.aria_description(description) + }) + .when_some(position, |this, (position, total)| { + this.set_position(position, total) + }) + .when_some(focus_handle, |this, focus_handle| { + this.track_focus(&focus_handle) + }) + .capture_key_down(confirm) + .on_change(move |_, _, window, cx| { + let _ = change_state.update(cx, |state, cx| { + let result = state.activate_choice(&item, &value, cx); + state.focus_choice(&item, &value, window, cx); + result + }); + }), + ) + }) + } +} diff --git a/crates/base/src/questionnaire/keyboard.rs b/crates/base/src/questionnaire/keyboard.rs new file mode 100644 index 0000000000..51a3fe6652 --- /dev/null +++ b/crates/base/src/questionnaire/keyboard.rs @@ -0,0 +1,104 @@ +use gpui::{App, Entity, KeyDownEvent, Window}; + +use super::QuestionnaireState; + +/// Routes a key press to the questionnaire's behavior and reports whether it +/// was consumed. The skin installs it on the root; the contract — arrows move +/// between answers and items, Enter confirms a filled answer, a bare letter or +/// digit activates a shortcut — lives here so a different skin keeps it. +pub fn handle_key_down( + state: &Entity, + event: &KeyDownEvent, + window: &mut Window, + cx: &mut App, +) { + if window.default_prevented() + || event.is_held + || event.prefer_character_input + || event.keystroke.is_ime_in_progress() + { + return; + } + + let modifiers = event.keystroke.modifiers; + let key = event.keystroke.key.as_str(); + let input_focused = state.read(cx).is_current_input_focused(window); + let input_has_text = input_focused && state.read(cx).current_input_has_text(cx); + let single_radio_focused = { + let state = state.read(cx); + state + .current_item() + .and_then(|item| state.item_state(item)) + .is_some_and(|item| !item.is_multiple()) + && state.focused_current_choice(window).is_some() + }; + + let handled = if key == "enter" && modifiers.secondary() && modifiers.number_of_modifiers() == 1 + { + state.update(cx, |state, cx| state.confirm_current(window, cx)) + } else if modifiers.number_of_modifiers() != 0 { + false + } else if input_focused { + match key { + "enter" if focused_answer_is_filled(state, window, cx) => { + state.update(cx, |state, cx| state.confirm_current(window, cx)) + } + "up" if !input_has_text => { + state.update(cx, |state, cx| state.focus_previous_answer(window, cx)) + } + "down" if !input_has_text => { + state.update(cx, |state, cx| state.focus_next_answer(window, cx)) + } + _ => false, + } + } else { + match key { + "up" => { + state.update(cx, |state, cx| state.focus_previous_answer(window, cx)) + || (single_radio_focused + && state.update(cx, |state, cx| state.move_current_radio(-1, window, cx))) + } + "down" => { + state.update(cx, |state, cx| state.focus_next_answer(window, cx)) + || (single_radio_focused + && state.update(cx, |state, cx| state.move_current_radio(1, window, cx))) + } + "left" if single_radio_focused => { + state.update(cx, |state, cx| state.move_current_radio(-1, window, cx)) + } + "right" if single_radio_focused => { + state.update(cx, |state, cx| state.move_current_radio(1, window, cx)) + } + "left" => state.update(cx, |state, cx| state.go_previous(window, cx)), + "right" if state.read(cx).navigation_state().is_confirmable() => { + state.update(cx, |state, cx| state.go_next(window, cx)) + } + "right" => false, + "enter" if focused_answer_is_filled(state, window, cx) => { + state.update(cx, |state, cx| state.confirm_current(window, cx)) + } + "enter" => false, + _ => state.update(cx, |state, cx| state.activate_shortcut(key, window, cx)), + } + }; + + if handled { + window.prevent_default(); + } +} + +fn focused_answer_is_filled(state: &Entity, window: &Window, cx: &App) -> bool { + let state = state.read(cx); + let Some(item) = state.current_item() else { + return false; + }; + if state.is_current_input_focused(window) { + return state + .answer(item) + .is_some_and(|answer| answer.freeform().is_some()); + } + state + .focused_current_choice(window) + .and_then(|value| state.choice_state(item, value)) + .is_some_and(|choice| choice.is_selected()) +} diff --git a/crates/base/src/questionnaire/mod.rs b/crates/base/src/questionnaire/mod.rs index b9a46c56aa..f94c0a5c1d 100644 --- a/crates/base/src/questionnaire/mod.rs +++ b/crates/base/src/questionnaire/mod.rs @@ -3,8 +3,12 @@ //! The state machine lives here so an application can replace the visual //! language without reimplementing it. `gpui-component` owns the skin. +mod control; +mod keyboard; mod state; mod types; +pub use control::QuestionnaireChoiceControl; +pub use keyboard::handle_key_down; pub use state::*; pub use types::*; diff --git a/crates/base/src/questionnaire/state.rs b/crates/base/src/questionnaire/state.rs index eb6b7059e4..18193ed68f 100644 --- a/crates/base/src/questionnaire/state.rs +++ b/crates/base/src/questionnaire/state.rs @@ -224,6 +224,24 @@ impl QuestionnaireState { )) } + /// One-based position of a choice among its item's enabled choices, with + /// the enabled total. Assistive technology announces the pair. + pub fn choice_position(&self, item: &str, value: &str) -> Option<(usize, usize)> { + let definition = self.item_definition(item)?; + let enabled: Vec<_> = definition + .choices() + .iter() + .filter(|choice| { + self.choice_state(item, choice.value()) + .is_some_and(|choice| !choice.is_disabled()) + }) + .collect(); + enabled + .iter() + .position(|choice| choice.value().as_ref() == value) + .map(|position| (position + 1, enabled.len())) + } + pub fn navigation_state(&self) -> QuestionnaireNavigationState { let Some(ix) = self.current_ix() else { return QuestionnaireNavigationState::default(); diff --git a/crates/component/src/questionnaire/components.rs b/crates/component/src/questionnaire/components.rs index 7528cceea3..2cc436ed43 100644 --- a/crates/component/src/questionnaire/components.rs +++ b/crates/component/src/questionnaire/components.rs @@ -1,11 +1,11 @@ use std::rc::Rc; use gpui::{ - AnyElement, App, ElementId, Entity, InteractiveElement, IntoElement, KeyDownEvent, - ParentElement, RenderOnce, Role, SharedString, StatefulInteractiveElement, StyleRefinement, - Styled, Window, div, prelude::FluentBuilder as _, svg, + AnyElement, App, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce, + Role, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window, div, + prelude::FluentBuilder as _, svg, }; -use gpui_base::{Checkbox, CheckboxState, Radio, RadioGroup}; +use gpui_base::RadioGroup; use rust_i18n::t; use crate::{ @@ -17,7 +17,8 @@ use crate::{ }; use gpui_base::questionnaire::{ - QuestionnaireChoiceState, QuestionnaireState, QuestionnaireValidationError, + QuestionnaireChoiceControl, QuestionnaireChoiceState, QuestionnaireState, + QuestionnaireValidationError, }; type ChoiceRenderer = @@ -160,111 +161,6 @@ impl Questionnaire { children: Vec::new(), } } - - fn on_key_down( - state: &Entity, - event: &KeyDownEvent, - window: &mut Window, - cx: &mut App, - ) { - if window.default_prevented() - || event.is_held - || event.prefer_character_input - || event.keystroke.is_ime_in_progress() - { - return; - } - - let modifiers = event.keystroke.modifiers; - let key = event.keystroke.key.as_str(); - let input_focused = state.read(cx).is_current_input_focused(window); - let input_has_text = input_focused && state.read(cx).current_input_has_text(cx); - let single_radio_focused = { - let state = state.read(cx); - state - .current_item() - .and_then(|item| state.item_state(item)) - .is_some_and(|item| !item.is_multiple()) - && state.focused_current_choice(window).is_some() - }; - - let handled = if key == "enter" - && modifiers.secondary() - && modifiers.number_of_modifiers() == 1 - { - state.update(cx, |state, cx| state.confirm_current(window, cx)) - } else if modifiers.number_of_modifiers() != 0 { - false - } else if input_focused { - match key { - "enter" if Self::focused_answer_is_filled(state, window, cx) => { - state.update(cx, |state, cx| state.confirm_current(window, cx)) - } - "up" if !input_has_text => { - state.update(cx, |state, cx| state.focus_previous_answer(window, cx)) - } - "down" if !input_has_text => { - state.update(cx, |state, cx| state.focus_next_answer(window, cx)) - } - _ => false, - } - } else { - match key { - "up" => { - state.update(cx, |state, cx| state.focus_previous_answer(window, cx)) - || (single_radio_focused - && state - .update(cx, |state, cx| state.move_current_radio(-1, window, cx))) - } - "down" => { - state.update(cx, |state, cx| state.focus_next_answer(window, cx)) - || (single_radio_focused - && state - .update(cx, |state, cx| state.move_current_radio(1, window, cx))) - } - "left" if single_radio_focused => { - state.update(cx, |state, cx| state.move_current_radio(-1, window, cx)) - } - "right" if single_radio_focused => { - state.update(cx, |state, cx| state.move_current_radio(1, window, cx)) - } - "left" => state.update(cx, |state, cx| state.go_previous(window, cx)), - "right" if state.read(cx).navigation_state().is_confirmable() => { - state.update(cx, |state, cx| state.go_next(window, cx)) - } - "right" => false, - "enter" if Self::focused_answer_is_filled(state, window, cx) => { - state.update(cx, |state, cx| state.confirm_current(window, cx)) - } - "enter" => false, - _ => state.update(cx, |state, cx| state.activate_shortcut(key, window, cx)), - } - }; - - if handled { - window.prevent_default(); - } - } - - fn focused_answer_is_filled( - state: &Entity, - window: &Window, - cx: &App, - ) -> bool { - let state = state.read(cx); - let Some(item) = state.current_item() else { - return false; - }; - if state.is_current_input_focused(window) { - return state - .answer(item) - .is_some_and(|answer| answer.freeform().is_some()); - } - state - .focused_current_choice(window) - .and_then(|value| state.choice_state(item, value)) - .is_some_and(|choice| choice.is_selected()) - } } impl Styled for Questionnaire { @@ -292,7 +188,9 @@ impl RenderOnce for Questionnaire { .role(Role::Form) .key_context("Questionnaire") .track_focus(&focus_handle) - .capture_key_down(move |event, window, cx| Self::on_key_down(&state, event, window, cx)) + .capture_key_down(move |event, window, cx| { + gpui_base::questionnaire::handle_key_down(&state, event, window, cx) + }) .flex() .flex_col() .min_w_0() @@ -737,21 +635,6 @@ impl RenderOnce for QuestionnaireChoice { let multiple = item.is_multiple(); let label = definition.accessibility_label().clone(); let description = definition.description().cloned(); - let position = state.item_definition(&self.item).and_then(|item| { - let enabled: Vec<_> = item - .choices() - .iter() - .filter(|choice| { - state - .choice_state(&self.item, choice.value()) - .is_some_and(|choice| !choice.is_disabled()) - }) - .collect(); - enabled - .iter() - .position(|choice| choice.value() == &self.value) - .map(|position| (position + 1, enabled.len())) - }); let selected = choice_state.is_selected(); let disabled = choice_state.is_disabled(); let invalid = choice_state.is_invalid(); @@ -869,51 +752,16 @@ impl RenderOnce for QuestionnaireChoice { let id = element_id(&self.state, format!("choice-{}-{}", self.item, self.value)); let instance_style = self.style.clone(); - let state = self.state.clone(); let item_name = self.item.clone(); let choice_value = self.value.clone(); - if multiple { - let callback_state = state.clone(); - let callback_item = item_name.clone(); - let callback_value = choice_value.clone(); - let confirm_state = state.clone(); - let base = Checkbox::new(id) - .state(if selected { - CheckboxState::Checked - } else { - CheckboxState::Unchecked - }) - .disabled(disabled) - .accessibility_label(label) - .when_some(description.clone(), |this, description| { - this.aria_description(description) - }) - .when_some(position, |this, (position, total)| { - this.aria_position_in_set(position).aria_size_of_set(total) - }) - .when_some(focus_handle, |this, focus_handle| { - this.track_focus(&focus_handle) - }) - .capture_key_down(move |event, window, cx| { - if selected - && !window.default_prevented() - && !event.is_held - && event.keystroke.key == "enter" - && event.keystroke.modifiers.number_of_modifiers() == 0 - && confirm_state.update(cx, |state, cx| state.confirm_current(window, cx)) - { - window.prevent_default(); - } - }) - .on_change(move |_, _, window, cx| { - let _ = callback_state.update(cx, |state, cx| { - let result = state.activate_choice(&callback_item, &callback_value, cx); - state.focus_choice(&callback_item, &callback_value, window, cx); - result - }); - }); - style_choice_card( + let Some(control) = + QuestionnaireChoiceControl::new(&self.state, item_name, choice_value, id, cx) + else { + return gpui::Empty.into_any_element(); + }; + match control { + QuestionnaireChoiceControl::Checkbox(base) => style_choice_card( base, indicator, content.into_any_element(), @@ -927,41 +775,8 @@ impl RenderOnce for QuestionnaireChoice { window, cx, ) - .into_any_element() - } else { - let confirm_state = state.clone(); - let base = Radio::new(id) - .checked(selected) - .disabled(disabled) - .accessibility_label(label) - .when_some(description, |this, description| { - this.aria_description(description) - }) - .when_some(position, |this, (position, total)| { - this.set_position(position, total) - }) - .when_some(focus_handle, |this, focus_handle| { - this.track_focus(&focus_handle) - }) - .capture_key_down(move |event, window, cx| { - if selected - && !window.default_prevented() - && !event.is_held - && event.keystroke.key == "enter" - && event.keystroke.modifiers.number_of_modifiers() == 0 - && confirm_state.update(cx, |state, cx| state.confirm_current(window, cx)) - { - window.prevent_default(); - } - }) - .on_change(move |_, _, window, cx| { - let _ = state.update(cx, |state, cx| { - let result = state.activate_choice(&item_name, &choice_value, cx); - state.focus_choice(&item_name, &choice_value, window, cx); - result - }); - }); - style_choice_card( + .into_any_element(), + QuestionnaireChoiceControl::Radio(base) => style_choice_card( base, indicator, content.into_any_element(), @@ -975,7 +790,7 @@ impl RenderOnce for QuestionnaireChoice { window, cx, ) - .into_any_element() + .into_any_element(), } } } @@ -1320,8 +1135,8 @@ questionnaire_action_part!( mod tests { use super::*; use gpui::{ - AppContext as _, Context, Element as _, Focusable as _, Keystroke, Render, TestAppContext, - VisualTestContext, accesskit, px, + AppContext as _, Context, Element as _, Focusable as _, KeyDownEvent, Keystroke, Render, + TestAppContext, VisualTestContext, accesskit, px, }; use gpui_base::questionnaire::{ From ef746f80ccc7fd2b5c3f947cb0cc2f77b3f1ca9f Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Sat, 19 Sep 2026 12:50:06 +0800 Subject: [PATCH 11/17] questionnaire: Drop a leftover web-validation note from the docs Co-Authored-By: Claude Opus 5 (1M context) --- website/component/questionnaire.md | 4 +--- website/zh-CN/component/questionnaire.md | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/website/component/questionnaire.md b/website/component/questionnaire.md index 0a58085304..ee3c940b12 100644 --- a/website/component/questionnaire.md +++ b/website/component/questionnaire.md @@ -274,9 +274,7 @@ state.update(cx, |state, cx| { ``` `reset` clears internal validation attempts and errors, but preserves -owner-managed external errors. Questionnaire semantic validation and -synchronous validators are supported; native HTML constraint validation is -not part of this GPUI component. +owner-managed external errors. ## Navigation and submission diff --git a/website/zh-CN/component/questionnaire.md b/website/zh-CN/component/questionnaire.md index 7543298a48..314384d6c7 100644 --- a/website/zh-CN/component/questionnaire.md +++ b/website/zh-CN/component/questionnaire.md @@ -260,9 +260,7 @@ state.update(cx, |state, cx| { }); ``` -`reset` 会清除内部校验尝试和错误,但保留 owner 管理的 external error。组件支持 -Questionnaire 语义校验和同步 validator;原生 HTML constraint validation 不属于此 -GPUI 组件。 +`reset` 会清除内部校验尝试和错误,但保留 owner 管理的 external error。 ## 导航与提交 From 15e69d58ce2af41587edbdcf09cac8b93b983cb3 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Sat, 19 Sep 2026 12:53:39 +0800 Subject: [PATCH 12/17] questionnaire: Drop the Stepper example that could not track the enabled items Co-Authored-By: Claude Opus 5 (1M context) --- .../story/src/stories/questionnaire_story.rs | 39 ------------------- website/component/questionnaire.md | 14 +++---- website/zh-CN/component/questionnaire.md | 14 +++---- 3 files changed, 14 insertions(+), 53 deletions(-) diff --git a/crates/story/src/stories/questionnaire_story.rs b/crates/story/src/stories/questionnaire_story.rs index d0db8a9ab2..14c91eb05f 100644 --- a/crates/story/src/stories/questionnaire_story.rs +++ b/crates/story/src/stories/questionnaire_story.rs @@ -6,7 +6,6 @@ use gpui_kit::component::{ h_flex, input::InputState, kbd::Kbd, - progress::Progress, questionnaire::{ Questionnaire, QuestionnaireActions, QuestionnaireAnswer, QuestionnaireChoice, QuestionnaireChoiceDefinition, QuestionnaireChoiceDescription, QuestionnaireChoices, @@ -16,7 +15,6 @@ use gpui_kit::component::{ QuestionnaireSkip, QuestionnaireState, QuestionnaireSubmission, QuestionnaireSubmit, QuestionnaireTitle, }, - stepper::{Stepper, StepperItem}, v_flex, }; use gpui_kit::prelude::FluentBuilder as _; @@ -514,13 +512,6 @@ impl Render for QuestionnaireStory { self.size, &[("server", &["personal", "team"])], ); - let progress = self.state.read(cx).progress(); - let progress_value = if progress.total() == 0 { - 0. - } else { - progress.current() as f32 / progress.total() as f32 * 100. - }; - let current_step = progress.current().saturating_sub(1); let event_log = self.event_log.clone(); let state_snapshot = self.state.read(cx); let navigation = state_snapshot.navigation_state(); @@ -547,9 +538,6 @@ impl Render for QuestionnaireStory { .map(|(name, answer)| format!("{}={:?}", name, answer)) .collect::>() .join(" · "); - let advanced_disabled = state_snapshot - .item_state("advanced") - .is_some_and(|item| item.is_disabled()); let letters = Self::questionnaire_view( &self.letters_state, self.size, @@ -981,33 +969,6 @@ impl Render for QuestionnaireStory { .child(event) })), ) - .child( - section("Custom Progress and Stepper") - .description("Compose the state snapshot with existing progress components.") - .w(px(600.)) - .child( - v_flex() - .w_full() - .gap_2() - .child(Progress::new("questionnaire-progress-custom").value(progress_value)) - .child( - Stepper::new("questionnaire-stepper") - .w_full() - .selected_index(current_step) - .items({ - let mut items = vec![ - StepperItem::new().child("Direction"), - StepperItem::new().child("Tools"), - StepperItem::new().child("Tone"), - ]; - if !advanced_disabled { - items.push(StepperItem::new().child("Advanced")); - } - items - }), - ), - ), - ) .child( section("Controlled current, conditional items, and custom actions") .description("The host synchronizes Environment from the Runtime answer; custom actions use NavigationState visibility.") diff --git a/website/component/questionnaire.md b/website/component/questionnaire.md index ee3c940b12..eeeecbda95 100644 --- a/website/component/questionnaire.md +++ b/website/component/questionnaire.md @@ -459,8 +459,7 @@ enabled-choice order (`A`–`Z` or `1`–`9`), and disabled choices receive no l ## Progress `QuestionnaireProgress` follows the default presentation: “Question 2 of 4”. -Its state can also be used to compose a custom indicator from the existing -`Progress` or `Stepper` components. +The same snapshot can drive an existing indicator instead. ```rust QuestionnaireProgress::new(&state); @@ -471,13 +470,14 @@ let percent = if progress.total() == 0 { } else { progress.current() as f32 / progress.total() as f32 * 100. }; -Progress::new("questionnaire-progress") - .value(percent); - -Stepper::new("questionnaire-steps") - .selected_index(progress.current().saturating_sub(1)); +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 The questionnaire skin has one scale. Spacing, typography, radius, border, diff --git a/website/zh-CN/component/questionnaire.md b/website/zh-CN/component/questionnaire.md index 314384d6c7..d1f989ec28 100644 --- a/website/zh-CN/component/questionnaire.md +++ b/website/zh-CN/component/questionnaire.md @@ -429,8 +429,8 @@ Enter 确认已填写的答案。Command/Ctrl+Enter 确认当前 item。空答 ## 进度 -`QuestionnaireProgress` 使用默认的 “Question 2 of 4” 样式。也可以读取 progress -state,使用现有 `Progress` 或 `Stepper` 组合自定义指示器。 +`QuestionnaireProgress` 使用默认的 “Question 2 of 4” 样式。同一份快照也可以用来 +驱动现有的指示器。 ```rust QuestionnaireProgress::new(&state); @@ -441,13 +441,13 @@ let percent = if progress.total() == 0 { } else { progress.current() as f32 / progress.total() as f32 * 100. }; -Progress::new("questionnaire-progress") - .value(percent); - -Stepper::new("questionnaire-steps") - .selected_index(progress.current().saturating_sub(1)); +Progress::new("questionnaire-progress").value(percent); ``` +`current` 和 `total` 只统计启用的 item,宿主禁用或重新启用某一题时两者都会变化。 +如果指示器为每一步固定一个标签(例如 `Stepper`),它的步骤必须从同一份启用集合 +推导出来,否则标签和选中步骤会与问卷错位。 + ## 尺寸与主题 Questionnaire 皮肤只有一套比例。spacing、typography、radius、border、input、 From bbc2b5a9fc41204199ddd4df32838e571fe71d75 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Sat, 19 Sep 2026 13:08:08 +0800 Subject: [PATCH 13/17] questionnaire: Let the root publish one scale for all its parts Co-Authored-By: Claude Opus 5 (1M context) --- .../component/src/questionnaire/components.rs | 420 ++++++++++++++++-- .../story/src/stories/questionnaire_story.rs | 26 +- website/component/questionnaire.md | 42 +- website/zh-CN/component/questionnaire.md | 34 +- 4 files changed, 436 insertions(+), 86 deletions(-) diff --git a/crates/component/src/questionnaire/components.rs b/crates/component/src/questionnaire/components.rs index 2cc436ed43..c5489323c5 100644 --- a/crates/component/src/questionnaire/components.rs +++ b/crates/component/src/questionnaire/components.rs @@ -24,9 +24,45 @@ use gpui_base::questionnaire::{ type ChoiceRenderer = Rc AnyElement + 'static>; -/// The questionnaire skin's fixed geometry, following the ReUI `base-nova` -/// questionnaire. Sizing is a theme concern here: the numbers come from the -/// semantic spacing and radius tokens rather than a per-part size scale. +/// Where a questionnaire's scale lives between the root and its parts. +/// +/// GPUI has no style cascade, and `with_rem_size` resolves during layout, which +/// a `RenderOnce` part cannot reach. So the root records the scale it was given +/// under its state's id, and every part of that questionnaire reads it back. A +/// part still accepts its own `with_size`, which wins. +/// +/// A root renders before its children, so the entry is in place by the time a +/// part looks for it; a part rendered outside a root falls back to `Medium`. +#[derive(Default)] +struct QuestionnaireSizes(std::collections::HashMap); + +impl gpui::Global for QuestionnaireSizes {} + +/// Bounded so an application that builds and drops many questionnaires cannot +/// grow the table without end. Every root rewrites its entry on the next frame, +/// so clearing it costs at most one frame at the default scale. +const MAX_TRACKED_QUESTIONNAIRES: usize = 128; + +fn publish_size(state: &Entity, size: Size, cx: &mut App) { + let id = state.entity_id(); + let sizes = cx.default_global::(); + if sizes.0.len() >= MAX_TRACKED_QUESTIONNAIRES && !sizes.0.contains_key(&id) { + sizes.0.clear(); + } + sizes.0.insert(id, size); +} + +fn resolve_size(own: Option, state: &Entity, cx: &App) -> Size { + own.unwrap_or_else(|| { + cx.try_global::() + .and_then(|sizes| sizes.0.get(&state.entity_id()).copied()) + .unwrap_or(Size::Medium) + }) +} + +/// The questionnaire skin's geometry, following the ReUI `base-nova` +/// questionnaire at `Medium`. Every number comes from the semantic spacing and +/// radius tokens; the size picks which of them apply. #[derive(Clone, Copy)] struct QuestionnaireMetrics { root_gap: gpui::Pixels, @@ -47,52 +83,147 @@ struct QuestionnaireMetrics { } impl QuestionnaireMetrics { - fn new(cx: &App) -> Self { + fn new(size: Size, cx: &App) -> Self { let tokens = cx.theme().semantic_tokens(); let spacing = tokens.spacing; let radius = tokens.radius; - Self { - root_gap: spacing.lg, - item_gap: spacing.lg, - choices_gap: spacing.sm, - choice_gap: spacing.sm + spacing.xxs, - content_gap: spacing.xxs, - choice_padding_x: spacing.md, - choice_padding_y: spacing.sm + spacing.xxs, - choice_min_height: spacing.xxl + spacing.md, - choice_radius: radius.lg, - indicator_size: spacing.lg, - indicator_mark_size: spacing.sm, - indicator_check_size: spacing.md + spacing.xxs, - shortcut_size: spacing.lg + spacing.xs, - shortcut_text_size: spacing.sm + spacing.xxs, - shortcut_radius: radius.md, + match size { + Size::XSmall => Self { + root_gap: spacing.sm, + item_gap: spacing.sm, + choices_gap: spacing.xs, + choice_gap: spacing.xs + spacing.xxs, + content_gap: spacing.xxs, + choice_padding_x: spacing.sm, + choice_padding_y: spacing.xs, + choice_min_height: spacing.xl + spacing.xs, + choice_radius: radius.md, + indicator_size: spacing.md, + indicator_mark_size: spacing.xs + spacing.xxs * 0.5, + indicator_check_size: spacing.sm + spacing.xxs, + shortcut_size: spacing.lg, + shortcut_text_size: spacing.sm, + shortcut_radius: radius.sm, + }, + Size::Small => Self { + root_gap: spacing.md, + item_gap: spacing.md, + choices_gap: spacing.xs + spacing.xxs, + choice_gap: spacing.sm, + content_gap: spacing.xxs, + choice_padding_x: spacing.sm + spacing.xxs, + choice_padding_y: spacing.sm, + choice_min_height: spacing.xxl + spacing.xs, + choice_radius: radius.lg, + indicator_size: spacing.md + spacing.xxs, + indicator_mark_size: spacing.xs + spacing.xxs, + indicator_check_size: spacing.md, + shortcut_size: spacing.lg + spacing.xxs, + shortcut_text_size: spacing.sm + spacing.xxs * 0.5, + shortcut_radius: radius.md, + }, + Size::Large => Self { + root_gap: spacing.xl, + item_gap: spacing.xl, + choices_gap: spacing.sm + spacing.xxs, + choice_gap: spacing.md, + content_gap: spacing.xs, + choice_padding_x: spacing.lg, + choice_padding_y: spacing.md, + choice_min_height: spacing.xxl + spacing.lg, + choice_radius: radius.xl, + indicator_size: spacing.lg + spacing.xxs, + indicator_mark_size: spacing.sm + spacing.xxs, + indicator_check_size: spacing.lg, + shortcut_size: spacing.xl, + shortcut_text_size: spacing.md, + shortcut_radius: radius.lg, + }, + Size::Size(value) => Self { + root_gap: value, + item_gap: value, + choices_gap: value * 0.5, + choice_gap: value * 0.625, + content_gap: value * 0.125, + choice_padding_x: value * 0.75, + choice_padding_y: value * 0.625, + choice_min_height: value * 2.75, + choice_radius: radius.lg, + indicator_size: value, + indicator_mark_size: value * 0.5, + indicator_check_size: value * 0.875, + shortcut_size: value * 1.25, + shortcut_text_size: value * 0.625, + shortcut_radius: radius.md, + }, + Size::Medium => Self { + root_gap: spacing.lg, + item_gap: spacing.lg, + choices_gap: spacing.sm, + choice_gap: spacing.sm + spacing.xxs, + content_gap: spacing.xxs, + choice_padding_x: spacing.md, + choice_padding_y: spacing.sm + spacing.xxs, + choice_min_height: spacing.xxl + spacing.md, + choice_radius: radius.lg, + indicator_size: spacing.lg, + indicator_mark_size: spacing.sm, + indicator_check_size: spacing.md + spacing.xxs, + shortcut_size: spacing.lg + spacing.xs, + shortcut_text_size: spacing.sm + spacing.xxs, + shortcut_radius: radius.md, + }, } } } -/// Answer text matches the Checkbox and Radio family's medium label. -fn text_style(element: T, cx: &App) -> T { - apply_text_token(element, cx.theme().semantic_tokens().typography.md) +/// Answer text matches the Checkbox and Radio family's label at each size. +fn text_style(element: T, size: Size, cx: &App) -> T { + let typography = cx.theme().semantic_tokens().typography; + match size { + Size::XSmall => apply_text_token(element, typography.xs), + Size::Small => apply_text_token(element, typography.sm), + Size::Large => apply_text_token(element, typography.lg), + Size::Size(value) => element.text_size(value), + Size::Medium => apply_text_token(element, typography.md), + } } /// Secondary text sits one step below the answer text. -fn secondary_text_style(element: T, cx: &App) -> T { - apply_text_token(element, cx.theme().semantic_tokens().typography.sm) +fn secondary_text_style(element: T, size: Size, cx: &App) -> T { + let typography = cx.theme().semantic_tokens().typography; + match size { + Size::XSmall | Size::Small => apply_text_token(element, typography.xs), + Size::Large => apply_text_token(element, typography.md), + Size::Size(value) => element.text_size(value * 0.875), + Size::Medium => apply_text_token(element, typography.sm), + } } -fn progress_text_style(element: T, cx: &App) -> T { - apply_text_token(element, cx.theme().semantic_tokens().typography.xs) - .font_weight(gpui::FontWeight::MEDIUM) +fn progress_text_style(element: T, size: Size, cx: &App) -> T { + let typography = cx.theme().semantic_tokens().typography; + match size { + Size::Large => apply_text_token(element, typography.sm), + Size::Size(value) => element.text_size(value * 0.75), + _ => apply_text_token(element, typography.xs), + } + .font_weight(gpui::FontWeight::MEDIUM) } -fn description_text_style(element: T, cx: &App) -> T { - secondary_text_style(element, cx) +fn description_text_style(element: T, size: Size, cx: &App) -> T { + secondary_text_style(element, size, cx) } -fn title_text_style(element: T, cx: &App) -> T { - apply_text_token(element, cx.theme().semantic_tokens().typography.lg) - .font_weight(gpui::FontWeight::MEDIUM) +fn title_text_style(element: T, size: Size, cx: &App) -> T { + let typography = cx.theme().semantic_tokens().typography; + match size { + Size::XSmall => apply_text_token(element, typography.sm), + Size::Small => apply_text_token(element, typography.md), + Size::Large => apply_text_token(element, typography.xl), + Size::Size(value) => element.text_size(value * 1.125), + Size::Medium => apply_text_token(element, typography.lg), + } + .font_weight(gpui::FontWeight::MEDIUM) } fn apply_text_token(element: T, token: gpui_base::TextStyleToken) -> T { @@ -150,6 +281,7 @@ fn element_id(state: &Entity, suffix: impl std::fmt::Display pub struct Questionnaire { state: Entity, style: StyleRefinement, + size: Option, children: Vec, } @@ -158,11 +290,19 @@ impl Questionnaire { Self { state: state.clone(), style: StyleRefinement::default(), + size: None, children: Vec::new(), } } } +impl Sizable for Questionnaire { + fn with_size(mut self, size: impl Into) -> Self { + self.size = Some(size.into()); + self + } +} + impl Styled for Questionnaire { fn style(&mut self) -> &mut StyleRefinement { &mut self.style @@ -177,7 +317,11 @@ impl ParentElement for Questionnaire { impl RenderOnce for Questionnaire { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let metrics = QuestionnaireMetrics::new(cx); + // The root is the one place a caller names the scale, so it records it + // for the parts before any of them render. + let size = self.size.unwrap_or(Size::Medium); + publish_size(&self.state, size, cx); + let metrics = QuestionnaireMetrics::new(size, cx); let focus_handle = self.state.read(cx).focus_handle().clone(); let state = self.state.clone(); let debug_selector = format!("questionnaire-{}-root", self.state.entity_id()); @@ -206,6 +350,7 @@ impl RenderOnce for Questionnaire { pub struct QuestionnaireProgress { state: Entity, style: StyleRefinement, + size: Option, children: Vec, } @@ -214,11 +359,19 @@ impl QuestionnaireProgress { Self { state: state.clone(), style: StyleRefinement::default(), + size: None, children: Vec::new(), } } } +impl Sizable for QuestionnaireProgress { + fn with_size(mut self, size: impl Into) -> Self { + self.size = Some(size.into()); + self + } +} + impl Styled for QuestionnaireProgress { fn style(&mut self) -> &mut StyleRefinement { &mut self.style @@ -241,6 +394,7 @@ impl RenderOnce for QuestionnaireProgress { let colors = cx.theme().semantic_tokens().colors; let has_children = !self.children.is_empty(); + let size = resolve_size(self.size, &self.state, cx); progress_text_style( div() .id(element_id(&self.state, "progress")) @@ -250,6 +404,7 @@ impl RenderOnce for QuestionnaireProgress { .aria_max_numeric_value(total as f64) .aria_numeric_value(current as f64) .text_color(colors.muted_foreground), + size, cx, ) .refine_style(&self.style) @@ -265,6 +420,7 @@ macro_rules! questionnaire_item_part { state: Entity, item: SharedString, style: StyleRefinement, + size: Option, children: Vec, } @@ -274,11 +430,19 @@ macro_rules! questionnaire_item_part { state: state.clone(), item: item.into(), style: StyleRefinement::default(), + size: None, children: Vec::new(), } } } + impl Sizable for $name { + fn with_size(mut self, size: impl Into) -> Self { + self.size = Some(size.into()); + self + } + } + impl Styled for $name { fn style(&mut self) -> &mut StyleRefinement { &mut self.style @@ -307,9 +471,10 @@ macro_rules! questionnaire_item_part { // description of its own closes the gap the description would // have filled, so answers never crowd the question. let closes_item_gap = $closes_item_gap && item_description(definition).is_none(); - $style(div().w_full().text_color(colors.$color), cx) + let size = resolve_size(self.size, &self.state, cx); + $style(div().w_full().text_color(colors.$color), size, cx) .when(closes_item_gap, |this| { - this.mb(QuestionnaireMetrics::new(cx).item_gap) + this.mb(QuestionnaireMetrics::new(size, cx).item_gap) }) .refine_style(&self.style) .when(!has_children, |this| { @@ -344,6 +509,7 @@ pub struct QuestionnaireItem { state: Entity, item: SharedString, style: StyleRefinement, + size: Option, children: Vec, } @@ -353,11 +519,19 @@ impl QuestionnaireItem { state: state.clone(), item: item.into(), style: StyleRefinement::default(), + size: None, children: Vec::new(), } } } +impl Sizable for QuestionnaireItem { + fn with_size(mut self, size: impl Into) -> Self { + self.size = Some(size.into()); + self + } +} + impl Styled for QuestionnaireItem { fn style(&mut self) -> &mut StyleRefinement { &mut self.style @@ -387,7 +561,7 @@ impl RenderOnce for QuestionnaireItem { let focus_handle = state.item_focus_handle(&self.item).cloned(); let label = definition.accessibility_label().clone(); let description = definition.description().cloned(); - let metrics = QuestionnaireMetrics::new(cx); + let metrics = QuestionnaireMetrics::new(resolve_size(self.size, &self.state, cx), cx); div() .id(element_id(&self.state, format!("item-{}", self.item))) @@ -415,6 +589,7 @@ pub struct QuestionnaireChoices { state: Entity, item: SharedString, style: StyleRefinement, + size: Option, children: Vec, } @@ -424,11 +599,19 @@ impl QuestionnaireChoices { state: state.clone(), item: item.into(), style: StyleRefinement::default(), + size: None, children: Vec::new(), } } } +impl Sizable for QuestionnaireChoices { + fn with_size(mut self, size: impl Into) -> Self { + self.size = Some(size.into()); + self + } +} + impl Styled for QuestionnaireChoices { fn style(&mut self) -> &mut StyleRefinement { &mut self.style @@ -452,7 +635,7 @@ impl RenderOnce for QuestionnaireChoices { if !active || item.is_disabled() { return gpui::Empty.into_any_element(); } - let metrics = QuestionnaireMetrics::new(cx); + let metrics = QuestionnaireMetrics::new(resolve_size(self.size, &self.state, cx), cx); if item.is_multiple() { div() @@ -488,6 +671,7 @@ pub struct QuestionnaireChoice { indicator_style: StyleRefinement, content_style: StyleRefinement, shortcut_style: StyleRefinement, + size: Option, children: Vec, indicator_renderer: Option, shortcut_renderer: Option, @@ -507,6 +691,7 @@ impl QuestionnaireChoice { indicator_style: StyleRefinement::default(), content_style: StyleRefinement::default(), shortcut_style: StyleRefinement::default(), + size: None, children: Vec::new(), indicator_renderer: None, shortcut_renderer: None, @@ -545,6 +730,13 @@ impl QuestionnaireChoice { } } +impl Sizable for QuestionnaireChoice { + fn with_size(mut self, size: impl Into) -> Self { + self.size = Some(size.into()); + self + } +} + impl Styled for QuestionnaireChoice { fn style(&mut self) -> &mut StyleRefinement { &mut self.style @@ -644,7 +836,8 @@ impl RenderOnce for QuestionnaireChoice { let radius = cx.theme().semantic_tokens().radius; let indicator_background = cx.theme().input_background(); let mono_font = cx.theme().semantic_tokens().typography.mono.clone(); - let metrics = QuestionnaireMetrics::new(cx); + let size = resolve_size(self.size, &self.state, cx); + let metrics = QuestionnaireMetrics::new(size, cx); let answer_alignment_offset = metrics.content_gap; let focused = focus_handle .as_ref() @@ -708,11 +901,13 @@ impl RenderOnce for QuestionnaireChoice { .when(!has_children, |this| { this.child(text_style( div().text_color(colors.foreground).child(label.clone()), + size, cx, )) .when_some(description.clone(), |this, description| { this.child(secondary_text_style( div().text_color(colors.muted_foreground).child(description), + size, cx, )) }) @@ -799,6 +994,7 @@ impl RenderOnce for QuestionnaireChoice { #[derive(IntoElement)] pub struct QuestionnaireChoiceDescription { style: StyleRefinement, + size: Option, children: Vec, } @@ -806,6 +1002,7 @@ impl QuestionnaireChoiceDescription { pub fn new() -> Self { Self { style: StyleRefinement::default(), + size: None, children: Vec::new(), } } @@ -817,6 +1014,13 @@ impl Default for QuestionnaireChoiceDescription { } } +impl Sizable for QuestionnaireChoiceDescription { + fn with_size(mut self, size: impl Into) -> Self { + self.size = Some(size.into()); + self + } +} + impl Styled for QuestionnaireChoiceDescription { fn style(&mut self) -> &mut StyleRefinement { &mut self.style @@ -832,9 +1036,13 @@ impl ParentElement for QuestionnaireChoiceDescription { impl RenderOnce for QuestionnaireChoiceDescription { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let colors = cx.theme().semantic_tokens().colors; - secondary_text_style(div().text_color(colors.muted_foreground), cx) - .refine_style(&self.style) - .children(self.children) + secondary_text_style( + div().text_color(colors.muted_foreground), + self.size.unwrap_or(Size::Medium), + cx, + ) + .refine_style(&self.style) + .children(self.children) } } @@ -844,6 +1052,7 @@ pub struct QuestionnaireInput { state: Entity, item: SharedString, style: StyleRefinement, + size: Option, } impl QuestionnaireInput { @@ -852,10 +1061,18 @@ impl QuestionnaireInput { state: state.clone(), item: item.into(), style: StyleRefinement::default(), + size: None, } } } +impl Sizable for QuestionnaireInput { + fn with_size(mut self, size: impl Into) -> Self { + self.size = Some(size.into()); + self + } +} + impl Styled for QuestionnaireInput { fn style(&mut self) -> &mut StyleRefinement { &mut self.style @@ -880,9 +1097,18 @@ impl RenderOnce for QuestionnaireInput { return gpui::Empty.into_any_element(); } + let size = resolve_size(self.size, &self.state, cx); + let metrics = QuestionnaireMetrics::new(size, cx); + Input::new(input_definition.state()) .aria_label(input_definition.accessibility_label().clone()) .disabled(item_state.is_disabled() || input_definition.is_disabled()) + .with_size(size) + // The freeform answer is one of the answers, so its text starts + // where a choice's label does: past the card padding, the + // indicator, and the gap between them. + .pl(metrics.choice_padding_x + metrics.indicator_size + metrics.choice_gap) + .rounded(metrics.choice_radius) .when(item_state.is_invalid(), |this| { this.border_color(cx.theme().semantic_tokens().colors.destructive) }) @@ -897,6 +1123,7 @@ pub struct QuestionnaireError { state: Entity, item: SharedString, style: StyleRefinement, + size: Option, children: Vec, } @@ -906,11 +1133,19 @@ impl QuestionnaireError { state: state.clone(), item: item.into(), style: StyleRefinement::default(), + size: None, children: Vec::new(), } } } +impl Sizable for QuestionnaireError { + fn with_size(mut self, size: impl Into) -> Self { + self.size = Some(size.into()); + self + } +} + impl Styled for QuestionnaireError { fn style(&mut self) -> &mut StyleRefinement { &mut self.style @@ -951,11 +1186,13 @@ impl RenderOnce for QuestionnaireError { let has_children = !self.children.is_empty(); let colors = cx.theme().semantic_tokens().colors; let spacing = cx.theme().semantic_tokens().spacing; + let size = resolve_size(self.size, &self.state, cx); - text_style( + secondary_text_style( questionnaire_error_root(element_id(&self.state, format!("error-{}", self.item))) .mt(spacing.sm) .text_color(colors.destructive), + size, cx, ) .refine_style(&self.style) @@ -972,6 +1209,7 @@ impl RenderOnce for QuestionnaireError { pub struct QuestionnaireActions { state: Entity, style: StyleRefinement, + size: Option, children: Vec, } @@ -980,11 +1218,19 @@ impl QuestionnaireActions { Self { state: state.clone(), style: StyleRefinement::default(), + size: None, children: Vec::new(), } } } +impl Sizable for QuestionnaireActions { + fn with_size(mut self, size: impl Into) -> Self { + self.size = Some(size.into()); + self + } +} + impl Styled for QuestionnaireActions { fn style(&mut self) -> &mut StyleRefinement { &mut self.style @@ -999,7 +1245,7 @@ impl ParentElement for QuestionnaireActions { impl RenderOnce for QuestionnaireActions { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let metrics = QuestionnaireMetrics::new(cx); + let metrics = QuestionnaireMetrics::new(resolve_size(self.size, &self.state, cx), cx); let debug_selector = format!("questionnaire-{}-actions", self.state.entity_id()); div() .id(element_id(&self.state, "actions")) @@ -1029,7 +1275,7 @@ macro_rules! questionnaire_action_part { pub struct $name { state: Entity, style: StyleRefinement, - size: Size, + size: Option, children: Vec, } @@ -1038,7 +1284,7 @@ macro_rules! questionnaire_action_part { Self { state: state.clone(), style: StyleRefinement::default(), - size: Size::Medium, + size: None, children: Vec::new(), } } @@ -1052,7 +1298,7 @@ macro_rules! questionnaire_action_part { impl Sizable for $name { fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); + self.size = Some(size.into()); self } } @@ -1093,7 +1339,7 @@ macro_rules! questionnaire_action_part { ); Button::new(element_id(&self.state, stringify!($action))) .debug_selector(move || debug_selector) - .with_size(self.size) + .with_size(resolve_size(self.size, &self.state, cx)) .when($outline, |this| this.outline()) .when($primary, |this| this.primary()) .when(anchors_trailing_actions, |this| this.ml_auto()) @@ -1404,6 +1650,84 @@ mod tests { assert!(next.right() < actions.right()); } + struct ScaleHarness { + state: Entity, + root_size: Size, + part_size: Option, + } + + impl Render for ScaleHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let part_size = self.part_size; + Questionnaire::new(&self.state) + .with_size(self.root_size) + .size(px(480.)) + .child( + QuestionnaireItem::new(&self.state, "scale") + .when_some(part_size, |this, size| this.with_size(size)) + .child( + QuestionnaireChoices::new(&self.state, "scale") + .when_some(part_size, |this, size| this.with_size(size)) + .child( + div() + .id("scale-choice") + .debug_selector(|| "scale-choice".to_string()) + .child( + QuestionnaireChoice::new(&self.state, "scale", "alpha") + .when_some(part_size, |this, size| { + this.with_size(size) + }), + ), + ), + ), + ) + } + } + + fn scale_harness( + cx: &mut TestAppContext, + root_size: Size, + part_size: Option, + ) -> gpui::Bounds { + cx.update(crate::init); + let (_view, cx) = cx.add_window_view(move |_, cx| { + let state = cx.new(|cx| { + QuestionnaireState::new( + vec![ + QuestionnaireItemDefinition::new("scale", "Scale") + .with_choice(QuestionnaireChoiceDefinition::new("alpha", "Alpha")), + ], + cx, + ) + .unwrap() + }); + ScaleHarness { + state, + root_size, + part_size, + } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.debug_bounds("scale-choice").expect("choice rendered") + } + + #[gpui::test] + fn parts_take_their_scale_from_the_root_and_a_part_may_override_it(cx: &mut TestAppContext) { + // A part left alone must land exactly where the same part told to use + // the root's size lands: the root publishes the scale for all of them. + let inherited = scale_harness(cx, Size::Small, None); + let explicit = scale_harness(cx, Size::Small, Some(Size::Small)); + assert_eq!(inherited.size, explicit.size); + + // A different root scale has to move the part, or nothing was inherited. + let large_root = scale_harness(cx, Size::Large, None); + assert!(large_root.size.height > inherited.size.height); + + // A part that names its own size keeps it whatever the root says. + let overridden = scale_harness(cx, Size::Large, Some(Size::Small)); + assert_eq!(overridden.size, inherited.size); + } + #[gpui::test] fn shortcut_guards_held_keys_before_activation(cx: &mut TestAppContext) { let (cx, state) = visual_harness( diff --git a/crates/story/src/stories/questionnaire_story.rs b/crates/story/src/stories/questionnaire_story.rs index 14c91eb05f..1a909585e9 100644 --- a/crates/story/src/stories/questionnaire_story.rs +++ b/crates/story/src/stories/questionnaire_story.rs @@ -236,17 +236,20 @@ impl QuestionnaireStory { size: Size, items: &[(&'static str, &'static [&'static str])], ) -> Questionnaire { - let mut questionnaire = Questionnaire::new(state).child(QuestionnaireProgress::new(state)); + // The root is the only place the scale is named; every part follows it. + let mut questionnaire = Questionnaire::new(state) + .with_size(size) + .child(QuestionnaireProgress::new(state)); for (name, choices) in items { questionnaire = questionnaire.child(Self::item_view(state, name, choices.iter().copied())); } questionnaire.child( QuestionnaireActions::new(state) - .child(QuestionnairePrevious::new(state).with_size(size)) - .child(QuestionnaireSkip::new(state).with_size(size)) - .child(QuestionnaireNext::new(state).with_size(size)) - .child(QuestionnaireSubmit::new(state).with_size(size)), + .child(QuestionnairePrevious::new(state)) + .child(QuestionnaireSkip::new(state)) + .child(QuestionnaireNext::new(state)) + .child(QuestionnaireSubmit::new(state)), ) } @@ -584,6 +587,7 @@ impl Render for QuestionnaireStory { .item_state("environment") .is_some_and(|item| !item.is_disabled()); let custom_control = Questionnaire::new(&control_state) + .with_size(self.size) .child(QuestionnaireProgress::new(&control_state)) .child(Self::item_view( &control_state, @@ -606,7 +610,6 @@ impl Render for QuestionnaireStory { actions.child( Button::new("questionnaire-custom-previous") .outline() - .with_size(self.size) .label("Back") .on_click({ let state = control_state.clone(); @@ -622,7 +625,6 @@ impl Render for QuestionnaireStory { actions.child( Button::new("questionnaire-custom-skip") .outline() - .with_size(self.size) .ml_auto() .label("Not now") .on_click({ @@ -639,7 +641,6 @@ impl Render for QuestionnaireStory { actions.child( Button::new("questionnaire-custom-next") .primary() - .with_size(self.size) .when(!control_skip_visible, |button| button.ml_auto()) .label("Continue") .on_click({ @@ -656,7 +657,6 @@ impl Render for QuestionnaireStory { actions.child( Button::new("questionnaire-custom-submit") .primary() - .with_size(self.size) .when(!control_skip_visible, |button| button.ml_auto()) .label("Finish") .on_click({ @@ -686,6 +686,7 @@ impl Render for QuestionnaireStory { let custom_choice_state = self.custom_choice_state.clone(); let custom_choice = Questionnaire::new(&custom_choice_state) + .with_size(self.size) .child( QuestionnaireItem::new(&custom_choice_state, "custom") .child(QuestionnaireTitle::new(&custom_choice_state, "custom")) @@ -739,7 +740,7 @@ impl Render for QuestionnaireStory { ) .child( QuestionnaireActions::new(&custom_choice_state) - .child(QuestionnaireSubmit::new(&custom_choice_state).with_size(self.size)), + .child(QuestionnaireSubmit::new(&custom_choice_state)), ); let dialog_content_state = self.dialog_state.clone(); @@ -762,6 +763,7 @@ impl Render for QuestionnaireStory { ) .child( Questionnaire::new(&dialog_content_state) + .with_size(Size::Small) .child( QuestionnaireProgress::new(&dialog_content_state), ) @@ -781,7 +783,6 @@ impl Render for QuestionnaireStory { DialogClose::new().child( Button::new("questionnaire-dialog-cancel") .outline() - .with_size(Size::Small) .label("Cancel"), ), ) @@ -789,15 +790,12 @@ impl Render for QuestionnaireStory { QuestionnaireActions::new(&dialog_content_state) .child( QuestionnairePrevious::new(&dialog_content_state) - .with_size(Size::Small), ) .child( QuestionnaireNext::new(&dialog_content_state) - .with_size(Size::Small), ) .child( QuestionnaireSubmit::new(&dialog_content_state) - .with_size(Size::Small), ), ), ) diff --git a/website/component/questionnaire.md b/website/component/questionnaire.md index eeeecbda95..c7c3fa9156 100644 --- a/website/component/questionnaire.md +++ b/website/component/questionnaire.md @@ -480,28 +480,42 @@ or its labels and its selected step drift apart from the questionnaire. ## Sizes and theming -The questionnaire skin has one scale. 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 -density and shape by changing the theme rather than by passing a size to every -part. Answer text matches the Checkbox and Radio family's medium label, which -makes a choice card slightly taller than the upstream skin's; the card keeps a -minimum height so a short answer still reads as a full row. - -`Sizable` reaches only the navigation buttons, which pass the size through to -`Button`: +`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. ```rust use gpui_kit::component::{Sizable as _, Size}; -QuestionnaireActions::new(&state) - .child(QuestionnairePrevious::new(&state).with_size(Size::Small)) - .child(QuestionnaireNext::new(&state).with_size(Size::Small)); +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")), + ), + ); ``` -Use `Styled` methods or `StyleRefinement` for local adjustments; local style +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 diff --git a/website/zh-CN/component/questionnaire.md b/website/zh-CN/component/questionnaire.md index d1f989ec28..8d26e78fd0 100644 --- a/website/zh-CN/component/questionnaire.md +++ b/website/zh-CN/component/questionnaire.md @@ -450,24 +450,38 @@ Progress::new("questionnaire-progress").value(percent); ## 尺寸与主题 -Questionnaire 皮肤只有一套比例。spacing、typography、radius、border、input、 -primary、muted、destructive 和 focus ring 全部取自当前主题的 semantic tokens, -应用通过调整主题来改变问卷的密度与形状,而不是给每个部件传 size。答案文字与 -Checkbox、Radio 家族的 medium label 一致,因此选项卡片会比上游皮肤略高;卡片仍 -保留最小高度,使内容很短的选项也是完整的一行。 - -`Sizable` 只作用于导航按钮,它们会把 size 透传给 `Button`: +`Questionnaire` 接受整份问卷的比例,该问卷的所有部件都会跟随 —— root 会把 size +记录在它的 state 上,因此组合部件不需要被逐个告知。部件自己声明的 size 优先。 ```rust use gpui_kit::component::{Sizable as _, Size}; -QuestionnaireActions::new(&state) - .child(QuestionnairePrevious::new(&state).with_size(Size::Small)) - .child(QuestionnaireNext::new(&state).with_size(Size::Small)); +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、 From bce64391c1ec6cfe71962c76b991ea310f9bd323 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Sat, 19 Sep 2026 13:26:49 +0800 Subject: [PATCH 14/17] questionnaire: Register the flow with the component shell Co-Authored-By: Claude Opus 5 (1M context) --- .../component-shell/component-inventory.json | 70 +++ crates/component-shell/src/shell/mod.rs | 2 + .../src/shell/questionnaire/mod.rs | 530 ++++++++++++++++++ .../tests/questionnaire_host.rs | 107 ++++ examples/js_story/catalog.js | 1 + examples/js_story/stories/coverage.js | 1 + examples/js_story/stories/inputs.js | 10 + examples/js_story/stories/registered.js | 50 ++ examples/js_story/stories/status.js | 1 + 9 files changed, 772 insertions(+) create mode 100644 crates/component-shell/src/shell/questionnaire/mod.rs create mode 100644 crates/component-shell/tests/questionnaire_host.rs diff --git a/crates/component-shell/component-inventory.json b/crates/component-shell/component-inventory.json index 4f9a0651c1..94a156731c 100644 --- a/crates/component-shell/component-inventory.json +++ b/crates/component-shell/component-inventory.json @@ -683,6 +683,41 @@ ] } }, + { + "source": "ui", + "name": "questionnaire", + "classification": "component", + "registration": { + "status": "registered", + "descriptor": "Questionnaire", + "exports": [ + "Questionnaire" + ], + "related": [ + { + "descriptor": "QuestionnaireItem", + "exports": [ + "QuestionnaireItem" + ], + "role": "typed-item" + }, + { + "descriptor": "QuestionnaireChoice", + "exports": [ + "QuestionnaireChoice" + ], + "role": "typed-item" + }, + { + "descriptor": "QuestionnaireInput", + "exports": [ + "QuestionnaireInput" + ], + "role": "typed-item" + } + ] + } + }, { "source": "ui", "name": "radio", @@ -1956,6 +1991,41 @@ ] } }, + { + "source": "story", + "name": "questionnaire", + "classification": "component", + "registration": { + "status": "registered", + "descriptor": "Questionnaire", + "exports": [ + "Questionnaire" + ], + "related": [ + { + "descriptor": "QuestionnaireItem", + "exports": [ + "QuestionnaireItem" + ], + "role": "typed-item" + }, + { + "descriptor": "QuestionnaireChoice", + "exports": [ + "QuestionnaireChoice" + ], + "role": "typed-item" + }, + { + "descriptor": "QuestionnaireInput", + "exports": [ + "QuestionnaireInput" + ], + "role": "typed-item" + } + ] + } + }, { "source": "story", "name": "radio", diff --git a/crates/component-shell/src/shell/mod.rs b/crates/component-shell/src/shell/mod.rs index ddb329abb0..e4944ec323 100644 --- a/crates/component-shell/src/shell/mod.rs +++ b/crates/component-shell/src/shell/mod.rs @@ -32,6 +32,7 @@ pub(super) fn register(registry: &mut ComponentRegistry) -> Result<(), RegistryE basic::register(registry)?; chart::register(registry)?; carousel::register(registry)?; + questionnaire::register(registry)?; Ok(()) } @@ -59,6 +60,7 @@ mod lifecycle; mod media; mod navigation; mod overlays; +mod questionnaire; mod retained_forms; mod scroll; mod separator; diff --git a/crates/component-shell/src/shell/questionnaire/mod.rs b/crates/component-shell/src/shell/questionnaire/mod.rs new file mode 100644 index 0000000000..9529a9d2eb --- /dev/null +++ b/crates/component-shell/src/shell/questionnaire/mod.rs @@ -0,0 +1,530 @@ +//! Questionnaire: typed question and choice data plus the retained flow. +//! +//! The schema a `QuestionnaireState` is built from is a tree of plain Rust +//! values, and a state factory cannot take elements, so the questions arrive as +//! typed children the way `Tree`'s items do. The root builds the state, keeps +//! it across frames, and renders the default composition: progress, the active +//! question with its title, description, answers and error, then the +//! navigation actions. + +use std::sync::Arc; + +use gpui_component::questionnaire::{ + Questionnaire, QuestionnaireActions, QuestionnaireChoice, QuestionnaireChoiceDefinition, + QuestionnaireChoices, QuestionnaireDescription, QuestionnaireError, QuestionnaireInput, + QuestionnaireInputDefinition, QuestionnaireItem, QuestionnaireItemDefinition, + QuestionnaireNext, QuestionnairePrevious, QuestionnaireProgress, QuestionnaireShortcutMode, + QuestionnaireSkip, QuestionnaireState, QuestionnaireSubmit, QuestionnaireTitle, +}; +use gpui_shell::{ + ArgumentDescriptor, ArgumentSchema, ComponentArgument, ComponentDescriptor, + ComponentMaterializer, ComponentPayload, ComponentRegistry, ConstructorDescriptor, + MaterializeRequest, MethodDescriptor, RegistryError, anyhow, + gpui::{ + self, AppContext as _, IntoElement as _, ParentElement as _, Refineable as _, Styled as _, + }, +}; + +use super::support::{bool_method, require_child, string_method}; +use super::typed_child::{Carrier, take}; + +#[derive(Clone)] +struct ChoicePayload { + value: String, + label: String, +} + +#[derive(Clone)] +enum ChoiceOp { + Description(String), + Disabled(bool), + DefaultSelected(bool), +} + +#[derive(Clone)] +struct ItemPayload { + name: String, + label: String, +} + +#[derive(Clone)] +enum ItemOp { + Description(String), + Required(bool), + Multiple(bool), + Disabled(bool), +} + +#[derive(Clone)] +struct InputPayload { + state: ComponentArgument, + label: String, +} + +#[derive(Clone)] +struct RootPayload(String); + +#[derive(Clone, Copy)] +enum RootOp { + Shortcuts(QuestionnaireShortcutMode), +} + +/// What the retained state was built from. A question or choice that changed +/// means a different questionnaire, and `QuestionnaireState` fixes its schema +/// at construction, so the state is rebuilt rather than patched. +#[derive(Clone, PartialEq, Eq)] +struct Fingerprint { + shortcuts: Option<&'static str>, + items: Vec, +} + +#[derive(Clone, PartialEq, Eq)] +struct ItemFingerprint { + name: String, + label: String, + description: Option, + required: bool, + multiple: bool, + disabled: bool, + input: Option<(u64, String)>, + choices: Vec, +} + +#[derive(Clone, PartialEq, Eq)] +struct ChoiceFingerprint { + value: String, + label: String, + description: Option, + disabled: bool, + default_selected: bool, +} + +struct RetainedQuestionnaire { + native: gpui::Entity, + fingerprint: Fingerprint, + /// A schema the state refused. The flow renders nothing and the script + /// hears why, instead of a questionnaire that silently lost a question. + error: Option, +} + +struct ChoiceMaterializer; + +impl ComponentMaterializer for ChoiceMaterializer { + fn materialize(&self, mut request: MaterializeRequest<'_>) -> anyhow::Result { + let payload = request + .payload() + .downcast_ref::() + .ok_or_else(|| anyhow::anyhow!("QuestionnaireChoice received an incompatible payload"))? + .clone(); + let mut choice = QuestionnaireChoiceDefinition::new(payload.value, payload.label); + for operation in request + .methods() + .filter_map(|method| method.payload().downcast_ref::().cloned()) + .collect::>() + { + choice = match operation { + ChoiceOp::Description(text) => choice.with_description(text), + ChoiceOp::Disabled(value) => choice.with_disabled(value), + ChoiceOp::DefaultSelected(value) => choice.with_default_selected(value), + }; + } + super::support::reject_style(request.take_style(), "QuestionnaireChoice")?; + Ok(Carrier::new(choice).into_any_element()) + } +} + +struct InputMaterializer; + +impl ComponentMaterializer for InputMaterializer { + fn materialize(&self, mut request: MaterializeRequest<'_>) -> anyhow::Result { + let payload = request + .payload() + .downcast_ref::() + .ok_or_else(|| anyhow::anyhow!("QuestionnaireInput received an incompatible payload"))? + .clone(); + let state = request.with_state::, _>( + &payload.state, + Clone::clone, + )?; + let mut input = QuestionnaireInputDefinition::new(state, payload.label); + for operation in request + .methods() + .filter_map(|method| method.payload().downcast_ref::().copied()) + .collect::>() + { + input = input.with_disabled(operation); + } + super::support::reject_style(request.take_style(), "QuestionnaireInput")?; + Ok(Carrier::new(input).into_any_element()) + } +} + +struct ItemMaterializer; + +impl ComponentMaterializer for ItemMaterializer { + fn materialize(&self, mut request: MaterializeRequest<'_>) -> anyhow::Result { + let payload = request + .payload() + .downcast_ref::() + .ok_or_else(|| anyhow::anyhow!("QuestionnaireItem received an incompatible payload"))? + .clone(); + let operations = request + .methods() + .filter_map(|method| method.payload().downcast_ref::().cloned()) + .collect::>(); + let mut item = QuestionnaireItemDefinition::new(payload.name, payload.label); + for operation in operations { + item = match operation { + ItemOp::Description(text) => item.with_description(text), + ItemOp::Required(value) => item.with_required(value), + ItemOp::Multiple(value) => item.with_multiple(value), + ItemOp::Disabled(value) => item.with_disabled(value), + }; + } + super::support::reject_style(request.take_style(), "QuestionnaireItem")?; + for mut child in request.take_typed_children()? { + let name = child.component_name(); + require_child( + "QuestionnaireItem", + name, + &["QuestionnaireChoice", "QuestionnaireInput"], + )?; + let freeform = name == Some("QuestionnaireInput"); + let mut element = request.materialize_child(&mut child)?; + item = if freeform { + item.with_input(take::( + &mut element, + "QuestionnaireInput", + )?) + } else { + item.with_choice(take::( + &mut element, + "QuestionnaireChoice", + )?) + }; + } + Ok(Carrier::new(item).into_any_element()) + } +} + +struct RootMaterializer; + +impl ComponentMaterializer for RootMaterializer { + fn materialize(&self, mut request: MaterializeRequest<'_>) -> anyhow::Result { + let id = request + .payload() + .downcast_ref::() + .ok_or_else(|| anyhow::anyhow!("Questionnaire received an incompatible payload"))? + .0 + .clone(); + let shortcuts = request + .methods() + .filter_map(|method| method.payload().downcast_ref::().copied()) + .fold(None, |_, operation| match operation { + RootOp::Shortcuts(mode) => Some(mode), + }); + + let mut definitions = Vec::new(); + for mut child in request.take_typed_children()? { + require_child( + "Questionnaire", + child.component_name(), + &["QuestionnaireItem"], + )?; + let mut element = request.materialize_child(&mut child)?; + definitions.push(take::( + &mut element, + "QuestionnaireItem", + )?); + } + + let names: Vec<(gpui::SharedString, Vec)> = definitions + .iter() + .map(|item| { + ( + item.name().clone(), + item.choices() + .iter() + .map(|choice| choice.value().clone()) + .collect(), + ) + }) + .collect(); + let fingerprint = fingerprint(&definitions, shortcuts); + let (state, error) = request.with_window_app(|window, cx| { + let retained = + window.use_keyed_state(format!("shell-questionnaire:{id}"), cx, |_, cx| { + build_retained(definitions.clone(), shortcuts, fingerprint.clone(), cx) + }); + retained.update(cx, |retained, cx| { + if retained.fingerprint != fingerprint { + *retained = + build_retained(definitions.clone(), shortcuts, fingerprint.clone(), cx); + } + }); + let retained = retained.read(cx); + Ok((retained.native.clone(), retained.error.clone())) + })?; + if let Some(error) = error { + anyhow::bail!(error); + } + + let mut questionnaire = + Questionnaire::new(&state).child(QuestionnaireProgress::new(&state)); + for (name, values) in names { + let mut answers = QuestionnaireChoices::new(&state, name.clone()); + for value in values { + answers = answers.child(QuestionnaireChoice::new(&state, name.clone(), value)); + } + questionnaire = questionnaire.child( + QuestionnaireItem::new(&state, name.clone()) + .child(QuestionnaireTitle::new(&state, name.clone())) + .child(QuestionnaireDescription::new(&state, name.clone())) + .child(answers.child(QuestionnaireInput::new(&state, name.clone()))) + .child(QuestionnaireError::new(&state, name)), + ); + } + let mut element = questionnaire.child( + QuestionnaireActions::new(&state) + .child(QuestionnairePrevious::new(&state)) + .child(QuestionnaireSkip::new(&state)) + .child(QuestionnaireNext::new(&state)) + .child(QuestionnaireSubmit::new(&state)), + ); + element.style().refine(&request.take_style()); + Ok(element.into_any_element()) + } +} + +fn build_retained( + definitions: Vec, + shortcuts: Option, + fingerprint: Fingerprint, + cx: &mut gpui::App, +) -> RetainedQuestionnaire { + let mut error = None; + let native = cx.new(|cx| match QuestionnaireState::new(definitions, cx) { + Ok(state) => match shortcuts { + Some(mode) => state.with_shortcuts(mode), + None => state, + }, + Err(schema_error) => { + error = Some(format!("Questionnaire schema is invalid: {schema_error}")); + QuestionnaireState::new(Vec::new(), cx).expect("an empty questionnaire is valid") + } + }); + RetainedQuestionnaire { + native, + fingerprint, + error, + } +} + +fn fingerprint( + definitions: &[QuestionnaireItemDefinition], + shortcuts: Option, +) -> Fingerprint { + Fingerprint { + shortcuts: shortcuts.map(|mode| match mode { + QuestionnaireShortcutMode::Letters => "letters", + QuestionnaireShortcutMode::Numbers => "numbers", + }), + items: definitions + .iter() + .map(|item| ItemFingerprint { + name: item.name().to_string(), + label: item.accessibility_label().to_string(), + description: item.description().map(ToString::to_string), + required: item.is_required(), + multiple: item.is_multiple(), + disabled: item.is_disabled(), + input: item.input().map(|input| { + ( + input.state().entity_id().as_u64(), + input.accessibility_label().to_string(), + ) + }), + choices: item + .choices() + .iter() + .map(|choice| ChoiceFingerprint { + value: choice.value().to_string(), + label: choice.accessibility_label().to_string(), + description: choice.description().map(ToString::to_string), + disabled: choice.is_disabled(), + default_selected: choice.is_default_selected(), + }) + .collect(), + }) + .collect(), + } +} + +pub(super) fn register(registry: &mut ComponentRegistry) -> Result<(), RegistryError> { + registry.register( + ComponentDescriptor::new("QuestionnaireChoice", Arc::new(ChoiceMaterializer)) + .with_constructors(vec![ConstructorDescriptor::new( + "QuestionnaireChoice", + vec![ + ArgumentDescriptor::new("value", ArgumentSchema::String), + ArgumentDescriptor::new("label", ArgumentSchema::String), + ], + |arguments| match arguments { + [ + ComponentArgument::String(value), + ComponentArgument::String(label), + ] if !value.trim().is_empty() && !label.trim().is_empty() => { + Ok(ComponentPayload::new(ChoicePayload { + value: value.clone(), + label: label.clone(), + })) + } + _ => Err("QuestionnaireChoice expects a non-empty value and label".into()), + }, + )]) + .with_methods(vec![ + string_method( + "QuestionnaireChoice", + "description", + "Adds secondary text under the choice label.", + ChoiceOp::Description, + ), + bool_method( + "QuestionnaireChoice", + "disabled", + "Keeps the choice visible but unselectable.", + ChoiceOp::Disabled, + ), + bool_method( + "QuestionnaireChoice", + "default_selected", + "Selects the choice in the questionnaire's initial snapshot.", + ChoiceOp::DefaultSelected, + ), + ]) + .with_documentation( + "Typed answer data for one questionnaire choice; style is rejected.", + ), + )?; + registry.register( + ComponentDescriptor::new("QuestionnaireItem", Arc::new(ItemMaterializer)) + .with_constructors(vec![ConstructorDescriptor::new( + "QuestionnaireItem", + vec![ + ArgumentDescriptor::new("name", ArgumentSchema::String), + ArgumentDescriptor::new("label", ArgumentSchema::String), + ], + |arguments| match arguments { + [ + ComponentArgument::String(name), + ComponentArgument::String(label), + ] if !name.trim().is_empty() && !label.trim().is_empty() => { + Ok(ComponentPayload::new(ItemPayload { + name: name.clone(), + label: label.clone(), + })) + } + _ => Err("QuestionnaireItem expects a non-empty name and label".into()), + }, + )]) + .with_methods(vec![ + string_method( + "QuestionnaireItem", + "description", + "Adds supporting text under the question title.", + ItemOp::Description, + ), + bool_method( + "QuestionnaireItem", + "required", + "Requires an answer and hides Skip for this question.", + ItemOp::Required, + ), + bool_method( + "QuestionnaireItem", + "multiple", + "Accepts more than one choice for this question.", + ItemOp::Multiple, + ), + bool_method( + "QuestionnaireItem", + "disabled", + "Removes the question from progress, navigation and submission.", + ItemOp::Disabled, + ), + ]) + .with_documentation( + "Typed data for one question, holding QuestionnaireChoice and QuestionnaireInput children; style is rejected.", + ), + )?; + registry.register( + ComponentDescriptor::new("QuestionnaireInput", Arc::new(InputMaterializer)) + .with_constructors(vec![ConstructorDescriptor::new( + "QuestionnaireInput", + vec![ + ArgumentDescriptor::new("state", ArgumentSchema::Entity("InputState")), + ArgumentDescriptor::new("label", ArgumentSchema::String), + ], + |arguments| match arguments { + [ + state @ ComponentArgument::Entity { .. }, + ComponentArgument::String(label), + ] if !label.trim().is_empty() => Ok(ComponentPayload::new(InputPayload { + state: state.clone(), + label: label.clone(), + })), + _ => Err( + "QuestionnaireInput expects an InputState entity and a non-empty label" + .into(), + ), + }, + )]) + .with_methods(vec![bool_method( + "QuestionnaireInput", + "disabled", + "Keeps the freeform answer visible but not editable.", + |value| value, + )]) + .with_documentation( + "Typed freeform-answer data for a question, backed by a retained InputState; style is rejected.", + ), + )?; + registry.register( + ComponentDescriptor::new("Questionnaire", Arc::new(RootMaterializer)) + .with_constructors(vec![ConstructorDescriptor::new( + "Questionnaire", + vec![ArgumentDescriptor::new("id", ArgumentSchema::String)], + |arguments| match arguments { + [ComponentArgument::String(id)] if !id.trim().is_empty() => { + Ok(ComponentPayload::new(RootPayload(id.clone()))) + } + _ => Err("Questionnaire expects a non-empty id".into()), + }, + )]) + .with_methods(vec![ + MethodDescriptor::new( + "shortcuts", + vec![ArgumentDescriptor::new( + "mode", + ArgumentSchema::Enum(&["letters", "numbers"]), + )], + |arguments| match arguments { + [ComponentArgument::Enum(mode)] if mode == "letters" => Ok( + ComponentPayload::new(RootOp::Shortcuts(QuestionnaireShortcutMode::Letters)), + ), + [ComponentArgument::Enum(mode)] if mode == "numbers" => Ok( + ComponentPayload::new(RootOp::Shortcuts(QuestionnaireShortcutMode::Numbers)), + ), + _ => Err("Questionnaire.shortcuts expects letters or numbers".into()), + }, + ) + .with_documentation( + "Hands each enabled choice of the active question a letter or number shortcut.", + ), + ]) + .with_documentation( + "Native retained questionnaire keyed by a stable id: it owns answers, validation, navigation, focus and shortcuts, and renders progress, the active question and the navigation actions. Changing the questions rebuilds the flow.", + ), + )?; + Ok(()) +} diff --git a/crates/component-shell/tests/questionnaire_host.rs b/crates/component-shell/tests/questionnaire_host.rs new file mode 100644 index 0000000000..85b2cba4c9 --- /dev/null +++ b/crates/component-shell/tests/questionnaire_host.rs @@ -0,0 +1,107 @@ +use std::{ + fs, + ops::Deref as _, + path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, +}; + +use gpui::{TestAppContext, VisualTestContext}; + +static NEXT_APP: AtomicU64 = AtomicU64::new(0); + +struct TempApp(PathBuf); + +impl TempApp { + fn new(source: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "gpui-component-shell-questionnaire-host-{}-{}", + std::process::id(), + NEXT_APP.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).expect("create temporary application directory"); + fs::write(path.join("main.js"), source).expect("write application entry"); + Self(path) + } +} + +impl Drop for TempApp { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).expect("remove temporary application directory"); + } +} + +/// The script declares the questions; the answers, validation and navigation +/// are the native flow's. This drives it the way a person would — click a +/// choice, then Next — and reads the questionnaire's own progress back. +#[gpui::test] +fn questionnaire_answers_and_advances_from_script_declared_questions(cx: &mut TestAppContext) { + cx.update(gpui_component_shell::init); + let runtime = gpui_component_shell::new_isolated_runtime().expect("runtime"); + let app = TempApp::new( + r##" +import { div, View } from "gpui-kit"; +import { Questionnaire, QuestionnaireItem, QuestionnaireChoice } from "gpui-component"; +export default class QuestionnaireHost extends View { + render() { + return div().w(500).h(400) + .child(new Questionnaire("host-questionnaire").shortcuts("letters") + .child(new QuestionnaireItem("direction", "Which direction?").required(true) + .child(new QuestionnaireChoice("delegation", "Delegation")) + .child(new QuestionnaireChoice("prompts", "Question prompts"))) + .child(new QuestionnaireItem("tools", "Which tools?").multiple(true) + .child(new QuestionnaireChoice("editor", "Editor")) + .child(new QuestionnaireChoice("terminal", "Terminal")) + .child(new QuestionnaireChoice("browser", "Browser")))); + } +} +"##, + ); + let loaded = runtime.load_application(&app.0, "main.js").expect("load"); + let mounted = std::rc::Rc::new(std::cell::RefCell::new(None)); + let capture = mounted.clone(); + let window = cx.add_window(move |window, cx| { + let view = runtime + .mount_application(&loaded, window, cx) + .expect("mount"); + *capture.borrow_mut() = Some(view.clone()); + gpui_component::Root::new(view, window, cx) + }); + let mut context = VisualTestContext::from_window(*window.deref(), cx); + let view = mounted.borrow().clone().unwrap(); + let draw = |context: &mut VisualTestContext| { + context.run_until_parked(); + context.update(|window, cx| window.draw(cx).clear(cx)); + context.update(|_, cx| { + assert_eq!(view.read(cx).build_error(), None); + view.read(cx).snapshot().unwrap().debug_tree() + }) + }; + + // Letter shortcuts are the native flow's, so a badge per enabled choice is + // proof that the script's questions and `shortcuts` reached it. The first + // question has two choices and the second three, which is what tells the + // two apart on screen. + draw(&mut context); + let shortcut = |context: &mut VisualTestContext, key: &str| { + context.debug_bounds(Box::leak(format!("kbd:{key}").into_boxed_str())) + }; + assert!(shortcut(&mut context, "a").is_some()); + assert!(shortcut(&mut context, "b").is_some()); + assert!( + shortcut(&mut context, "c").is_none(), + "the second question must not be in the tree yet" + ); + + // Clicking the first choice answers it and takes focus, then Enter confirms + // it — the whole navigation contract running inside a script-declared + // questionnaire. + let first_choice = shortcut(&mut context, "a").expect("first choice shortcut"); + context.simulate_click(first_choice.center(), gpui::Modifiers::default()); + draw(&mut context); + context.simulate_keystrokes("enter"); + draw(&mut context); + assert!( + shortcut(&mut context, "c").is_some(), + "Enter on a filled answer must advance to the three-choice question" + ); +} diff --git a/examples/js_story/catalog.js b/examples/js_story/catalog.js index f6e0fa1c7b..8ae605d94e 100644 --- a/examples/js_story/catalog.js +++ b/examples/js_story/catalog.js @@ -77,6 +77,7 @@ const RUST_STORY_ORDER = [ "PaginationStory", "PopoverStory", "ProgressStory", + "QuestionnaireStory", "RadioStory", "RatingStory", "ResizableStory", diff --git a/examples/js_story/stories/coverage.js b/examples/js_story/stories/coverage.js index be6227bf25..35122e3999 100644 --- a/examples/js_story/stories/coverage.js +++ b/examples/js_story/stories/coverage.js @@ -49,6 +49,7 @@ export const coveredBy = [ { route: "pagination", registrations: ["Pagination"] }, { route: "popover", registrations: ["Popover"] }, { route: "progress", registrations: ["Progress"] }, + { route: "questionnaire", registrations: ["Questionnaire"] }, { route: "radio", registrations: ["Radio"] }, { route: "rating", registrations: ["Rating"] }, { route: "resizable", registrations: ["Resizable"] }, diff --git a/examples/js_story/stories/inputs.js b/examples/js_story/stories/inputs.js index 9c80e58f3f..052efac5d7 100644 --- a/examples/js_story/stories/inputs.js +++ b/examples/js_story/stories/inputs.js @@ -1,6 +1,16 @@ import { pendingStory } from "./story.js"; export const stories = [ + pendingStory({ + id: "questionnaire", + title: "Questionnaire", + group: "Inputs", + rustStory: "QuestionnaireStory", + description: "Multi-step questions with answers, validation, and navigation.", + states: ["single", "multiple", "freeform", "skip", "validation"], + availability: "pending", + api: "Questionnaire", + }), pendingStory({ id: "input-group", title: "Input Group", diff --git a/examples/js_story/stories/registered.js b/examples/js_story/stories/registered.js index aad2c642f0..34146d7dee 100644 --- a/examples/js_story/stories/registered.js +++ b/examples/js_story/stories/registered.js @@ -93,6 +93,10 @@ import { PieChart, Popover, Progress, + Questionnaire, + QuestionnaireChoice, + QuestionnaireInput, + QuestionnaireItem, RadarChart, Radio, RadioGroup, @@ -236,6 +240,7 @@ const tokenDraft = { export function initializeRegisteredExamples() { retained("token-input", () => { const input = InputState(); input.set_value(tokenDraft); return input; }); + retained("questionnaire-direction", () => InputState("Type another direction…")); retained("token-textarea", () => { const input = TextareaState(); input.set_value(tokenDraft); return input; }); for (const [id, placeholder, value] of inputGroupFields) { retained(`input-group-extra:${id}`, () => InputState(placeholder, value)); @@ -1715,6 +1720,51 @@ export function registeredExamples(surface, cx) { ), }, ]; + case "Questionnaire": + return [ + { + label: "Guided setup", + description: + "One question at a time, with letter shortcuts, a freeform answer, an optional question, and validation on Next.", + element: asElement( + new Questionnaire("registered-questionnaire") + .shortcuts("letters") + .child( + new QuestionnaireItem("direction", "What should we prototype next?") + .required(true) + .description("Choose a direction or write your own.") + .child( + new QuestionnaireChoice("delegation", "Delegation").description( + "Show how work moves to a specialist.", + ), + ) + .child(new QuestionnaireChoice("questions", "Question prompts")) + .child(new QuestionnaireChoice("both", "Both together")) + .child( + new QuestionnaireInput( + retained("questionnaire-direction", () => + InputState("Type another direction…"), + ), + "Another direction", + ), + ), + ) + .child( + new QuestionnaireItem("tools", "Which tools do you use?") + .multiple(true) + .child(new QuestionnaireChoice("editor", "Editor").default_selected(true)) + .child(new QuestionnaireChoice("terminal", "Terminal")) + .child(new QuestionnaireChoice("browser", "Browser").disabled(true)), + ) + .child( + new QuestionnaireItem("tone", "What tone should the interface use?") + .description("This optional question can be skipped.") + .child(new QuestionnaireChoice("direct", "Direct")) + .child(new QuestionnaireChoice("warm", "Warm")), + ), + ), + }, + ]; case "Progress": return [ { diff --git a/examples/js_story/stories/status.js b/examples/js_story/stories/status.js index b87e933585..6d6fa7aaaf 100644 --- a/examples/js_story/stories/status.js +++ b/examples/js_story/stories/status.js @@ -48,6 +48,7 @@ export const REGISTERED_SURFACES = [ "Pagination", "Popover", "Progress", + "Questionnaire", "Radio", "Rating", "Resizable", From 80e42928cb07e34db8620f99523057943cbca2a6 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Sat, 19 Sep 2026 13:47:37 +0800 Subject: [PATCH 15/17] questionnaire: Start the freeform answer on the indicator edge Co-Authored-By: Claude Opus 5 (1M context) --- crates/component/src/questionnaire/components.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/component/src/questionnaire/components.rs b/crates/component/src/questionnaire/components.rs index c5489323c5..39fa641bd3 100644 --- a/crates/component/src/questionnaire/components.rs +++ b/crates/component/src/questionnaire/components.rs @@ -1104,10 +1104,9 @@ impl RenderOnce for QuestionnaireInput { .aria_label(input_definition.accessibility_label().clone()) .disabled(item_state.is_disabled() || input_definition.is_disabled()) .with_size(size) - // The freeform answer is one of the answers, so its text starts - // where a choice's label does: past the card padding, the - // indicator, and the gap between them. - .pl(metrics.choice_padding_x + metrics.indicator_size + metrics.choice_gap) + // The freeform answer is one of the answers, so its text starts on + // the same edge a choice's indicator does — the card padding. + .pl(metrics.choice_padding_x) .rounded(metrics.choice_radius) .when(item_state.is_invalid(), |this| { this.border_color(cx.theme().semantic_tokens().colors.destructive) From cf904c64f8d15fff1fa9637baba5ce497eab692c Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Sat, 19 Sep 2026 13:53:50 +0800 Subject: [PATCH 16/17] questionnaire: Center a choice's indicator and shortcut on the label line Co-Authored-By: Claude Opus 5 (1M context) --- .../component/src/questionnaire/components.rs | 56 ++++++++++++++----- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/crates/component/src/questionnaire/components.rs b/crates/component/src/questionnaire/components.rs index 39fa641bd3..71e70537aa 100644 --- a/crates/component/src/questionnaire/components.rs +++ b/crates/component/src/questionnaire/components.rs @@ -226,6 +226,25 @@ fn title_text_style(element: T, size: Size, cx: &App) -> T { .font_weight(gpui::FontWeight::MEDIUM) } +/// The line box the answer label occupies. An indicator or a shortcut badge +/// centers on that first line, so a two-line answer keeps them beside the +/// label rather than drifting toward the description. +fn answer_line_height(size: Size, cx: &App) -> gpui::Pixels { + let typography = cx.theme().semantic_tokens().typography; + match size { + Size::XSmall => typography.xs.line_height, + Size::Small => typography.sm.line_height, + Size::Large => typography.lg.line_height, + Size::Size(value) => value * 1.5, + Size::Medium => typography.md.line_height, + } +} + +/// How far to push an adornment of `height` down so it centers on that line. +fn center_on_answer_line(height: gpui::Pixels, size: Size, cx: &App) -> gpui::Pixels { + ((answer_line_height(size, cx) - height) * 0.5).max(gpui::Pixels::ZERO) +} + fn apply_text_token(element: T, token: gpui_base::TextStyleToken) -> T { element .text_size(token.size) @@ -838,7 +857,8 @@ impl RenderOnce for QuestionnaireChoice { let mono_font = cx.theme().semantic_tokens().typography.mono.clone(); let size = resolve_size(self.size, &self.state, cx); let metrics = QuestionnaireMetrics::new(size, cx); - let answer_alignment_offset = metrics.content_gap; + let indicator_offset = center_on_answer_line(metrics.indicator_size, size, cx); + let shortcut_offset = center_on_answer_line(metrics.shortcut_size, size, cx); let focused = focus_handle .as_ref() .is_some_and(|focus_handle| focus_handle.is_focused(window)); @@ -865,7 +885,6 @@ impl RenderOnce for QuestionnaireChoice { }) .when(multiple, |this| this.rounded(radius.sm)) .when(!multiple, |this| this.rounded(radius.full)) - .mt(answer_alignment_offset) .refine_style(&self.indicator_style) .when(selected && multiple, |this| { this.child( @@ -886,11 +905,18 @@ impl RenderOnce for QuestionnaireChoice { .into_any_element() }; - let indicator = self - .indicator_renderer - .as_ref() - .map(|renderer| renderer(&choice_state, window, cx)) - .unwrap_or_else(default_indicator); + // The slot, not the element, owns the vertical alignment, so a custom + // indicator lands on the label's line without having to know the metrics. + let indicator = div() + .flex_shrink_0() + .mt(indicator_offset) + .child( + self.indicator_renderer + .as_ref() + .map(|renderer| renderer(&choice_state, window, cx)) + .unwrap_or_else(default_indicator), + ) + .into_any_element(); let content = div() .flex() @@ -935,15 +961,19 @@ impl RenderOnce for QuestionnaireChoice { .text_size(metrics.shortcut_text_size) .font_weight(gpui::FontWeight::MEDIUM) .rounded(metrics.shortcut_radius) - .mt(answer_alignment_offset) .refine_style(&self.shortcut_style) .into_any_element() }; - let shortcut_element = self - .shortcut_renderer - .as_ref() - .map(|renderer| renderer(&choice_state, window, cx)) - .unwrap_or_else(default_shortcut); + let shortcut_element = div() + .flex_shrink_0() + .mt(shortcut_offset) + .child( + self.shortcut_renderer + .as_ref() + .map(|renderer| renderer(&choice_state, window, cx)) + .unwrap_or_else(default_shortcut), + ) + .into_any_element(); let id = element_id(&self.state, format!("choice-{}-{}", self.item, self.value)); let instance_style = self.style.clone(); From cf76949ba19bf6052294dfccebdb8fa575140e2d Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Sat, 19 Sep 2026 14:07:47 +0800 Subject: [PATCH 17/17] questionnaire: Keep the shell binding and its gallery example cheap per frame Co-Authored-By: Claude Opus 5 (1M context) --- .../src/shell/questionnaire/mod.rs | 106 +++++++----------- examples/js_story/stories/registered.js | 16 +-- 2 files changed, 43 insertions(+), 79 deletions(-) diff --git a/crates/component-shell/src/shell/questionnaire/mod.rs b/crates/component-shell/src/shell/questionnaire/mod.rs index 9529a9d2eb..127c1306cd 100644 --- a/crates/component-shell/src/shell/questionnaire/mod.rs +++ b/crates/component-shell/src/shell/questionnaire/mod.rs @@ -69,35 +69,13 @@ enum RootOp { Shortcuts(QuestionnaireShortcutMode), } -/// What the retained state was built from. A question or choice that changed -/// means a different questionnaire, and `QuestionnaireState` fixes its schema -/// at construction, so the state is rebuilt rather than patched. -#[derive(Clone, PartialEq, Eq)] -struct Fingerprint { - shortcuts: Option<&'static str>, - items: Vec, -} - -#[derive(Clone, PartialEq, Eq)] -struct ItemFingerprint { - name: String, - label: String, - description: Option, - required: bool, - multiple: bool, - disabled: bool, - input: Option<(u64, String)>, - choices: Vec, -} - -#[derive(Clone, PartialEq, Eq)] -struct ChoiceFingerprint { - value: String, - label: String, - description: Option, - disabled: bool, - default_selected: bool, -} +/// What the retained state was built from, as one hash. A question or choice +/// that changed means a different questionnaire, and `QuestionnaireState` fixes +/// its schema at construction, so the state is rebuilt rather than patched. +/// +/// This runs on every frame of every questionnaire on screen, so it hashes the +/// schema in place instead of copying it into comparable values. +type Fingerprint = u64; struct RetainedQuestionnaire { native: gpui::Entity, @@ -254,12 +232,11 @@ impl ComponentMaterializer for RootMaterializer { let (state, error) = request.with_window_app(|window, cx| { let retained = window.use_keyed_state(format!("shell-questionnaire:{id}"), cx, |_, cx| { - build_retained(definitions.clone(), shortcuts, fingerprint.clone(), cx) + build_retained(definitions.clone(), shortcuts, fingerprint, cx) }); retained.update(cx, |retained, cx| { if retained.fingerprint != fingerprint { - *retained = - build_retained(definitions.clone(), shortcuts, fingerprint.clone(), cx); + *retained = build_retained(definitions.clone(), shortcuts, fingerprint, cx); } }); let retained = retained.read(cx); @@ -324,40 +301,39 @@ fn fingerprint( definitions: &[QuestionnaireItemDefinition], shortcuts: Option, ) -> Fingerprint { - Fingerprint { - shortcuts: shortcuts.map(|mode| match mode { - QuestionnaireShortcutMode::Letters => "letters", - QuestionnaireShortcutMode::Numbers => "numbers", - }), - items: definitions - .iter() - .map(|item| ItemFingerprint { - name: item.name().to_string(), - label: item.accessibility_label().to_string(), - description: item.description().map(ToString::to_string), - required: item.is_required(), - multiple: item.is_multiple(), - disabled: item.is_disabled(), - input: item.input().map(|input| { - ( - input.state().entity_id().as_u64(), - input.accessibility_label().to_string(), - ) - }), - choices: item - .choices() - .iter() - .map(|choice| ChoiceFingerprint { - value: choice.value().to_string(), - label: choice.accessibility_label().to_string(), - description: choice.description().map(ToString::to_string), - disabled: choice.is_disabled(), - default_selected: choice.is_default_selected(), - }) - .collect(), - }) - .collect(), + use std::hash::{Hash as _, Hasher as _}; + + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + shortcuts + .map(|mode| match mode { + QuestionnaireShortcutMode::Letters => 1u8, + QuestionnaireShortcutMode::Numbers => 2, + }) + .hash(&mut hasher); + for item in definitions { + item.name().as_ref().hash(&mut hasher); + item.accessibility_label().as_ref().hash(&mut hasher); + item.description().map(AsRef::as_ref).hash(&mut hasher); + ( + item.is_required(), + item.is_multiple(), + item.is_disabled(), + item.choices().len(), + ) + .hash(&mut hasher); + if let Some(input) = item.input() { + input.state().entity_id().as_u64().hash(&mut hasher); + input.accessibility_label().as_ref().hash(&mut hasher); + input.is_disabled().hash(&mut hasher); + } + for choice in item.choices() { + choice.value().as_ref().hash(&mut hasher); + choice.accessibility_label().as_ref().hash(&mut hasher); + choice.description().map(AsRef::as_ref).hash(&mut hasher); + (choice.is_disabled(), choice.is_default_selected()).hash(&mut hasher); + } } + hasher.finish() } pub(super) fn register(registry: &mut ComponentRegistry) -> Result<(), RegistryError> { diff --git a/examples/js_story/stories/registered.js b/examples/js_story/stories/registered.js index 34146d7dee..7ecc44c670 100644 --- a/examples/js_story/stories/registered.js +++ b/examples/js_story/stories/registered.js @@ -1725,7 +1725,7 @@ export function registeredExamples(surface, cx) { { label: "Guided setup", description: - "One question at a time, with letter shortcuts, a freeform answer, an optional question, and validation on Next.", + "One question at a time, with letter shortcuts, a freeform answer, and validation on Next.", element: asElement( new Questionnaire("registered-questionnaire") .shortcuts("letters") @@ -1733,13 +1733,8 @@ export function registeredExamples(surface, cx) { new QuestionnaireItem("direction", "What should we prototype next?") .required(true) .description("Choose a direction or write your own.") - .child( - new QuestionnaireChoice("delegation", "Delegation").description( - "Show how work moves to a specialist.", - ), - ) + .child(new QuestionnaireChoice("delegation", "Delegation")) .child(new QuestionnaireChoice("questions", "Question prompts")) - .child(new QuestionnaireChoice("both", "Both together")) .child( new QuestionnaireInput( retained("questionnaire-direction", () => @@ -1749,13 +1744,6 @@ export function registeredExamples(surface, cx) { ), ), ) - .child( - new QuestionnaireItem("tools", "Which tools do you use?") - .multiple(true) - .child(new QuestionnaireChoice("editor", "Editor").default_selected(true)) - .child(new QuestionnaireChoice("terminal", "Terminal")) - .child(new QuestionnaireChoice("browser", "Browser").disabled(true)), - ) .child( new QuestionnaireItem("tone", "What tone should the interface use?") .description("This optional question can be skipped.")