Skip to main content

yggdrasil/components/ui/
feedback.rs

1//! 全站提示(Toast):操作结果的短暂反馈,出现在屏幕底部居中;区块加载失败卡片。
2use super::controls::{Button, ButtonSize, ButtonVariant, IconButton};
3use super::icons::{Icon, IconName};
4use leptos::prelude::*;
5use std::time::Duration;
6
7/// 提示的语气。
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum ToastTone {
10    Info,
11    Success,
12    Danger,
13}
14
15impl ToastTone {
16    fn icon(self) -> IconName {
17        match self {
18            ToastTone::Info => IconName::Info,
19            ToastTone::Success => IconName::CircleCheck,
20            ToastTone::Danger => IconName::CircleAlert,
21        }
22    }
23
24    fn attr(self) -> &'static str {
25        match self {
26            ToastTone::Info => "info",
27            ToastTone::Success => "success",
28            ToastTone::Danger => "danger",
29        }
30    }
31
32    /// 停留时长:错误多留一会儿,给人读完的时间。
33    fn duration_ms(self) -> u32 {
34        match self {
35            ToastTone::Danger => 6000,
36            _ => 3600,
37        }
38    }
39}
40
41#[derive(Clone, Debug, PartialEq)]
42struct ToastEntry {
43    id: u64,
44    tone: ToastTone,
45    message: String,
46    leaving: bool,
47}
48
49/// 同时最多显示的提示条数,更早的会被挤掉。
50const MAX_TOASTS: usize = 3;
51/// 退出动画时长,与 ui.css 的 `toast-leave` 一致。
52const LEAVE_MS: u32 = 180;
53
54/// 发提示的句柄,经 [`use_toast`] 取得。
55///
56/// 两个信号都创建在应用根 owner 上;计时器用普通 `set_timeout` 直接操作它们,
57/// 调用页卸载(例如提示后立即导航)也不会取消自动消失。
58#[derive(Clone, Copy, PartialEq)]
59pub struct Toaster {
60    entries: RwSignal<Vec<ToastEntry>>,
61    next_id: RwSignal<u64>,
62}
63
64impl Toaster {
65    fn new() -> Self {
66        Self {
67            entries: RwSignal::new(Vec::new()),
68            next_id: RwSignal::new(0),
69        }
70    }
71
72    /// 显示一条提示,到时自动消失。
73    pub fn show(self, tone: ToastTone, message: impl Into<String>) {
74        let id = self.next_id.get_untracked();
75        self.next_id.set(id + 1);
76        let message = message.into();
77        self.entries.update(|list| {
78            list.push(ToastEntry {
79                id,
80                tone,
81                message,
82                leaving: false,
83            });
84            let overflow = list.len().saturating_sub(MAX_TOASTS);
85            list.drain(..overflow);
86        });
87        set_timeout(
88            move || self.dismiss(id),
89            Duration::from_millis(u64::from(tone.duration_ms())),
90        );
91    }
92
93    pub fn success(self, message: impl Into<String>) {
94        self.show(ToastTone::Success, message);
95    }
96
97    pub fn error(self, message: impl Into<String>) {
98        self.show(ToastTone::Danger, message);
99    }
100
101    /// 先播退出动画,再从列表移除。
102    fn dismiss(self, id: u64) {
103        let mut found = false;
104        self.entries.update(|list| {
105            if let Some(entry) = list.iter_mut().find(|e| e.id == id && !e.leaving) {
106                entry.leaving = true;
107                found = true;
108            }
109        });
110        if found {
111            let entries = self.entries;
112            set_timeout(
113                move || entries.update(|list| list.retain(|e| e.id != id)),
114                Duration::from_millis(u64::from(LEAVE_MS)),
115            );
116        }
117    }
118}
119
120/// 在应用根部提供提示上下文(`App` 调用一次)。
121pub fn provide_toaster() -> Toaster {
122    let toaster = Toaster::new();
123    provide_context(toaster);
124    toaster
125}
126
127/// 取得发提示的句柄。
128pub fn use_toast() -> Toaster {
129    expect_context::<Toaster>()
130}
131
132/// 提示显示区:固定在屏幕底部居中,`aria-live` 让读屏软件播报新提示。
133#[component]
134pub fn ToastRegion() -> impl IntoView {
135    let toaster = use_toast();
136    let entries = toaster.entries;
137    view! {
138        <div class="toast-region" role="region" aria-label="通知" aria-live="polite">
139            <For
140                each=move || entries.get()
141                key=|entry| entry.id
142                children=move |entry| {
143                    let id = entry.id;
144                    let leaving = move || {
145                        entries.with(|list| list.iter().any(|e| e.id == id && e.leaving))
146                    };
147                    view! {
148                        <div
149                            class="toast"
150                            role=if entry.tone == ToastTone::Danger { "alert" } else { "status" }
151                            data-tone=entry.tone.attr()
152                            data-leaving=move || leaving().then_some("true")
153                        >
154                            <span class="toast-icon">
155                                <Icon name=entry.tone.icon() size=16/>
156                            </span>
157                            <span class="min-w-0 flex-1">{entry.message}</span>
158                            <IconButton
159                                icon=IconName::X
160                                label="关闭提示"
161                                size=ButtonSize::Xs
162                                onclick=move |_| toaster.dismiss(id)
163                            />
164                        </div>
165                    }
166                }
167            />
168        </div>
169    }
170}
171
172/// 区块加载失败卡片:页面其余部分照常显示,只有出错的区块换成它;重试在原位完成。
173///
174/// `retry_failed` 为再次失败后的文案;重试中按钮转圈并向读屏软件播报。
175#[component]
176pub fn LoadError(
177    #[prop(into)] title: Signal<String>,
178    #[prop(optional, into)] retrying: Signal<bool>,
179    #[prop(optional, into)] retry_failed: Signal<bool>,
180    #[prop(optional, into)] class: Signal<String>,
181    #[prop(into)] on_retry: Callback<()>,
182) -> impl IntoView {
183    view! {
184        <div
185            class=move || format!("load-error {}", class.get())
186            role="group"
187            aria-label=move || title.get()
188        >
189            <div class="load-error-copy">
190                <h3>{move || title.get()}</h3>
191                <p role="status" aria-live="polite" aria-atomic="true">
192                    {move || {
193                        if retry_failed.get() {
194                            "这次仍未加载成功,稍后再试一次吧。"
195                        } else {
196                            "网络或服务出了点问题,稍后再试一次。"
197                        }
198                    }}
199                    <Show when=move || retrying.get()>
200                        <span class="sr-only">"正在重新加载,请稍候。"</span>
201                    </Show>
202                </p>
203            </div>
204            <Button
205                variant=ButtonVariant::Secondary
206                icon=IconName::RotateCw
207                loading=retrying
208                onclick=move |_| {
209                    if !retrying.get_untracked() {
210                        on_retry.run(());
211                    }
212                }
213            >
214                "重新加载"
215            </Button>
216        </div>
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn danger_toasts_stay_longer() {
226        assert!(ToastTone::Danger.duration_ms() > ToastTone::Success.duration_ms());
227        assert_eq!(ToastTone::Success.icon(), IconName::CircleCheck);
228        assert_eq!(ToastTone::Danger.attr(), "danger");
229    }
230
231    #[test]
232    fn toaster_keeps_at_most_max_toasts() {
233        Owner::new().with(|| {
234            let toaster = Toaster::new();
235            for n in 0..5 {
236                toaster.entries.update(|list| {
237                    list.push(ToastEntry {
238                        id: n,
239                        tone: ToastTone::Info,
240                        message: n.to_string(),
241                        leaving: false,
242                    });
243                    let overflow = list.len().saturating_sub(MAX_TOASTS);
244                    list.drain(..overflow);
245                });
246            }
247            assert_eq!(toaster.entries.get_untracked().len(), MAX_TOASTS);
248        });
249    }
250
251    #[test]
252    fn toast_region_renders_aria_container() {
253        let html = Owner::new().with(|| {
254            provide_toaster();
255            view! { <ToastRegion/> }.to_html()
256        });
257        assert!(html.contains("class=\"toast-region\""), "{html}");
258        assert!(html.contains("aria-live=\"polite\""), "{html}");
259    }
260}