1use dioxus::prelude::*;
2
3#[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#[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 #[props(optional)]
46 pub custom: Option<Element>,
47}
48
49#[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 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 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 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 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}