presentar_widgets/
modal.rs

1//! Modal dialog widget for overlay content.
2//!
3//! The Modal widget displays content in a centered overlay with a backdrop,
4//! supporting keyboard navigation, focus trap, and animation.
5
6use presentar_core::{
7    widget::{LayoutResult, TextStyle},
8    Canvas, Color, Constraints, Event, Key, Point, Rect, Size, TypeId, Widget,
9};
10use serde::{Deserialize, Serialize};
11use std::any::Any;
12
13/// Modal size variants.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
15pub enum ModalSize {
16    /// Small modal (300px)
17    Small,
18    /// Medium modal (500px)
19    #[default]
20    Medium,
21    /// Large modal (800px)
22    Large,
23    /// Full width (with padding)
24    FullWidth,
25    /// Custom width
26    Custom(u32),
27}
28
29impl ModalSize {
30    /// Get the max width for this size.
31    #[must_use]
32    pub const fn max_width(&self) -> f32 {
33        match self {
34            Self::Small => 300.0,
35            Self::Medium => 500.0,
36            Self::Large => 800.0,
37            Self::FullWidth => f32::MAX,
38            Self::Custom(w) => *w as f32,
39        }
40    }
41}
42
43/// Modal backdrop behavior.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
45pub enum BackdropBehavior {
46    /// Click backdrop to close modal
47    #[default]
48    CloseOnClick,
49    /// Backdrop click does nothing (modal must be closed explicitly)
50    Static,
51    /// No backdrop shown
52    None,
53}
54
55/// Modal dialog widget.
56#[derive(Serialize, Deserialize)]
57pub struct Modal {
58    /// Whether modal is open
59    pub open: bool,
60    /// Modal size
61    pub size: ModalSize,
62    /// Backdrop behavior
63    pub backdrop: BackdropBehavior,
64    /// Close on escape key
65    pub close_on_escape: bool,
66    /// Optional title
67    pub title: Option<String>,
68    /// Show close button
69    pub show_close_button: bool,
70    /// Backdrop color
71    pub backdrop_color: Color,
72    /// Modal background color
73    pub background_color: Color,
74    /// Border radius
75    pub border_radius: f32,
76    /// Padding
77    pub padding: f32,
78    /// Test ID
79    test_id_value: Option<String>,
80    /// Cached bounds
81    #[serde(skip)]
82    bounds: Rect,
83    /// Modal content bounds
84    #[serde(skip)]
85    content_bounds: Rect,
86    /// Modal content
87    #[serde(skip)]
88    content: Option<Box<dyn Widget>>,
89    /// Footer content
90    #[serde(skip)]
91    footer: Option<Box<dyn Widget>>,
92    /// Animation progress (0.0 = closed, 1.0 = open)
93    #[serde(skip)]
94    animation_progress: f32,
95}
96
97impl Default for Modal {
98    fn default() -> Self {
99        Self {
100            open: false,
101            size: ModalSize::Medium,
102            backdrop: BackdropBehavior::CloseOnClick,
103            close_on_escape: true,
104            title: None,
105            show_close_button: true,
106            backdrop_color: Color::rgba(0.0, 0.0, 0.0, 0.5),
107            background_color: Color::WHITE,
108            border_radius: 8.0,
109            padding: 24.0,
110            test_id_value: None,
111            bounds: Rect::default(),
112            content_bounds: Rect::default(),
113            content: None,
114            footer: None,
115            animation_progress: 0.0,
116        }
117    }
118}
119
120impl Modal {
121    /// Create a new modal dialog.
122    #[must_use]
123    pub fn new() -> Self {
124        Self::default()
125    }
126
127    /// Set modal open state.
128    #[must_use]
129    pub const fn open(mut self, open: bool) -> Self {
130        self.open = open;
131        self
132    }
133
134    /// Set modal size.
135    #[must_use]
136    pub const fn size(mut self, size: ModalSize) -> Self {
137        self.size = size;
138        self
139    }
140
141    /// Set backdrop behavior.
142    #[must_use]
143    pub const fn backdrop(mut self, behavior: BackdropBehavior) -> Self {
144        self.backdrop = behavior;
145        self
146    }
147
148    /// Set close on escape.
149    #[must_use]
150    pub const fn close_on_escape(mut self, enabled: bool) -> Self {
151        self.close_on_escape = enabled;
152        self
153    }
154
155    /// Set the title.
156    #[must_use]
157    pub fn title(mut self, title: impl Into<String>) -> Self {
158        self.title = Some(title.into());
159        self
160    }
161
162    /// Set show close button.
163    #[must_use]
164    pub const fn show_close_button(mut self, show: bool) -> Self {
165        self.show_close_button = show;
166        self
167    }
168
169    /// Set backdrop color.
170    #[must_use]
171    pub const fn backdrop_color(mut self, color: Color) -> Self {
172        self.backdrop_color = color;
173        self
174    }
175
176    /// Set background color.
177    #[must_use]
178    pub const fn background_color(mut self, color: Color) -> Self {
179        self.background_color = color;
180        self
181    }
182
183    /// Set border radius.
184    #[must_use]
185    pub const fn border_radius(mut self, radius: f32) -> Self {
186        self.border_radius = radius;
187        self
188    }
189
190    /// Set padding.
191    #[must_use]
192    pub const fn padding(mut self, padding: f32) -> Self {
193        self.padding = padding;
194        self
195    }
196
197    /// Set the content widget.
198    pub fn content(mut self, widget: impl Widget + 'static) -> Self {
199        self.content = Some(Box::new(widget));
200        self
201    }
202
203    /// Set the footer widget.
204    pub fn footer(mut self, widget: impl Widget + 'static) -> Self {
205        self.footer = Some(Box::new(widget));
206        self
207    }
208
209    /// Set the test ID.
210    #[must_use]
211    pub fn with_test_id(mut self, id: impl Into<String>) -> Self {
212        self.test_id_value = Some(id.into());
213        self
214    }
215
216    /// Open the modal.
217    pub fn show(&mut self) {
218        self.open = true;
219    }
220
221    /// Close the modal.
222    pub fn hide(&mut self) {
223        self.open = false;
224    }
225
226    /// Toggle the modal.
227    pub fn toggle(&mut self) {
228        self.open = !self.open;
229    }
230
231    /// Check if modal is open.
232    #[must_use]
233    pub const fn is_open(&self) -> bool {
234        self.open
235    }
236
237    /// Get animation progress.
238    #[must_use]
239    pub const fn animation_progress(&self) -> f32 {
240        self.animation_progress
241    }
242
243    /// Get content bounds.
244    #[must_use]
245    pub const fn content_bounds(&self) -> Rect {
246        self.content_bounds
247    }
248
249    /// Calculate modal dimensions based on viewport.
250    fn calculate_modal_bounds(&self, viewport: Rect) -> Rect {
251        let max_width = self.size.max_width();
252        let modal_width = max_width.min(viewport.width - 32.0); // 16px margin on each side
253
254        // Estimate height based on content + header + footer
255        let header_height = if self.title.is_some() { 56.0 } else { 0.0 };
256        let footer_height = if self.footer.is_some() { 64.0 } else { 0.0 };
257        let content_height = 200.0; // Placeholder, will be measured properly
258        let total_height = self
259            .padding
260            .mul_add(2.0, header_height + content_height + footer_height);
261        let modal_height = total_height.min(viewport.height - 64.0); // 32px margin top/bottom
262
263        let x = viewport.x + (viewport.width - modal_width) / 2.0;
264        let y = viewport.y + (viewport.height - modal_height) / 2.0;
265
266        Rect::new(x, y, modal_width, modal_height)
267    }
268}
269
270impl Widget for Modal {
271    fn type_id(&self) -> TypeId {
272        TypeId::of::<Self>()
273    }
274
275    fn measure(&self, constraints: Constraints) -> Size {
276        // Modal overlays the entire viewport
277        constraints.constrain(Size::new(constraints.max_width, constraints.max_height))
278    }
279
280    fn layout(&mut self, bounds: Rect) -> LayoutResult {
281        self.bounds = bounds;
282
283        if self.open {
284            self.content_bounds = self.calculate_modal_bounds(bounds);
285
286            // Layout content
287            if let Some(ref mut content) = self.content {
288                let header_height = if self.title.is_some() { 56.0 } else { 0.0 };
289                let footer_height = if self.footer.is_some() { 64.0 } else { 0.0 };
290
291                let content_rect = Rect::new(
292                    self.content_bounds.x + self.padding,
293                    self.content_bounds.y + header_height + self.padding,
294                    self.padding.mul_add(-2.0, self.content_bounds.width),
295                    self.padding.mul_add(
296                        -2.0,
297                        self.content_bounds.height - header_height - footer_height,
298                    ),
299                );
300                content.layout(content_rect);
301            }
302
303            // Layout footer
304            if let Some(ref mut footer) = self.footer {
305                let footer_rect = Rect::new(
306                    self.content_bounds.x + self.padding,
307                    self.content_bounds.y + self.content_bounds.height - 64.0 - self.padding,
308                    self.padding.mul_add(-2.0, self.content_bounds.width),
309                    64.0,
310                );
311                footer.layout(footer_rect);
312            }
313
314            // Animate towards open
315            self.animation_progress = (self.animation_progress + 0.15).min(1.0);
316        } else {
317            // Animate towards closed
318            self.animation_progress = (self.animation_progress - 0.15).max(0.0);
319        }
320
321        LayoutResult {
322            size: bounds.size(),
323        }
324    }
325
326    fn paint(&self, canvas: &mut dyn Canvas) {
327        if self.animation_progress <= 0.0 {
328            return;
329        }
330
331        let opacity = self.animation_progress;
332
333        // Draw backdrop
334        if self.backdrop != BackdropBehavior::None {
335            let backdrop_color = Color::rgba(
336                self.backdrop_color.r,
337                self.backdrop_color.g,
338                self.backdrop_color.b,
339                self.backdrop_color.a * opacity,
340            );
341            canvas.fill_rect(self.bounds, backdrop_color);
342        }
343
344        // Draw modal container with slight animation offset
345        let y_offset = (1.0 - opacity) * 20.0;
346        let animated_bounds = Rect::new(
347            self.content_bounds.x,
348            self.content_bounds.y + y_offset,
349            self.content_bounds.width,
350            self.content_bounds.height,
351        );
352
353        // Draw shadow (simplified) - draw first so it's behind
354        let shadow_color = Color::rgba(0.0, 0.0, 0.0, 0.1 * opacity);
355        let shadow_bounds = Rect::new(
356            animated_bounds.x + 4.0,
357            animated_bounds.y + 4.0,
358            animated_bounds.width,
359            animated_bounds.height,
360        );
361        canvas.fill_rect(shadow_bounds, shadow_color);
362
363        // Modal background
364        canvas.fill_rect(animated_bounds, self.background_color);
365
366        // Draw title
367        if let Some(ref title) = self.title {
368            let title_pos = Point::new(
369                animated_bounds.x + self.padding,
370                animated_bounds.y + self.padding + 16.0, // Baseline offset
371            );
372            let title_style = TextStyle {
373                size: 18.0,
374                color: Color::BLACK,
375                ..Default::default()
376            };
377            canvas.draw_text(title, title_pos, &title_style);
378        }
379
380        // Draw close button
381        if self.show_close_button {
382            let close_x = animated_bounds.x + animated_bounds.width - 40.0 - self.padding;
383            let close_y = animated_bounds.y + self.padding + 16.0;
384            let close_style = TextStyle {
385                size: 24.0,
386                color: Color::rgb(0.5, 0.5, 0.5),
387                ..Default::default()
388            };
389            canvas.draw_text("×", Point::new(close_x, close_y), &close_style);
390        }
391
392        // Draw content
393        if let Some(ref content) = self.content {
394            content.paint(canvas);
395        }
396
397        // Draw footer
398        if let Some(ref footer) = self.footer {
399            footer.paint(canvas);
400        }
401    }
402
403    fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
404        if !self.open {
405            return None;
406        }
407
408        match event {
409            Event::KeyDown { key: Key::Escape } if self.close_on_escape => {
410                self.hide();
411                return Some(Box::new(ModalClosed {
412                    reason: CloseReason::Escape,
413                }));
414            }
415            Event::MouseDown { position, .. } => {
416                // Check if click is on backdrop
417                if self.backdrop == BackdropBehavior::CloseOnClick {
418                    let in_modal = position.x >= self.content_bounds.x
419                        && position.x <= self.content_bounds.x + self.content_bounds.width
420                        && position.y >= self.content_bounds.y
421                        && position.y <= self.content_bounds.y + self.content_bounds.height;
422
423                    if !in_modal {
424                        self.hide();
425                        return Some(Box::new(ModalClosed {
426                            reason: CloseReason::Backdrop,
427                        }));
428                    }
429                }
430
431                // Check if click is on close button
432                if self.show_close_button {
433                    let close_x =
434                        self.content_bounds.x + self.content_bounds.width - 40.0 - self.padding;
435                    let close_y = self.content_bounds.y + self.padding;
436                    let on_close_btn = position.x >= close_x
437                        && position.x <= close_x + 24.0
438                        && position.y >= close_y
439                        && position.y <= close_y + 24.0;
440
441                    if on_close_btn {
442                        self.hide();
443                        return Some(Box::new(ModalClosed {
444                            reason: CloseReason::CloseButton,
445                        }));
446                    }
447                }
448
449                // Forward to content
450                if let Some(ref mut content) = self.content {
451                    if let Some(msg) = content.event(event) {
452                        return Some(msg);
453                    }
454                }
455
456                // Forward to footer
457                if let Some(ref mut footer) = self.footer {
458                    if let Some(msg) = footer.event(event) {
459                        return Some(msg);
460                    }
461                }
462            }
463            _ => {
464                // Forward other events to content
465                if let Some(ref mut content) = self.content {
466                    if let Some(msg) = content.event(event) {
467                        return Some(msg);
468                    }
469                }
470
471                if let Some(ref mut footer) = self.footer {
472                    if let Some(msg) = footer.event(event) {
473                        return Some(msg);
474                    }
475                }
476            }
477        }
478
479        None
480    }
481
482    fn children(&self) -> &[Box<dyn Widget>] {
483        &[]
484    }
485
486    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
487        &mut []
488    }
489
490    fn is_focusable(&self) -> bool {
491        self.open
492    }
493
494    fn test_id(&self) -> Option<&str> {
495        self.test_id_value.as_deref()
496    }
497
498    fn bounds(&self) -> Rect {
499        self.bounds
500    }
501}
502
503/// Reason the modal was closed.
504#[derive(Debug, Clone, Copy, PartialEq, Eq)]
505pub enum CloseReason {
506    /// Closed via escape key
507    Escape,
508    /// Closed via backdrop click
509    Backdrop,
510    /// Closed via close button
511    CloseButton,
512    /// Closed programmatically
513    Programmatic,
514}
515
516/// Message emitted when modal is closed.
517#[derive(Debug, Clone)]
518pub struct ModalClosed {
519    /// Reason for closure
520    pub reason: CloseReason,
521}
522
523/// Message emitted when modal is opened.
524#[derive(Debug, Clone)]
525pub struct ModalOpened;
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    // =========================================================================
532    // ModalSize Tests
533    // =========================================================================
534
535    #[test]
536    fn test_modal_size_default() {
537        assert_eq!(ModalSize::default(), ModalSize::Medium);
538    }
539
540    #[test]
541    fn test_modal_size_max_width() {
542        assert_eq!(ModalSize::Small.max_width(), 300.0);
543        assert_eq!(ModalSize::Medium.max_width(), 500.0);
544        assert_eq!(ModalSize::Large.max_width(), 800.0);
545        assert_eq!(ModalSize::FullWidth.max_width(), f32::MAX);
546        assert_eq!(ModalSize::Custom(600).max_width(), 600.0);
547    }
548
549    // =========================================================================
550    // BackdropBehavior Tests
551    // =========================================================================
552
553    #[test]
554    fn test_backdrop_behavior_default() {
555        assert_eq!(BackdropBehavior::default(), BackdropBehavior::CloseOnClick);
556    }
557
558    // =========================================================================
559    // Modal Tests
560    // =========================================================================
561
562    #[test]
563    fn test_modal_new() {
564        let modal = Modal::new();
565        assert!(!modal.open);
566        assert_eq!(modal.size, ModalSize::Medium);
567        assert_eq!(modal.backdrop, BackdropBehavior::CloseOnClick);
568        assert!(modal.close_on_escape);
569        assert!(modal.title.is_none());
570        assert!(modal.show_close_button);
571    }
572
573    #[test]
574    fn test_modal_builder() {
575        let modal = Modal::new()
576            .open(true)
577            .size(ModalSize::Large)
578            .backdrop(BackdropBehavior::Static)
579            .close_on_escape(false)
580            .title("Test Modal")
581            .show_close_button(false)
582            .border_radius(16.0)
583            .padding(32.0);
584
585        assert!(modal.open);
586        assert_eq!(modal.size, ModalSize::Large);
587        assert_eq!(modal.backdrop, BackdropBehavior::Static);
588        assert!(!modal.close_on_escape);
589        assert_eq!(modal.title, Some("Test Modal".to_string()));
590        assert!(!modal.show_close_button);
591        assert_eq!(modal.border_radius, 16.0);
592        assert_eq!(modal.padding, 32.0);
593    }
594
595    #[test]
596    fn test_modal_show_hide() {
597        let mut modal = Modal::new();
598        assert!(!modal.is_open());
599
600        modal.show();
601        assert!(modal.is_open());
602
603        modal.hide();
604        assert!(!modal.is_open());
605    }
606
607    #[test]
608    fn test_modal_toggle() {
609        let mut modal = Modal::new();
610        assert!(!modal.is_open());
611
612        modal.toggle();
613        assert!(modal.is_open());
614
615        modal.toggle();
616        assert!(!modal.is_open());
617    }
618
619    #[test]
620    fn test_modal_measure() {
621        let modal = Modal::new();
622        let size = modal.measure(Constraints::loose(Size::new(1024.0, 768.0)));
623        assert_eq!(size, Size::new(1024.0, 768.0));
624    }
625
626    #[test]
627    fn test_modal_layout_closed() {
628        let mut modal = Modal::new();
629        let result = modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
630        assert_eq!(result.size, Size::new(1024.0, 768.0));
631        assert_eq!(modal.animation_progress, 0.0);
632    }
633
634    #[test]
635    fn test_modal_layout_open() {
636        let mut modal = Modal::new().open(true);
637        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
638        assert!(modal.animation_progress > 0.0);
639    }
640
641    #[test]
642    fn test_modal_calculate_bounds() {
643        let modal = Modal::new().size(ModalSize::Medium);
644        let viewport = Rect::new(0.0, 0.0, 1024.0, 768.0);
645        let bounds = modal.calculate_modal_bounds(viewport);
646
647        // Modal should be centered
648        assert!(bounds.x > 0.0);
649        assert!(bounds.y > 0.0);
650        assert!(bounds.width <= 500.0);
651    }
652
653    #[test]
654    fn test_modal_type_id() {
655        let modal = Modal::new();
656        assert_eq!(Widget::type_id(&modal), TypeId::of::<Modal>());
657    }
658
659    #[test]
660    fn test_modal_is_focusable() {
661        let modal = Modal::new();
662        assert!(!modal.is_focusable()); // Not focusable when closed
663
664        let modal_open = Modal::new().open(true);
665        assert!(modal_open.is_focusable()); // Focusable when open
666    }
667
668    #[test]
669    fn test_modal_test_id() {
670        let modal = Modal::new().with_test_id("my-modal");
671        assert_eq!(modal.test_id(), Some("my-modal"));
672    }
673
674    #[test]
675    fn test_modal_children_empty() {
676        let modal = Modal::new();
677        assert!(modal.children().is_empty());
678    }
679
680    #[test]
681    fn test_modal_bounds() {
682        let mut modal = Modal::new();
683        modal.layout(Rect::new(10.0, 20.0, 1024.0, 768.0));
684        assert_eq!(modal.bounds(), Rect::new(10.0, 20.0, 1024.0, 768.0));
685    }
686
687    #[test]
688    fn test_modal_backdrop_color() {
689        let modal = Modal::new().backdrop_color(Color::rgba(0.0, 0.0, 0.0, 0.7));
690        assert_eq!(modal.backdrop_color.a, 0.7);
691    }
692
693    #[test]
694    fn test_modal_background_color() {
695        let modal = Modal::new().background_color(Color::rgb(0.9, 0.9, 0.9));
696        assert_eq!(modal.background_color.r, 0.9);
697    }
698
699    #[test]
700    fn test_modal_escape_closes() {
701        let mut modal = Modal::new().open(true);
702        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
703
704        let result = modal.event(&Event::KeyDown { key: Key::Escape });
705        assert!(result.is_some());
706        assert!(!modal.is_open());
707    }
708
709    #[test]
710    fn test_modal_escape_disabled() {
711        let mut modal = Modal::new().open(true).close_on_escape(false);
712        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
713
714        let result = modal.event(&Event::KeyDown { key: Key::Escape });
715        assert!(result.is_none());
716        assert!(modal.is_open());
717    }
718
719    #[test]
720    fn test_modal_animation_progress() {
721        let modal = Modal::new();
722        assert_eq!(modal.animation_progress(), 0.0);
723    }
724
725    #[test]
726    fn test_modal_content_bounds() {
727        let mut modal = Modal::new().open(true);
728        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
729        let content_bounds = modal.content_bounds();
730        assert!(content_bounds.width > 0.0);
731        assert!(content_bounds.height > 0.0);
732    }
733
734    // =========================================================================
735    // CloseReason Tests
736    // =========================================================================
737
738    #[test]
739    fn test_close_reason_eq() {
740        assert_eq!(CloseReason::Escape, CloseReason::Escape);
741        assert_ne!(CloseReason::Escape, CloseReason::Backdrop);
742    }
743
744    // =========================================================================
745    // Message Tests
746    // =========================================================================
747
748    #[test]
749    fn test_modal_closed_message() {
750        let msg = ModalClosed {
751            reason: CloseReason::CloseButton,
752        };
753        assert_eq!(msg.reason, CloseReason::CloseButton);
754    }
755
756    #[test]
757    fn test_modal_opened_message() {
758        let _msg = ModalOpened;
759        // Just ensure it compiles
760    }
761
762    // =========================================================================
763    // Additional Coverage Tests
764    // =========================================================================
765
766    #[test]
767    fn test_modal_backdrop_none() {
768        let modal = Modal::new().backdrop(BackdropBehavior::None);
769        assert_eq!(modal.backdrop, BackdropBehavior::None);
770    }
771
772    #[test]
773    fn test_modal_backdrop_static() {
774        let modal = Modal::new().backdrop(BackdropBehavior::Static);
775        assert_eq!(modal.backdrop, BackdropBehavior::Static);
776    }
777
778    #[test]
779    fn test_modal_size_small() {
780        assert_eq!(ModalSize::Small.max_width(), 300.0);
781    }
782
783    #[test]
784    fn test_modal_size_full_width() {
785        assert_eq!(ModalSize::FullWidth.max_width(), f32::MAX);
786    }
787
788    #[test]
789    fn test_modal_children_mut_empty() {
790        let mut modal = Modal::new();
791        assert!(modal.children_mut().is_empty());
792    }
793
794    #[test]
795    fn test_modal_calculate_bounds_with_title() {
796        let modal = Modal::new().title("Test Title");
797        let viewport = Rect::new(0.0, 0.0, 1024.0, 768.0);
798        let bounds = modal.calculate_modal_bounds(viewport);
799        assert!(bounds.height > 0.0);
800    }
801
802    #[test]
803    fn test_modal_layout_animation_closes() {
804        let mut modal = Modal::new().open(true);
805        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
806        // Progress should increase
807        let prog1 = modal.animation_progress;
808        modal.open = false;
809        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
810        // Progress should decrease
811        assert!(modal.animation_progress < prog1);
812    }
813
814    #[test]
815    fn test_modal_event_not_open_returns_none() {
816        let mut modal = Modal::new();
817        let result = modal.event(&Event::KeyDown { key: Key::Escape });
818        assert!(result.is_none());
819    }
820
821    #[test]
822    fn test_modal_other_key_does_nothing() {
823        let mut modal = Modal::new().open(true);
824        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
825        let result = modal.event(&Event::KeyDown { key: Key::Tab });
826        assert!(result.is_none());
827        assert!(modal.is_open());
828    }
829
830    #[test]
831    fn test_close_reason_programmatic() {
832        let reason = CloseReason::Programmatic;
833        assert_eq!(reason, CloseReason::Programmatic);
834    }
835
836    #[test]
837    fn test_close_reason_close_button() {
838        let reason = CloseReason::CloseButton;
839        assert_eq!(reason, CloseReason::CloseButton);
840    }
841
842    #[test]
843    fn test_modal_size_custom_value() {
844        let size = ModalSize::Custom(750);
845        assert_eq!(size.max_width(), 750.0);
846    }
847
848    #[test]
849    fn test_modal_backdrop_eq() {
850        assert_eq!(
851            BackdropBehavior::CloseOnClick,
852            BackdropBehavior::CloseOnClick
853        );
854        assert_ne!(BackdropBehavior::CloseOnClick, BackdropBehavior::Static);
855    }
856
857    #[test]
858    fn test_modal_size_eq() {
859        assert_eq!(ModalSize::Medium, ModalSize::Medium);
860        assert_ne!(ModalSize::Small, ModalSize::Large);
861    }
862}