adui_dioxus/components/
tag.rs

1use crate::components::icon::{Icon, IconKind};
2use dioxus::prelude::*;
3
4/// Preset tag colors aligned with Ant Design semantics (MVP subset).
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6pub enum TagColor {
7    Default,
8    Primary,
9    Success,
10    Warning,
11    Error,
12}
13
14impl TagColor {
15    fn as_class(&self) -> &'static str {
16        match self {
17            TagColor::Default => "adui-tag-default",
18            TagColor::Primary => "adui-tag-primary",
19            TagColor::Success => "adui-tag-success",
20            TagColor::Warning => "adui-tag-warning",
21            TagColor::Error => "adui-tag-error",
22        }
23    }
24}
25
26#[cfg(test)]
27mod tests {
28    use super::*;
29
30    #[test]
31    fn tag_color_class_mapping_is_stable() {
32        assert_eq!(TagColor::Default.as_class(), "adui-tag-default");
33        assert_eq!(TagColor::Primary.as_class(), "adui-tag-primary");
34        assert_eq!(TagColor::Success.as_class(), "adui-tag-success");
35        assert_eq!(TagColor::Warning.as_class(), "adui-tag-warning");
36        assert_eq!(TagColor::Error.as_class(), "adui-tag-error");
37    }
38
39    #[test]
40    fn tag_color_all_variants() {
41        let variants = [
42            TagColor::Default,
43            TagColor::Primary,
44            TagColor::Success,
45            TagColor::Warning,
46            TagColor::Error,
47        ];
48        for variant in variants.iter() {
49            let class = variant.as_class();
50            assert!(!class.is_empty());
51            assert!(class.starts_with("adui-tag-"));
52        }
53    }
54
55    #[test]
56    fn tag_color_equality() {
57        assert_eq!(TagColor::Default, TagColor::Default);
58        assert_eq!(TagColor::Primary, TagColor::Primary);
59        assert_ne!(TagColor::Default, TagColor::Primary);
60    }
61
62    #[test]
63    fn tag_color_clone() {
64        let original = TagColor::Success;
65        let cloned = original;
66        assert_eq!(original, cloned);
67        assert_eq!(original.as_class(), cloned.as_class());
68    }
69
70    #[test]
71    fn tag_props_defaults() {
72        // Note: TagProps requires children, so we can't create a fully default instance
73        // But we can test that optional fields have expected defaults
74        // closable defaults to false
75        // checkable defaults to false
76        // These are tested implicitly through the component behavior
77    }
78
79    #[test]
80    fn tag_color_debug() {
81        let color = TagColor::Warning;
82        let debug_str = format!("{:?}", color);
83        assert!(debug_str.contains("Warning"));
84    }
85}
86
87/// Props for the Tag component (MVP subset).
88#[derive(Props, Clone, PartialEq)]
89pub struct TagProps {
90    /// Preset color for the tag.
91    #[props(optional)]
92    pub color: Option<TagColor>,
93    /// Whether the tag can be closed.
94    #[props(default)]
95    pub closable: bool,
96    /// Called when the close icon is clicked.
97    #[props(optional)]
98    pub on_close: Option<EventHandler<()>>,
99    /// Whether the tag is checkable (togglable selection).
100    #[props(default)]
101    pub checkable: bool,
102    /// Controlled checked state for checkable tags.
103    #[props(optional)]
104    pub checked: Option<bool>,
105    /// Default checked state for uncontrolled checkable tags.
106    #[props(optional)]
107    pub default_checked: Option<bool>,
108    /// Called when the checked state changes.
109    #[props(optional)]
110    pub on_change: Option<EventHandler<bool>>,
111    /// Extra class for the tag.
112    #[props(optional)]
113    pub class: Option<String>,
114    /// Inline style for the tag.
115    #[props(optional)]
116    pub style: Option<String>,
117    /// Tag content.
118    pub children: Element,
119}
120
121/// Ant Design flavored Tag (MVP: preset colors, closable, simple checkable).
122#[component]
123pub fn Tag(props: TagProps) -> Element {
124    let TagProps {
125        color,
126        closable,
127        on_close,
128        checkable,
129        checked,
130        default_checked,
131        on_change,
132        class,
133        style,
134        children,
135    } = props;
136
137    let is_checked_controlled = checked.is_some();
138    let initial_checked = default_checked.unwrap_or(false);
139    let checked_signal: Signal<bool> = use_signal(|| initial_checked);
140    let current_checked = checked.unwrap_or_else(|| *checked_signal.read());
141
142    let mut class_list = vec!["adui-tag".to_string()];
143    if let Some(color_kind) = color {
144        class_list.push(color_kind.as_class().into());
145    }
146    if checkable {
147        class_list.push("adui-tag-checkable".into());
148        if current_checked {
149            class_list.push("adui-tag-checkable-checked".into());
150        }
151    }
152    if let Some(extra) = class {
153        class_list.push(extra);
154    }
155    let class_attr = class_list.join(" ");
156    let style_attr = style.unwrap_or_default();
157
158    let on_close_cb = on_close;
159    let on_change_cb = on_change;
160    let checked_signal_for_click = checked_signal;
161
162    rsx! {
163        span {
164            class: "{class_attr}",
165            style: "{style_attr}",
166            onclick: move |_| {
167                if checkable {
168                    let next = !current_checked;
169                    if !is_checked_controlled {
170                        let mut sig = checked_signal_for_click;
171                        sig.set(next);
172                    }
173                    if let Some(cb) = on_change_cb {
174                        cb.call(next);
175                    }
176                }
177            },
178            {children}
179            if closable {
180                button {
181                    r#type: "button",
182                    class: "adui-tag-close",
183                    onclick: move |evt| {
184                        evt.stop_propagation();
185                        if let Some(cb) = on_close_cb {
186                            cb.call(());
187                        }
188                    },
189                    Icon { kind: IconKind::Close, size: 12.0 }
190                }
191            }
192        }
193    }
194}