Skip to main content

ui/components/
stepper.rs

1use gpui::{AnyElement, ClickEvent, white};
2use smallvec::SmallVec;
3
4use crate::prelude::*;
5
6/// A single step within a [`Stepper`].
7pub struct StepperStep {
8    label: SharedString,
9    on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
10}
11
12impl StepperStep {
13    pub fn new(label: impl Into<SharedString>) -> Self {
14        Self {
15            label: label.into(),
16            on_click: None,
17        }
18    }
19
20    pub fn on_click(
21        mut self,
22        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
23    ) -> Self {
24        self.on_click = Some(Box::new(handler));
25        self
26    }
27}
28
29#[derive(Clone, Copy, PartialEq, Eq)]
30enum StepState {
31    Completed,
32    Current,
33    Upcoming,
34}
35
36/// A horizontal row of step circles connected by a line, for multi-step flows
37/// (e.g. checkout, onboarding wizards).
38///
39/// Presentational only: the caller owns the current step index; this
40/// component does not hold any state itself.
41#[derive(IntoElement, RegisterComponent)]
42pub struct Stepper {
43    current_step: usize,
44    steps: SmallVec<[StepperStep; 4]>,
45}
46
47impl Stepper {
48    /// `current_step` is 0-indexed: `0` means the first step is in progress.
49    pub fn new(current_step: usize) -> Self {
50        Self {
51            current_step,
52            steps: SmallVec::new(),
53        }
54    }
55
56    pub fn step(mut self, step: StepperStep) -> Self {
57        self.steps.push(step);
58        self
59    }
60
61    pub fn steps(mut self, steps: impl IntoIterator<Item = StepperStep>) -> Self {
62        self.steps.extend(steps);
63        self
64    }
65}
66
67impl RenderOnce for Stepper {
68    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
69        let total = self.steps.len();
70        let current_step = self.current_step;
71
72        h_flex()
73            .items_start()
74            .children(self.steps.into_iter().enumerate().map(|(index, step)| {
75                let state = if index < current_step {
76                    StepState::Completed
77                } else if index == current_step {
78                    StepState::Current
79                } else {
80                    StepState::Upcoming
81                };
82
83                let (circle_bg, circle_border, accent_color) = match state {
84                    StepState::Completed => (palette::primary(600), palette::primary(600), white()),
85                    StepState::Current => (
86                        gpui::transparent_black(),
87                        palette::primary(600),
88                        palette::primary(600),
89                    ),
90                    StepState::Upcoming => (
91                        gpui::transparent_black(),
92                        semantic::border_muted(cx),
93                        semantic::text_muted(cx),
94                    ),
95                };
96
97                let circle_content = if state == StepState::Completed {
98                    Icon::new(IconName::Check)
99                        .size(IconSize::Small)
100                        .color(Color::Custom(white()))
101                        .into_any_element()
102                } else {
103                    Label::new((index + 1).to_string())
104                        .size(LabelSize::Small)
105                        .color(Color::Custom(accent_color))
106                        .into_any_element()
107                };
108
109                let circle = h_flex()
110                    .id(("stepper-step", index))
111                    .size_8()
112                    .justify_center()
113                    .rounded_full()
114                    .border_2()
115                    .border_color(circle_border)
116                    .bg(circle_bg)
117                    .when(step.on_click.is_some(), |this| this.cursor_pointer())
118                    .child(circle_content)
119                    .when_some(step.on_click, |this, handler| this.on_click(handler));
120
121                h_flex()
122                    .items_start()
123                    .child(
124                        v_flex().items_center().gap_1().child(circle).child(
125                            Label::new(step.label)
126                                .size(LabelSize::XSmall)
127                                .color(Color::Custom(accent_color)),
128                        ),
129                    )
130                    .when(index + 1 < total, |this| {
131                        let line_color = if state == StepState::Completed {
132                            palette::primary(600)
133                        } else {
134                            semantic::border_muted(cx)
135                        };
136                        this.child(div().w_8().h_px().mt(px(16.)).bg(line_color))
137                    })
138            }))
139    }
140}
141
142impl Component for Stepper {
143    fn scope() -> ComponentScope {
144        ComponentScope::Navigation
145    }
146
147    fn description() -> Option<&'static str> {
148        Some("A horizontal row of step circles connected by a line, for multi-step flows.")
149    }
150
151    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
152        let steps = || {
153            [
154                StepperStep::new("Account"),
155                StepperStep::new("Profile"),
156                StepperStep::new("Confirm"),
157            ]
158        };
159
160        Some(
161            v_flex()
162                .gap_6()
163                .child(example_group_with_title(
164                    "Progress States",
165                    vec![
166                        single_example(
167                            "Not Started",
168                            Stepper::new(0).steps(steps()).into_any_element(),
169                        ),
170                        single_example(
171                            "Second Step In Progress",
172                            Stepper::new(1).steps(steps()).into_any_element(),
173                        ),
174                        single_example(
175                            "All Complete",
176                            Stepper::new(3).steps(steps()).into_any_element(),
177                        ),
178                    ],
179                ))
180                .into_any_element(),
181        )
182    }
183}