orbital_base_components/overlay/toast/
provider.rs1use leptos::prelude::*;
2
3use super::toast_container::BaseToastContainer;
4use crate::overlay::{feedback_intent::FeedbackIntent, themed_portal::ThemedPortal};
5
6pub const DEFAULT_TOAST_TIMEOUT_MS: i32 = 8000;
7pub const DEFAULT_TOAST_LIMIT: u32 = 4;
8
9#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
10pub enum ToastStackPosition {
11 Top,
12 TopStart,
13 TopEnd,
14 #[default]
15 Bottom,
16 BottomStart,
17 BottomEnd,
18}
19
20impl ToastStackPosition {
21 pub const ALL: [Self; 6] = [
22 Self::TopStart,
23 Self::TopEnd,
24 Self::Top,
25 Self::BottomStart,
26 Self::BottomEnd,
27 Self::Bottom,
28 ];
29
30 pub fn as_str(&self) -> &'static str {
31 match self {
32 Self::Top => "top",
33 Self::TopStart => "top-start",
34 Self::TopEnd => "top-end",
35 Self::Bottom => "bottom",
36 Self::BottomStart => "bottom-start",
37 Self::BottomEnd => "bottom-end",
38 }
39 }
40}
41
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub struct ToastOffset {
44 pub horizontal: i32,
45 pub vertical: i32,
46}
47
48impl Default for ToastOffset {
49 fn default() -> Self {
50 Self {
51 horizontal: 20,
52 vertical: 16,
53 }
54 }
55}
56
57#[derive(Clone, Copy, Debug)]
58pub struct ToasterConfig {
59 pub default_position: ToastStackPosition,
60 pub default_intent: FeedbackIntent,
61 pub default_timeout: i32,
62 pub default_pause_on_hover: bool,
63 pub limit: u32,
64 pub offset: ToastOffset,
65 pub inline: bool,
66}
67
68impl Default for ToasterConfig {
69 fn default() -> Self {
70 Self {
71 default_position: ToastStackPosition::BottomEnd,
72 default_intent: FeedbackIntent::Info,
73 default_timeout: DEFAULT_TOAST_TIMEOUT_MS,
74 default_pause_on_hover: false,
75 limit: DEFAULT_TOAST_LIMIT,
76 offset: ToastOffset::default(),
77 inline: false,
78 }
79 }
80}
81
82#[derive(Clone)]
83pub struct ToastAction {
84 pub label: String,
85 pub dismiss: bool,
86 pub on_click: Option<Callback<()>>,
87}
88
89#[derive(Clone)]
90enum ToastContent {
91 Text {
92 title: String,
93 body: Option<String>,
94 footer_actions: Vec<ToastAction>,
95 },
96 View(StoredValue<Box<dyn Fn() -> AnyView + Send + Sync>>),
97}
98
99#[derive(Clone)]
100pub struct ToastOptions {
101 content: ToastContent,
102 intent: Option<FeedbackIntent>,
103 pub position: Option<ToastStackPosition>,
104 pub id: Option<String>,
105 pub timeout: Option<i32>,
106 pub pause_on_hover: Option<bool>,
107}
108
109impl ToastOptions {
110 pub fn new(title: impl Into<String>) -> Self {
111 Self {
112 content: ToastContent::Text {
113 title: title.into(),
114 body: None,
115 footer_actions: Vec::new(),
116 },
117 intent: None,
118 position: None,
119 id: None,
120 timeout: None,
121 pause_on_hover: None,
122 }
123 }
124
125 pub fn composed<V>(content: impl Fn() -> V + Send + Sync + 'static) -> Self
127 where
128 V: IntoView + Send + 'static,
129 {
130 Self {
131 content: ToastContent::View(StoredValue::new(Box::new(move || content().into_any()))),
132 intent: None,
133 position: None,
134 id: None,
135 timeout: None,
136 pause_on_hover: None,
137 }
138 }
139
140 pub fn body(mut self, body: impl Into<String>) -> Self {
141 if let ToastContent::Text { body: slot, .. } = &mut self.content {
142 *slot = Some(body.into());
143 }
144 self
145 }
146
147 pub fn intent(mut self, intent: FeedbackIntent) -> Self {
148 self.intent = Some(intent);
149 self
150 }
151
152 pub fn position(mut self, position: ToastStackPosition) -> Self {
153 self.position = Some(position);
154 self
155 }
156
157 pub fn id(mut self, id: impl Into<String>) -> Self {
158 self.id = Some(id.into());
159 self
160 }
161
162 pub fn timeout(mut self, timeout: i32) -> Self {
163 self.timeout = Some(timeout);
164 self
165 }
166
167 pub fn pause_on_hover(mut self, pause_on_hover: bool) -> Self {
168 self.pause_on_hover = Some(pause_on_hover);
169 self
170 }
171
172 pub fn footer_action(self, label: impl Into<String>, dismiss: bool) -> Self {
173 self.footer_action_callback(label, dismiss, None)
174 }
175
176 pub fn footer_action_callback(
177 mut self,
178 label: impl Into<String>,
179 dismiss: bool,
180 on_click: Option<Callback<()>>,
181 ) -> Self {
182 if let ToastContent::Text { footer_actions, .. } = &mut self.content {
183 footer_actions.push(ToastAction {
184 label: label.into(),
185 dismiss,
186 on_click,
187 });
188 }
189 self
190 }
191}
192
193pub type ToastId = String;
194
195#[derive(Clone)]
196pub(crate) enum ToastRecordContent {
197 Text {
198 title: String,
199 body: Option<String>,
200 footer_actions: Vec<ToastAction>,
201 },
202 View(StoredValue<Box<dyn Fn() -> AnyView + Send + Sync>>),
203}
204
205#[derive(Clone)]
206pub(crate) struct ToastRecord {
207 pub id: String,
208 pub content: ToastRecordContent,
209 pub intent: FeedbackIntent,
210 pub position: ToastStackPosition,
211 pub timeout: i32,
212 pub pause_on_hover: bool,
213 pub queued: bool,
214}
215
216#[derive(Clone, Copy)]
217pub struct ToasterInjection {
218 toasts: RwSignal<Vec<ToastRecord>>,
219 config: StoredValue<ToasterConfig>,
220}
221
222impl ToasterInjection {
223 pub fn expect_context() -> Self {
224 expect_context::<Self>()
225 }
226
227 pub fn new(config: ToasterConfig) -> Self {
228 Self {
229 toasts: RwSignal::new(Vec::new()),
230 config: StoredValue::new(config),
231 }
232 }
233
234 pub fn config(&self) -> ToasterConfig {
235 self.config.get_value()
236 }
237
238 pub fn show(&self, message: impl Into<String>) -> ToastId {
239 self.dispatch(ToastOptions::new(message))
240 }
241
242 pub fn dispatch(&self, options: ToastOptions) -> ToastId {
243 let config = self.config.get_value();
244 let id = options
245 .id
246 .clone()
247 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
248
249 if self.toasts.with(|list| list.iter().any(|t| t.id == id)) {
250 return id;
251 }
252
253 let visible_count = self
254 .toasts
255 .with(|list| list.iter().filter(|t| !t.queued).count());
256 let queued = visible_count >= config.limit as usize;
257
258 let content = match options.content {
259 ToastContent::Text {
260 title,
261 body,
262 footer_actions,
263 } => ToastRecordContent::Text {
264 title,
265 body,
266 footer_actions,
267 },
268 ToastContent::View(view) => ToastRecordContent::View(view),
269 };
270
271 let record = ToastRecord {
272 id: id.clone(),
273 content,
274 intent: options.intent.unwrap_or(config.default_intent),
275 position: options.position.unwrap_or(config.default_position),
276 timeout: options.timeout.unwrap_or(config.default_timeout),
277 pause_on_hover: options
278 .pause_on_hover
279 .unwrap_or(config.default_pause_on_hover),
280 queued,
281 };
282
283 self.toasts.update(|list| list.push(record));
284 id
285 }
286
287 pub fn dismiss(&self, id: &str) {
288 let config = self.config.get_value();
289 self.toasts.update(|list| {
290 list.retain(|t| t.id != id);
291 let visible = list.iter().filter(|t| !t.queued).count();
292 if visible < config.limit as usize {
293 if let Some(next) = list.iter_mut().find(|t| t.queued) {
294 next.queued = false;
295 }
296 }
297 });
298 }
299
300 pub fn dismiss_all(&self) {
304 self.toasts.set(Vec::new());
305 }
306
307 pub(crate) fn visible_by_position(&self, position: ToastStackPosition) -> Vec<ToastRecord> {
308 self.toasts.with(|list| {
309 list.iter()
310 .filter(|t| !t.queued && t.position == position)
311 .cloned()
312 .collect()
313 })
314 }
315
316 pub(crate) fn has_visible(&self) -> bool {
317 self.toasts.with(|list| list.iter().any(|t| !t.queued))
318 }
319
320 fn dismiss_callback(injection: ToasterInjection) -> Callback<String> {
321 Callback::new(move |id: String| injection.dismiss(&id))
322 }
323}
324
325#[component]
326fn ToastPositionStack(
327 position: ToastStackPosition,
328 injection: ToasterInjection,
329 inline: bool,
330 offset: ToastOffset,
331) -> impl IntoView {
332 let inline_class = if inline {
333 " orbital-toast-stack--inline"
334 } else {
335 ""
336 };
337 let has_toasts = Signal::derive(move || !injection.visible_by_position(position).is_empty());
338 let on_dismiss = ToasterInjection::dismiss_callback(injection);
339
340 view! {
341 <Show when=has_toasts>
342 <div
343 class=move || {
344 format!(
345 "orbital-toast-stack orbital-toast-stack--{}{inline_class}",
346 position.as_str()
347 )
348 }
349 data-orbital-toast-position=move || position.as_str()
350 style=move || {
351 format!(
352 "--orbital-toast-offset-x: {}px; --orbital-toast-offset-y: {}px;",
353 offset.horizontal, offset.vertical
354 )
355 }
356 >
357 <For
358 each=move || injection.visible_by_position(position)
359 key=|record| record.id.clone()
360 let:record
361 >
362 <BaseToastContainer record=record on_dismiss=on_dismiss />
363 </For>
364 </div>
365 </Show>
366 }
367}
368
369#[component]
370fn ToastStackLayer(
371 injection: ToasterInjection,
372 inline: bool,
373 offset: ToastOffset,
374) -> impl IntoView {
375 view! {
376 {ToastStackPosition::ALL.into_iter().map(|position| {
377 view! {
378 <ToastPositionStack
379 position=position
380 injection=injection
381 inline=inline
382 offset=offset
383 />
384 }
385 }).collect_view()}
386 }
387}
388
389#[component]
390pub fn BaseToastStack() -> impl IntoView {
391 let injection = ToasterInjection::expect_context();
392 let config = injection.config();
393 let has_messages = Signal::derive(move || injection.has_visible());
394
395 if config.inline {
396 view! {
397 <Show when=has_messages>
398 <ToastStackLayer injection=injection inline=config.inline offset=config.offset />
399 </Show>
400 }
401 .into_any()
402 } else {
403 view! {
404 <ThemedPortal immediate=has_messages>
405 <ToastStackLayer injection=injection inline=config.inline offset=config.offset />
406 </ThemedPortal>
407 }
408 .into_any()
409 }
410}
411
412#[component]
413pub fn BaseToasterProvider(toaster_config: ToasterConfig, children: Children) -> impl IntoView {
414 let injection = ToasterInjection::new(toaster_config);
415
416 view! {
417 <leptos::context::Provider value=injection>
418 {children()}
419 </leptos::context::Provider>
420 }
421}