1use 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#[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#[derive(Props, Clone)]
44pub struct ModalProps {
45 pub open: bool,
47 #[props(optional)]
49 pub title: Option<String>,
50 #[props(optional)]
53 pub footer: Option<Element>,
54 #[props(optional)]
57 pub footer_render: Option<Rc<dyn Fn(Element, FooterExtra) -> Element>>,
58 #[props(default = true)]
60 pub show_footer: bool,
61 #[props(optional)]
63 pub on_ok: Option<EventHandler<()>>,
64 #[props(optional)]
66 pub on_cancel: Option<EventHandler<()>>,
67 #[props(default = true)]
70 pub closable: bool,
71 #[props(optional)]
73 pub closable_config: Option<ClosableConfig>,
74 #[props(default = true)]
76 pub mask_closable: bool,
77 #[props(default)]
79 pub destroy_on_close: bool,
80 #[props(default)]
82 pub destroy_on_hidden: bool,
83 #[props(default)]
85 pub force_render: bool,
86 #[props(optional)]
89 pub width: Option<f32>,
90 #[props(optional)]
92 pub width_responsive: Option<HashMap<String, f32>>,
93 #[props(default)]
95 pub centered: bool,
96 #[props(default)]
98 pub confirm_loading: bool,
99 #[props(optional)]
101 pub ok_text: Option<String>,
102 #[props(optional)]
104 pub cancel_text: Option<String>,
105 #[props(optional)]
107 pub ok_type: Option<ButtonType>,
108 #[props(default = true)]
110 pub keyboard: bool,
111 #[props(optional)]
113 pub close_icon: Option<Element>,
114 #[props(optional)]
116 pub after_close: Option<EventHandler<()>>,
117 #[props(optional)]
119 pub after_open_change: Option<EventHandler<bool>>,
120 #[props(optional)]
122 pub class: Option<String>,
123 #[props(optional)]
125 pub style: Option<String>,
126 #[props(optional)]
128 pub class_names: Option<ModalClassNames>,
129 #[props(optional)]
131 pub styles: Option<ModalStyles>,
132 #[props(optional)]
134 pub get_container: Option<String>,
135 #[props(optional)]
137 pub z_index: Option<i32>,
138 #[props(optional)]
140 pub mask: Option<MaskConfig>,
141 #[props(optional)]
143 pub modal_render: Option<Rc<dyn Fn(Element) -> Element>>,
144 #[props(optional)]
146 pub mouse_position: Option<(f32, f32)>,
147 #[props(default)]
149 pub loading: bool,
150 #[props(optional)]
152 pub ok_button_props: Option<HashMap<String, String>>,
153 #[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 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 }
196}
197
198#[derive(Clone, Debug)]
200pub struct FooterExtra {
201 pub ok_btn: Element,
203 pub cancel_btn: Element,
205}
206
207#[derive(Clone)]
209pub struct ClosableConfig {
210 pub show: bool,
212 pub on_close: Option<Rc<dyn Fn()>>,
214 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 }
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#[derive(Clone, Debug, PartialEq)]
237pub struct MaskConfig {
238 pub visible: bool,
240 pub closable: bool,
242 pub style: Option<String>,
244}
245
246#[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 let prev_open: Signal<bool> = use_signal(|| open);
279
280 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 let prev = *prev_signal.read();
311 if prev != open {
312 if let Some(cb) = after_open_change {
313 cb.call(open);
314 }
315 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 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 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 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 let close_icon_element = close_icon.unwrap_or_else(|| {
381 rsx! { "×" }
382 });
383
384 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 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 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 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 div {
442 class: "adui-modal-body",
443 style: "padding: 16px;",
444 {children}
445 }
446 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}