1use leptos::*;
2use leptos::prelude::*;
3use wasm_bindgen::JsCast;
4use crate::utils::merge_classes;
5
6#[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#[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 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#[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 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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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); }
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); }
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); }
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); }
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); }
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); }
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); }
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); }
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); }
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 #[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 #[test]
699 fn test_range_slider_user_interaction() {
700 assert!(true);
702 }
703
704 #[test]
705 fn test_range_slider_accessibility() {
706 assert!(true);
708 }
709
710 #[test]
711 fn test_range_slider_keyboard_navigation() {
712 assert!(true);
714 }
715
716 #[test]
717 fn test_range_slider_drag_interaction() {
718 assert!(true);
720 }
721
722 #[test]
723 fn test_range_slider_touch_interaction() {
724 assert!(true);
726 }
727
728 #[test]
730 fn test_range_slider_large_ranges() {
731 assert!(true);
733 }
734
735 #[test]
736 fn test_range_slider_render_performance() {
737 let start = std::time::Instant::now();
739 let duration = start.elapsed();
741 assert!(duration.as_millis() < 100); }
743
744 #[test]
745 fn test_range_slider_memory_usage() {
746 assert!(true);
748 }
749
750 #[test]
751 fn test_range_slider_update_performance() {
752 assert!(true);
754 }
755}