Skip to main content

dioxus_ui_system/organisms/
kanban.rs

1//! Kanban Board organism component
2//!
3//! A drag-and-drop style board with columns and cards for task management.
4
5use crate::atoms::{Button, ButtonSize, ButtonVariant, Icon, IconColor, IconSize, Label, TextSize};
6use crate::styles::Style;
7use crate::theme::{use_style, use_theme};
8use dioxus::prelude::*;
9
10/// Kanban card data
11#[derive(Clone, PartialEq, Debug)]
12pub struct KanbanCard {
13    pub id: String,
14    pub title: String,
15    pub description: Option<String>,
16    pub tags: Vec<String>,
17    pub assignee: Option<String>,
18    pub due_date: Option<String>,
19}
20
21impl KanbanCard {
22    /// Create a new kanban card
23    pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
24        Self {
25            id: id.into(),
26            title: title.into(),
27            description: None,
28            tags: Vec::new(),
29            assignee: None,
30            due_date: None,
31        }
32    }
33
34    /// Set description
35    pub fn with_description(mut self, description: impl Into<String>) -> Self {
36        self.description = Some(description.into());
37        self
38    }
39
40    /// Set tags
41    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
42        self.tags = tags;
43        self
44    }
45
46    /// Set assignee (avatar URL or initials)
47    pub fn with_assignee(mut self, assignee: impl Into<String>) -> Self {
48        self.assignee = Some(assignee.into());
49        self
50    }
51
52    /// Set due date
53    pub fn with_due_date(mut self, due_date: impl Into<String>) -> Self {
54        self.due_date = Some(due_date.into());
55        self
56    }
57}
58
59/// Kanban column data
60#[derive(Clone, PartialEq, Debug)]
61pub struct KanbanColumn {
62    pub id: String,
63    pub title: String,
64    pub cards: Vec<KanbanCard>,
65    pub color: Option<String>,
66}
67
68impl KanbanColumn {
69    /// Create a new kanban column
70    pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
71        Self {
72            id: id.into(),
73            title: title.into(),
74            cards: Vec::new(),
75            color: None,
76        }
77    }
78
79    /// Set cards
80    pub fn with_cards(mut self, cards: Vec<KanbanCard>) -> Self {
81        self.cards = cards;
82        self
83    }
84
85    /// Set column color (accent color for header)
86    pub fn with_color(mut self, color: impl Into<String>) -> Self {
87        self.color = Some(color.into());
88        self
89    }
90
91    /// Add a card to the column
92    pub fn add_card(&mut self, card: KanbanCard) {
93        self.cards.push(card);
94    }
95}
96
97/// Kanban board properties
98#[derive(Props, Clone, PartialEq)]
99pub struct KanbanProps {
100    /// Columns with their cards
101    pub columns: Vec<KanbanColumn>,
102    /// Handler for column changes (new column order or content)
103    #[props(default)]
104    pub on_columns_change: Option<EventHandler<Vec<KanbanColumn>>>,
105    /// Handler for card moves (card_id, column_id, new_index)
106    #[props(default)]
107    pub on_card_move: Option<EventHandler<(String, String, usize)>>,
108    /// Handler for card clicks
109    #[props(default)]
110    pub on_card_click: Option<EventHandler<String>>,
111    /// Allow adding new columns
112    #[props(default = false)]
113    pub allow_add_column: bool,
114    /// Handler for add column button
115    #[props(default)]
116    pub on_add_column: Option<EventHandler<()>>,
117    /// Handler for add card in a column (column_id)
118    #[props(default)]
119    pub on_add_card: Option<EventHandler<String>>,
120    /// Custom inline styles
121    #[props(default)]
122    pub style: Option<String>,
123    /// Column width (default: 280px)
124    #[props(default = "280px".to_string())]
125    pub column_width: String,
126    /// Board height (default: 100%)
127    #[props(default = "100%".to_string())]
128    pub height: String,
129    /// Show card count in column headers
130    #[props(default = true)]
131    pub show_card_count: bool,
132    /// Enable card hover effects
133    #[props(default = true)]
134    pub card_hover: bool,
135}
136
137/// Kanban board component
138///
139/// # Example
140/// ```rust,ignore
141/// use dioxus_ui_system::organisms::{Kanban, KanbanColumn, KanbanCard};
142///
143/// let columns = vec![
144///     KanbanColumn::new("todo", "To Do")
145///         .with_color("#ef4444")
146///         .with_cards(vec![
147///             KanbanCard::new("1", "Task 1")
148///                 .with_description("Description here")
149///                 .with_tags(vec!["urgent".to_string()])
150///                 .with_assignee("JD"),
151///         ]),
152///     KanbanColumn::new("done", "Done"),
153/// ];
154///
155/// rsx! {
156///     Kanban {
157///         columns: columns,
158///         on_card_click: |card_id| println!("Clicked: {}", card_id),
159///     }
160/// }
161/// ```
162#[component]
163pub fn Kanban(props: KanbanProps) -> Element {
164    let _theme = use_theme();
165    let height = props.height.clone();
166
167    let board_style = use_style(move |t| {
168        Style::new()
169            .flex()
170            .flex_row()
171            .gap(&t.spacing, "md")
172            .overflow_x_auto()
173            .p(&t.spacing, "md")
174            .h(&height)
175            .build()
176    });
177
178    let final_style = if let Some(custom) = &props.style {
179        format!("{} {}", board_style(), custom)
180    } else {
181        board_style()
182    };
183
184    let columns = props.columns.clone();
185    let column_width = props.column_width.clone();
186
187    rsx! {
188        div {
189            style: "{final_style}",
190
191            for column in columns {
192                KanbanColumnView {
193                    key: "{column.id}",
194                    column: column,
195                    width: column_width.clone(),
196                    on_card_click: props.on_card_click,
197                    on_add_card: props.on_add_card,
198                    show_card_count: props.show_card_count,
199                    card_hover: props.card_hover,
200                }
201            }
202
203            if props.allow_add_column {
204                AddColumnButton {
205                    on_add_column: props.on_add_column,
206                }
207            }
208        }
209    }
210}
211
212/// Individual kanban column component
213#[derive(Props, Clone, PartialEq)]
214pub struct KanbanColumnViewProps {
215    pub column: KanbanColumn,
216    pub width: String,
217    pub on_card_click: Option<EventHandler<String>>,
218    pub on_add_card: Option<EventHandler<String>>,
219    pub show_card_count: bool,
220    pub card_hover: bool,
221}
222
223#[component]
224pub fn KanbanColumnView(props: KanbanColumnViewProps) -> Element {
225    let theme = use_theme();
226    let width = props.width.clone();
227
228    let column_style = use_style(move |t| {
229        Style::new()
230            .flex()
231            .flex_col()
232            .rounded(&t.radius, "lg")
233            .bg(&t.colors.muted)
234            .min_w(&width)
235            .max_w(&width)
236            .h_full()
237            .build()
238    });
239
240    let header_style = use_style(|t| {
241        Style::new()
242            .flex()
243            .items_center()
244            .justify_between()
245            .p(&t.spacing, "md")
246            .border_bottom(1, &t.colors.border)
247            .build()
248    });
249
250    let title_style = use_style(|t| {
251        Style::new()
252            .flex()
253            .items_center()
254            .gap(&t.spacing, "sm")
255            .font_weight(600)
256            .text(&t.typography, "sm")
257            .build()
258    });
259
260    let cards_container_style = use_style(|t| {
261        Style::new()
262            .flex()
263            .flex_col()
264            .gap(&t.spacing, "sm")
265            .p(&t.spacing, "md")
266            .flex_grow(1)
267            .overflow_y_auto()
268            .build()
269    });
270
271    let column = props.column.clone();
272    let card_count = column.cards.len();
273    let accent_color = column.color.clone();
274    let column_id = column.id.clone();
275
276    rsx! {
277        div {
278            style: "{column_style}",
279
280            // Column Header
281            div {
282                style: "{header_style}",
283
284                div {
285                    style: "{title_style}",
286
287                    if let Some(color) = accent_color {
288                        div {
289                            style: "width: 12px; height: 12px; border-radius: 50%; background: {color}; flex-shrink: 0;",
290                        }
291                    }
292
293                    span {
294                        "{column.title}"
295                    }
296
297                    if props.show_card_count {
298                        span {
299                            style: "background: {theme.tokens.read().colors.muted_foreground.to_rgba()}20; color: {theme.tokens.read().colors.muted_foreground.to_rgba()}; padding: 2px 8px; border-radius: 9999px; font-size: 12px; font-weight: 500;",
300                            "{card_count}"
301                        }
302                    }
303                }
304
305                // Column actions menu placeholder
306                Button {
307                    variant: ButtonVariant::Ghost,
308                    size: ButtonSize::Sm,
309                    Icon {
310                        name: "more-horizontal".to_string(),
311                        size: IconSize::Small,
312                        color: IconColor::Muted,
313                    }
314                }
315            }
316
317            // Cards Container
318            div {
319                style: "{cards_container_style}",
320
321                for card in column.cards {
322                    KanbanCardView {
323                        key: "{card.id}",
324                        card: card,
325                        on_click: props.on_card_click.clone(),
326                        hover: props.card_hover,
327                    }
328                }
329            }
330
331            // Add Card Button
332            if let Some(on_add) = props.on_add_card.clone() {
333                div {
334                    style: "padding: 0 12px 12px 12px;",
335                    Button {
336                        variant: ButtonVariant::Ghost,
337                        size: ButtonSize::Sm,
338                        full_width: true,
339                        onclick: move |_| on_add.call(column_id.clone()),
340
341                        Icon {
342                            name: "plus".to_string(),
343                            size: IconSize::Small,
344                            color: IconColor::Muted,
345                        }
346                        "Add card"
347                    }
348                }
349            }
350        }
351    }
352}
353
354/// Individual kanban card component
355#[derive(Props, Clone, PartialEq)]
356pub struct KanbanCardViewProps {
357    pub card: KanbanCard,
358    pub on_click: Option<EventHandler<String>>,
359    pub hover: bool,
360}
361
362#[component]
363pub fn KanbanCardView(props: KanbanCardViewProps) -> Element {
364    let theme = use_theme();
365    let mut is_hovered = use_signal(|| false);
366
367    let card_style = use_style(move |t| {
368        let base = Style::new()
369            .bg(&t.colors.background)
370            .rounded(&t.radius, "md")
371            .border(1, &t.colors.border)
372            .p(&t.spacing, "md")
373            .cursor_pointer()
374            .transition("all 150ms ease");
375
376        if props.hover && is_hovered() {
377            base.shadow("0 4px 12px rgba(0, 0, 0, 0.1)")
378                .transform("translateY(-2px)")
379        } else {
380            base
381        }
382        .build()
383    });
384
385    let drag_handle_style = use_style(|t| {
386        Style::new()
387            .flex()
388            .items_center()
389            .justify_center()
390            .cursor("grab")
391            .text_color(&t.colors.muted_foreground)
392            .build()
393    });
394
395    let tags_style = use_style(|t| {
396        Style::new()
397            .flex()
398            .flex_wrap()
399            .gap_px(4)
400            .mt(&t.spacing, "sm")
401            .build()
402    });
403
404    let footer_style = use_style(|t| {
405        Style::new()
406            .flex()
407            .items_center()
408            .justify_between()
409            .mt(&t.spacing, "md")
410            .pt(&t.spacing, "sm")
411            .border_top(1, &t.colors.border)
412            .build()
413    });
414
415    let card = props.card.clone();
416    let card_id = card.id.clone();
417    let onclick_handler = props.on_click.clone();
418
419    rsx! {
420        div {
421            style: "{card_style}",
422            onmouseenter: move |_| is_hovered.set(true),
423            onmouseleave: move |_| is_hovered.set(false),
424            onclick: move |_| {
425                if let Some(handler) = &onclick_handler {
426                    handler.call(card_id.clone());
427                }
428            },
429
430            // Drag Handle & Title Row
431            div {
432                style: "display: flex; gap: 8px; align-items: flex-start;",
433
434                // Drag handle (visual only)
435                div {
436                    style: "{drag_handle_style} flex-shrink: 0; padding-top: 2px;",
437                    Icon {
438                        name: "grip-vertical".to_string(),
439                        size: IconSize::Small,
440                        color: IconColor::Muted,
441                    }
442                }
443
444                // Card content
445                div {
446                    style: "flex: 1; min-width: 0;",
447
448                    Label {
449                        size: TextSize::Small,
450                        weight: crate::atoms::TextWeight::Medium,
451                        "{card.title}"
452                    }
453
454                    if let Some(description) = card.description {
455                        p {
456                            style: "margin: 4px 0 0 0; color: {theme.tokens.read().colors.muted_foreground.to_rgba()}; font-size: 13px; line-height: 1.4; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;",
457                            "{description}"
458                        }
459                    }
460                }
461            }
462
463            // Tags
464            if !card.tags.is_empty() {
465                div {
466                    style: "{tags_style}",
467
468                    for tag in card.tags {
469                        KanbanTag {
470                            key: "{tag}",
471                            label: tag,
472                        }
473                    }
474                }
475            }
476
477            // Footer: Assignee & Due Date
478            if card.assignee.is_some() || card.due_date.is_some() {
479                div {
480                    style: "{footer_style}",
481
482                    div {
483                        style: "display: flex; align-items: center; gap: 8px;",
484
485                        if let Some(assignee) = card.assignee {
486                            div {
487                                style: "display: flex; align-items: center; gap: 4px;",
488
489                                if assignee.starts_with("http") {
490                                    // Avatar image
491                                    img {
492                                        src: "{assignee}",
493                                        style: "width: 24px; height: 24px; border-radius: 50%; object-fit: cover;",
494                                        alt: "Assignee",
495                                    }
496                                } else {
497                                    // Initials avatar
498                                    div {
499                                        style: "width: 24px; height: 24px; border-radius: 50%; background: {theme.tokens.read().colors.primary.to_rgba()}; color: white; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 600;",
500                                        "{assignee.chars().take(2).collect::<String>().to_uppercase()}"
501                                    }
502                                }
503                            }
504                        }
505                    }
506
507                    if let Some(due_date) = card.due_date {
508                        div {
509                            style: "display: flex; align-items: center; gap: 4px; color: {theme.tokens.read().colors.muted_foreground.to_rgba()}; font-size: 12px;",
510
511                            Icon {
512                                name: "calendar".to_string(),
513                                size: IconSize::Small,
514                                color: IconColor::Muted,
515                            }
516                            "{due_date}"
517                        }
518                    }
519                }
520            }
521        }
522    }
523}
524
525/// Simple tag for kanban cards
526#[derive(Props, Clone, PartialEq)]
527pub struct KanbanTagProps {
528    pub label: String,
529}
530
531#[component]
532pub fn KanbanTag(props: KanbanTagProps) -> Element {
533    // Generate a consistent color based on the label
534    let color_hash = props
535        .label
536        .bytes()
537        .fold(0u32, |acc, b| acc.wrapping_add(b as u32));
538    let hue = color_hash % 360;
539    let bg_color = format!("hsl({}, 70%, 90%)", hue);
540    let text_color = format!("hsl({}, 70%, 30%)", hue);
541
542    rsx! {
543        span {
544            style: "display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 500; background: {bg_color}; color: {text_color}; white-space: nowrap;",
545            "{props.label}"
546        }
547    }
548}
549
550/// Add column button component
551#[derive(Props, Clone, PartialEq)]
552pub struct AddColumnButtonProps {
553    pub on_add_column: Option<EventHandler<()>>,
554}
555
556#[component]
557pub fn AddColumnButton(props: AddColumnButtonProps) -> Element {
558    let theme = use_theme();
559    let mut is_hovered = use_signal(|| false);
560
561    let button_style = use_style(move |t| {
562        let base = Style::new()
563            .flex()
564            .flex_col()
565            .items_center()
566            .justify_center()
567            .rounded(&t.radius, "lg")
568            .border(2, &t.colors.border)
569            .min_w_px(280)
570            .h_full()
571            .cursor_pointer()
572            .gap(&t.spacing, "sm")
573            .transition("all 150ms ease");
574
575        if is_hovered() {
576            base.border_color(&t.colors.primary).bg(&t.colors.muted)
577        } else {
578            base.border_style("dashed")
579        }
580        .build()
581    });
582
583    let onclick_handler = props.on_add_column.clone();
584
585    rsx! {
586        div {
587            style: "{button_style}",
588            onmouseenter: move |_| is_hovered.set(true),
589            onmouseleave: move |_| is_hovered.set(false),
590            onclick: move |_| {
591                if let Some(handler) = &onclick_handler {
592                    handler.call(());
593                }
594            },
595
596            Icon {
597                name: "plus".to_string(),
598                size: IconSize::Large,
599                color: IconColor::Muted,
600            }
601
602            span {
603                style: "color: {theme.tokens.read().colors.muted_foreground.to_rgba()}; font-size: 14px; font-weight: 500;",
604                "Add column"
605            }
606        }
607    }
608}
609
610/// Simple Kanban board without complex state management
611///
612/// A simplified version for basic use cases
613#[derive(Props, Clone, PartialEq)]
614pub struct SimpleKanbanProps {
615    pub columns: Vec<KanbanColumn>,
616    #[props(default)]
617    pub on_card_click: Option<EventHandler<String>>,
618    #[props(default = "100%".to_string())]
619    pub height: String,
620}
621
622#[component]
623pub fn SimpleKanban(props: SimpleKanbanProps) -> Element {
624    rsx! {
625        Kanban {
626            columns: props.columns.clone(),
627            on_card_click: props.on_card_click.clone(),
628            height: props.height.clone(),
629            show_card_count: true,
630            card_hover: true,
631        }
632    }
633}