adui_dioxus/components/
message.rs

1use crate::components::overlay::{OverlayHandle, OverlayKey, OverlayKind, OverlayMeta};
2use dioxus::prelude::*;
3
4/// Message types aligned with Ant Design semantics.
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6pub enum MessageType {
7    Info,
8    Success,
9    Warning,
10    Error,
11    Loading,
12}
13
14/// Configuration for a single message instance.
15#[derive(Clone, Debug, PartialEq)]
16pub struct MessageConfig {
17    pub content: String,
18    pub r#type: MessageType,
19    /// Auto close delay in seconds. Set to 0 for no auto-dismiss.
20    pub duration: f32,
21    /// Custom icon element. When None, default icon based on type is used.
22    pub icon: Option<Element>,
23    /// Additional CSS class.
24    pub class: Option<String>,
25    /// Inline styles.
26    pub style: Option<String>,
27    /// Unique key for this message (for programmatic updates).
28    pub key: Option<String>,
29    /// Callback when message is clicked.
30    pub on_click: Option<EventHandler<()>>,
31}
32
33impl Default for MessageConfig {
34    fn default() -> Self {
35        Self {
36            content: String::new(),
37            r#type: MessageType::Info,
38            duration: 3.0,
39            icon: None,
40            class: None,
41            style: None,
42            key: None,
43            on_click: None,
44        }
45    }
46}
47
48/// Internal representation of an active message.
49#[derive(Clone, Debug, PartialEq)]
50pub struct MessageEntry {
51    pub key: OverlayKey,
52    pub meta: OverlayMeta,
53    pub config: MessageConfig,
54}
55
56/// Signal type used to hold the current message queue.
57pub type MessageEntriesSignal = Signal<Vec<MessageEntry>>;
58
59/// Create the message entries signal and install it into context.
60///
61/// This should be called once near the top of the App tree.
62pub fn use_message_entries_provider() -> MessageEntriesSignal {
63    let signal: MessageEntriesSignal = use_context_provider(|| Signal::new(Vec::new()));
64    signal
65}
66
67/// Retrieve the message entries signal from context.
68pub fn use_message_entries() -> MessageEntriesSignal {
69    use_context::<MessageEntriesSignal>()
70}
71
72/// Public API used by `use_message()` callers.
73#[derive(Clone)]
74pub struct MessageApi {
75    overlay: OverlayHandle,
76    entries: MessageEntriesSignal,
77}
78
79impl MessageApi {
80    pub fn new(overlay: OverlayHandle, entries: MessageEntriesSignal) -> Self {
81        Self { overlay, entries }
82    }
83
84    /// Low-level open method that accepts a full config object.
85    pub fn open(&self, config: MessageConfig) -> OverlayKey {
86        let (key, meta) = self.overlay.open(OverlayKind::Message, false);
87        let mut entries = self.entries;
88        entries.write().push(MessageEntry {
89            key,
90            meta,
91            config: config.clone(),
92        });
93
94        schedule_message_dismiss(key, self.entries, self.overlay.clone(), config.duration);
95        key
96    }
97
98    fn open_with_type(&self, content: impl Into<String>, kind: MessageType) -> OverlayKey {
99        let cfg = MessageConfig {
100            content: content.into(),
101            r#type: kind,
102            duration: 3.0,
103            icon: None,
104            class: None,
105            style: None,
106            key: None,
107            on_click: None,
108        };
109        self.open(cfg)
110    }
111
112    pub fn info(&self, content: impl Into<String>) -> OverlayKey {
113        self.open_with_type(content, MessageType::Info)
114    }
115
116    pub fn success(&self, content: impl Into<String>) -> OverlayKey {
117        self.open_with_type(content, MessageType::Success)
118    }
119
120    pub fn warning(&self, content: impl Into<String>) -> OverlayKey {
121        self.open_with_type(content, MessageType::Warning)
122    }
123
124    pub fn error(&self, content: impl Into<String>) -> OverlayKey {
125        self.open_with_type(content, MessageType::Error)
126    }
127
128    pub fn loading(&self, content: impl Into<String>) -> OverlayKey {
129        // Loading 默认不自动关闭,交给调用方控制。
130        let cfg = MessageConfig {
131            content: content.into(),
132            r#type: MessageType::Loading,
133            duration: 0.0,
134            icon: None,
135            class: None,
136            style: None,
137            key: None,
138            on_click: None,
139        };
140        self.open(cfg)
141    }
142
143    /// Destroy a specific message or all messages when `key` is None.
144    pub fn destroy(&self, key: Option<OverlayKey>) {
145        let mut entries = self.entries;
146        match key {
147            Some(k) => {
148                entries.write().retain(|e| e.key != k);
149                self.overlay.close(k);
150            }
151            None => {
152                let current: Vec<_> = entries.read().iter().map(|e| e.key).collect();
153                entries.write().clear();
154                for k in current {
155                    self.overlay.close(k);
156                }
157            }
158        }
159    }
160}
161
162/// Host component rendering the active message list.
163#[component]
164pub fn MessageHost() -> Element {
165    let entries_signal = use_message_entries();
166    let entries = entries_signal.read().clone();
167
168    if entries.is_empty() {
169        return rsx! {};
170    }
171
172    rsx! {
173        div {
174            class: "adui-message-root",
175            style: "position: fixed; top: 24px; inset-inline-end: 0; z-index: 1000; display: flex; flex-direction: column; gap: 8px; padding-inline-end: 24px; pointer-events: none;",
176            {entries.iter().map(|entry| {
177                let key = entry.key;
178                let z = entry.meta.z_index;
179                let text = entry.config.content.clone();
180                let kind_class = match entry.config.r#type {
181                    MessageType::Info => "adui-message-info",
182                    MessageType::Success => "adui-message-success",
183                    MessageType::Warning => "adui-message-warning",
184                    MessageType::Error => "adui-message-error",
185                    MessageType::Loading => "adui-message-loading",
186                };
187                rsx! {
188                    div {
189                        key: "message-{key:?}",
190                        class: "adui-message {kind_class}",
191                        style: "pointer-events: auto; z-index: {z}; min-width: 200px; max-width: 480px; padding: 8px 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);",
192                        span { "{text}" }
193                        button {
194                            style: "margin-left: 8px; background: none; border: none; cursor: pointer; color: var(--adui-color-text-secondary);",
195                            onclick: move |_| {
196                                let mut entries = entries_signal;
197                                entries.write().retain(|e| e.key != key);
198                            },
199                            "×"
200                        }
201                    }
202                }
203            })}
204        }
205    }
206}
207
208#[cfg(target_arch = "wasm32")]
209fn schedule_message_dismiss(
210    key: OverlayKey,
211    entries: MessageEntriesSignal,
212    overlay: OverlayHandle,
213    duration_secs: f32,
214) {
215    use wasm_bindgen::{JsCast, closure::Closure};
216
217    if duration_secs <= 0.0 {
218        return;
219    }
220
221    if let Some(window) = web_sys::window() {
222        let delay_ms = (duration_secs * 1000.0) as i32;
223        let mut entries_signal = entries;
224        let overlay_clone = overlay.clone();
225        let callback = Closure::once(move || {
226            entries_signal.write().retain(|e| e.key != key);
227            overlay_clone.close(key);
228        });
229        let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
230            callback.as_ref().unchecked_ref(),
231            delay_ms,
232        );
233        callback.forget();
234    }
235}
236
237#[cfg(not(target_arch = "wasm32"))]
238fn schedule_message_dismiss(
239    _key: OverlayKey,
240    _entries: MessageEntriesSignal,
241    _overlay: OverlayHandle,
242    _duration_secs: f32,
243) {
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn message_type_all_variants() {
252        assert_eq!(MessageType::Info, MessageType::Info);
253        assert_eq!(MessageType::Success, MessageType::Success);
254        assert_eq!(MessageType::Warning, MessageType::Warning);
255        assert_eq!(MessageType::Error, MessageType::Error);
256        assert_eq!(MessageType::Loading, MessageType::Loading);
257        assert_ne!(MessageType::Info, MessageType::Success);
258        assert_ne!(MessageType::Success, MessageType::Warning);
259        assert_ne!(MessageType::Warning, MessageType::Error);
260        assert_ne!(MessageType::Error, MessageType::Loading);
261    }
262
263    #[test]
264    fn message_type_clone() {
265        let original = MessageType::Success;
266        let cloned = original;
267        assert_eq!(original, cloned);
268    }
269
270    #[test]
271    fn message_config_default() {
272        let config = MessageConfig::default();
273        assert_eq!(config.content, "");
274        assert_eq!(config.r#type, MessageType::Info);
275        assert_eq!(config.duration, 3.0);
276        assert_eq!(config.icon, None);
277        assert_eq!(config.class, None);
278        assert_eq!(config.style, None);
279        assert_eq!(config.key, None);
280        assert_eq!(config.on_click, None);
281    }
282
283    #[test]
284    fn message_config_clone() {
285        let config1 = MessageConfig {
286            content: "Test message".to_string(),
287            r#type: MessageType::Success,
288            duration: 5.0,
289            icon: None,
290            class: Some("custom-class".to_string()),
291            style: Some("color: red;".to_string()),
292            key: Some("msg-1".to_string()),
293            on_click: None,
294        };
295        let config2 = config1.clone();
296        assert_eq!(config1, config2);
297    }
298
299    #[test]
300    fn message_config_partial_eq() {
301        let config1 = MessageConfig {
302            content: "Test".to_string(),
303            r#type: MessageType::Info,
304            duration: 3.0,
305            icon: None,
306            class: None,
307            style: None,
308            key: None,
309            on_click: None,
310        };
311        let config2 = MessageConfig {
312            content: "Test".to_string(),
313            r#type: MessageType::Info,
314            duration: 3.0,
315            icon: None,
316            class: None,
317            style: None,
318            key: None,
319            on_click: None,
320        };
321        let config3 = MessageConfig {
322            content: "Different".to_string(),
323            r#type: MessageType::Error,
324            duration: 5.0,
325            icon: None,
326            class: None,
327            style: None,
328            key: None,
329            on_click: None,
330        };
331        assert_eq!(config1, config2);
332        assert_ne!(config1, config3);
333    }
334}