dioxus_ui_system/organisms/
timeline.rs1use dioxus::prelude::*;
31
32use crate::styles::Style;
33use crate::theme::{use_theme, Color};
34
35#[derive(Clone, Copy, PartialEq, Default, Debug)]
37pub enum TimelinePosition {
38 Left,
40 Right,
42 #[default]
44 Alternate,
45}
46
47#[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
59fn use_timeline_context() -> TimelineContext {
61 try_use_context::<TimelineContext>().unwrap_or_else(|| TimelineContext {
62 position: TimelinePosition::default(),
63 })
64}
65
66#[derive(Props, Clone, PartialEq)]
68pub struct TimelineProps {
69 pub children: Element,
71 #[props(default)]
73 pub position: TimelinePosition,
74}
75
76#[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 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 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#[derive(Props, Clone, PartialEq)]
116pub struct TimelineItemProps {
117 pub children: Element,
119 #[props(default)]
121 pub dot: Option<Element>,
122 #[props(default)]
124 pub dot_color: Option<Color>,
125 #[props(default)]
127 pub icon: Option<String>,
128 #[props(default = false)]
130 pub last: bool,
131}
132
133#[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 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 div {
185 style: content_style,
186 {props.children}
187 }
188
189 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 if position == TimelinePosition::Alternate {
200 div {
201 style: "flex: 1;",
202 }
203 }
204 }
205 }
206}
207
208#[derive(Props, Clone, PartialEq)]
210pub struct TimelineSeparatorProps {
211 #[props(default)]
213 pub dot: Option<Element>,
214 #[props(default)]
216 pub dot_color: Option<Color>,
217 #[props(default)]
219 pub icon: Option<String>,
220 #[props(default = false)]
222 pub last: bool,
223 #[props(default)]
225 pub line_color: Option<String>,
226}
227
228#[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 TimelineDot {
250 dot: props.dot.clone(),
251 color: props.dot_color.clone(),
252 icon: props.icon.clone(),
253 }
254
255 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#[derive(Props, Clone, PartialEq)]
273pub struct TimelineDotProps {
274 #[props(default)]
276 pub dot: Option<Element>,
277 #[props(default)]
279 pub color: Option<Color>,
280 #[props(default)]
282 pub icon: Option<String>,
283 #[props(default = TimelineDotSize::Md)]
285 pub size: TimelineDotSize,
286}
287
288#[derive(Clone, PartialEq, Default, Debug)]
290pub enum TimelineDotSize {
291 Sm,
293 #[default]
295 Md,
296 Lg,
298}
299
300#[component]
305pub fn TimelineDot(props: TimelineDotProps) -> Element {
306 let theme = use_theme();
307
308 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 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 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 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 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 }
383 }
384 }
385}
386
387#[derive(Props, Clone, PartialEq)]
389pub struct TimelineContentProps {
390 pub children: Element,
392 #[props(default)]
394 pub title: Option<String>,
395 #[props(default)]
397 pub subtitle: Option<String>,
398 #[props(default)]
400 pub align: Option<String>,
401}
402
403#[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#[derive(Props, Clone, PartialEq)]
451pub struct TimelineOppositeContentProps {
452 pub children: Element,
454 #[props(default)]
456 pub align: Option<String>,
457}
458
459#[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#[derive(Clone, PartialEq)]
486pub struct TimelineEvent {
487 pub title: String,
489 pub description: Option<String>,
491 pub timestamp: Option<String>,
493 pub dot_color: Option<Color>,
495 pub icon: Option<String>,
497}
498
499impl TimelineEvent {
500 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 pub fn with_description(mut self, desc: impl Into<String>) -> Self {
513 self.description = Some(desc.into());
514 self
515 }
516
517 pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
519 self.timestamp = Some(timestamp.into());
520 self
521 }
522
523 pub fn with_color(mut self, color: Color) -> Self {
525 self.dot_color = Some(color);
526 self
527 }
528
529 pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
531 self.icon = Some(icon.into());
532 self
533 }
534}
535
536#[derive(Props, Clone, PartialEq)]
538pub struct SimpleTimelineProps {
539 pub events: Vec<TimelineEvent>,
541 #[props(default)]
543 pub position: TimelinePosition,
544}
545
546#[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}