adui_dioxus/components/
timeline.rs

1use crate::components::icon::{Icon, IconKind};
2use crate::theme::use_theme;
3use dioxus::prelude::*;
4
5/// Timeline mode (position of items).
6#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
7pub enum TimelineMode {
8    /// All items on the left (or top in horizontal).
9    #[default]
10    Left,
11    /// All items on the right (or bottom in horizontal).
12    Right,
13    /// Items alternate between left and right.
14    Alternate,
15}
16
17impl TimelineMode {
18    fn as_class(&self) -> &'static str {
19        match self {
20            TimelineMode::Left => "adui-timeline-left",
21            TimelineMode::Right => "adui-timeline-right",
22            TimelineMode::Alternate => "adui-timeline-alternate",
23        }
24    }
25}
26
27/// Timeline orientation.
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
29pub enum TimelineOrientation {
30    #[default]
31    Vertical,
32    Horizontal,
33}
34
35impl TimelineOrientation {
36    fn as_class(&self) -> &'static str {
37        match self {
38            TimelineOrientation::Vertical => "adui-timeline-vertical",
39            TimelineOrientation::Horizontal => "adui-timeline-horizontal",
40        }
41    }
42}
43
44/// Color presets for timeline items.
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub enum TimelineColor {
47    Blue,
48    Green,
49    Red,
50    Gray,
51}
52
53impl TimelineColor {
54    fn as_class(&self) -> &'static str {
55        match self {
56            TimelineColor::Blue => "adui-timeline-item-blue",
57            TimelineColor::Green => "adui-timeline-item-green",
58            TimelineColor::Red => "adui-timeline-item-red",
59            TimelineColor::Gray => "adui-timeline-item-gray",
60        }
61    }
62}
63
64/// Data model for a single timeline item.
65#[derive(Clone, PartialEq)]
66pub struct TimelineItem {
67    pub key: String,
68    pub title: Option<Element>,
69    pub content: Option<Element>,
70    pub icon: Option<Element>,
71    pub color: Option<TimelineColor>,
72    /// Whether this item is in pending/loading state.
73    pub pending: bool,
74}
75
76impl TimelineItem {
77    pub fn new(key: impl Into<String>) -> Self {
78        Self {
79            key: key.into(),
80            title: None,
81            content: None,
82            icon: None,
83            color: None,
84            pending: false,
85        }
86    }
87
88    pub fn title(mut self, title: Element) -> Self {
89        self.title = Some(title);
90        self
91    }
92
93    pub fn content(mut self, content: Element) -> Self {
94        self.content = Some(content);
95        self
96    }
97
98    pub fn icon(mut self, icon: Element) -> Self {
99        self.icon = Some(icon);
100        self
101    }
102
103    pub fn color(mut self, color: TimelineColor) -> Self {
104        self.color = Some(color);
105        self
106    }
107
108    pub fn pending(mut self, pending: bool) -> Self {
109        self.pending = pending;
110        self
111    }
112}
113
114/// Props for the Timeline component.
115#[derive(Props, Clone, PartialEq)]
116pub struct TimelineProps {
117    /// Timeline items to display.
118    pub items: Vec<TimelineItem>,
119    /// Timeline mode (position).
120    #[props(default)]
121    pub mode: TimelineMode,
122    /// Timeline orientation.
123    #[props(default)]
124    pub orientation: TimelineOrientation,
125    /// Whether to reverse the order of items.
126    #[props(default)]
127    pub reverse: bool,
128    /// Extra class name.
129    #[props(optional)]
130    pub class: Option<String>,
131    /// Inline style.
132    #[props(optional)]
133    pub style: Option<String>,
134}
135
136/// Ant Design flavored Timeline component.
137#[component]
138pub fn Timeline(props: TimelineProps) -> Element {
139    let TimelineProps {
140        items,
141        mode,
142        orientation,
143        reverse,
144        class,
145        style,
146    } = props;
147
148    let theme = use_theme();
149    let tokens = theme.tokens();
150
151    // Build root classes
152    let mut class_list = vec!["adui-timeline".to_string()];
153    class_list.push(mode.as_class().to_string());
154    class_list.push(orientation.as_class().to_string());
155    if let Some(extra) = class {
156        class_list.push(extra);
157    }
158    let class_attr = class_list.join(" ");
159
160    let style_attr = style.unwrap_or_default();
161
162    // Prepare items in the correct order
163    let display_items: Vec<&TimelineItem> = if reverse {
164        items.iter().rev().collect()
165    } else {
166        items.iter().collect()
167    };
168
169    rsx! {
170        div {
171            class: "{class_attr}",
172            style: "{style_attr}",
173            role: "list",
174            {display_items.iter().enumerate().map(|(idx, item)| {
175                let key = item.key.clone();
176                let title = item.title.clone();
177                let content = item.content.clone();
178                let icon = item.icon.clone();
179                let color = item.color;
180                let pending = item.pending;
181
182                // Determine position for alternate mode
183                let is_left = match mode {
184                    TimelineMode::Left => true,
185                    TimelineMode::Right => false,
186                    TimelineMode::Alternate => idx % 2 == 0,
187                };
188
189                let mut item_class = vec!["adui-timeline-item".to_string()];
190                if pending {
191                    item_class.push("adui-timeline-item-pending".into());
192                }
193                if is_left {
194                    item_class.push("adui-timeline-item-left".into());
195                } else {
196                    item_class.push("adui-timeline-item-right".into());
197                }
198                if let Some(c) = color {
199                    item_class.push(c.as_class().into());
200                }
201                let item_class_attr = item_class.join(" ");
202
203                rsx! {
204                    div {
205                        key: "{key}",
206                        class: "{item_class_attr}",
207                        role: "listitem",
208                        div { class: "adui-timeline-item-tail",
209                            style: "border-left-color: {tokens.color_border};"
210                        },
211                        div { class: "adui-timeline-item-head",
212                            style: "border-color: {tokens.color_border};",
213                            {if let Some(icon_elem) = icon {
214                                Some(rsx! {
215                                    span { class: "adui-timeline-item-icon",
216                                        {icon_elem}
217                                    }
218                                })
219                            } else if pending {
220                                Some(rsx! {
221                                    span { class: "adui-timeline-item-icon",
222                                        Icon {
223                                            kind: IconKind::Loading,
224                                            spin: true,
225                                        }
226                                    }
227                                })
228                            } else {
229                                None
230                            }}
231                        },
232                        div { class: "adui-timeline-item-content",
233                            {title.map(|t| rsx! {
234                                div { class: "adui-timeline-item-title",
235                                    {t}
236                                }
237                            })},
238                            {content.map(|c| rsx! {
239                                div { class: "adui-timeline-item-description",
240                                    {c}
241                                }
242                            })},
243                        }
244                    }
245                }
246            })}
247        }
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn timeline_mode_class_mapping_is_stable() {
257        assert_eq!(TimelineMode::Left.as_class(), "adui-timeline-left");
258        assert_eq!(TimelineMode::Right.as_class(), "adui-timeline-right");
259        assert_eq!(
260            TimelineMode::Alternate.as_class(),
261            "adui-timeline-alternate"
262        );
263    }
264
265    #[test]
266    fn timeline_orientation_class_mapping_is_stable() {
267        assert_eq!(
268            TimelineOrientation::Vertical.as_class(),
269            "adui-timeline-vertical"
270        );
271        assert_eq!(
272            TimelineOrientation::Horizontal.as_class(),
273            "adui-timeline-horizontal"
274        );
275    }
276
277    #[test]
278    fn timeline_color_class_mapping_is_stable() {
279        assert_eq!(TimelineColor::Blue.as_class(), "adui-timeline-item-blue");
280        assert_eq!(TimelineColor::Green.as_class(), "adui-timeline-item-green");
281        assert_eq!(TimelineColor::Red.as_class(), "adui-timeline-item-red");
282        assert_eq!(TimelineColor::Gray.as_class(), "adui-timeline-item-gray");
283    }
284}