adui_dioxus/components/
notification.rs

1use crate::components::overlay::{OverlayHandle, OverlayKey, OverlayKind, OverlayMeta};
2use dioxus::prelude::*;
3
4/// Notification types aligned with message types for simplicity.
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6pub enum NotificationType {
7    Info,
8    Success,
9    Warning,
10    Error,
11}
12
13/// Placement for notification list.
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
15pub enum NotificationPlacement {
16    #[default]
17    TopRight,
18    TopLeft,
19    BottomRight,
20    BottomLeft,
21}
22
23impl NotificationPlacement {
24    #[allow(dead_code)]
25    pub(crate) fn as_style(&self) -> &'static str {
26        match self {
27            NotificationPlacement::TopRight => "top: 24px; right: 24px;",
28            NotificationPlacement::TopLeft => "top: 24px; left: 24px;",
29            NotificationPlacement::BottomRight => "bottom: 24px; right: 24px;",
30            NotificationPlacement::BottomLeft => "bottom: 24px; left: 24px;",
31        }
32    }
33}
34
35/// Configuration of a single notification.
36#[derive(Clone, Debug, PartialEq)]
37pub struct NotificationConfig {
38    pub title: String,
39    pub description: Option<String>,
40    pub r#type: NotificationType,
41    pub placement: NotificationPlacement,
42    /// Auto close delay in seconds. Set to 0 for no auto-dismiss.
43    pub duration: f32,
44    /// Custom icon element. When None, default icon based on type is used.
45    pub icon: Option<Element>,
46    /// Additional CSS class.
47    pub class: Option<String>,
48    /// Inline styles.
49    pub style: Option<String>,
50    /// Callback when notification is clicked.
51    pub on_click: Option<EventHandler<()>>,
52    /// Unique key for this notification.
53    pub key: Option<String>,
54}
55
56impl Default for NotificationConfig {
57    fn default() -> Self {
58        Self {
59            title: String::new(),
60            description: None,
61            r#type: NotificationType::Info,
62            placement: NotificationPlacement::TopRight,
63            duration: 4.5,
64            icon: None,
65            class: None,
66            style: None,
67            on_click: None,
68            key: None,
69        }
70    }
71}
72
73#[derive(Clone, Debug, PartialEq)]
74pub struct NotificationEntry {
75    pub key: OverlayKey,
76    pub meta: OverlayMeta,
77    pub config: NotificationConfig,
78}
79
80pub type NotificationEntriesSignal = Signal<Vec<NotificationEntry>>;
81
82pub fn use_notification_entries_provider() -> NotificationEntriesSignal {
83    let signal: NotificationEntriesSignal = use_context_provider(|| Signal::new(Vec::new()));
84    signal
85}
86
87pub fn use_notification_entries() -> NotificationEntriesSignal {
88    use_context::<NotificationEntriesSignal>()
89}
90
91#[derive(Clone)]
92pub struct NotificationApi {
93    overlay: OverlayHandle,
94    entries: NotificationEntriesSignal,
95}
96
97impl NotificationApi {
98    pub fn new(overlay: OverlayHandle, entries: NotificationEntriesSignal) -> Self {
99        Self { overlay, entries }
100    }
101
102    pub fn open(&self, config: NotificationConfig) -> OverlayKey {
103        let (key, meta) = self.overlay.open(OverlayKind::Notification, false);
104        let mut entries = self.entries;
105        entries.write().push(NotificationEntry {
106            key,
107            meta,
108            config: config.clone(),
109        });
110        schedule_notification_dismiss(key, self.entries, self.overlay.clone(), config.duration);
111        key
112    }
113
114    fn open_with_type(
115        &self,
116        title: impl Into<String>,
117        description: Option<String>,
118        placement: NotificationPlacement,
119        kind: NotificationType,
120    ) -> OverlayKey {
121        let cfg = NotificationConfig {
122            title: title.into(),
123            description,
124            r#type: kind,
125            placement,
126            duration: 4.5,
127            icon: None,
128            class: None,
129            style: None,
130            on_click: None,
131            key: None,
132        };
133        self.open(cfg)
134    }
135
136    pub fn info(&self, title: impl Into<String>, description: Option<String>) -> OverlayKey {
137        self.open_with_type(
138            title,
139            description,
140            NotificationPlacement::TopRight,
141            NotificationType::Info,
142        )
143    }
144
145    pub fn success(&self, title: impl Into<String>, description: Option<String>) -> OverlayKey {
146        self.open_with_type(
147            title,
148            description,
149            NotificationPlacement::TopRight,
150            NotificationType::Success,
151        )
152    }
153
154    pub fn warning(&self, title: impl Into<String>, description: Option<String>) -> OverlayKey {
155        self.open_with_type(
156            title,
157            description,
158            NotificationPlacement::TopRight,
159            NotificationType::Warning,
160        )
161    }
162
163    pub fn error(&self, title: impl Into<String>, description: Option<String>) -> OverlayKey {
164        self.open_with_type(
165            title,
166            description,
167            NotificationPlacement::TopRight,
168            NotificationType::Error,
169        )
170    }
171
172    pub fn close(&self, key: OverlayKey) {
173        let mut entries = self.entries;
174        entries.write().retain(|e| e.key != key);
175        self.overlay.close(key);
176    }
177
178    pub fn destroy(&self) {
179        let mut entries = self.entries;
180        let current: Vec<_> = entries.read().iter().map(|e| e.key).collect();
181        entries.write().clear();
182        for k in current {
183            self.overlay.close(k);
184        }
185    }
186}
187
188#[component]
189pub fn NotificationHost() -> Element {
190    let entries_signal = use_notification_entries();
191    let entries = entries_signal.read().clone();
192
193    if entries.is_empty() {
194        return rsx! {};
195    }
196
197    let top_right: Vec<_> = entries
198        .iter()
199        .filter(|e| e.config.placement == NotificationPlacement::TopRight)
200        .cloned()
201        .collect();
202    let bottom_right: Vec<_> = entries
203        .iter()
204        .filter(|e| e.config.placement == NotificationPlacement::BottomRight)
205        .cloned()
206        .collect();
207
208    rsx! {
209        // topRight container
210        if !top_right.is_empty() {
211            div {
212                class: "adui-notification-root adui-notification-top-right",
213                style: "position: fixed; top: 24px; inset-inline-end: 0; z-index: 1000; display: flex; flex-direction: column; gap: 8px; padding-inline-end: 24px;",
214                {top_right.iter().map(|entry| {
215                    let key = entry.key;
216                    let z = entry.meta.z_index;
217                    let title = entry.config.title.clone();
218                    let desc = entry.config.description.clone();
219                    let kind_class = match entry.config.r#type {
220                        NotificationType::Info => "adui-notification-info",
221                        NotificationType::Success => "adui-notification-success",
222                        NotificationType::Warning => "adui-notification-warning",
223                        NotificationType::Error => "adui-notification-error",
224                    };
225                    rsx! {
226                        div {
227                            key: "notice-{key:?}",
228                            class: "adui-notification {kind_class}",
229                            style: "pointer-events: auto; z-index: {z}; min-width: 280px; max-width: 480px; padding: 12px 16px; border-radius: 4px; background: var(--adui-color-bg-container); box-shadow: var(--adui-shadow); color: var(--adui-color-text); border: 1px solid var(--adui-color-border);",
230                            div {
231                                style: "font-weight: 500; margin-bottom: 4px;",
232                                "{title}"
233                            }
234                            if let Some(text) = desc {
235                                div { style: "font-size: 13px; color: var(--adui-color-text-secondary);", "{text}" }
236                            }
237                            button {
238                                style: "margin-left: 8px; background: none; border: none; cursor: pointer; color: var(--adui-color-text-secondary); float: right;",
239                                onclick: move |_| {
240                                    let mut entries = entries_signal;
241                                    entries.write().retain(|e| e.key != key);
242                                },
243                                "×"
244                            }
245                        }
246                    }
247                })}
248            }
249        }
250
251        // bottomRight container
252        if !bottom_right.is_empty() {
253            div {
254                class: "adui-notification-root adui-notification-bottom-right",
255                style: "position: fixed; bottom: 24px; inset-inline-end: 0; z-index: 1000; display: flex; flex-direction: column; gap: 8px; padding-inline-end: 24px;",
256                {bottom_right.iter().map(|entry| {
257                    let key = entry.key;
258                    let z = entry.meta.z_index;
259                    let title = entry.config.title.clone();
260                    let desc = entry.config.description.clone();
261                    let kind_class = match entry.config.r#type {
262                        NotificationType::Info => "adui-notification-info",
263                        NotificationType::Success => "adui-notification-success",
264                        NotificationType::Warning => "adui-notification-warning",
265                        NotificationType::Error => "adui-notification-error",
266                    };
267                    rsx! {
268                        div {
269                            key: "notice-bottom-{key:?}",
270                            class: "adui-notification {kind_class}",
271                            style: "pointer-events: auto; z-index: {z}; min-width: 280px; max-width: 480px; padding: 12px 16px; border-radius: 4px; background: var(--adui-color-bg-container); box-shadow: var(--adui-shadow); color: var(--adui-color-text); border: 1px solid var(--adui-color-border);",
272                            div {
273                                style: "font-weight: 500; margin-bottom: 4px;",
274                                "{title}"
275                            }
276                            if let Some(text) = desc {
277                                div { style: "font-size: 13px; color: var(--adui-color-text-secondary);", "{text}" }
278                            }
279                            button {
280                                style: "margin-left: 8px; background: none; border: none; cursor: pointer; color: var(--adui-color-text-secondary); float: right;",
281                                onclick: move |_| {
282                                    let mut entries = entries_signal;
283                                    entries.write().retain(|e| e.key != key);
284                                },
285                                "×"
286                            }
287                        }
288                    }
289                })}
290            }
291        }
292    }
293}
294
295#[cfg(target_arch = "wasm32")]
296fn schedule_notification_dismiss(
297    key: OverlayKey,
298    entries: NotificationEntriesSignal,
299    overlay: OverlayHandle,
300    duration_secs: f32,
301) {
302    use wasm_bindgen::{JsCast, closure::Closure};
303
304    if duration_secs <= 0.0 {
305        return;
306    }
307
308    if let Some(window) = web_sys::window() {
309        let delay_ms = (duration_secs * 1000.0) as i32;
310        let mut entries_signal = entries;
311        let overlay_clone = overlay.clone();
312        let callback = Closure::once(move || {
313            entries_signal.write().retain(|e| e.key != key);
314            overlay_clone.close(key);
315        });
316        let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
317            callback.as_ref().unchecked_ref(),
318            delay_ms,
319        );
320        callback.forget();
321    }
322}
323
324#[cfg(not(target_arch = "wasm32"))]
325fn schedule_notification_dismiss(
326    _key: OverlayKey,
327    _entries: NotificationEntriesSignal,
328    _overlay: OverlayHandle,
329    _duration_secs: f32,
330) {
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn notification_type_all_variants() {
339        assert_eq!(NotificationType::Info, NotificationType::Info);
340        assert_eq!(NotificationType::Success, NotificationType::Success);
341        assert_eq!(NotificationType::Warning, NotificationType::Warning);
342        assert_eq!(NotificationType::Error, NotificationType::Error);
343        assert_ne!(NotificationType::Info, NotificationType::Success);
344        assert_ne!(NotificationType::Success, NotificationType::Warning);
345        assert_ne!(NotificationType::Warning, NotificationType::Error);
346    }
347
348    #[test]
349    fn notification_type_clone() {
350        let original = NotificationType::Success;
351        let cloned = original;
352        assert_eq!(original, cloned);
353    }
354
355    #[test]
356    fn notification_placement_default() {
357        assert_eq!(
358            NotificationPlacement::default(),
359            NotificationPlacement::TopRight
360        );
361    }
362
363    #[test]
364    fn notification_placement_all_variants() {
365        assert_eq!(
366            NotificationPlacement::TopRight,
367            NotificationPlacement::TopRight
368        );
369        assert_eq!(
370            NotificationPlacement::TopLeft,
371            NotificationPlacement::TopLeft
372        );
373        assert_eq!(
374            NotificationPlacement::BottomRight,
375            NotificationPlacement::BottomRight
376        );
377        assert_eq!(
378            NotificationPlacement::BottomLeft,
379            NotificationPlacement::BottomLeft
380        );
381        assert_ne!(
382            NotificationPlacement::TopRight,
383            NotificationPlacement::TopLeft
384        );
385        assert_ne!(
386            NotificationPlacement::TopRight,
387            NotificationPlacement::BottomRight
388        );
389    }
390
391    #[test]
392    fn notification_placement_clone() {
393        let original = NotificationPlacement::BottomLeft;
394        let cloned = original;
395        assert_eq!(original, cloned);
396    }
397
398    #[test]
399    fn notification_placement_as_style() {
400        assert_eq!(
401            NotificationPlacement::TopRight.as_style(),
402            "top: 24px; right: 24px;"
403        );
404        assert_eq!(
405            NotificationPlacement::TopLeft.as_style(),
406            "top: 24px; left: 24px;"
407        );
408        assert_eq!(
409            NotificationPlacement::BottomRight.as_style(),
410            "bottom: 24px; right: 24px;"
411        );
412        assert_eq!(
413            NotificationPlacement::BottomLeft.as_style(),
414            "bottom: 24px; left: 24px;"
415        );
416    }
417
418    #[test]
419    fn notification_config_default() {
420        let config = NotificationConfig::default();
421        assert_eq!(config.title, "");
422        assert_eq!(config.description, None);
423        assert_eq!(config.r#type, NotificationType::Info);
424        assert_eq!(config.placement, NotificationPlacement::TopRight);
425        assert_eq!(config.duration, 4.5);
426        assert_eq!(config.icon, None);
427        assert_eq!(config.class, None);
428        assert_eq!(config.style, None);
429        assert_eq!(config.on_click, None);
430        assert_eq!(config.key, None);
431    }
432
433    #[test]
434    fn notification_config_clone() {
435        let config1 = NotificationConfig {
436            title: "Test notification".to_string(),
437            description: Some("Test description".to_string()),
438            r#type: NotificationType::Success,
439            placement: NotificationPlacement::TopLeft,
440            duration: 5.0,
441            icon: None,
442            class: Some("custom-class".to_string()),
443            style: Some("color: blue;".to_string()),
444            on_click: None,
445            key: Some("notif-1".to_string()),
446        };
447        let config2 = config1.clone();
448        assert_eq!(config1, config2);
449    }
450
451    #[test]
452    fn notification_config_partial_eq() {
453        let config1 = NotificationConfig {
454            title: "Test".to_string(),
455            description: None,
456            r#type: NotificationType::Info,
457            placement: NotificationPlacement::TopRight,
458            duration: 4.5,
459            icon: None,
460            class: None,
461            style: None,
462            on_click: None,
463            key: None,
464        };
465        let config2 = NotificationConfig {
466            title: "Test".to_string(),
467            description: None,
468            r#type: NotificationType::Info,
469            placement: NotificationPlacement::TopRight,
470            duration: 4.5,
471            icon: None,
472            class: None,
473            style: None,
474            on_click: None,
475            key: None,
476        };
477        let config3 = NotificationConfig {
478            title: "Different".to_string(),
479            description: Some("Desc".to_string()),
480            r#type: NotificationType::Error,
481            placement: NotificationPlacement::BottomLeft,
482            duration: 6.0,
483            icon: None,
484            class: None,
485            style: None,
486            on_click: None,
487            key: None,
488        };
489        assert_eq!(config1, config2);
490        assert_ne!(config1, config3);
491    }
492}