radix_leptos_primitives/components/
resizable.rs

1use leptos::callback::Callback;
2use leptos::children::Children;
3use leptos::prelude::*;
4
5/// Resizable component for resizable panels with constraints
6#[component]
7pub fn Resizable(
8    /// Initial width
9    #[prop(optional)]
10    width: Option<f64>,
11    /// Initial height
12    #[prop(optional)]
13    height: Option<f64>,
14    /// Minimum width
15    #[prop(optional)]
16    min_width: Option<f64>,
17    /// Minimum height
18    #[prop(optional)]
19    min_height: Option<f64>,
20    /// Maximum width
21    #[prop(optional)]
22    max_width: Option<f64>,
23    /// Maximum height
24    #[prop(optional)]
25    max_height: Option<f64>,
26    /// Whether resizing is enabled
27    #[prop(optional)]
28    enabled: Option<bool>,
29    /// Resize handles to show
30    #[prop(optional)]
31    handles: Option<Vec<ResizeHandle>>,
32    /// Whether to maintain aspect ratio
33    #[prop(optional)]
34    maintain_aspect_ratio: Option<bool>,
35    /// Aspect ratio to maintain
36    #[prop(optional)]
37    aspect_ratio: Option<f64>,
38    /// Callback when resize starts
39    #[prop(optional)]
40    on_resize_start: Option<Callback<ResizeEvent>>,
41    /// Callback during resize
42    #[prop(optional)]
43    on_resize: Option<Callback<ResizeEvent>>,
44    /// Callback when resize ends
45    #[prop(optional)]
46    on_resize_end: Option<Callback<ResizeEvent>>,
47    /// Additional CSS classes
48    #[prop(optional)]
49    class: Option<String>,
50    /// Inline styles
51    #[prop(optional)]
52    style: Option<String>,
53    /// Children content
54    children: Option<Children>,
55) -> impl IntoView {
56    let width = width.unwrap_or(200.0);
57    let height = height.unwrap_or(200.0);
58    let min_width = min_width.unwrap_or(50.0);
59    let min_height = min_height.unwrap_or(50.0);
60    let max_width = max_width.unwrap_or(f64::INFINITY);
61    let max_height = max_height.unwrap_or(f64::INFINITY);
62    let enabled = enabled.unwrap_or(true);
63    let handles = handles.unwrap_or_else(|| [ResizeHandle::BottomRight].to_vec());
64    let maintain_aspect_ratio = maintain_aspect_ratio.unwrap_or(false);
65    let aspect_ratio = aspect_ratio.unwrap_or(1.0);
66
67    let class = format!(
68        "resizable {} {} {} {} {} {} {}",
69        width,
70        height,
71        min_width,
72        min_height,
73        max_width,
74        max_height,
75        style.as_ref().unwrap_or(&String::new())
76    );
77
78    let handle_resize_start = move |event: web_sys::MouseEvent| {
79        if enabled {
80            let resize_event = ResizeEvent {
81                width,
82                height,
83                delta_x: 0.0,
84                delta_y: 0.0,
85                handle: ResizeHandle::BottomRight,
86            };
87            if let Some(callback) = on_resize_start {
88                callback.run(resize_event);
89            }
90        }
91    };
92
93    let handle_resize = move |event: web_sys::MouseEvent| {
94        if enabled {
95            let resize_event = ResizeEvent {
96                width: width + event.client_x() as f64,
97                height: height + event.client_y() as f64,
98                delta_x: event.client_x() as f64,
99                delta_y: event.client_y() as f64,
100                handle: ResizeHandle::BottomRight,
101            };
102            if let Some(callback) = on_resize {
103                callback.run(resize_event);
104            }
105        }
106    };
107
108    let handle_resize_end = move |event: web_sys::MouseEvent| {
109        if enabled {
110            let resize_event = ResizeEvent {
111                width: width + event.client_x() as f64,
112                height: height + event.client_y() as f64,
113                delta_x: event.client_x() as f64,
114                delta_y: event.client_y() as f64,
115                handle: ResizeHandle::BottomRight,
116            };
117            if let Some(callback) = on_resize_end {
118                callback.run(resize_event);
119            }
120        }
121    };
122
123    view! {
124        <div class=class style=style>
125            {children.map(|c| c())}
126            {if enabled {
127                view! {
128                    <div class="resize-handles">
129                        {handles.into_iter().map(|handle| {
130                            view! {
131                                <ResizeHandle
132                                    handle=handle
133                                    on_resize_start=on_resize_start.unwrap_or_else(|| Callback::new(|_| {}))
134                                    on_resize=on_resize.unwrap_or_else(|| Callback::new(|_| {}))
135                                    on_resize_end=on_resize_end.unwrap_or_else(|| Callback::new(|_| {}))
136                                />
137                            }
138                        }).collect::<Vec<_>>()}
139                    </div>
140                }.into_any()
141            } else {
142                view! { <div></div> }.into_any()
143            }}
144        </div>
145    }
146}
147
148/// Resize handle component
149#[component]
150pub fn ResizeHandle(
151    /// Handle type
152    handle: ResizeHandle,
153    /// Callback when resize starts
154    #[prop(optional)]
155    on_resize_start: Option<Callback<ResizeEvent>>,
156    /// Callback during resize
157    #[prop(optional)]
158    on_resize: Option<Callback<ResizeEvent>>,
159    /// Callback when resize ends
160    #[prop(optional)]
161    on_resize_end: Option<Callback<ResizeEvent>>,
162    /// Additional CSS classes
163    #[prop(optional)]
164    class: Option<String>,
165    /// Inline styles
166    #[prop(optional)]
167    style: Option<String>,
168) -> impl IntoView {
169    let class = format!(
170        "resize-handle {} {}",
171        match handle {
172            ResizeHandle::Top => "top",
173            ResizeHandle::Right => "right",
174            ResizeHandle::Bottom => "bottom",
175            ResizeHandle::Left => "left",
176            ResizeHandle::TopLeft => "top-left",
177            ResizeHandle::TopRight => "top-right",
178            ResizeHandle::BottomLeft => "bottom-left",
179            ResizeHandle::BottomRight => "bottom-right",
180        },
181        class.unwrap_or_default()
182    );
183
184    let style = style.unwrap_or_default();
185
186    let handle_resize_start = move |event: web_sys::MouseEvent| {
187        let resize_event = ResizeEvent {
188            width: 0.0,
189            height: 0.0,
190            delta_x: event.client_x() as f64,
191            delta_y: event.client_y() as f64,
192            handle,
193        };
194        if let Some(callback) = on_resize_start {
195            callback.run(resize_event);
196        }
197    };
198
199    let handle_resize = move |event: web_sys::MouseEvent| {
200        let resize_event = ResizeEvent {
201            width: 0.0,
202            height: 0.0,
203            delta_x: event.client_x() as f64,
204            delta_y: event.client_y() as f64,
205            handle,
206        };
207        if let Some(callback) = on_resize {
208            callback.run(resize_event);
209        }
210    };
211
212    let handle_resize_end = move |event: web_sys::MouseEvent| {
213        let resize_event = ResizeEvent {
214            width: 0.0,
215            height: 0.0,
216            delta_x: event.client_x() as f64,
217            delta_y: event.client_y() as f64,
218            handle,
219        };
220        if let Some(callback) = on_resize_end {
221            callback.run(resize_event);
222        }
223    };
224
225    view! {
226        <div
227            class=class
228            style=style
229            on:mousedown=handle_resize_start
230            on:mousemove=handle_resize
231            on:mouseup=handle_resize_end
232        />
233    }
234}
235
236/// Resize handle types
237#[derive(Debug, Clone, Copy, PartialEq, Default)]
238pub enum ResizeHandle {
239    #[default]
240    BottomRight,
241    Top,
242    Right,
243    Bottom,
244    Left,
245    TopLeft,
246    TopRight,
247    BottomLeft,
248}
249
250/// Resize event data
251#[derive(Debug, Clone, PartialEq, Default)]
252pub struct ResizeEvent {
253    pub width: f64,
254    pub height: f64,
255    pub delta_x: f64,
256    pub delta_y: f64,
257    pub handle: ResizeHandle,
258}
259
260/// Resizable panel component
261#[component]
262pub fn ResizablePanel(
263    /// Panel content
264    #[prop(optional)]
265    content: Option<String>,
266    /// Panel title
267    #[prop(optional)]
268    title: Option<String>,
269    /// Whether the panel is collapsible
270    #[prop(optional)]
271    collapsible: Option<bool>,
272    /// Whether the panel is collapsed
273    #[prop(optional)]
274    collapsed: Option<bool>,
275    /// Callback when panel is collapsed/expanded
276    #[prop(optional)]
277    on_toggle: Option<Callback<bool>>,
278    /// Additional CSS classes
279    #[prop(optional)]
280    class: Option<String>,
281    /// Inline styles
282    #[prop(optional)]
283    style: Option<String>,
284    /// Children content
285    children: Option<Children>,
286) -> impl IntoView {
287    let content = content.unwrap_or_default();
288    let title = title.unwrap_or_default();
289    let collapsible = collapsible.unwrap_or(false);
290    let collapsed = collapsed.unwrap_or(false);
291
292    let class = "resizable-panel".to_string();
293
294    let style = style.unwrap_or_default();
295
296    let handle_toggle = move |_| {
297        if collapsible {
298            if let Some(callback) = on_toggle {
299                callback.run(!collapsed);
300            }
301        }
302    };
303
304    view! {
305        <div class=class style=style>
306            {if !title.is_empty() {
307                view! {
308                    <div class="panel-header">
309                        <h3 class="panel-title">{title}</h3>
310                        {if collapsible {
311                            view! {
312                                <button
313                                    class="panel-toggle"
314                                    type="button"
315                                    on:click=handle_toggle
316                                >
317                                    {if collapsed {
318                                        "Expand"
319                                    } else {
320                                        "Collapse"
321                                    }}
322                                </button>
323                            }.into_any()
324                        } else {
325                            view! { <div></div> }.into_any()
326                        }}
327                    </div>
328                }.into_any()
329            } else {
330                view! { <div></div> }.into_any()
331            }}
332            {if !collapsed {
333                view! {
334                    <div class="panel-content">
335                        {if !content.is_empty() {
336                            view! { <div class="panel-text">{content}</div> }.into_any()
337                        } else {
338                            view! { <div></div> }.into_any()
339                        }}
340                        {children.map(|c| c())}
341                    </div>
342                }.into_any()
343            } else {
344                view! { <div></div> }.into_any()
345            }}
346        </div>
347    }
348}
349
350/// Resizable splitter component
351#[component]
352pub fn ResizableSplitter(
353    /// Splitter orientation
354    #[prop(optional)]
355    orientation: Option<SplitterOrientation>,
356    /// Splitter position (0.0 to 1.0)
357    #[prop(optional)]
358    position: Option<f64>,
359    /// Minimum position
360    #[prop(optional)]
361    min_position: Option<f64>,
362    /// Maximum position
363    #[prop(optional)]
364    max_position: Option<f64>,
365    /// Callback when position changes
366    #[prop(optional)]
367    on_position_change: Option<Callback<f64>>,
368    /// Additional CSS classes
369    #[prop(optional)]
370    class: Option<String>,
371    /// Inline styles
372    #[prop(optional)]
373    style: Option<String>,
374) -> impl IntoView {
375    let orientation = orientation.unwrap_or_default();
376    let position = position.unwrap_or(0.5);
377    let min_position = min_position.unwrap_or(0.1);
378    let max_position = max_position.unwrap_or(0.9);
379
380    let class = format!(
381        "resizable-splitter {} {}",
382        match orientation {
383            SplitterOrientation::Horizontal => "horizontal",
384            SplitterOrientation::Vertical => "vertical",
385        },
386        class.unwrap_or_default()
387    );
388
389    let style = format!(
390        "{}: {}%; {}",
391        match orientation {
392            SplitterOrientation::Horizontal => "top",
393            SplitterOrientation::Vertical => "left",
394        },
395        position * 100.0,
396        style.unwrap_or_default()
397    );
398
399    let handle_drag = move |event: web_sys::MouseEvent| {
400        let new_position: f64 = match orientation {
401            SplitterOrientation::Horizontal => {
402                // Simplified calculation for horizontal splitter
403                0.5
404            }
405            SplitterOrientation::Vertical => {
406                // Simplified calculation for vertical splitter
407                0.5
408            }
409        };
410
411        let clamped_position = new_position.clamp(min_position, max_position);
412        if let Some(callback) = on_position_change {
413            callback.run(clamped_position);
414        }
415    };
416
417    view! {
418        <div
419            class=class
420            style=style
421            on:mousedown=handle_drag
422        />
423    }
424}
425
426/// Splitter orientation
427#[derive(Debug, Clone, Copy, PartialEq, Default)]
428pub enum SplitterOrientation {
429    #[default]
430    Vertical,
431    Horizontal,
432}
433
434#[cfg(test)]
435mod tests {
436    use crate::{ResizeEvent, ResizeHandle, SplitterOrientation};
437use crate::utils::{merge_optional_classes, generate_id};
438
439    // Component structure tests
440    #[test]
441    fn test_resizable_component_creation() {}
442
443    #[test]
444    fn test_resize_handle_component_creation() {}
445
446    #[test]
447    fn test_resizable_panel_component_creation() {}
448
449    #[test]
450    fn test_resizable_splitter_component_creation() {}
451
452    // Data structure tests
453    #[test]
454    fn test_resize_handle_enum() {
455        assert_eq!(ResizeHandle::BottomRight, ResizeHandle::default());
456        assert_eq!(ResizeHandle::Top, ResizeHandle::Top);
457        assert_eq!(ResizeHandle::Right, ResizeHandle::Right);
458        assert_eq!(ResizeHandle::Bottom, ResizeHandle::Bottom);
459        assert_eq!(ResizeHandle::Left, ResizeHandle::Left);
460        assert_eq!(ResizeHandle::TopLeft, ResizeHandle::TopLeft);
461        assert_eq!(ResizeHandle::TopRight, ResizeHandle::TopRight);
462        assert_eq!(ResizeHandle::BottomLeft, ResizeHandle::BottomLeft);
463    }
464
465    #[test]
466    fn test_resize_event_struct() {
467        let event = ResizeEvent {
468            width: 100.0,
469            height: 200.0,
470            delta_x: 10.0,
471            delta_y: 20.0,
472            handle: ResizeHandle::BottomRight,
473        };
474        assert_eq!(event.width, 100.0);
475        assert_eq!(event.height, 200.0);
476        assert_eq!(event.delta_x, 10.0);
477        assert_eq!(event.delta_y, 20.0);
478        assert_eq!(event.handle, ResizeHandle::BottomRight);
479    }
480
481    #[test]
482    fn test_resize_event_default() {
483        let event = ResizeEvent::default();
484        assert_eq!(event.width, 0.0);
485        assert_eq!(event.height, 0.0);
486        assert_eq!(event.delta_x, 0.0);
487        assert_eq!(event.delta_y, 0.0);
488        assert_eq!(event.handle, ResizeHandle::BottomRight);
489    }
490
491    #[test]
492    fn test_splitter_orientation_enum() {
493        assert_eq!(
494            SplitterOrientation::Vertical,
495            SplitterOrientation::default()
496        );
497        assert_eq!(
498            SplitterOrientation::Horizontal,
499            SplitterOrientation::Horizontal
500        );
501        assert_eq!(SplitterOrientation::Vertical, SplitterOrientation::Vertical);
502    }
503
504    // Props and state tests
505    #[test]
506    fn test_resizable_props_handling() {}
507
508    #[test]
509    fn test_resizable_dimensions() {}
510
511    #[test]
512    fn test_resizable_constraints() {}
513
514    #[test]
515    fn test_resizable_enabled_state() {}
516
517    #[test]
518    fn test_resizable_handles() {}
519
520    #[test]
521    fn test_resizable_aspect_ratio() {}
522
523    // Event handling tests
524    #[test]
525    fn test_resizable_resize_start() {}
526
527    #[test]
528    fn test_resizable_resize() {}
529
530    #[test]
531    fn test_resizable_resize_end() {}
532
533    #[test]
534    fn test_resize_handle_events() {}
535
536    // Panel functionality tests
537    #[test]
538    fn test_resizable_panel_content() {}
539
540    #[test]
541    fn test_resizable_panel_title() {}
542
543    #[test]
544    fn test_resizable_panel_collapsible() {}
545
546    #[test]
547    fn test_resizable_panel_collapsed() {}
548
549    #[test]
550    fn test_resizable_panel_toggle() {}
551
552    // Splitter functionality tests
553    #[test]
554    fn test_resizable_splitter_orientation() {}
555
556    #[test]
557    fn test_resizable_splitter_position() {}
558
559    #[test]
560    fn test_resizable_splitter_constraints() {}
561
562    #[test]
563    fn test_resizable_splitter_drag() {}
564
565    // Constraint validation tests
566    #[test]
567    fn test_resizable_min_width_constraint() {}
568
569    #[test]
570    fn test_resizable_min_height_constraint() {}
571
572    #[test]
573    fn test_resizable_max_width_constraint() {}
574
575    #[test]
576    fn test_resizable_max_height_constraint() {}
577
578    // Aspect ratio tests
579    #[test]
580    fn test_resizable_maintain_aspect_ratio() {}
581
582    #[test]
583    fn test_resizable_custom_aspect_ratio() {}
584
585    // Handle positioning tests
586    #[test]
587    fn test_resize_handle_positioning() {}
588
589    #[test]
590    fn test_resize_handle_cursor_styles() {}
591
592    // Accessibility tests
593    #[test]
594    fn test_resizable_accessibility() {}
595
596    #[test]
597    fn test_resizable_keyboard_navigation() {}
598
599    #[test]
600    fn test_resizable_screen_reader_support() {}
601
602    // Performance tests
603    #[test]
604    fn test_resizable_performance() {}
605
606    #[test]
607    fn test_resizable_large_content() {}
608
609    // Integration tests
610    #[test]
611    fn test_resizable_full_workflow() {}
612
613    #[test]
614    fn test_resizable_with_panel() {}
615
616    #[test]
617    fn test_resizable_with_splitter() {}
618
619    // Edge case tests
620    #[test]
621    fn test_resizable_zero_dimensions() {}
622
623    #[test]
624    fn test_resizable_negative_dimensions() {}
625
626    #[test]
627    fn test_resizable_invalid_constraints() {}
628
629    // Styling tests
630    #[test]
631    fn test_resizable_custom_classes() {}
632
633    #[test]
634    fn test_resizable_custom_styles() {}
635
636    #[test]
637    fn test_resizable_responsive_design() {}
638}