Skip to main content

orbital_base_components/overlay/toast/
provider.rs

1use 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    /// Dispatch a toast composed with title, body, and footer slot components.
126    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    /// Dismiss every visible and queued toast for this [`ToasterProvider`] only.
301    ///
302    /// This does not affect toasts from other toaster instances on the page.
303    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}