yggdrasil/components/ui/
feedback.rs1use super::controls::{Button, ButtonSize, ButtonVariant, IconButton};
3use super::icons::{Icon, IconName};
4use leptos::prelude::*;
5use std::time::Duration;
6
7#[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 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
49const MAX_TOASTS: usize = 3;
51const LEAVE_MS: u32 = 180;
53
54#[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 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 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
120pub fn provide_toaster() -> Toaster {
122 let toaster = Toaster::new();
123 provide_context(toaster);
124 toaster
125}
126
127pub fn use_toast() -> Toaster {
129 expect_context::<Toaster>()
130}
131
132#[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#[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}