Skip to main content

yggdrasil/components/ui/
display.rs

1//! Badges, avatars, placeholders and CSS tooltips.
2use super::icons::{Icon, IconName};
3use super::styles::BADGE_BASE;
4use leptos::prelude::*;
5
6/// 状态徽章组件。
7///
8/// 外层固定 `.badge`,语气类由调用方传入(`badge-success`、`badge-warning` 等,
9/// 空串为中性)。之所以用 `color_class` prop 而非枚举变体,是因为部分场景
10/// (如回收站剩余天数)的语气由动态逻辑决定,硬编码 variant 反而不够灵活。
11///
12/// Props:
13/// - `color_class`:语气类(如 `badge-success`)
14/// - `label`:徽章文本
15/// - `class`:附加类,用于所在位置的间距与对齐(如文章标题后的 `post-draft`),缺省为空
16#[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/// 状态圆点:6px 圆点 + 文字(`.status-dot`),后台列表与面板的状态标识。
30///
31/// 比 [`StatusBadge`] 安静,适合表格列和元信息行里反复出现的状态。
32///
33/// Props:
34/// - `label`:状态文字
35/// - `tone`:圆点语气,`clay` / `success` / `warning` / `danger`,缺省 `muted`(灰)
36/// - `class`:附加类,用于所在位置的变体(如笔记列表私密态的空心圆点),缺省为空
37#[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/// 封面与头像加载失败时的图片占位:Lucide `image-off` 图标,尺寸与颜色由 `class` 控制。
57#[component]
58pub fn ImagePlaceholder(#[prop(into)] class: String) -> impl IntoView {
59    view! { <Icon name=IconName::ImageOff size=24 class=class/> }
60}
61
62/// 用户头像:加载失败显示图片占位,无图回退展示名首字符(陶土浅底)。
63///
64/// 头像三态(图片 / 占位 / 首字符)的统一实现,调用方经 `class` 控制尺寸与形状
65/// (须含 `w-* h-* rounded-full` 与字号如 `text-xs`)。使用处:后台侧栏
66/// 用户卡片(28px)、个人信息页身份卡(96px,外层按钮带 hover 遮罩)、
67/// 前台评论表单身份行(24px)与评论列表(32px)。
68///
69/// Props:
70/// - `name`:展示名(用于首字符兜底与 alt 文本)
71/// - `avatar_url`:头像 URL;`None` 或空白串时渲染首字符兜底
72/// - `class`:尺寸与形状类;组件内补 `object-cover`(图片)/ flex 居中(首字符)
73#[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    // 按 URL 记录失败,更换头像后仍可重新加载。
80    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    // SSR 图片可能在 hydration 前已失败,挂载时补查完成状态。
85    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/// 悬浮提示:深色小气泡,悬停 300ms 后出现,键盘聚焦触发器时立即出现。
139///
140/// 只给图标按钮补名称或快捷键,不放可点击内容。样式见 ui.css 的 `.tooltip`:
141/// 只认包裹层的直接子元素,外层祖先上的悬停不会误触。
142///
143/// Props:
144/// - `tip`:提示文字
145/// - `shortcut`:可选的快捷键,显示在文字右侧(如 `⌘K`)
146/// - `placement`:`"top"`(默认)/ `"bottom"`
147/// - `align`:`"center"`(默认)/ `"start"` / `"end"`;触发器贴容器边缘时朝内侧对齐,
148///   避免居中气泡被 `overflow-hidden` 裁掉
149#[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        // erase_components 下 SSR 在动态文本后输出 `<!>` 水合标记;断言只看内容与属性。
195        let html = html.replace("<!>", "");
196        assert!(html.contains("role=\"tooltip\""), "{html}");
197        assert!(html.contains("<kbd>⌘K</kbd>"), "{html}");
198        // 非法取值回落到默认的上方居中,不会输出未定义的 data 值。
199        assert!(html.contains("data-placement=\"top\""), "{html}");
200        assert!(html.contains("data-align=\"center\""), "{html}");
201    }
202}