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    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}