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