adui_dioxus/components/
modal.rs

1//! Modal component aligned with Ant Design 6.0.
2//!
3//! Features:
4//! - Confirm loading state
5//! - Centered positioning
6//! - Customizable buttons
7//! - Semantic classNames/styles
8
9use crate::components::button::{Button, ButtonType};
10use crate::components::overlay::{OverlayKey, OverlayKind, use_overlay};
11use crate::foundation::{
12    ClassListExt, ModalClassNames, ModalSemantic, ModalStyles, StyleStringExt,
13};
14use dioxus::events::KeyboardEvent;
15use dioxus::prelude::*;
16use std::collections::HashMap;
17use std::rc::Rc;
18
19/// Modal type for static method variants.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum ModalType {
22    Info,
23    Success,
24    Error,
25    Warning,
26    Confirm,
27}
28
29impl ModalType {
30    #[allow(dead_code)]
31    fn as_class(&self) -> &'static str {
32        match self {
33            ModalType::Info => "adui-modal-info",
34            ModalType::Success => "adui-modal-success",
35            ModalType::Error => "adui-modal-error",
36            ModalType::Warning => "adui-modal-warning",
37            ModalType::Confirm => "adui-modal-confirm",
38        }
39    }
40}
41
42/// Basic modal props, targeting the most common controlled use cases.
43#[derive(Props, Clone)]
44pub struct ModalProps {
45    /// Whether the modal is visible.
46    pub open: bool,
47    /// Optional title displayed in the header.
48    #[props(optional)]
49    pub title: Option<String>,
50    /// Custom footer content. When `None`, default OK/Cancel buttons are shown.
51    /// Can be an Element or a function: (originNode, extra) -> Element
52    #[props(optional)]
53    pub footer: Option<Element>,
54    /// Custom footer render function: (originNode, extra) -> Element
55    /// When provided, this takes precedence over `footer`.
56    #[props(optional)]
57    pub footer_render: Option<Rc<dyn Fn(Element, FooterExtra) -> Element>>,
58    /// Whether to show the footer (set to false to hide default footer).
59    #[props(default = true)]
60    pub show_footer: bool,
61    /// Called when the user confirms the dialog.
62    #[props(optional)]
63    pub on_ok: Option<EventHandler<()>>,
64    /// Called when the user cancels or dismisses the dialog.
65    #[props(optional)]
66    pub on_cancel: Option<EventHandler<()>>,
67    /// When true, show a close button in the top-right corner.
68    /// Can also be a ClosableConfig object for advanced configuration.
69    #[props(default = true)]
70    pub closable: bool,
71    /// Advanced closable configuration (takes precedence over `closable` boolean).
72    #[props(optional)]
73    pub closable_config: Option<ClosableConfig>,
74    /// Whether clicking the mask should trigger `on_cancel`.
75    #[props(default = true)]
76    pub mask_closable: bool,
77    /// Remove modal content from the tree when closed.
78    #[props(default)]
79    pub destroy_on_close: bool,
80    /// Remove modal content from the tree when hidden (since 5.25.0).
81    #[props(default)]
82    pub destroy_on_hidden: bool,
83    /// Force render Modal even when not visible.
84    #[props(default)]
85    pub force_render: bool,
86    /// Optional fixed width for the modal content in pixels.
87    /// Can also be a responsive width map: { xs: 300, sm: 400, ... }
88    #[props(optional)]
89    pub width: Option<f32>,
90    /// Responsive width configuration: { breakpoint: width }
91    #[props(optional)]
92    pub width_responsive: Option<HashMap<String, f32>>,
93    /// Whether to vertically center the modal.
94    #[props(default)]
95    pub centered: bool,
96    /// Whether the OK button is in loading state.
97    #[props(default)]
98    pub confirm_loading: bool,
99    /// OK button text.
100    #[props(optional)]
101    pub ok_text: Option<String>,
102    /// Cancel button text.
103    #[props(optional)]
104    pub cancel_text: Option<String>,
105    /// OK button type.
106    #[props(optional)]
107    pub ok_type: Option<ButtonType>,
108    /// Whether to enable keyboard (Escape to close).
109    #[props(default = true)]
110    pub keyboard: bool,
111    /// Custom close icon element.
112    #[props(optional)]
113    pub close_icon: Option<Element>,
114    /// Callback after modal closes completely.
115    #[props(optional)]
116    pub after_close: Option<EventHandler<()>>,
117    /// Callback when modal open state changes.
118    #[props(optional)]
119    pub after_open_change: Option<EventHandler<bool>>,
120    /// Additional CSS class on the root container.
121    #[props(optional)]
122    pub class: Option<String>,
123    /// Inline styles applied to the root container.
124    #[props(optional)]
125    pub style: Option<String>,
126    /// Semantic class names.
127    #[props(optional)]
128    pub class_names: Option<ModalClassNames>,
129    /// Semantic styles.
130    #[props(optional)]
131    pub styles: Option<ModalStyles>,
132    /// Custom container for modal rendering (selector string or "false" to disable portal).
133    #[props(optional)]
134    pub get_container: Option<String>,
135    /// Custom z-index for the modal.
136    #[props(optional)]
137    pub z_index: Option<i32>,
138    /// Mask configuration (style, closable, etc.).
139    #[props(optional)]
140    pub mask: Option<MaskConfig>,
141    /// Custom modal render function: (node) -> Element
142    #[props(optional)]
143    pub modal_render: Option<Rc<dyn Fn(Element) -> Element>>,
144    /// Mouse position for modal placement (x, y).
145    #[props(optional)]
146    pub mouse_position: Option<(f32, f32)>,
147    /// Loading state for the entire modal (since 5.18.0).
148    #[props(default)]
149    pub loading: bool,
150    /// OK button props.
151    #[props(optional)]
152    pub ok_button_props: Option<HashMap<String, String>>,
153    /// Cancel button props.
154    #[props(optional)]
155    pub cancel_button_props: Option<HashMap<String, String>>,
156    pub children: Element,
157}
158
159impl PartialEq for ModalProps {
160    fn eq(&self, other: &Self) -> bool {
161        // Compare all fields except function pointers
162        self.open == other.open
163            && self.title == other.title
164            && self.footer == other.footer
165            && self.show_footer == other.show_footer
166            && self.closable == other.closable
167            && self.mask_closable == other.mask_closable
168            && self.destroy_on_close == other.destroy_on_close
169            && self.destroy_on_hidden == other.destroy_on_hidden
170            && self.force_render == other.force_render
171            && self.width == other.width
172            && self.width_responsive == other.width_responsive
173            && self.centered == other.centered
174            && self.confirm_loading == other.confirm_loading
175            && self.ok_text == other.ok_text
176            && self.cancel_text == other.cancel_text
177            && self.ok_type == other.ok_type
178            && self.keyboard == other.keyboard
179            && self.close_icon == other.close_icon
180            && self.after_close == other.after_close
181            && self.after_open_change == other.after_open_change
182            && self.class == other.class
183            && self.style == other.style
184            && self.class_names == other.class_names
185            && self.styles == other.styles
186            && self.get_container == other.get_container
187            && self.z_index == other.z_index
188            && self.mask == other.mask
189            && self.mouse_position == other.mouse_position
190            && self.loading == other.loading
191            && self.ok_button_props == other.ok_button_props
192            && self.cancel_button_props == other.cancel_button_props
193            && self.closable_config == other.closable_config
194        // Function pointers cannot be compared for equality
195    }
196}
197
198/// Extra props for footer render function.
199#[derive(Clone, Debug)]
200pub struct FooterExtra {
201    /// OK button component (as Element).
202    pub ok_btn: Element,
203    /// Cancel button component (as Element).
204    pub cancel_btn: Element,
205}
206
207/// Closable configuration for advanced close button control.
208#[derive(Clone)]
209pub struct ClosableConfig {
210    /// Whether to show the close button.
211    pub show: bool,
212    /// Custom close handler.
213    pub on_close: Option<Rc<dyn Fn()>>,
214    /// Callback after close animation completes.
215    pub after_close: Option<Rc<dyn Fn()>>,
216}
217
218impl PartialEq for ClosableConfig {
219    fn eq(&self, other: &Self) -> bool {
220        self.show == other.show
221        // Function pointers cannot be compared for equality
222    }
223}
224
225impl std::fmt::Debug for ClosableConfig {
226    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227        f.debug_struct("ClosableConfig")
228            .field("show", &self.show)
229            .field("on_close", &"<function>")
230            .field("after_close", &"<function>")
231            .finish()
232    }
233}
234
235/// Mask configuration for modal backdrop.
236#[derive(Clone, Debug, PartialEq)]
237pub struct MaskConfig {
238    /// Whether mask is visible.
239    pub visible: bool,
240    /// Whether clicking mask closes the modal.
241    pub closable: bool,
242    /// Custom mask style.
243    pub style: Option<String>,
244}
245
246/// Simple Ant Design flavored modal.
247#[component]
248pub fn Modal(props: ModalProps) -> Element {
249    let ModalProps {
250        open,
251        title,
252        footer,
253        show_footer,
254        on_ok,
255        on_cancel,
256        closable,
257        mask_closable,
258        destroy_on_close,
259        width,
260        centered,
261        confirm_loading,
262        ok_text,
263        cancel_text,
264        ok_type,
265        keyboard,
266        close_icon,
267        after_close,
268        after_open_change,
269        class,
270        style,
271        class_names,
272        styles,
273        children,
274        ..
275    } = props;
276
277    // Track previous open state for after_open_change callback
278    let prev_open: Signal<bool> = use_signal(|| open);
279
280    // Track the overlay key associated with this modal so we can release the
281    // z-index slot when it closes.
282    let overlay = use_overlay();
283    let modal_key: Signal<Option<OverlayKey>> = use_signal(|| None);
284    let z_index: Signal<i32> = use_signal(|| 1000);
285
286    {
287        let overlay = overlay.clone();
288        let mut key_signal = modal_key;
289        let mut z_signal = z_index;
290        let mut prev_signal = prev_open;
291        use_effect(move || {
292            if let Some(handle) = overlay.clone() {
293                let current_key = {
294                    let guard = key_signal.read();
295                    *guard
296                };
297                if open {
298                    if current_key.is_none() {
299                        let (key, meta) = handle.open(OverlayKind::Modal, true);
300                        z_signal.set(meta.z_index);
301                        key_signal.set(Some(key));
302                    }
303                } else if let Some(key) = current_key {
304                    handle.close(key);
305                    key_signal.set(None);
306                }
307            }
308
309            // Call after_open_change when open state changes
310            let prev = *prev_signal.read();
311            if prev != open {
312                if let Some(cb) = after_open_change {
313                    cb.call(open);
314                }
315                // Call after_close when closing
316                if !open {
317                    if let Some(cb) = after_close {
318                        cb.call(());
319                    }
320                }
321                prev_signal.set(open);
322            }
323        });
324    }
325
326    if !open && destroy_on_close {
327        return rsx! {};
328    }
329
330    let current_z = *z_index.read();
331    let width_px = width.unwrap_or(520.0);
332
333    // Build classes
334    let mut class_list = vec!["adui-modal".to_string()];
335    if centered {
336        class_list.push("adui-modal-centered".into());
337    }
338    class_list.push_semantic(&class_names, ModalSemantic::Root);
339    if let Some(extra) = class {
340        class_list.push(extra);
341    }
342    let class_attr = class_list
343        .into_iter()
344        .filter(|s| !s.is_empty())
345        .collect::<Vec<_>>()
346        .join(" ");
347
348    let mut style_attr = style.unwrap_or_default();
349    style_attr.append_semantic(&styles, ModalSemantic::Root);
350
351    // Handlers
352    let ok_handler = on_ok;
353    let cancel_handler = on_cancel;
354
355    let on_close = move || {
356        if let Some(cb) = cancel_handler {
357            cb.call(());
358        }
359    };
360
361    let handle_ok = move || {
362        if let Some(cb) = ok_handler {
363            cb.call(());
364        }
365    };
366
367    let on_keydown = move |evt: KeyboardEvent| {
368        if keyboard && matches!(evt.key(), Key::Escape) {
369            evt.prevent_default();
370            on_close();
371        }
372    };
373
374    // Default button texts
375    let ok_button_text = ok_text.unwrap_or_else(|| "确定".to_string());
376    let cancel_button_text = cancel_text.unwrap_or_else(|| "取消".to_string());
377    let ok_button_type = ok_type.unwrap_or(ButtonType::Primary);
378
379    // Close icon
380    let close_icon_element = close_icon.unwrap_or_else(|| {
381        rsx! { "×" }
382    });
383
384    // Content positioning style
385    let content_style = if centered {
386        format!(
387            "position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: {}; {}",
388            current_z + 1,
389            style_attr
390        )
391    } else {
392        format!(
393            "position: fixed; top: 100px; left: 50%; transform: translateX(-50%); z-index: {}; {}",
394            current_z + 1,
395            style_attr
396        )
397    };
398
399    rsx! {
400        if open {
401            // Mask layer
402            div {
403                class: "adui-modal-mask",
404                style: "position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: {current_z};",
405                onclick: move |_| {
406                    if mask_closable {
407                        on_close();
408                    }
409                }
410            }
411            // Modal content layer
412            div {
413                class: "{class_attr}",
414                style: "{content_style}",
415                onkeydown: on_keydown,
416                tabindex: 0,
417                div {
418                    class: "adui-modal-content",
419                    style: "min-width: {width_px}px; max-width: 80vw; background: var(--adui-color-bg-container); border-radius: var(--adui-radius-lg, 8px); box-shadow: var(--adui-shadow-secondary); border: 1px solid var(--adui-color-border); overflow: hidden;",
420                    onclick: move |evt| evt.stop_propagation(),
421                    // Header
422                    if title.is_some() || closable {
423                        div {
424                            class: "adui-modal-header",
425                            style: "display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--adui-color-border);",
426                            if let Some(text) = title {
427                                div { class: "adui-modal-title", "{text}" }
428                            }
429                            if closable {
430                                button {
431                                    class: "adui-modal-close",
432                                    r#type: "button",
433                                    style: "border: none; background: none; cursor: pointer; font-size: 16px;",
434                                    onclick: move |_| on_close(),
435                                    {close_icon_element}
436                                }
437                            }
438                        }
439                    }
440                    // Body
441                    div {
442                        class: "adui-modal-body",
443                        style: "padding: 16px;",
444                        {children}
445                    }
446                    // Footer
447                    if show_footer {
448                        if let Some(footer_node) = footer {
449                            div {
450                                class: "adui-modal-footer",
451                                style: "padding: 10px 16px; border-top: 1px solid var(--adui-color-border); text-align: right;",
452                                {footer_node}
453                            }
454                        } else {
455                            div {
456                                class: "adui-modal-footer",
457                                style: "padding: 10px 16px; border-top: 1px solid var(--adui-color-border); text-align: right; display: flex; gap: 8px; justify-content: flex-end;",
458                                Button {
459                                    onclick: move |_| on_close(),
460                                    "{cancel_button_text}"
461                                }
462                                Button {
463                                    r#type: ok_button_type,
464                                    loading: confirm_loading,
465                                    onclick: move |_| handle_ok(),
466                                    "{ok_button_text}"
467                                }
468                            }
469                        }
470                    }
471                }
472            }
473        }
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    #[test]
482    fn modal_type_classes() {
483        assert_eq!(ModalType::Info.as_class(), "adui-modal-info");
484        assert_eq!(ModalType::Success.as_class(), "adui-modal-success");
485        assert_eq!(ModalType::Error.as_class(), "adui-modal-error");
486    }
487
488    #[test]
489    fn modal_type_all_variants() {
490        assert_eq!(ModalType::Info, ModalType::Info);
491        assert_eq!(ModalType::Success, ModalType::Success);
492        assert_eq!(ModalType::Error, ModalType::Error);
493        assert_eq!(ModalType::Warning, ModalType::Warning);
494        assert_eq!(ModalType::Confirm, ModalType::Confirm);
495        assert_ne!(ModalType::Info, ModalType::Error);
496    }
497
498    #[test]
499    fn modal_type_all_classes() {
500        assert_eq!(ModalType::Info.as_class(), "adui-modal-info");
501        assert_eq!(ModalType::Success.as_class(), "adui-modal-success");
502        assert_eq!(ModalType::Error.as_class(), "adui-modal-error");
503        assert_eq!(ModalType::Warning.as_class(), "adui-modal-warning");
504        assert_eq!(ModalType::Confirm.as_class(), "adui-modal-confirm");
505    }
506
507    #[test]
508    fn modal_type_equality() {
509        let info1 = ModalType::Info;
510        let info2 = ModalType::Info;
511        let error = ModalType::Error;
512        assert_eq!(info1, info2);
513        assert_ne!(info1, error);
514    }
515
516    #[test]
517    fn modal_type_clone() {
518        let original = ModalType::Warning;
519        let cloned = original;
520        assert_eq!(original, cloned);
521        assert_eq!(original.as_class(), cloned.as_class());
522    }
523
524    #[test]
525    fn mask_config_equality() {
526        let config1 = MaskConfig {
527            visible: true,
528            closable: true,
529            style: None,
530        };
531        let config2 = MaskConfig {
532            visible: true,
533            closable: true,
534            style: None,
535        };
536        let config3 = MaskConfig {
537            visible: false,
538            closable: true,
539            style: None,
540        };
541        assert_eq!(config1, config2);
542        assert_ne!(config1, config3);
543    }
544
545    #[test]
546    fn mask_config_with_style() {
547        let config = MaskConfig {
548            visible: true,
549            closable: true,
550            style: Some("background: red;".to_string()),
551        };
552        assert_eq!(config.visible, true);
553        assert_eq!(config.closable, true);
554        assert_eq!(config.style, Some("background: red;".to_string()));
555    }
556
557    #[test]
558    fn mask_config_defaults() {
559        let config = MaskConfig {
560            visible: true,
561            closable: true,
562            style: None,
563        };
564        assert_eq!(config.visible, true);
565        assert_eq!(config.closable, true);
566        assert!(config.style.is_none());
567    }
568}