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}