Skip to main content

yggdrasil/components/ui/
icons.rs

1//! Lucide 图标(ISC 许可,见 `public/icons/LICENSE-lucide.txt`)。
2//!
3//! 全站统一 1.5 线宽、`currentColor` 描边,颜色随文字。图标数据由
4//! `libs/shared/scripts/generate-icons.ts` 生成到 `icons_data.rs`。
5use leptos::prelude::*;
6
7#[path = "icons_data.rs"]
8mod data;
9
10pub use data::IconName;
11
12/// 内联 SVG 图标。
13///
14/// - 默认装饰性(`aria-hidden`);传 `label` 时作为独立图形朗读(`role="img"`)。
15/// - `size` 为像素边长,默认 16;行内图标可加 `class="icon-inline"` 做 -0.125em 光学对齐。
16#[component]
17pub fn Icon(
18    name: IconName,
19    #[prop(default = 16)] size: u32,
20    #[prop(optional, into)] class: String,
21    #[prop(optional, into)] label: Option<String>,
22) -> impl IntoView {
23    let body = name.body();
24    let slug = name.slug();
25    let class = format!("lucide lucide-{slug} shrink-0 {class}");
26    let size = size.to_string();
27    match label {
28        Some(label) => view! {
29            <svg
30                class=class
31                width=size.clone()
32                height=size
33                viewBox="0 0 24 24"
34                fill="none"
35                stroke="currentColor"
36                stroke-width="1.5"
37                stroke-linecap="round"
38                stroke-linejoin="round"
39                role="img"
40                aria-label=label
41                inner_html=body
42            ></svg>
43        }
44        .into_any(),
45        None => view! {
46            <svg
47                class=class
48                width=size.clone()
49                height=size
50                viewBox="0 0 24 24"
51                fill="none"
52                stroke="currentColor"
53                stroke-width="1.5"
54                stroke-linecap="round"
55                stroke-linejoin="round"
56                aria-hidden="true"
57                focusable="false"
58                inner_html=body
59            ></svg>
60        }
61        .into_any(),
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    #[test]
70    fn every_icon_has_drawable_body() {
71        for icon in IconName::ALL {
72            let body = icon.body();
73            assert!(body.starts_with('<'), "{} 没有图形", icon.slug());
74            assert!(!body.contains("<script"), "{} 含脚本", icon.slug());
75            assert!(!body.contains("stroke-width"), "{} 自带线宽", icon.slug());
76        }
77    }
78
79    #[test]
80    fn slugs_are_unique() {
81        let mut slugs: Vec<_> = IconName::ALL.iter().map(|i| i.slug()).collect();
82        slugs.sort_unstable();
83        slugs.dedup();
84        assert_eq!(slugs.len(), IconName::ALL.len());
85    }
86
87    #[test]
88    fn decorative_icons_are_hidden_from_assistive_tech() {
89        let html = Owner::new().with(|| view! { <Icon name=IconName::Search/> }.to_html());
90        assert!(html.contains("aria-hidden=\"true\""), "{html}");
91        assert!(html.contains("stroke-width=\"1.5\""), "{html}");
92        assert!(html.contains("lucide-search"), "{html}");
93        assert!(html.contains("<circle"), "{html}");
94    }
95
96    #[test]
97    fn labelled_icons_are_announced() {
98        let html =
99            Owner::new().with(|| view! { <Icon name=IconName::X size=20 label="关闭"/> }.to_html());
100        assert!(html.contains("role=\"img\""), "{html}");
101        assert!(html.contains("aria-label=\"关闭\""), "{html}");
102        assert!(html.contains("width=\"20\""), "{html}");
103        assert!(!html.contains("aria-hidden"), "{html}");
104    }
105}