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    fn as_style(&self) -> &'static str {
25        match self {
26            NotificationPlacement::TopRight => "top: 24px; right: 24px;",
27            NotificationPlacement::TopLeft => "top: 24px; left: 24px;",
28            NotificationPlacement::BottomRight => "bottom: 24px; right: 24px;",
29            NotificationPlacement::BottomLeft => "bottom: 24px; left: 24px;",
30        }
31    }
32}
33
34/// Configuration of a single notification.
35#[derive(Clone, Debug, PartialEq)]
36pub struct NotificationConfig {
37    pub title: String,
38    pub description: Option<String>,
39    pub r#type: NotificationType,
40    pub placement: NotificationPlacement,
41    /// Auto close delay in seconds. Set to 0 for no auto-dismiss.
42    pub duration: f32,
43    /// Custom icon element. When None, default icon based on type is used.
44    pub icon: Option<Element>,
45    /// Additional CSS class.
46    pub class: Option<String>,
47    /// Inline styles.
48    pub style: Option<String>,
49    /// Callback when notification is clicked.
50    pub on_click: Option<EventHandler<()>>,
51    /// Unique key for this notification.
52    pub key: Option<String>,
53}
54
55impl Default for NotificationConfig {
56    fn default() -> Self {
57        Self {
58            title: String::new(),
59            description: None,
60            r#type: NotificationType::Info,
61            placement: NotificationPlacement::TopRight,
62            duration: 4.5,
63            icon: None,
64            class: None,
65            style: None,
66            on_click: None,
67            key: None,
68        }
69    }
70}
71
72#[derive(Clone, Debug, PartialEq)]
73pub struct NotificationEntry {
74    pub key: OverlayKey,
75    pub meta: OverlayMeta,
76    pub config: NotificationConfig,
77}
78
79pub type NotificationEntriesSignal = Signal<Vec<NotificationEntry>>;
80
81pub fn use_notification_entries_provider() -> NotificationEntriesSignal {
82    let signal: NotificationEntriesSignal = use_context_provider(|| Signal::new(Vec::new()));
83    signal
84}
85
86pub fn use_notification_entries() -> NotificationEntriesSignal {
87    use_context::<NotificationEntriesSignal>()
88}
89
90#[derive(Clone)]
91pub struct NotificationApi {
92    overlay: OverlayHandle,
93    entries: NotificationEntriesSignal,
94}
95
96impl NotificationApi {
97    pub fn new(overlay: OverlayHandle, entries: NotificationEntriesSignal) -> Self {
98        Self { overlay, entries }
99    }
100
101    pub fn open(&self, config: NotificationConfig) -> OverlayKey {
102        let (key, meta) = self.overlay.open(OverlayKind::Notification, false);
103        let mut entries = self.entries;
104        entries.write().push(NotificationEntry {
105            key,
106            meta,
107            config: config.clone(),
108        });
109        schedule_notification_dismiss(key, self.entries, self.overlay.clone(), config.duration);
110        key
111    }
112
113    fn open_with_type(
114        &self,
115        title: impl Into<String>,
116        description: Option<String>,
117        placement: NotificationPlacement,
118        kind: NotificationType,
119    ) -> OverlayKey {
120        let cfg = NotificationConfig {
121            title: title.into(),
122            description,
123            r#type: kind,
124            placement,
125            duration: 4.5,
126            icon: None,
127            class: None,
128            style: None,
129            on_click: None,
130            key: None,
131        };
132        self.open(cfg)
133    }
134
135    pub fn info(&self, title: impl Into<String>, description: Option<String>) -> OverlayKey {
136        self.open_with_type(
137            title,
138            description,
139            NotificationPlacement::TopRight,
140            NotificationType::Info,
141        )
142    }
143
144    pub fn success(&self, title: impl Into<String>, description: Option<String>) -> OverlayKey {
145        self.open_with_type(
146            title,
147            description,
148            NotificationPlacement::TopRight,
149            NotificationType::Success,
150        )
151    }
152
153    pub fn warning(&self, title: impl Into<String>, description: Option<String>) -> OverlayKey {
154        self.open_with_type(
155            title,
156            description,
157            NotificationPlacement::TopRight,
158            NotificationType::Warning,
159        )
160    }
161
162    pub fn error(&self, title: impl Into<String>, description: Option<String>) -> OverlayKey {
163        self.open_with_type(
164            title,
165            description,
166            NotificationPlacement::TopRight,
167            NotificationType::Error,
168        )
169    }
170
171    pub fn close(&self, key: OverlayKey) {
172        let mut entries = self.entries;
173        entries.write().retain(|e| e.key != key);
174        self.overlay.close(key);
175    }
176
177    pub fn destroy(&self) {
178        let mut entries = self.entries;
179        let current: Vec<_> = entries.read().iter().map(|e| e.key).collect();
180        entries.write().clear();
181        for k in current {
182            self.overlay.close(k);
183        }
184    }
185}
186
187#[component]
188pub fn NotificationHost() -> Element {
189    let entries_signal = use_notification_entries();
190    let entries = entries_signal.read().clone();
191
192    if entries.is_empty() {
193        return rsx! {};
194    }
195
196    let top_right: Vec<_> = entries
197        .iter()
198        .filter(|e| e.config.placement == NotificationPlacement::TopRight)
199        .cloned()
200        .collect();
201    let bottom_right: Vec<_> = entries
202        .iter()
203        .filter(|e| e.config.placement == NotificationPlacement::BottomRight)
204        .cloned()
205        .collect();
206
207    rsx! {
208        // topRight container
209        if !top_right.is_empty() {
210            div {
211                class: "adui-notification-root adui-notification-top-right",
212                style: "position: fixed; top: 24px; inset-inline-end: 0; z-index: 1000; display: flex; flex-direction: column; gap: 8px; padding-inline-end: 24px;",
213                {top_right.iter().map(|entry| {
214                    let key = entry.key;
215                    let z = entry.meta.z_index;
216                    let title = entry.config.title.clone();
217                    let desc = entry.config.description.clone();
218                    let kind_class = match entry.config.r#type {
219                        NotificationType::Info => "adui-notification-info",
220                        NotificationType::Success => "adui-notification-success",
221                        NotificationType::Warning => "adui-notification-warning",
222                        NotificationType::Error => "adui-notification-error",
223                    };
224                    rsx! {
225                        div {
226                            key: "notice-{key:?}",
227                            class: "adui-notification {kind_class}",
228                            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);",
229                            div {
230                                style: "font-weight: 500; margin-bottom: 4px;",
231                                "{title}"
232                            }
233                            if let Some(text) = desc {
234                                div { style: "font-size: 13px; color: var(--adui-color-text-secondary);", "{text}" }
235                            }
236                            button {
237                                style: "margin-left: 8px; background: none; border: none; cursor: pointer; color: var(--adui-color-text-secondary); float: right;",
238                                onclick: move |_| {
239                                    let mut entries = entries_signal;
240                                    entries.write().retain(|e| e.key != key);
241                                },
242                                "×"
243                            }
244                        }
245                    }
246                })}
247            }
248        }
249
250        // bottomRight container
251        if !bottom_right.is_empty() {
252            div {
253                class: "adui-notification-root adui-notification-bottom-right",
254                style: "position: fixed; bottom: 24px; inset-inline-end: 0; z-index: 1000; display: flex; flex-direction: column; gap: 8px; padding-inline-end: 24px;",
255                {bottom_right.iter().map(|entry| {
256                    let key = entry.key;
257                    let z = entry.meta.z_index;
258                    let title = entry.config.title.clone();
259                    let desc = entry.config.description.clone();
260                    let kind_class = match entry.config.r#type {
261                        NotificationType::Info => "adui-notification-info",
262                        NotificationType::Success => "adui-notification-success",
263                        NotificationType::Warning => "adui-notification-warning",
264                        NotificationType::Error => "adui-notification-error",
265                    };
266                    rsx! {
267                        div {
268                            key: "notice-bottom-{key:?}",
269                            class: "adui-notification {kind_class}",
270                            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);",
271                            div {
272                                style: "font-weight: 500; margin-bottom: 4px;",
273                                "{title}"
274                            }
275                            if let Some(text) = desc {
276                                div { style: "font-size: 13px; color: var(--adui-color-text-secondary);", "{text}" }
277                            }
278                            button {
279                                style: "margin-left: 8px; background: none; border: none; cursor: pointer; color: var(--adui-color-text-secondary); float: right;",
280                                onclick: move |_| {
281                                    let mut entries = entries_signal;
282                                    entries.write().retain(|e| e.key != key);
283                                },
284                                "×"
285                            }
286                        }
287                    }
288                })}
289            }
290        }
291    }
292}
293
294#[cfg(target_arch = "wasm32")]
295fn schedule_notification_dismiss(
296    key: OverlayKey,
297    entries: NotificationEntriesSignal,
298    overlay: OverlayHandle,
299    duration_secs: f32,
300) {
301    use wasm_bindgen::{JsCast, closure::Closure};
302
303    if duration_secs <= 0.0 {
304        return;
305    }
306
307    if let Some(window) = web_sys::window() {
308        let delay_ms = (duration_secs * 1000.0) as i32;
309        let mut entries_signal = entries;
310        let overlay_clone = overlay.clone();
311        let callback = Closure::once(move || {
312            entries_signal.write().retain(|e| e.key != key);
313            overlay_clone.close(key);
314        });
315        let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
316            callback.as_ref().unchecked_ref(),
317            delay_ms,
318        );
319        callback.forget();
320    }
321}
322
323#[cfg(not(target_arch = "wasm32"))]
324fn schedule_notification_dismiss(
325    _key: OverlayKey,
326    _entries: NotificationEntriesSignal,
327    _overlay: OverlayHandle,
328    _duration_secs: f32,
329) {
330}