adui_dioxus/components/
icon.rs

1use dioxus::prelude::*;
2
3/// Built-in icon set (minimal subset aligned with Ant Design semantics).
4#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
5pub enum IconKind {
6    Plus,
7    Minus,
8    Check,
9    Close,
10    #[default]
11    Info,
12    Question,
13    ArrowRight,
14    ArrowLeft,
15    ArrowUp,
16    ArrowDown,
17    Search,
18    Copy,
19    Edit,
20    Loading,
21    Eye,
22    EyeInvisible,
23}
24
25/// Icon props.
26#[derive(Props, Clone, PartialEq)]
27pub struct IconProps {
28    #[props(default)]
29    pub kind: IconKind,
30    #[props(default = 20.0)]
31    pub size: f32,
32    #[props(optional)]
33    pub color: Option<String>,
34    #[props(optional)]
35    pub rotate: Option<f32>,
36    #[props(default)]
37    pub spin: bool,
38    #[props(optional)]
39    pub class: Option<String>,
40    #[props(optional)]
41    pub aria_label: Option<String>,
42    #[props(optional)]
43    pub view_box: Option<String>,
44    /// 自定义 SVG 内容,若提供则忽略内置 `kind`。
45    #[props(optional)]
46    pub custom: Option<Element>,
47}
48
49/// SVG-based icon component with small built-in set.
50#[component]
51pub fn Icon(props: IconProps) -> Element {
52    let IconProps {
53        kind,
54        size,
55        color,
56        rotate,
57        spin,
58        class,
59        aria_label,
60        view_box,
61        custom,
62    } = props;
63
64    let def = icon_def(kind);
65    let mut class_list = vec!["adui-icon".to_string()];
66    if spin || matches!(kind, IconKind::Loading) {
67        class_list.push("adui-icon-spin".into());
68    }
69    if let Some(extra) = class.as_ref() {
70        class_list.push(extra.clone());
71    }
72    let class_attr = class_list.join(" ");
73
74    let style = format!(
75        "width:{size}px;height:{size}px;{}",
76        rotate
77            .map(|deg| format!("transform:rotate({deg}deg);"))
78            .unwrap_or_default()
79    );
80
81    let stroke_color = color.clone().unwrap_or_else(|| "currentColor".into());
82
83    let aria_text = aria_label.unwrap_or_else(|| format!("{:?}", kind));
84    let view_box_attr = view_box.unwrap_or_else(|| def.view_box.to_string());
85
86    rsx! {
87        svg {
88            class: "{class_attr}",
89            style: "{style}",
90            width: "{size}",
91            height: "{size}",
92            view_box: "{view_box_attr}",
93            fill: if def.fill { stroke_color.clone() } else { "none".into() },
94            stroke: if def.fill { "none" } else { stroke_color.as_str() },
95            stroke_width: "1.6",
96            stroke_linecap: "round",
97            stroke_linejoin: "round",
98            role: "img",
99            "aria-label": aria_text.clone(),
100            "aria-hidden": if aria_text.is_empty() { "true" } else { "false" },
101            if let Some(content) = custom {
102                {content}
103            } else {
104                {def.paths.iter().map(|d| {
105                    let fill = if def.fill { "currentColor" } else { "none" };
106                    rsx!(path { d: "{d}", fill: "{fill}" })
107                })}
108            }
109        }
110    }
111}
112
113struct IconDef {
114    view_box: &'static str,
115    fill: bool,
116    paths: &'static [&'static str],
117}
118
119fn icon_def(kind: IconKind) -> IconDef {
120    match kind {
121        IconKind::Plus => IconDef {
122            view_box: "0 0 24 24",
123            fill: false,
124            paths: &["M12 5v14", "M5 12h14"],
125        },
126        IconKind::Minus => IconDef {
127            view_box: "0 0 24 24",
128            fill: false,
129            paths: &["M5 12h14"],
130        },
131        IconKind::Check => IconDef {
132            view_box: "0 0 24 24",
133            fill: false,
134            paths: &["M5 13l4 4 10-10"],
135        },
136        IconKind::Close => IconDef {
137            view_box: "0 0 24 24",
138            fill: false,
139            paths: &["M6 6l12 12", "M6 18L18 6"],
140        },
141        IconKind::Info => IconDef {
142            view_box: "0 0 24 24",
143            fill: false,
144            paths: &[
145                "M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16Z",
146                "M12 10v6",
147                "M12 8h.01",
148            ],
149        },
150        IconKind::Question => IconDef {
151            view_box: "0 0 24 24",
152            fill: false,
153            paths: &[
154                "M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16Z",
155                "M9.5 9.5a2.5 2.5 0 0 1 5 0c0 1.667-1.5 2-2 3",
156                "M12 16h.01",
157            ],
158        },
159        IconKind::ArrowRight => IconDef {
160            view_box: "0 0 24 24",
161            fill: false,
162            paths: &["M5 12h14", "M13 6l6 6-6 6"],
163        },
164        IconKind::ArrowLeft => IconDef {
165            view_box: "0 0 24 24",
166            fill: false,
167            paths: &["M19 12H5", "M11 6l-6 6 6 6"],
168        },
169        IconKind::ArrowUp => IconDef {
170            view_box: "0 0 24 24",
171            fill: false,
172            paths: &["M12 19V5", "M6 11l6-6 6 6"],
173        },
174        IconKind::ArrowDown => IconDef {
175            view_box: "0 0 24 24",
176            fill: false,
177            paths: &["M12 5v14", "M18 13l-6 6-6-6"],
178        },
179        IconKind::Search => IconDef {
180            view_box: "0 0 24 24",
181            fill: false,
182            paths: &["M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14Z", "M21 21l-4.35-4.35"],
183        },
184        IconKind::Copy => IconDef {
185            view_box: "0 0 24 24",
186            fill: false,
187            paths: &[
188                "M9 9V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-4",
189                "M5 9h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2Z",
190            ],
191        },
192        IconKind::Edit => IconDef {
193            view_box: "0 0 24 24",
194            fill: false,
195            paths: &[
196                "M4 20h4l10.5-10.5a2.121 2.121 0 0 0-3-3L5 17v3Z",
197                "M14.5 6.5l3 3",
198            ],
199        },
200        IconKind::Loading => IconDef {
201            view_box: "0 0 24 24",
202            fill: false,
203            paths: &[
204                "M12 2v4",
205                "M12 18v4",
206                "M4.93 4.93l2.83 2.83",
207                "M16.24 16.24l2.83 2.83",
208                "M2 12h4",
209                "M18 12h4",
210                "M4.93 19.07l2.83-2.83",
211                "M16.24 7.76l2.83-2.83",
212            ],
213        },
214        IconKind::Eye => IconDef {
215            view_box: "0 0 24 24",
216            fill: false,
217            paths: &[
218                "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8Z",
219                "M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z",
220            ],
221        },
222        IconKind::EyeInvisible => IconDef {
223            view_box: "0 0 24 24",
224            fill: false,
225            paths: &[
226                "M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94",
227                "M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19",
228                "M14.12 14.12a3 3 0 1 1-4.24-4.24",
229                "M1 1l22 22",
230            ],
231        },
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn icon_kind_default() {
241        assert_eq!(IconKind::default(), IconKind::Info);
242    }
243
244    #[test]
245    fn icon_kind_all_variants() {
246        // Test all icon kinds exist and are distinct
247        assert_ne!(IconKind::Plus, IconKind::Minus);
248        assert_ne!(IconKind::Check, IconKind::Close);
249        assert_ne!(IconKind::Info, IconKind::Question);
250        assert_ne!(IconKind::ArrowRight, IconKind::ArrowLeft);
251        assert_ne!(IconKind::ArrowUp, IconKind::ArrowDown);
252        assert_ne!(IconKind::Search, IconKind::Copy);
253        assert_ne!(IconKind::Edit, IconKind::Loading);
254        assert_ne!(IconKind::Eye, IconKind::EyeInvisible);
255    }
256
257    #[test]
258    fn icon_props_defaults() {
259        let props = IconProps {
260            kind: IconKind::default(),
261            size: 20.0,
262            color: None,
263            rotate: None,
264            spin: false,
265            class: None,
266            aria_label: None,
267            view_box: None,
268            custom: None,
269        };
270        assert_eq!(props.kind, IconKind::Info);
271        assert_eq!(props.size, 20.0);
272        assert_eq!(props.spin, false);
273    }
274
275    #[test]
276    fn icon_def_returns_valid_definitions() {
277        // Test that all icon kinds have valid definitions
278        let plus_def = icon_def(IconKind::Plus);
279        assert_eq!(plus_def.view_box, "0 0 24 24");
280        assert_eq!(plus_def.fill, false);
281        assert!(!plus_def.paths.is_empty());
282
283        let info_def = icon_def(IconKind::Info);
284        assert_eq!(info_def.view_box, "0 0 24 24");
285        assert_eq!(info_def.fill, false);
286        assert!(!info_def.paths.is_empty());
287
288        let loading_def = icon_def(IconKind::Loading);
289        assert_eq!(loading_def.view_box, "0 0 24 24");
290        assert_eq!(loading_def.fill, false);
291        assert!(!loading_def.paths.is_empty());
292    }
293
294    #[test]
295    fn icon_def_all_kinds_have_paths() {
296        // Ensure all icon kinds have at least one path
297        let all_kinds = [
298            IconKind::Plus,
299            IconKind::Minus,
300            IconKind::Check,
301            IconKind::Close,
302            IconKind::Info,
303            IconKind::Question,
304            IconKind::ArrowRight,
305            IconKind::ArrowLeft,
306            IconKind::ArrowUp,
307            IconKind::ArrowDown,
308            IconKind::Search,
309            IconKind::Copy,
310            IconKind::Edit,
311            IconKind::Loading,
312            IconKind::Eye,
313            IconKind::EyeInvisible,
314        ];
315
316        for kind in all_kinds.iter() {
317            let def = icon_def(*kind);
318            assert!(
319                !def.paths.is_empty(),
320                "IconKind {:?} should have at least one path",
321                kind
322            );
323            assert_eq!(
324                def.view_box, "0 0 24 24",
325                "IconKind {:?} should have standard view_box",
326                kind
327            );
328        }
329    }
330
331    #[test]
332    fn icon_kind_equality() {
333        // Test that same icon kinds are equal
334        assert_eq!(IconKind::Plus, IconKind::Plus);
335        assert_eq!(IconKind::Info, IconKind::Info);
336        assert_eq!(IconKind::Loading, IconKind::Loading);
337    }
338
339    #[test]
340    fn icon_kind_clone() {
341        let original = IconKind::Check;
342        let cloned = original;
343        assert_eq!(original, cloned);
344    }
345}