1use crate::components::overlay::{OverlayHandle, OverlayKey, OverlayKind, OverlayMeta};
2use dioxus::prelude::*;
3
4#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6pub enum NotificationType {
7 Info,
8 Success,
9 Warning,
10 Error,
11}
12
13#[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#[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 pub duration: f32,
44 pub icon: Option<Element>,
46 pub class: Option<String>,
48 pub style: Option<String>,
50 pub on_click: Option<EventHandler<()>>,
52 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 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 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}