yggdrasil/components/ui/
display.rs1use super::icons::{Icon, IconName};
3use super::styles::BADGE_BASE;
4use leptos::prelude::*;
5
6#[component]
17pub fn StatusBadge(
18 #[prop(into)] color_class: Signal<String>,
19 #[prop(into)] label: Signal<String>,
20 #[prop(optional, into)] class: Signal<String>,
21) -> impl IntoView {
22 view! {
23 <span class=move || {
24 format!("{BADGE_BASE} {} {}", color_class.get(), class.get())
25 }>{move || label.get()}</span>
26 }
27}
28
29#[component]
38pub fn StatusDot(
39 #[prop(into)] label: Signal<String>,
40 #[prop(optional, into)] tone: Signal<String>,
41 #[prop(optional, into)] class: Signal<String>,
42) -> impl IntoView {
43 view! {
44 <span
45 class=move || format!("status-dot {}", class.get())
46 data-tone=move || {
47 let tone = tone.get();
48 if tone.is_empty() { "muted".to_string() } else { tone }
49 }
50 >
51 {move || label.get()}
52 </span>
53 }
54}
55
56#[component]
58pub fn ImagePlaceholder(#[prop(into)] class: String) -> impl IntoView {
59 view! { <Icon name=IconName::ImageOff size=24 class=class/> }
60}
61
62#[component]
74pub fn UserAvatar(
75 #[prop(into)] name: Signal<String>,
76 #[prop(into)] avatar_url: Signal<Option<String>>,
77 #[prop(into)] class: String,
78) -> impl IntoView {
79 let failed_url = RwSignal::new(None::<String>);
81 let img_ref = NodeRef::<leptos::html::Img>::new();
82 let current_url = move || avatar_url.get().filter(|u| !u.trim().is_empty());
83
84 Effect::new(move |_| {
86 if let (Some(img), Some(url)) = (img_ref.get(), current_url()) {
87 if img.complete() && img.natural_width() == 0 {
88 failed_url.set(Some(url));
89 }
90 }
91 });
92
93 move || {
94 let name = name.get();
95 match current_url() {
96 Some(url) if failed_url.read().as_ref() == Some(&url) => view! {
97 <span
98 class=format!("{class} flex items-center justify-center bg-ink-1 text-fg-3")
99 role="img"
100 aria-label=format!("{name} 的头像")
101 >
102 <ImagePlaceholder class="w-1/2 h-1/2"/>
103 </span>
104 }
105 .into_any(),
106 Some(url) => {
107 let failed = url.clone();
108 view! {
109 <img
110 node_ref=img_ref
111 class=format!("{class} object-cover")
112 src=url
113 alt=format!("{name} 的头像")
114 loading="lazy"
115 decoding="async"
116 on:error=move |_| failed_url.set(Some(failed.clone()))
117 />
118 }
119 .into_any()
120 }
121 None => {
122 let initial = name
123 .chars()
124 .next()
125 .map(|c| c.to_uppercase().collect::<String>())
126 .unwrap_or_else(|| "?".to_string());
127 view! {
128 <span class=format!(
129 "{class} flex items-center justify-center bg-clay-soft text-clay-text font-semibold select-none",
130 )>{initial}</span>
131 }
132 .into_any()
133 }
134 }
135 }
136}
137
138#[component]
150pub fn Tooltip(
151 #[prop(into)] tip: Signal<String>,
152 children: Children,
153 #[prop(optional, into)] shortcut: MaybeProp<&'static str>,
154 #[prop(default = "top".into(), into)] placement: Signal<String>,
155 #[prop(default = "center".into(), into)] align: Signal<String>,
156) -> impl IntoView {
157 let placement = move || {
158 if placement.get() == "bottom" {
159 "bottom"
160 } else {
161 "top"
162 }
163 };
164 let align = move || match align.get().as_str() {
165 "start" => "start",
166 "end" => "end",
167 _ => "center",
168 };
169 view! {
170 <span class="tooltip-wrap">
171 {children()}
172 <span class="tooltip" role="tooltip" data-placement=placement data-align=align>
173 {move || tip.get()}
174 {move || shortcut.get().map(|shortcut| view! { <kbd>{shortcut}</kbd> })}
175 </span>
176 </span>
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn tooltip_renders_shortcut_and_normalizes_position() {
186 let html = Owner::new().with(|| {
187 view! {
188 <Tooltip tip="搜索" shortcut="⌘K" placement="left" align="middle">
189 <button>"?"</button>
190 </Tooltip>
191 }
192 .to_html()
193 });
194 let html = html.replace("<!>", "");
196 assert!(html.contains("role=\"tooltip\""), "{html}");
197 assert!(html.contains("<kbd>⌘K</kbd>"), "{html}");
198 assert!(html.contains("data-placement=\"top\""), "{html}");
200 assert!(html.contains("data-align=\"center\""), "{html}");
201 }
202}