Skip to main content

teksilo_widgets/stepper/
step.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`Step`] — one page of a [`Stepper`](crate::stepper::Stepper), plus the
5//! per-step [`StepStatus`] state model (Material/Ant/Flutter-style).
6
7use std::rc::Rc;
8
9use teksilo_core::widget::Widget;
10use teksilo_i18n::LocalizedString;
11
12/// Lifecycle state of a single step, surfaced in the indicator strip and
13/// (for the active step) as `aria-current="step"`.
14///
15/// Mirrors the modern stepper status model (Ant `wait/process/finish/error`,
16/// Flutter `StepState`): `Upcoming` = not yet reached, `Active` = currently
17/// shown, `Complete` = validated, `Error` = failed validation, `Disabled` =
18/// unreachable, `Optional` = reachable but skippable, `Skipped` = an optional
19/// step the user bypassed.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum StepStatus {
22    #[default]
23    Upcoming,
24    Active,
25    Complete,
26    Error,
27    Disabled,
28    Optional,
29    Skipped,
30}
31
32impl StepStatus {
33    /// `true` for `Optional` — the only status that surfaces a Skip button.
34    pub fn is_optional(self) -> bool {
35        matches!(self, StepStatus::Optional)
36    }
37}
38
39pub(crate) type StepContentFactory = Rc<dyn Fn() -> Box<dyn Widget>>;
40pub(crate) type StepValidator = Rc<dyn Fn() -> bool>;
41
42/// One page in a [`Stepper`](crate::stepper::Stepper).
43///
44/// A step carries a localized `title`, optional `supporting_text`, a content
45/// factory (the body shown when the step is active), and an optional
46/// completion gate. The recommended data-flow pattern: the application owns
47/// its form state as `Signal`s, the content factory binds widgets to those
48/// signals (write side), and [`complete_when`](Self::complete_when) derives
49/// the Next gate from the same signals.
50#[derive(Clone)]
51pub struct Step {
52    pub(crate) title: LocalizedString,
53    pub(crate) supporting_text: Option<LocalizedString>,
54    pub(crate) content_factory: Option<StepContentFactory>,
55    pub(crate) initial_status: StepStatus,
56    /// Reactive completion gate — when `Some`, the Next button binds its
57    /// enabled state to this signal while this step is active.
58    pub(crate) complete: Option<teksilo_core::signal::Prop<bool>>,
59    /// Imperative fallback — checked on the Next click; if it returns
60    /// `false`, navigation does not advance.
61    pub(crate) validate: Option<StepValidator>,
62    /// Reactive visibility gate — when `Some(false)`, the step drops out of
63    /// the flow (navigation skips it, the indicator strip hides it).
64    pub(crate) visible: Option<teksilo_core::signal::Prop<bool>>,
65}
66
67impl Step {
68    pub fn new(title: impl Into<LocalizedString>) -> Self {
69        Self {
70            title: title.into(),
71            supporting_text: None,
72            content_factory: None,
73            initial_status: StepStatus::Upcoming,
74            complete: None,
75            validate: None,
76            visible: None,
77        }
78    }
79
80    /// The body shown while this step is active. The factory may capture
81    /// clones of the application's form `Signal`s to read/write step input.
82    pub fn content<W, F>(mut self, factory: F) -> Self
83    where
84        W: Widget + 'static,
85        F: Fn() -> W + 'static,
86    {
87        self.content_factory = Some(Rc::new(move || Box::new(factory()) as Box<dyn Widget>));
88        self
89    }
90
91    /// The body shown while this step is active, as a **boxed** widget — the
92    /// escape hatch for a body whose concrete type varies at runtime.
93    ///
94    /// [`content`](Self::content) is generic over one `W: Widget`, and
95    /// `Box<dyn Widget>` does not itself implement `Widget`, so a step whose
96    /// body branches on app state cannot be expressed as a single `content`
97    /// factory. Box each branch instead of duplicating the surrounding
98    /// builder:
99    ///
100    /// ```ignore
101    /// Step::new(lit!("Details")).content_boxed({
102    ///     let purpose = purpose.clone();
103    ///     move || -> Box<dyn Widget> {
104    ///         match purpose.get() {
105    ///             Purpose::Novel => Box::new(novel_form()),
106    ///             Purpose::Import => Box::new(import_form()),
107    ///         }
108    ///     }
109    /// })
110    /// ```
111    pub fn content_boxed(mut self, factory: impl Fn() -> Box<dyn Widget> + 'static) -> Self {
112        self.content_factory = Some(Rc::new(factory));
113        self
114    }
115
116    /// A pre-boxed content factory (used by the `Wizard` bridge).
117    #[allow(dead_code)]
118    pub(crate) fn content_factory_rc(mut self, factory: StepContentFactory) -> Self {
119        self.content_factory = Some(factory);
120        self
121    }
122
123    /// Secondary line under the title in the header / indicator.
124    pub fn supporting_text(mut self, text: impl Into<LocalizedString>) -> Self {
125        self.supporting_text = Some(text.into());
126        self
127    }
128
129    /// Set the step's initial [`StepStatus`].
130    pub fn status(mut self, status: StepStatus) -> Self {
131        self.initial_status = status;
132        self
133    }
134
135    /// Mark the step optional (reachable but skippable — surfaces a Skip
136    /// button while active). Equivalent to `.status(StepStatus::Optional)`.
137    pub fn optional(mut self, optional: bool) -> Self {
138        if optional {
139            self.initial_status = StepStatus::Optional;
140        } else if self.initial_status == StepStatus::Optional {
141            self.initial_status = StepStatus::Upcoming;
142        }
143        self
144    }
145
146    /// Reactive Next gate: while this step is active, Next is enabled iff
147    /// `signal` is `true`. Derive it from the same form signals the step's
148    /// content writes — e.g. `name.map(|n| !n.is_empty())`.
149    pub fn complete_when(mut self, signal: impl Into<teksilo_core::signal::Prop<bool>>) -> Self {
150        self.complete = Some(signal.into());
151        self
152    }
153
154    /// Imperative validation fallback: checked on the Next click. Returning
155    /// `false` blocks navigation. Prefer [`complete_when`](Self::complete_when)
156    /// where a reactive signal is available.
157    pub fn validate_on_next(mut self, f: impl Fn() -> bool + 'static) -> Self {
158        self.validate = Some(Rc::new(f));
159        self
160    }
161
162    /// Reactive visibility: while `visible` is `false` this step drops out of
163    /// the flow — Next / Back / indicator clicks skip it, and its marker is
164    /// hidden from the indicator strip (and from AT).
165    ///
166    /// This is how a **branching** wizard is expressed: declare every step
167    /// once and gate the conditional ones on the choice that selects them,
168    /// instead of maintaining one step list per branch.
169    ///
170    /// ```ignore
171    /// let purpose = Signal::new(Purpose::Novel);
172    /// Stepper::new()
173    ///     .step(Step::new(lit!("Purpose")).content(|| purpose_picker()))
174    ///     .step(Step::new(lit!("Import source"))
175    ///         .visible_when(purpose.map(|p| *p == Purpose::Import))
176    ///         .content(|| import_form()))
177    /// ```
178    ///
179    /// Hiding the step the user is *currently on* does not navigate away from
180    /// it — gate steps ahead of the choice, not the one making it.
181    pub fn visible_when(mut self, visible: impl Into<teksilo_core::signal::Prop<bool>>) -> Self {
182        self.visible = Some(visible.into());
183        self
184    }
185}
186
187impl std::fmt::Debug for Step {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        f.debug_struct("Step")
190            .field("title", &self.title)
191            .field("supporting_text", &self.supporting_text)
192            .field("initial_status", &self.initial_status)
193            .field("has_content", &self.content_factory.is_some())
194            .field("has_complete_gate", &self.complete.is_some())
195            .finish()
196    }
197}