1use crate::{
9 core::{
10 assets,
11 attrs::{component_attributes, leaving_attribute, FcData, Kind},
12 platform::DomBridge,
13 },
14 prelude::*,
15 theme::use_stylesheets,
16};
17const TOAST: Bem = Bem::new("fc-toast");
18const TOASTER: Bem = Bem::new("fc-toaster");
19fc_modifier! {
20 pub enum ToastTone for TOAST {
22 Plain => None,
24 Info => "info",
26 Success => "success",
28 Warning => "warning",
30 Danger => "danger",
32 }
33}
34impl ToastTone {
35 const fn glyph(self) -> &'static str {
36 match self {
37 Self::Plain | Self::Info => "i",
38 Self::Success => "✓",
39 Self::Warning => "!",
40 Self::Danger => "✕",
41 }
42 }
43}
44fc_modifier! { enum ToasterPlacement for TOASTER { Corner => None, Top => "top" } }
45
46#[derive(Debug, Clone, PartialEq)]
48pub struct ToastAction {
49 pub label: String,
51 pub onaction: EventHandler<()>,
53}
54
55#[component]
57pub fn Toast(
58 #[props(default = ToastTone::Info)] tone: ToastTone,
59 #[props(default)] title: Option<String>,
60 #[props(default)] body: Option<String>,
61 #[props(default)] action: Option<ToastAction>,
62 ondismiss: EventHandler<()>,
63 #[props(default)]
65 fc_id: Option<Id>,
66 #[props(default)] leaving: bool,
67 #[props(default)]
70 onfocuschange: Option<EventHandler<bool>>,
71) -> Element {
72 let class = ClassList::new(TOAST.block())
73 .with_modifier(TOAST, tone)
74 .to_string();
75 let mut data = FcData {
76 kind: Some(Kind::Toast),
77 id: fc_id,
78 ..FcData::default()
79 }
80 .attributes();
81 data.extend(leaving_attribute(leaving));
82 let what = title
84 .clone()
85 .or_else(|| body.clone())
86 .unwrap_or_else(|| "notification".to_owned());
87 rsx! {
88 li { class,
89 onfocusin: move |_| if let Some(on) = onfocuschange { on.call(true) },
90 onfocusout: move |_| if let Some(on) = onfocuschange { on.call(false) },
91 onkeydown: move |event| if event.key() == Key::Escape { event.prevent_default(); ondismiss.call(()); },
92 ..data,
93 span { class: TOAST.element("icon"), aria_hidden: "true", "{tone.glyph()}" }
94 div { class: TOAST.element("content"),
95 if let Some(title) = title { p { class: TOAST.element("title"), "{title}" } }
96 if let Some(body) = body { p { class: TOAST.element("body"), "{body}" } }
97 }
98 if let Some(action) = action {
99 button { r#type: "button", class: TOAST.element("action"),
100 onclick: move |_| { action.onaction.call(()); ondismiss.call(()); }, "{action.label}" }
101 }
102 button { r#type: "button", class: TOAST.element("close"), aria_label: "Dismiss: {what}",
103 onclick: move |_| ondismiss.call(()), "✕" }
104 }
105 }
106}
107
108#[component]
111pub fn ToastList(children: Element) -> Element {
112 rsx! {
113 if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_TOAST_CSS } }
114 ol { class: TOASTER.element("list"), {children} }
115 }
116}
117
118#[derive(Debug, Clone, PartialEq)]
120pub struct ToastOptions {
121 pub tone: ToastTone,
123 pub title: Option<String>,
125 pub body: Option<String>,
127 pub timeout: Option<u32>,
129 pub action: Option<ToastAction>,
131}
132
133impl Default for ToastOptions {
134 fn default() -> Self {
135 Self {
136 tone: ToastTone::Info,
137 title: None,
138 body: None,
139 timeout: None,
140 action: None,
141 }
142 }
143}
144
145#[must_use]
148pub fn default_timeout(tone: ToastTone, has_action: bool, base: u32) -> u32 {
149 if tone == ToastTone::Danger {
150 0
151 } else if has_action {
152 base.saturating_add(base / 2)
153 } else {
154 base
155 }
156}
157
158#[must_use]
161pub fn overflow(keys: &[u64], max: usize, focused: Option<u64>) -> Vec<u64> {
162 let excess = keys.len().saturating_sub(max);
163 keys.iter()
166 .take(excess)
167 .copied()
168 .filter(|key| Some(*key) != focused)
169 .collect()
170}
171
172#[derive(Debug, Clone, PartialEq)]
173struct Live {
174 key: u64,
175 options: ToastOptions,
176 remaining: u32,
177 leaving: bool,
178}
179
180#[derive(Clone, Copy)]
182pub struct Toasts {
183 live: Signal<Vec<Live>>,
184 next: Signal<u64>,
185 focused: Signal<Option<u64>>,
186 max: usize,
187 base: u32,
188}
189impl Toasts {
190 #[allow(clippy::must_use_candidate)]
193 pub fn show(mut self, options: ToastOptions) -> u64 {
194 let key = *self.next.peek();
195 self.next.set(key + 1);
196 let remaining = options
197 .timeout
198 .unwrap_or_else(|| default_timeout(options.tone, options.action.is_some(), self.base));
199 self.live.write().push(Live {
200 key,
201 options,
202 remaining,
203 leaving: false,
204 });
205 let keys: Vec<u64> = self
206 .live
207 .peek()
208 .iter()
209 .filter(|t| !t.leaving)
210 .map(|t| t.key)
211 .collect();
212 for old in overflow(&keys, self.max, *self.focused.peek()) {
213 self.dismiss(old);
214 }
215 key
216 }
217 pub fn dismiss(self, key: u64) {
219 let mut live = self.live;
220 if let Some(toast) = live.write().iter_mut().find(|t| t.key == key) {
221 toast.leaving = true;
222 }
223 spawn(async move {
224 let _ = DomBridge.delay(400).await;
226 live.write().retain(|t| t.key != key);
227 });
228 }
229}
230
231#[must_use]
236pub fn use_toasts() -> Toasts {
237 use_hook(|| {
238 try_consume_context::<Toasts>().unwrap_or_else(|| Toasts {
239 live: Signal::new(Vec::new()),
240 next: Signal::new(0),
241 focused: Signal::new(None),
242 max: 3,
243 base: 6000,
244 })
245 })
246}
247
248#[component]
250pub fn Toaster(
251 #[props(default)]
253 label: Option<AccessibleName>,
254 #[props(default)]
256 top: bool,
257 #[props(default = 3)] max: usize,
258 #[props(default = 6000)]
260 timeout: u32,
261 #[props(default)] class: Option<String>,
262 #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
263 children: Element,
264) -> Element {
265 let live = use_signal(Vec::<Live>::new);
266 let next = use_signal(|| 0);
267 let mut focused = use_signal(|| None::<u64>);
268 let mut paused = use_signal(|| false);
269 let toasts = use_context_provider(|| Toasts {
270 live,
271 next,
272 focused,
273 max,
274 base: timeout,
275 });
276 use_future(move || async move {
278 const TICK: u32 = 250;
279 while DomBridge.delay(TICK).await.is_ok() {
280 if *paused.peek() {
281 continue;
282 }
283 let mut expired = Vec::new();
284 let mut list = live;
285 for toast in list
286 .write()
287 .iter_mut()
288 .filter(|t| !t.leaving && t.remaining > 0)
289 {
290 toast.remaining = toast.remaining.saturating_sub(TICK);
291 if toast.remaining == 0 {
292 expired.push(toast.key);
293 }
294 }
295 for key in expired {
296 toasts.dismiss(key);
297 }
298 }
299 });
300 let attributes = component_attributes(
301 attributes,
302 ClassList::new(TOASTER.block())
303 .with_modifier(
304 TOASTER,
305 if top {
306 ToasterPlacement::Top
307 } else {
308 ToasterPlacement::Corner
309 },
310 )
311 .extend(class.as_deref()),
312 &["aria-label"],
313 );
314 let label = label.map_or_else(|| "Notifications".to_owned(), |l| l.to_string());
315 let render = move |danger: bool| {
316 let items: Vec<Live> = live
317 .read()
318 .iter()
319 .filter(|t| (t.options.tone == ToastTone::Danger) == danger)
320 .cloned()
321 .collect();
322 rsx! {
323 for toast in items {
324 Toast { key: "{toast.key}", tone: toast.options.tone, title: toast.options.title.clone(),
325 body: toast.options.body.clone(), action: toast.options.action.clone(), leaving: toast.leaving,
326 fc_id: Id::try_from(format!("toast-{}", toast.key)).ok(),
327 ondismiss: move |()| toasts.dismiss(toast.key),
328 onfocuschange: move |inside: bool| {
330 if inside { focused.set(Some(toast.key)); }
331 else if *focused.peek() == Some(toast.key) { focused.set(None); }
332 } }
333 }
334 }
335 };
336 rsx! {
337 {children}
338 if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_TOAST_CSS } }
339 section { aria_label: label,
340 onpointerenter: move |_| paused.set(true),
341 onpointerleave: move |_| if focused.peek().is_none() { paused.set(false); },
342 onfocusin: move |_| paused.set(true),
343 onfocusout: move |_| { focused.set(None); paused.set(false); },
344 ..attributes,
345 ol { class: TOASTER.element("list"), role: "status", aria_live: "polite", {render(false)} }
348 ol { class: TOASTER.element("list"), role: "alert", aria_live: "assertive", {render(true)} }
349 }
350 }
351}