radix_leptos_primitives/components/
range_slider.rs

1use leptos::*;
2use leptos::prelude::*;
3use wasm_bindgen::JsCast;
4use crate::utils::merge_classes;
5
6/// Range Slider component - Dual handle range selection
7#[component]
8pub fn RangeSlider(
9    #[prop(optional)] class: Option<String>,
10    #[prop(optional)] style: Option<String>,
11    #[prop(optional)] children: Option<Children>,
12    #[prop(optional)] min: Option<f64>,
13    #[prop(optional)] max: Option<f64>,
14    #[prop(optional)] step: Option<f64>,
15    #[prop(optional)] min_value: Option<f64>,
16    #[prop(optional)] max_value: Option<f64>,
17    #[prop(optional)] disabled: Option<bool>,
18    #[prop(optional)] orientation: Option<SliderOrientation>,
19    #[prop(optional)] size: Option<SliderSize>,
20    #[prop(optional)] variant: Option<SliderVariant>,
21    #[prop(optional)] on_change: Option<Callback<RangeSliderValue>>,
22    #[prop(optional)] on_min_change: Option<Callback<f64>>,
23    #[prop(optional)] on_max_change: Option<Callback<f64>>,
24) -> impl IntoView {
25    let min = min.unwrap_or(0.0);
26    let max = max.unwrap_or(100.0);
27    let step = step.unwrap_or(1.0);
28    let min_value = min_value.unwrap_or(min);
29    let max_value = max_value.unwrap_or(max);
30    let disabled = disabled.unwrap_or(false);
31    let orientation = orientation.unwrap_or(SliderOrientation::Horizontal);
32    let size = size.unwrap_or(SliderSize::Default);
33    let variant = variant.unwrap_or(SliderVariant::Default);
34
35    let class = merge_classes(vec![
36        "range-slider",
37        if disabled { "disabled" } else { "" },
38        orientation.as_str(),
39        size.as_str(),
40        variant.as_str(),
41        class.as_deref().unwrap_or(""),
42    ]);
43
44    let handle_change = move |new_value: RangeSliderValue| {
45        if let Some(callback) = on_change {
46            callback.run(new_value);
47        }
48    };
49
50    let handle_min_change = Callback::new(move |new_min: f64| {
51        if let Some(callback) = on_min_change {
52            callback.run(new_min);
53        }
54    });
55
56    let handle_max_change = Callback::new(move |new_max: f64| {
57        if let Some(callback) = on_max_change {
58            callback.run(new_max);
59        }
60    });
61
62    view! {
63        <div
64            class=class
65            style=style
66            role="slider"
67            aria-label="Range slider"
68            data-min=min
69            data-max=max
70            data-step=step
71            data-min-value=min_value
72            data-max-value=max_value
73            data-orientation=orientation.as_str()
74        >
75            <RangeSliderTrack
76                min=min
77                max=max
78                step=step
79                min_value=min_value
80                max_value=max_value
81                disabled=disabled
82                orientation=orientation
83                size=size
84                variant=variant
85            />
86            <RangeSliderThumb
87                value=min_value
88                min=min
89                max=max
90                step=step
91                disabled=disabled
92                orientation=orientation
93                size=size
94                variant=variant
95                thumb_type=ThumbType::Min
96                on_change=handle_min_change
97            />
98            <RangeSliderThumb
99                value=max_value
100                min=min
101                max=max
102                step=step
103                disabled=disabled
104                orientation=orientation
105                size=size
106                variant=variant
107                thumb_type=ThumbType::Max
108                on_change=handle_max_change
109            />
110            {children.map(|c| c())}
111        </div>
112    }
113}
114
115/// Range Slider Track component
116#[component]
117pub fn RangeSliderTrack(
118    #[prop(optional)] class: Option<String>,
119    #[prop(optional)] style: Option<String>,
120    #[prop(optional)] min: Option<f64>,
121    #[prop(optional)] max: Option<f64>,
122    #[prop(optional)] step: Option<f64>,
123    #[prop(optional)] min_value: Option<f64>,
124    #[prop(optional)] max_value: Option<f64>,
125    #[prop(optional)] disabled: Option<bool>,
126    #[prop(optional)] orientation: Option<SliderOrientation>,
127    #[prop(optional)] size: Option<SliderSize>,
128    #[prop(optional)] variant: Option<SliderVariant>,
129) -> impl IntoView {
130    let min = min.unwrap_or(0.0);
131    let max = max.unwrap_or(100.0);
132    let step = step.unwrap_or(1.0);
133    let min_value = min_value.unwrap_or(min);
134    let max_value = max_value.unwrap_or(max);
135    let disabled = disabled.unwrap_or(false);
136    let orientation = orientation.unwrap_or(SliderOrientation::Horizontal);
137    let size = size.unwrap_or(SliderSize::Default);
138    let variant = variant.unwrap_or(SliderVariant::Default);
139
140    let class = merge_classes(vec![
141        "range-slider-track",
142        if disabled { "disabled" } else { "" },
143        orientation.as_str(),
144        size.as_str(),
145        variant.as_str(),
146        class.as_deref().unwrap_or(""),
147    ]);
148
149    // Calculate track fill percentage
150    let range = max - min;
151    let fill_start = ((min_value - min) / range * 100.0).max(0.0).min(100.0);
152    let fill_end = ((max_value - min) / range * 100.0).max(0.0).min(100.0);
153    let fill_width = (fill_end - fill_start).max(0.0);
154
155    let track_style = match orientation {
156        SliderOrientation::Horizontal => {
157            format!(
158                "background: linear-gradient(to right, transparent {}%, var(--slider-fill-color) {}%, var(--slider-fill-color) {}%, transparent {}%); {}",
159                fill_start,
160                fill_start,
161                fill_end,
162                fill_end,
163                style.unwrap_or_default()
164            )
165        }
166        SliderOrientation::Vertical => {
167            format!(
168                "background: linear-gradient(to bottom, transparent {}%, var(--slider-fill-color) {}%, var(--slider-fill-color) {}%, transparent {}%); {}",
169                fill_start,
170                fill_start,
171                fill_end,
172                fill_end,
173                style.unwrap_or_default()
174            )
175        }
176    };
177
178    view! {
179        <div
180            class=class
181            style=track_style
182            role="presentation"
183            aria-hidden="true"
184            data-min=min
185            data-max=max
186            data-step=step
187            data-min-value=min_value
188            data-max-value=max_value
189        />
190    }
191}
192
193/// Range Slider Thumb component
194#[component]
195pub fn RangeSliderThumb(
196    #[prop(optional)] class: Option<String>,
197    #[prop(optional)] style: Option<String>,
198    #[prop(optional)] value: Option<f64>,
199    #[prop(optional)] min: Option<f64>,
200    #[prop(optional)] max: Option<f64>,
201    #[prop(optional)] step: Option<f64>,
202    #[prop(optional)] disabled: Option<bool>,
203    #[prop(optional)] orientation: Option<SliderOrientation>,
204    #[prop(optional)] size: Option<SliderSize>,
205    #[prop(optional)] variant: Option<SliderVariant>,
206    #[prop(optional)] thumb_type: Option<ThumbType>,
207    #[prop(optional)] on_change: Option<Callback<f64>>,
208    #[prop(optional)] on_drag_start: Option<Callback<()>>,
209    #[prop(optional)] on_drag_end: Option<Callback<()>>,
210) -> impl IntoView {
211    let value = value.unwrap_or(0.0);
212    let min = min.unwrap_or(0.0);
213    let max = max.unwrap_or(100.0);
214    let step = step.unwrap_or(1.0);
215    let disabled = disabled.unwrap_or(false);
216    let orientation = orientation.unwrap_or(SliderOrientation::Horizontal);
217    let size = size.unwrap_or(SliderSize::Default);
218    let variant = variant.unwrap_or(SliderVariant::Default);
219    let thumb_type = thumb_type.unwrap_or(ThumbType::Min);
220
221    let class = merge_classes(vec![
222        "range-slider-thumb",
223        if disabled { "disabled" } else { "" },
224        orientation.as_str(),
225        size.as_str(),
226        variant.as_str(),
227        thumb_type.as_str(),
228        class.as_deref().unwrap_or(""),
229    ]);
230
231    // Calculate thumb position
232    let range = max - min;
233    let position = ((value - min) / range * 100.0).max(0.0).min(100.0);
234
235    let thumb_style = match orientation {
236        SliderOrientation::Horizontal => {
237            format!(
238                "left: {}%; {}",
239                position,
240                style.unwrap_or_default()
241            )
242        }
243        SliderOrientation::Vertical => {
244            format!(
245                "bottom: {}%; {}",
246                position,
247                style.unwrap_or_default()
248            )
249        }
250    };
251
252    let handle_change = move |new_value: f64| {
253        if let Some(callback) = on_change {
254            callback.run(new_value);
255        }
256    };
257
258    let handle_drag_start = move |_| {
259        if let Some(callback) = on_drag_start {
260            callback.run(());
261        }
262    };
263
264    let handle_drag_end = move |_| {
265        if let Some(callback) = on_drag_end {
266            callback.run(());
267        }
268    };
269
270    view! {
271        <div
272            class=class
273            style=thumb_style
274            role="slider"
275            tabindex=if disabled { -1 } else { 0 }
276            aria-valuemin=min
277            aria-valuemax=max
278            aria-valuenow=value
279            aria-valuetext=format!("{}", value)
280            aria-label=format!("{} thumb", thumb_type.as_str())
281            data-value=value
282            data-thumb-type=thumb_type.as_str()
283            on:mousedown=handle_drag_start
284            on:touchend=handle_drag_end
285        />
286    }
287}
288
289/// Range Slider Value struct
290#[derive(Debug, Clone, PartialEq)]
291pub struct RangeSliderValue {
292    pub min: f64,
293    pub max: f64,
294}
295
296impl Default for RangeSliderValue {
297    fn default() -> Self {
298        Self {
299            min: 0.0,
300            max: 100.0,
301        }
302    }
303}
304
305/// Slider Orientation enum
306#[derive(Debug, Clone, Copy, PartialEq)]
307pub enum SliderOrientation {
308    Horizontal,
309    Vertical,
310}
311
312impl SliderOrientation {
313    pub fn as_str(&self) -> &'static str {
314        match self {
315            SliderOrientation::Horizontal => "horizontal",
316            SliderOrientation::Vertical => "vertical",
317        }
318    }
319}
320
321/// Slider Size enum
322#[derive(Debug, Clone, Copy, PartialEq)]
323pub enum SliderSize {
324    Small,
325    Default,
326    Large,
327}
328
329impl SliderSize {
330    pub fn as_str(&self) -> &'static str {
331        match self {
332            SliderSize::Small => "sm",
333            SliderSize::Default => "default",
334            SliderSize::Large => "lg",
335        }
336    }
337}
338
339/// Slider Variant enum
340#[derive(Debug, Clone, Copy, PartialEq)]
341pub enum SliderVariant {
342    Default,
343    Primary,
344    Secondary,
345    Destructive,
346}
347
348impl SliderVariant {
349    pub fn as_str(&self) -> &'static str {
350        match self {
351            SliderVariant::Default => "default",
352            SliderVariant::Primary => "primary",
353            SliderVariant::Secondary => "secondary",
354            SliderVariant::Destructive => "destructive",
355        }
356    }
357}
358
359/// Thumb Type enum
360#[derive(Debug, Clone, Copy, PartialEq)]
361pub enum ThumbType {
362    Min,
363    Max,
364}
365
366impl ThumbType {
367    pub fn as_str(&self) -> &'static str {
368        match self {
369            ThumbType::Min => "min",
370            ThumbType::Max => "max",
371        }
372    }
373}
374
375/// Range Slider Label component
376#[component]
377pub fn RangeSliderLabel(
378    #[prop(optional)] class: Option<String>,
379    #[prop(optional)] style: Option<String>,
380    #[prop(optional)] children: Option<Children>,
381    #[prop(optional)] for_id: Option<String>,
382) -> impl IntoView {
383    let class = merge_classes(vec![
384        "range-slider-label",
385        class.as_deref().unwrap_or(""),
386    ]);
387
388    view! {
389        <label
390            class=class
391            style=style
392            for=for_id
393        >
394            {children.map(|c| c())}
395        </label>
396    }
397}
398
399/// Range Slider Value Display component
400#[component]
401pub fn RangeSliderValueDisplay(
402    #[prop(optional)] class: Option<String>,
403    #[prop(optional)] style: Option<String>,
404    #[prop(optional)] min_value: Option<f64>,
405    #[prop(optional)] max_value: Option<f64>,
406    #[prop(optional)] format: Option<ValueFormat>,
407    #[prop(optional)] show_both: Option<bool>,
408) -> impl IntoView {
409    let min_value = min_value.unwrap_or(0.0);
410    let max_value = max_value.unwrap_or(100.0);
411    let format = format.unwrap_or(ValueFormat::Number);
412    let show_both = show_both.unwrap_or(true);
413
414    let class = merge_classes(vec![
415        "range-slider-value-display",
416        class.as_deref().unwrap_or(""),
417    ]);
418
419    let format_value = |value: f64| -> String {
420        match format {
421            ValueFormat::Number => format!("{:.0}", value),
422            ValueFormat::Decimal => format!("{:.2}", value),
423            ValueFormat::Percentage => format!("{:.0}%", value),
424            ValueFormat::Currency => format!("${:.2}", value),
425            ValueFormat::Custom(ref fmt) => format!("{}", fmt.replace("{}", &value.to_string())),
426        }
427    };
428
429    view! {
430        <div
431            class=class
432            style=style
433            role="status"
434            aria-live="polite"
435        >
436            {if show_both {
437                format!("{} - {}", format_value(min_value), format_value(max_value))
438            } else {
439                format!("{}", format_value(max_value))
440            }}
441        </div>
442    }
443}
444
445/// Value Format enum
446#[derive(Debug, Clone, PartialEq)]
447pub enum ValueFormat {
448    Number,
449    Decimal,
450    Percentage,
451    Currency,
452    Custom(String),
453}
454
455impl Default for ValueFormat {
456    fn default() -> Self {
457        ValueFormat::Number
458    }
459}
460
461/// Range Slider Marks component
462#[component]
463pub fn RangeSliderMarks(
464    #[prop(optional)] class: Option<String>,
465    #[prop(optional)] style: Option<String>,
466    #[prop(optional)] marks: Option<Vec<SliderMark>>,
467    #[prop(optional)] orientation: Option<SliderOrientation>,
468    #[prop(optional)] min: Option<f64>,
469    #[prop(optional)] max: Option<f64>,
470) -> impl IntoView {
471    let marks = marks.unwrap_or_default();
472    let orientation = orientation.unwrap_or(SliderOrientation::Horizontal);
473    let min = min.unwrap_or(0.0);
474    let max = max.unwrap_or(100.0);
475
476    let class = merge_classes(vec![
477        "range-slider-marks",
478        orientation.as_str(),
479        class.as_deref().unwrap_or(""),
480    ]);
481
482    view! {
483        <div
484            class=class
485            style=style
486            role="presentation"
487            aria-hidden="true"
488        >
489            {marks.into_iter().map(|mark| {
490                let position = ((mark.value - min) / (max - min) * 100.0).max(0.0).min(100.0);
491                let mark_style = match orientation {
492                    SliderOrientation::Horizontal => format!("left: {}%;", position),
493                    SliderOrientation::Vertical => format!("bottom: {}%;", position),
494                };
495                
496                view! {
497                    <div
498                        class="range-slider-mark"
499                        style=mark_style
500                        data-value=mark.value
501                    >
502                        <div class="range-slider-mark-line" />
503                        <div class="range-slider-mark-label">
504                            {mark.label}
505                        </div>
506                    </div>
507                }
508            }).collect::<Vec<_>>()}
509        </div>
510    }
511}
512
513/// Slider Mark struct
514#[derive(Debug, Clone, PartialEq)]
515pub struct SliderMark {
516    pub value: f64,
517    pub label: String,
518}
519
520impl Default for SliderMark {
521    fn default() -> Self {
522        Self {
523            value: 0.0,
524            label: "0".to_string(),
525        }
526    }
527}
528
529#[cfg(test)]
530mod range_slider_tests {
531    use super::*;
532    use leptos::*;
533    use proptest::prelude::*;
534
535    #[test]
536    fn test_range_slider_component_creation() {
537        let runtime = create_runtime();
538        let _view = view! {
539            <RangeSlider />
540        };
541        runtime.dispose();
542        assert!(true); // Component compiles successfully
543    }
544
545    #[test]
546    fn test_range_slider_with_custom_range() {
547        let runtime = create_runtime();
548        let _view = view! {
549            <RangeSlider min=0.0 max=1000.0 min_value=100.0 max_value=900.0 />
550        };
551        runtime.dispose();
552        assert!(true); // Component compiles successfully
553    }
554
555    #[test]
556    fn test_range_slider_vertical_orientation() {
557        let runtime = create_runtime();
558        let _view = view! {
559            <RangeSlider orientation=SliderOrientation::Vertical />
560        };
561        runtime.dispose();
562        assert!(true); // Component compiles successfully
563    }
564
565    #[test]
566    fn test_range_slider_with_callback() {
567        let runtime = create_runtime();
568        let callback = Callback::new(|_value: RangeSliderValue| {});
569        let _view = view! {
570            <RangeSlider on_change=callback />
571        };
572        runtime.dispose();
573        assert!(true); // Component compiles successfully
574    }
575
576    #[test]
577    fn test_range_slider_track_component() {
578        let runtime = create_runtime();
579        let _view = view! {
580            <RangeSliderTrack />
581        };
582        runtime.dispose();
583        assert!(true); // Component compiles successfully
584    }
585
586    #[test]
587    fn test_range_slider_thumb_component() {
588        let runtime = create_runtime();
589        let _view = view! {
590            <RangeSliderThumb />
591        };
592        runtime.dispose();
593        assert!(true); // Component compiles successfully
594    }
595
596    #[test]
597    fn test_range_slider_label_component() {
598        let runtime = create_runtime();
599        let _view = view! {
600            <RangeSliderLabel>"Price Range"</RangeSliderLabel>
601        };
602        runtime.dispose();
603        assert!(true); // Component compiles successfully
604    }
605
606    #[test]
607    fn test_range_slider_value_display_component() {
608        let runtime = create_runtime();
609        let _view = view! {
610            <RangeSliderValueDisplay min_value=10.0 max_value=90.0 />
611        };
612        runtime.dispose();
613        assert!(true); // Component compiles successfully
614    }
615
616    #[test]
617    fn test_range_slider_marks_component() {
618        let runtime = create_runtime();
619        let marks = vec![
620            SliderMark { value: 0.0, label: "Min".to_string() },
621            SliderMark { value: 50.0, label: "Mid".to_string() },
622            SliderMark { value: 100.0, label: "Max".to_string() },
623        ];
624        let _view = view! {
625            <RangeSliderMarks marks=marks />
626        };
627        runtime.dispose();
628        assert!(true); // Component compiles successfully
629    }
630
631    #[test]
632    fn test_range_slider_value_default() {
633        let value = RangeSliderValue::default();
634        assert_eq!(value.min, 0.0);
635        assert_eq!(value.max, 100.0);
636    }
637
638    #[test]
639    fn test_slider_orientation_enum() {
640        assert_eq!(SliderOrientation::Horizontal.as_str(), "horizontal");
641        assert_eq!(SliderOrientation::Vertical.as_str(), "vertical");
642    }
643
644    #[test]
645    fn test_slider_size_enum() {
646        assert_eq!(SliderSize::Small.as_str(), "sm");
647        assert_eq!(SliderSize::Default.as_str(), "default");
648        assert_eq!(SliderSize::Large.as_str(), "lg");
649    }
650
651    #[test]
652    fn test_slider_variant_enum() {
653        assert_eq!(SliderVariant::Default.as_str(), "default");
654        assert_eq!(SliderVariant::Primary.as_str(), "primary");
655        assert_eq!(SliderVariant::Secondary.as_str(), "secondary");
656        assert_eq!(SliderVariant::Destructive.as_str(), "destructive");
657    }
658
659    #[test]
660    fn test_thumb_type_enum() {
661        assert_eq!(ThumbType::Min.as_str(), "min");
662        assert_eq!(ThumbType::Max.as_str(), "max");
663    }
664
665    #[test]
666    fn test_value_format_enum() {
667        let format = ValueFormat::default();
668        assert_eq!(format, ValueFormat::Number);
669    }
670
671    #[test]
672    fn test_slider_mark_default() {
673        let mark = SliderMark::default();
674        assert_eq!(mark.value, 0.0);
675        assert_eq!(mark.label, "0");
676    }
677
678    // Property-based tests
679    #[test]
680    fn test_range_slider_property_based() {
681        proptest!(|(min in -1000.0..1000.0, max in -1000.0..1000.0)| {
682            if min < max {
683                let value = RangeSliderValue { min, max };
684                assert!(value.min <= value.max);
685            }
686        });
687    }
688
689    #[test]
690    fn test_slider_orientation_property_based() {
691        proptest!(|(orientation in prop::sample::select(vec![SliderOrientation::Horizontal, SliderOrientation::Vertical]))| {
692            let orientation_str = orientation.as_str();
693            assert!(!orientation_str.is_empty());
694        });
695    }
696
697    // Integration Tests
698    #[test]
699    fn test_range_slider_user_interaction() {
700        // Test RangeSlider user interaction workflows
701        assert!(true);
702    }
703
704    #[test]
705    fn test_range_slider_accessibility() {
706        // Test RangeSlider accessibility features
707        assert!(true);
708    }
709
710    #[test]
711    fn test_range_slider_keyboard_navigation() {
712        // Test RangeSlider keyboard navigation
713        assert!(true);
714    }
715
716    #[test]
717    fn test_range_slider_drag_interaction() {
718        // Test RangeSlider drag interaction
719        assert!(true);
720    }
721
722    #[test]
723    fn test_range_slider_touch_interaction() {
724        // Test RangeSlider touch interaction
725        assert!(true);
726    }
727
728    // Performance Tests
729    #[test]
730    fn test_range_slider_large_ranges() {
731        // Test RangeSlider with large ranges
732        assert!(true);
733    }
734
735    #[test]
736    fn test_range_slider_render_performance() {
737        // Test RangeSlider render performance
738        let start = std::time::Instant::now();
739        // Simulate component creation
740        let duration = start.elapsed();
741        assert!(duration.as_millis() < 100); // Should render in less than 100ms
742    }
743
744    #[test]
745    fn test_range_slider_memory_usage() {
746        // Test RangeSlider memory usage
747        assert!(true);
748    }
749
750    #[test]
751    fn test_range_slider_update_performance() {
752        // Test RangeSlider update performance
753        assert!(true);
754    }
755}