Skip to main content

dioxus_ui_system/organisms/
timeline.rs

1//! Timeline organism component
2//!
3//! A vertical timeline display for showing chronological events.
4//!
5//! # Example
6//! ```rust,ignore
7//! use dioxus_ui_system::organisms::timeline::*;
8//!
9//! rsx! {
10//!     Timeline {
11//!         TimelineItem {
12//!             dot_color: Some(Color::new(34, 197, 94)),
13//!             icon: Some("check".to_string()),
14//!             TimelineContent {
15//!                 h4 { "Project Started" }
16//!                 p { "Initial setup and planning phase" }
17//!                 span { "Jan 15, 2024" }
18//!             }
19//!         }
20//!         TimelineItem {
21//!             TimelineContent {
22//!                 h4 { "Development Phase" }
23//!                 p { "Active development in progress" }
24//!             }
25//!         }
26//!     }
27//! }
28//! ```
29
30use dioxus::prelude::*;
31
32use crate::styles::Style;
33use crate::theme::{use_theme, Color};
34
35/// Timeline position variant
36#[derive(Clone, Copy, PartialEq, Default, Debug)]
37pub enum TimelinePosition {
38    /// Content on the left side only
39    Left,
40    /// Content on the right side only
41    Right,
42    /// Content alternates sides (default)
43    #[default]
44    Alternate,
45}
46
47/// Context to share timeline position with child items
48#[derive(Clone, Copy)]
49pub struct TimelineContext {
50    position: TimelinePosition,
51}
52
53impl TimelineContext {
54    fn new(position: TimelinePosition) -> Self {
55        Self { position }
56    }
57}
58
59/// Hook to access timeline context
60fn use_timeline_context() -> TimelineContext {
61    try_use_context::<TimelineContext>().unwrap_or_else(|| TimelineContext {
62        position: TimelinePosition::default(),
63    })
64}
65
66/// Timeline container props
67#[derive(Props, Clone, PartialEq)]
68pub struct TimelineProps {
69    /// Timeline items as children
70    pub children: Element,
71    /// Position of timeline content
72    #[props(default)]
73    pub position: TimelinePosition,
74}
75
76/// Timeline organism - container for chronological events
77///
78/// Displays a vertical line with events positioned along it.
79/// Supports left, right, or alternating content positioning.
80#[component]
81pub fn Timeline(props: TimelineProps) -> Element {
82    let theme = use_theme();
83    let border_color = theme.tokens.read().colors.border.to_rgba();
84
85    // Provide context for child items
86    use_context_provider(|| TimelineContext::new(props.position.clone()));
87
88    let timeline_style = Style::new().flex().flex_col().relative().py_px(16).w_full();
89
90    rsx! {
91        div {
92            style: timeline_style.build(),
93
94            // Central vertical line (for alternate position)
95            if props.position == TimelinePosition::Alternate {
96                div {
97                    style: "
98                        position: absolute;
99                        left: 50%;
100                        top: 0;
101                        bottom: 0;
102                        width: 2px;
103                        background: {border_color};
104                        transform: translateX(-50%);
105                    ",
106                }
107            }
108
109            {props.children}
110        }
111    }
112}
113
114/// Timeline item props
115#[derive(Props, Clone, PartialEq)]
116pub struct TimelineItemProps {
117    /// Content for this timeline item
118    pub children: Element,
119    /// Custom dot element (replaces default dot)
120    #[props(default)]
121    pub dot: Option<Element>,
122    /// Custom dot color
123    #[props(default)]
124    pub dot_color: Option<Color>,
125    /// Icon name to display inside dot
126    #[props(default)]
127    pub icon: Option<String>,
128    /// Whether this is the last item (no connector line after)
129    #[props(default = false)]
130    pub last: bool,
131}
132
133/// Individual timeline item component
134///
135/// Represents a single event in the timeline with a dot marker
136/// and associated content.
137#[component]
138pub fn TimelineItem(props: TimelineItemProps) -> Element {
139    let context = use_timeline_context();
140    let position = context.position.clone();
141
142    let theme = use_theme();
143    let border_color = theme.tokens.read().colors.border.to_rgba();
144
145    // Determine flex direction based on position
146    let (item_style, content_style) = match position {
147        TimelinePosition::Left => (
148            Style::new()
149                .flex()
150                .flex_row()
151                .relative()
152                .w_full()
153                .mb_px(if props.last { 0 } else { 24 })
154                .build(),
155            "flex: 1; padding-right: 24px; text-align: right;",
156        ),
157        TimelinePosition::Right => (
158            Style::new()
159                .flex()
160                .flex_row()
161                .relative()
162                .w_full()
163                .mb_px(if props.last { 0 } else { 24 })
164                .build(),
165            "flex: 1; padding-left: 24px;",
166        ),
167        TimelinePosition::Alternate => (
168            Style::new()
169                .flex()
170                .flex_row()
171                .relative()
172                .w_full()
173                .mb_px(if props.last { 0 } else { 24 })
174                .build(),
175            "flex: 1; padding: 0 24px;",
176        ),
177    };
178
179    rsx! {
180        div {
181            style: item_style,
182
183            // Content area
184            div {
185                style: content_style,
186                {props.children}
187            }
188
189            // Separator with dot
190            TimelineSeparator {
191                dot: props.dot.clone(),
192                dot_color: props.dot_color.clone(),
193                icon: props.icon.clone(),
194                last: props.last,
195                line_color: border_color.clone(),
196            }
197
198            // Empty space for alternate layout
199            if position == TimelinePosition::Alternate {
200                div {
201                    style: "flex: 1;",
202                }
203            }
204        }
205    }
206}
207
208/// Timeline separator props
209#[derive(Props, Clone, PartialEq)]
210pub struct TimelineSeparatorProps {
211    /// Custom dot element
212    #[props(default)]
213    pub dot: Option<Element>,
214    /// Custom dot color
215    #[props(default)]
216    pub dot_color: Option<Color>,
217    /// Icon name for dot
218    #[props(default)]
219    pub icon: Option<String>,
220    /// Whether this is the last item
221    #[props(default = false)]
222    pub last: bool,
223    /// Line color
224    #[props(default)]
225    pub line_color: Option<String>,
226}
227
228/// Timeline separator component
229///
230/// Contains the dot marker and connecting line between items.
231#[component]
232pub fn TimelineSeparator(props: TimelineSeparatorProps) -> Element {
233    let theme = use_theme();
234    let default_line_color = theme.tokens.read().colors.border.to_rgba();
235    let line_color = props.line_color.clone().unwrap_or(default_line_color);
236
237    let separator_style = Style::new()
238        .flex()
239        .flex_col()
240        .items_center()
241        .relative()
242        .build();
243
244    rsx! {
245        div {
246            style: separator_style,
247
248            // Dot
249            TimelineDot {
250                dot: props.dot.clone(),
251                color: props.dot_color.clone(),
252                icon: props.icon.clone(),
253            }
254
255            // Connector line (if not last item)
256            if !props.last {
257                div {
258                    style: "
259                        width: 2px;
260                        flex: 1;
261                        min-height: 40px;
262                        background: {line_color};
263                        margin-top: 4px;
264                    ",
265                }
266            }
267        }
268    }
269}
270
271/// Timeline dot props
272#[derive(Props, Clone, PartialEq)]
273pub struct TimelineDotProps {
274    /// Custom dot element (replaces default)
275    #[props(default)]
276    pub dot: Option<Element>,
277    /// Dot background color
278    #[props(default)]
279    pub color: Option<Color>,
280    /// Icon name to display inside dot
281    #[props(default)]
282    pub icon: Option<String>,
283    /// Dot size
284    #[props(default = TimelineDotSize::Md)]
285    pub size: TimelineDotSize,
286}
287
288/// Timeline dot size
289#[derive(Clone, PartialEq, Default, Debug)]
290pub enum TimelineDotSize {
291    /// Small dot (12px)
292    Sm,
293    /// Medium dot (16px) - default
294    #[default]
295    Md,
296    /// Large dot (24px)
297    Lg,
298}
299
300/// Timeline dot component
301///
302/// The circular marker displayed on the timeline.
303/// Can display an icon or custom content.
304#[component]
305pub fn TimelineDot(props: TimelineDotProps) -> Element {
306    let theme = use_theme();
307
308    // Use provided color or default to primary
309    let bg_color = props
310        .color
311        .clone()
312        .unwrap_or_else(|| theme.tokens.read().colors.primary.clone())
313        .to_rgba();
314
315    let fg_color = props
316        .color
317        .clone()
318        .map(|c| {
319            // Determine if color is dark to choose appropriate text color
320            let luminance = (0.299 * c.r as f32 + 0.587 * c.g as f32 + 0.114 * c.b as f32) / 255.0;
321            if luminance < 0.5 {
322                "white".to_string()
323            } else {
324                "black".to_string()
325            }
326        })
327        .unwrap_or_else(|| theme.tokens.read().colors.primary_foreground.to_rgba());
328
329    // Size dimensions
330    let (size, font_size) = match props.size {
331        TimelineDotSize::Sm => ("12px", "8px"),
332        TimelineDotSize::Md => ("16px", "10px"),
333        TimelineDotSize::Lg => ("24px", "14px"),
334    };
335
336    // Use custom dot if provided
337    if let Some(custom_dot) = props.dot {
338        return rsx! { {custom_dot} };
339    }
340
341    let dot_style = format!(
342        "width: {}; height: {}; border-radius: 50%; background: {}; display: flex; align-items: center; justify-content: center; flex-shrink: 0; box-shadow: 0 0 0 2px {};",
343        size,
344        size,
345        bg_color,
346        theme.tokens.read().colors.background.to_rgba()
347    );
348
349    rsx! {
350        div {
351            style: dot_style,
352
353            if let Some(icon_name) = props.icon.clone() {
354                // Simple icon display using Unicode symbols
355                span {
356                    style: "color: {fg_color}; font-size: {font_size}; line-height: 1;",
357                    match icon_name.as_str() {
358                        "check" => "✓",
359                        "x" | "close" => "✕",
360                        "plus" => "+",
361                        "minus" => "−",
362                        "star" => "★",
363                        "heart" => "♥",
364                        "bell" => "🔔",
365                        "calendar" => "📅",
366                        "user" => "👤",
367                        "mail" => "✉",
368                        "phone" => "📞",
369                        "location" | "pin" => "📍",
370                        "flag" => "🚩",
371                        "rocket" => "🚀",
372                        "fire" => "🔥",
373                        "bolt" | "lightning" => "⚡",
374                        "info" => "ℹ",
375                        "warning" => "⚠",
376                        "error" | "alert" => "⚠",
377                        _ => "●",
378                    }
379                }
380            } else {
381                // Default filled circle (no content needed)
382            }
383        }
384    }
385}
386
387/// Timeline content props
388#[derive(Props, Clone, PartialEq)]
389pub struct TimelineContentProps {
390    /// Content children
391    pub children: Element,
392    /// Optional title
393    #[props(default)]
394    pub title: Option<String>,
395    /// Optional subtitle/timestamp
396    #[props(default)]
397    pub subtitle: Option<String>,
398    /// Content alignment
399    #[props(default)]
400    pub align: Option<String>,
401}
402
403/// Timeline content component
404///
405/// Wrapper for timeline item content with optional title and subtitle styling.
406#[component]
407pub fn TimelineContent(props: TimelineContentProps) -> Element {
408    let theme = use_theme();
409    let text_color = theme.tokens.read().colors.foreground.to_rgba();
410    let muted_color = theme.tokens.read().colors.muted_foreground.to_rgba();
411
412    let align = props.align.clone().unwrap_or_else(|| "left".to_string());
413
414    let content_style = Style::new()
415        .flex()
416        .flex_col()
417        .gap_px(4)
418        .text_align(&align)
419        .build();
420
421    rsx! {
422        div {
423            style: content_style,
424
425            if let Some(title) = props.title.clone() {
426                h4 {
427                    style: "margin: 0; color: {text_color}; font-size: 16px; font-weight: 600;",
428                    "{title}"
429                }
430            }
431
432            if let Some(subtitle) = props.subtitle.clone() {
433                span {
434                    style: "color: {muted_color}; font-size: 12px;",
435                    "{subtitle}"
436                }
437            }
438
439            div {
440                style: "color: {text_color}; font-size: 14px;",
441                {props.children}
442            }
443        }
444    }
445}
446
447/// Timeline opposite content props
448///
449/// Content displayed on the opposite side of the timeline (for alternate layout)
450#[derive(Props, Clone, PartialEq)]
451pub struct TimelineOppositeContentProps {
452    /// Content children
453    pub children: Element,
454    /// Text alignment
455    #[props(default)]
456    pub align: Option<String>,
457}
458
459/// Timeline opposite content component
460///
461/// Displays content on the opposite side of the timeline item.
462/// Useful for showing timestamps or supplementary information.
463#[component]
464pub fn TimelineOppositeContent(props: TimelineOppositeContentProps) -> Element {
465    let theme = use_theme();
466    let muted_color = theme.tokens.read().colors.muted_foreground.to_rgba();
467
468    let align = props.align.clone().unwrap_or_else(|| "right".to_string());
469
470    let style = Style::new()
471        .flex_grow(1)
472        .px_px(24)
473        .text_align(&align)
474        .build();
475
476    rsx! {
477        div {
478            style: "{style} color: {muted_color}; font-size: 14px;",
479            {props.children}
480        }
481    }
482}
483
484/// Convenience builder for creating timeline items
485#[derive(Clone, PartialEq)]
486pub struct TimelineEvent {
487    /// Event title
488    pub title: String,
489    /// Event description
490    pub description: Option<String>,
491    /// Event timestamp
492    pub timestamp: Option<String>,
493    /// Dot color
494    pub dot_color: Option<Color>,
495    /// Icon name
496    pub icon: Option<String>,
497}
498
499impl TimelineEvent {
500    /// Create a new timeline event
501    pub fn new(title: impl Into<String>) -> Self {
502        Self {
503            title: title.into(),
504            description: None,
505            timestamp: None,
506            dot_color: None,
507            icon: None,
508        }
509    }
510
511    /// Add description
512    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
513        self.description = Some(desc.into());
514        self
515    }
516
517    /// Add timestamp
518    pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
519        self.timestamp = Some(timestamp.into());
520        self
521    }
522
523    /// Set dot color
524    pub fn with_color(mut self, color: Color) -> Self {
525        self.dot_color = Some(color);
526        self
527    }
528
529    /// Set icon
530    pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
531        self.icon = Some(icon.into());
532        self
533    }
534}
535
536/// Simple timeline builder component props
537#[derive(Props, Clone, PartialEq)]
538pub struct SimpleTimelineProps {
539    /// List of timeline events
540    pub events: Vec<TimelineEvent>,
541    /// Timeline position
542    #[props(default)]
543    pub position: TimelinePosition,
544}
545
546/// Simple timeline builder component
547///
548/// Creates a timeline from a list of TimelineEvent items.
549#[component]
550pub fn SimpleTimeline(props: SimpleTimelineProps) -> Element {
551    let events_len = props.events.len();
552
553    rsx! {
554        Timeline {
555            position: props.position.clone(),
556
557            for (index, event) in props.events.iter().enumerate() {
558                TimelineItem {
559                    key: "{index}",
560                    dot_color: event.dot_color.clone(),
561                    icon: event.icon.clone(),
562                    last: index == events_len - 1,
563
564                    TimelineContent {
565                        title: Some(event.title.clone()),
566                        subtitle: event.timestamp.clone(),
567
568                        if let Some(desc) = event.description.clone() {
569                            p {
570                                style: "margin: 4px 0 0 0;",
571                                "{desc}"
572                            }
573                        }
574                    }
575                }
576            }
577        }
578    }
579}