slider_rs/
dioxus.rs

1#![doc = include_str!("../DIOXUS.md")]
2
3use crate::common::{Color, Cursor, Height, Orientation, Size, Width};
4use dioxus::prelude::*;
5use std::rc::Rc;
6use uuid::Uuid;
7use web_sys::HtmlInputElement;
8
9#[derive(Props, PartialEq, Clone)]
10pub struct LabelProps {
11    #[props(default)]
12    label: &'static str,
13    #[props(default = "font-size: 14px; margin-bottom: 8px; text-align: center;")]
14    label_style: &'static str,
15    #[props(default = "slider-label")]
16    label_class: &'static str,
17}
18
19#[component]
20fn Label(props: LabelProps) -> Element {
21    rsx! {
22        label {
23            class: "{props.label_class}",
24            style: "{props.label_style}",
25            "{props.label}"
26        }
27    }
28}
29
30#[derive(Props, PartialEq, Clone)]
31pub struct StepsProps {
32    #[props(default = 0.0)]
33    min: f64,
34    #[props(default = 10.0)]
35    max: f64,
36    #[props(default = 1.0)]
37    step: f64,
38    #[props(
39        default = "width: 100%; display: flex; justify-content: space-between; margin-top: 8px; font-size: 10px;"
40    )]
41    steps_style: &'static str,
42    #[props(default)]
43    orientation: Orientation,
44}
45
46#[component]
47fn Steps(props: StepsProps) -> Element {
48    let count = ((props.max - props.min) / props.step).floor() as usize;
49
50    let steps = (0..=count).map(|i| {
51        let val = props.min + (i as f64 * props.step);
52        let style = if props.orientation.is_vertical() {
53            "margin: 4px 0; writing-mode: vertical-rl; text-align: center;"
54        } else {
55            "text-align: center;"
56        };
57        rsx! {
58            span {
59                style: "{style}",
60                "{val:.0}"
61            }
62        }
63    });
64
65    let container_style = if props.orientation.is_vertical() {
66        "display: flex; flex-direction: column; align-items: center; height: 100%; font-size: 10px;"
67    } else {
68        props.steps_style
69    };
70
71    rsx! {
72        div {
73            style: "{container_style}",
74            {steps}
75        }
76    }
77}
78
79#[derive(Props, PartialEq, Clone)]
80pub struct OutputProps {
81    #[props(default)]
82    value_display: String,
83    #[props(default = "font-size: 12px; margin-top: 8px; text-align: center;")]
84    output_style: &'static str,
85    #[props(default = "slider-output")]
86    output_class: &'static str,
87    #[props(
88        default = "background-color: #333; color: #fff; padding: 4px 8px; border-radius: 4px; font-size: 12px; position: absolute; transform: translate(-50%, -120%); display: block; pointer-events: none;"
89    )]
90    tooltip_style: &'static str,
91    #[props(default = false)]
92    show_tooltip: bool,
93    #[props(default)]
94    tooltip_left: String,
95}
96
97#[component]
98fn Output(props: OutputProps) -> Element {
99    let style = format!("{} left: {};", props.tooltip_style, props.tooltip_left);
100    rsx! {
101        output {
102            class: "{props.output_class}",
103            style: "{props.output_style}",
104            aria_live: "polite",
105            "{props.value_display}"
106        }
107        if props.show_tooltip {
108            div {
109                class: "{props.output_class}",
110                style: "{style}",
111                "{props.value_display}"
112            }
113        }
114    }
115}
116
117#[derive(Props, PartialEq, Clone)]
118pub struct TicksProps {
119    #[props(default)]
120    id: String,
121    #[props(default = 0.0)]
122    min: f64,
123    #[props(default = 10.0)]
124    max: f64,
125    #[props(default = 1.0)]
126    step: f64,
127}
128
129#[component]
130fn Ticks(props: TicksProps) -> Element {
131    let mut current = props.min;
132    let mut options = vec![];
133
134    while current <= props.max {
135        options.push(rsx! {
136            option {
137                value: "{current}"
138            }
139        });
140        current += props.step;
141    }
142
143    rsx! {
144        datalist {
145            id: "{props.id}",
146            {options.into_iter()}
147        }
148    }
149}
150
151#[derive(Props, PartialEq, Clone)]
152pub struct InputProps {
153    #[props(default)]
154    input_ref: Signal<Option<Rc<MountedData>>>,
155    #[props(default = 0.0)]
156    min: f64,
157    #[props(default = 10.0)]
158    max: f64,
159    #[props(default = 1.0)]
160    step: f64,
161    #[props(default = 0.0)]
162    value: f64,
163    #[props(default)]
164    orientation: Orientation,
165    #[props(default)]
166    size: Size,
167    #[props(default)]
168    width: Width,
169    #[props(default)]
170    height: Height,
171    #[props(default)]
172    color: Color,
173    #[props(default)]
174    cursor_style: Cursor,
175    #[props(default = false)]
176    disabled: bool,
177    #[props(default)]
178    on_input: Callback<FormEvent>,
179    #[props(default)]
180    on_focus: Callback<FocusEvent>,
181    #[props(default)]
182    on_blur: Callback<FocusEvent>,
183    #[props(default)]
184    aria_label: Option<&'static str>,
185    #[props(default)]
186    aria_describedby: Option<&'static str>,
187    #[props(default)]
188    datalist_id: Option<String>,
189    #[props(default = "slider-input")]
190    input_class: &'static str,
191    #[props(default = "border-radius: 8px; appearance: none; outline: none;")]
192    input_style: &'static str,
193    #[props(default = true)]
194    use_gradient: bool,
195    #[props(default)]
196    custom_thumb_css: Option<&'static str>,
197    #[props(default)]
198    custom_thumb_html: Option<Element>,
199    #[props(default = 1.0)]
200    keyboard_step: f64,
201    #[props(default = false)]
202    rtl_fill: bool,
203}
204
205#[component]
206fn Input(props: InputProps) -> Element {
207    let mut props = props.clone();
208    let value_percent = ((props.value - props.min) / (props.max - props.min)) * 100.0;
209    let fill_color = props.color.to_color_code();
210    let gradient = if props.use_gradient {
211        if props.orientation.is_vertical() {
212            if props.rtl_fill {
213                format!(
214                    "background: linear-gradient(to top, {} 0%, {} {:.2}%, #ccc {:.2}%, #ccc 100%);",
215                    fill_color, fill_color, value_percent, value_percent
216                )
217            } else {
218                format!(
219                    "background: linear-gradient(to bottom, {} 0%, {} {:.2}%, #ccc {:.2}%, #ccc 100%);",
220                    fill_color, fill_color, value_percent, value_percent
221                )
222            }
223        } else if props.rtl_fill {
224            format!(
225                "background: linear-gradient(to left, {} 0%, {} {:.2}%, #ccc {:.2}%, #ccc 100%);",
226                fill_color, fill_color, value_percent, value_percent
227            )
228        } else {
229            format!(
230                "background: linear-gradient(to right, {} 0%, {} {:.2}%, #ccc {:.2}%, #ccc 100%);",
231                fill_color, fill_color, value_percent, value_percent
232            )
233        }
234    } else {
235        format!("background: {};", fill_color)
236    };
237
238    let base_style = format!(
239        "cursor: pointer; transition: background 0.3s; {} {} {} {} {} {}",
240        props.input_style,
241        props.width.to_style(),
242        props.height.to_style(),
243        gradient,
244        props.orientation.to_style(),
245        props.size.to_style(),
246    );
247
248    let on_key_down = Callback::new({
249        move |e: Event<KeyboardData>| {
250            let data = e.data;
251            if let Some(el) = (props.input_ref)() {
252                if let Some(input) = el.downcast::<HtmlInputElement>() {
253                    let current = input.value().parse::<f64>().unwrap_or(0.0);
254                    let new_val = match data.key() {
255                        Key::ArrowLeft | Key::ArrowDown => current - props.keyboard_step,
256                        Key::ArrowRight | Key::ArrowUp => current + props.keyboard_step,
257                        _ => current,
258                    }
259                    .clamp(props.min, props.max);
260
261                    input.set_value(&new_val.to_string());
262
263                    if let Ok(event) = web_sys::Event::new("input") {
264                        let _ = input.dispatch_event(&event);
265                    }
266                }
267            }
268        }
269    });
270
271    rsx! {
272        input {
273            onmounted: move |cx| props.input_ref.set(Some(cx.data())),
274            r#type: "range",
275            class: "{props.input_class}",
276            min: "{props.min}",
277            max: "{props.max}",
278            step: if props.step == 0.0 { "any".to_string() } else { props.step.to_string() },
279            value: "{props.value}",
280            list: props.datalist_id.clone().unwrap_or_default(),
281            oninput: move |e| props.on_input.call(e),
282            onfocus: move |e| props.on_focus.call(e),
283            onblur: move |e| props.on_blur.call(e),
284            onkeydown: on_key_down,
285            disabled: props.disabled,
286            aria_label: props.aria_label.unwrap_or("Slider"),
287            aria_describedby: props.aria_describedby.unwrap_or("Slider description"),
288            style: "{base_style}",
289        }
290        if let Some(custom_html) = props.custom_thumb_html.clone() {
291            {custom_html}
292        }
293    }
294}
295
296/// Props for the `Slider` component.
297///
298/// These props configure the behavior, appearance, and accessibility of a slider input component
299/// supporting both single and double (range) sliders.
300///
301/// # Features
302/// - Single or double slider modes.
303/// - Vertical or horizontal orientation.
304/// - Customizable appearance with CSS styles and classes.
305/// - Tooltip, value display, and step indicators.
306/// - Full accessibility support (ARIA attributes, keyboard steps).
307#[derive(PartialEq, Clone, Props)]
308pub struct SliderProps {
309    /// Label text displayed above the slider.
310    #[props(default)]
311    pub label: &'static str,
312
313    /// Minimum value for the slider.
314    #[props(default = 0.0)]
315    pub min: f64,
316
317    /// Maximum value for the slider.
318    #[props(default = 10.0)]
319    pub max: f64,
320
321    /// Increment step size.
322    #[props(default = 1.0)]
323    pub step: f64,
324
325    /// Current value for a single slider mode.
326    #[props(default)]
327    pub value: Option<f64>,
328
329    /// Current range values (start, end) for double slider mode.
330    #[props(default)]
331    pub range: Option<(f64, f64)>,
332
333    /// Whether to enable double slider mode (range selector).
334    #[props(default = false)]
335    pub double: bool,
336
337    /// Slider orientation: horizontal or vertical.
338    #[props(default)]
339    pub orientation: Orientation,
340
341    /// Size variant for styling the slider track and thumb.
342    #[props(default)]
343    pub size: Size,
344
345    /// Color variant for styling the slider.
346    #[props(default)]
347    pub color: Color,
348
349    /// Cursor style when hovering over the slider.
350    #[props(default)]
351    pub cursor_style: Cursor,
352
353    /// Whether to show the current value as an output.
354    #[props(default = false)]
355    pub show_value: bool,
356
357    /// Whether to show step ticks along the slider track.
358    #[props(default = false)]
359    pub show_steps: bool,
360
361    /// Whether to show tooltip above the thumb on hover.
362    #[props(default = false)]
363    pub show_tooltip: bool,
364
365    /// Whether to disable interaction with the slider.
366    #[props(default = false)]
367    pub disabled: bool,
368
369    /// Callback triggered when the slider value changes.
370    #[props(default)]
371    pub on_change: Callback<f64>,
372
373    /// Callback triggered when the slider range changes (double mode).
374    #[props(default)]
375    pub on_change_range: Callback<(f64, f64)>,
376
377    /// Callback triggered when the slider gains focus.
378    #[props(default)]
379    pub on_focus: Callback<()>,
380
381    /// Callback triggered when the slider loses focus.
382    #[props(default)]
383    pub on_blur: Callback<()>,
384
385    /// ARIA label for accessibility.
386    #[props(default)]
387    pub aria_label: Option<&'static str>,
388
389    /// ARIA describedby attribute for accessibility.
390    #[props(default)]
391    pub aria_describedby: Option<&'static str>,
392
393    /// CSS class for the container wrapping the slider.
394    #[props(default = "slider-container")]
395    pub container_class: &'static str,
396
397    /// Inline style for the container wrapping the slider.
398    #[props(
399        default = "display: flex; flex-direction: column; align-items: center; margin: 20px; position: relative;"
400    )]
401    pub container_style: &'static str,
402
403    /// CSS class for the slider label.
404    #[props(default = "slider-label")]
405    pub label_class: &'static str,
406
407    /// Inline style for the slider label.
408    #[props(default = "font-size: 14px; margin-bottom: 8px;")]
409    pub label_style: &'static str,
410
411    /// CSS class for the slider input element.
412    #[props(default = "slider-input")]
413    pub input_class: &'static str,
414
415    /// Inline style for the slider input element.
416    #[props(default = "border-radius: 8px; appearance: none; outline: none;")]
417    pub input_style: &'static str,
418
419    /// CSS class for the value/output display.
420    #[props(default = "slider-output")]
421    pub output_class: &'static str,
422
423    /// Inline style for the value/output display.
424    #[props(default = "font-size: 12px; margin-top: 8px;")]
425    pub output_style: &'static str,
426
427    /// Inline style for the tooltip element.
428    #[props(
429        default = "background-color: #333; color: #fff; padding: 4px 8px; border-radius: 4px; font-size: 12px; display: none;"
430    )]
431    pub tooltip_style: &'static str,
432
433    /// Inline style for the steps indicator below the slider track.
434    #[props(
435        default = "width: 100%; display: flex; justify-content: space-between; margin-top: 8px; font-size: 10px;"
436    )]
437    pub steps_style: &'static str,
438
439    /// Custom width for the slider track.
440    #[props(default)]
441    pub slider_width: Width,
442
443    /// Custom height for the slider track.
444    #[props(default)]
445    pub slider_height: Height,
446
447    /// Optional custom CSS for the slider thumb.
448    #[props(default)]
449    pub custom_thumb_css: Option<&'static str>,
450
451    /// Optional custom HTML content for the slider thumb.
452    #[props(default)]
453    pub custom_thumb_html: Option<Element>,
454
455    /// Keyboard step increment for arrow key adjustments.
456    #[props(default = 1.0)]
457    pub keyboard_step: f64,
458
459    /// Optional icon element displayed before the slider.
460    #[props(default)]
461    pub icon_start: Option<Element>,
462
463    /// Optional icon element displayed after the slider.
464    #[props(default)]
465    pub icon_end: Option<Element>,
466}
467
468/// Slider Component
469///
470/// A Dioxus range slider component with extensive customization and accessibility support.
471///
472/// # Features
473/// - Single value or double range mode (TODO).
474/// - Horizontal and vertical orientations.
475/// - Customizable track, thumb, tooltip, and step indicators.
476/// - Full ARIA support for accessibility.
477/// - Keyboard controls with adjustable increments.
478///
479/// # Examples
480///
481/// ## Single Value Slider
482/// ```rust
483/// use dioxus::prelude::*;
484/// use slider_rs::dioxus::Slider;
485///
486/// fn app() -> Element {
487///     rsx! {
488///         Slider {
489///             label: "Volume",
490///             min: 0.0,
491///             max: 100.0,
492///             step: 1.0,
493///             show_value: true,
494///             on_change: Callback::new(move |v| log::info!("Value: {}", v)),
495///         }
496///     }
497/// }
498/// ```
499///
500/// ## Double Range Slider
501/// ```rust
502/// use dioxus::prelude::*;
503/// use slider_rs::dioxus::Slider;
504///
505/// fn app() -> Element {
506///     rsx! {
507///         Slider {
508///             label: "Select Range",
509///             min: 0.0,
510///             max: 100.0,
511///             step: 5.0,
512///             range: Some((20.0, 80.0)),
513///             double: true,
514///             show_value: true,
515///             on_change_range: Callback::new(move |(start, end)| log::info!("Range: {} - {}", start, end)),
516///         }
517///     }
518/// }
519/// ```
520///
521/// # Notes
522/// - Subcomponents include: `Label`, `Input`, `Ticks`, `Output`, `Steps`.
523/// - Double slider mode renders two overlapping input elements.
524/// - Styling is fully customizable via `*_style` and `*_class` props.
525///
526/// # Accessibility
527/// - Supports ARIA roles, orientation, and description attributes.
528/// - Keyboard navigation with configurable step increments.
529///
530/// # See Also
531/// - [MDN <input type="range"> Element](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/range)
532#[component]
533pub fn Slider(props: SliderProps) -> Element {
534    let mut val1 = use_signal(|| props.range.unwrap_or((props.min, props.max)).0);
535    let mut val2 = use_signal(|| props.range.unwrap_or((props.min, props.max)).1);
536
537    let input_ref1: Signal<Option<Rc<MountedData>>> = use_signal(|| None);
538    let input_ref2: Signal<Option<Rc<MountedData>>> = use_signal(|| None);
539
540    let list_id = use_memo(|| format!("slider-list-{}", Uuid::new_v4()));
541
542    let update_range = {
543        Callback::new(move |_| {
544            props.on_change_range.call((val1(), val2()));
545            props.on_change.call(val1());
546        })
547    };
548
549    let on_input1 = {
550        Callback::new(move |e: FormEvent| {
551            if let Ok(input) = e.value().parse::<f64>() {
552                val1.set(input);
553                update_range.call(());
554                props.on_change.call(input);
555            }
556        })
557    };
558
559    let on_input2 = {
560        Callback::new(move |e: FormEvent| {
561            if let Ok(input) = e.value().parse::<f64>() {
562                val2.set(input);
563                update_range.call(());
564                props.on_change.call(input);
565            }
566        })
567    };
568
569    let on_focus_cb = { Callback::new(move |_e: FocusEvent| props.on_focus.call(())) };
570
571    let on_blur_cb = { Callback::new(move |_e: FocusEvent| props.on_blur.call(())) };
572
573    let (input_style1, input_style2): (&'static str, &'static str) = if props.double {
574        let flipped_style = Box::leak(Box::new(format!(
575            "{}; transform: rotate(0deg); direction: rtl; z-index: 3; position: relative; flex: 1;",
576            props.input_style
577        )));
578        let normal_style = Box::leak(Box::new(format!(
579            "{}; z-index: 2; position: relative; flex: 1;",
580            props.input_style
581        )));
582        (flipped_style, normal_style)
583    } else {
584        (props.input_style, props.input_style)
585    };
586
587    let orientation_attr = if props.orientation.is_vertical() {
588        "vertical"
589    } else {
590        "horizontal"
591    };
592
593    let steps_component = if props.show_steps {
594        rsx! {
595            Ticks { id: list_id().clone(), min: props.min, max: props.max, step: props.step }
596            Steps {
597                min: props.min,
598                max: props.max,
599                step: props.step,
600                steps_style: props.steps_style,
601                orientation: props.orientation.clone()
602            }
603        }
604    } else {
605        rsx! {}
606    };
607
608    let double_input = if props.double {
609        rsx! {
610            Input {
611                input_ref: input_ref2,
612                min: props.min,
613                max: props.max,
614                step: props.step,
615                value: val2(),
616                orientation: props.orientation.clone(),
617                disabled: props.disabled,
618                size: props.size.clone(),
619                color: props.color.clone(),
620                cursor_style: props.cursor_style.clone(),
621                input_class: props.input_class,
622                input_style: input_style2,
623                on_input: on_input2,
624                on_focus: on_focus_cb,
625                on_blur: on_blur_cb,
626                datalist_id: Some(list_id().clone()),
627                aria_label: props.aria_label,
628                aria_describedby: props.aria_describedby,
629                width: props.slider_width.clone(),
630                height: props.slider_height.clone(),
631                custom_thumb_css: props.custom_thumb_css,
632                custom_thumb_html: props.custom_thumb_html.clone(),
633                keyboard_step: props.keyboard_step,
634            }
635        }
636    } else {
637        rsx! {}
638    };
639
640    let value_display = if props.show_value {
641        rsx! {
642            Output {
643                value_display: format!("{:.1}", val1()),
644                output_class: props.output_class,
645                output_style: props.output_style,
646                tooltip_style: props.tooltip_style,
647                show_tooltip: props.show_tooltip,
648                tooltip_left: format!("{:.2}%", ((val1() - props.min) / (props.max - props.min)) * 100.0),
649            }
650        }
651    } else {
652        rsx! {}
653    };
654
655    let input_group = if props.orientation.is_vertical() {
656        rsx! {
657            div {
658                style: "display: flex; flex-direction: row; align-items: flex-start;",
659                {props.icon_start.unwrap_or(rsx!{})}
660                Input {
661                    input_ref: input_ref1,
662                    min: props.min,
663                    max: props.max,
664                    step: props.step,
665                    value: val1(),
666                    orientation: props.orientation.clone(),
667                    disabled: props.disabled,
668                    size: props.size,
669                    color: props.color,
670                    cursor_style: props.cursor_style,
671                    input_class: props.input_class,
672                    input_style: input_style1,
673                    on_input: on_input1,
674                    on_focus: on_focus_cb,
675                    on_blur: on_blur_cb,
676                    datalist_id: Some(list_id()),
677                    aria_label: props.aria_label,
678                    aria_describedby: props.aria_describedby,
679                    width: props.slider_width,
680                    height: props.slider_height,
681                    custom_thumb_css: props.custom_thumb_css,
682                    custom_thumb_html: props.custom_thumb_html,
683                    keyboard_step: props.keyboard_step,
684                }
685                {double_input}
686                {props.icon_end.unwrap_or(rsx!{})}
687                {steps_component}
688            }
689        }
690    } else if props.double {
691        rsx! {
692            div {
693                style: "position: relative; width: 100%; display: flex; align-items: center;",
694                {props.icon_start.unwrap_or(rsx!{})}
695                Input {
696                    input_ref: input_ref1,
697                    min: props.min,
698                    max: props.max,
699                    step: props.step,
700                    value: val1(),
701                    orientation: props.orientation.clone(),
702                    disabled: props.disabled,
703                    size: props.size,
704                    color: props.color.clone(),
705                    cursor_style: props.cursor_style.clone(),
706                    input_class: props.input_class,
707                    input_style: input_style1,
708                    on_input: on_input1,
709                    on_focus: on_focus_cb,
710                    on_blur: on_blur_cb,
711                    datalist_id: Some(list_id().clone()),
712                    aria_label: props.aria_label,
713                    aria_describedby: props.aria_describedby,
714                    width: props.slider_width.clone(),
715                    height: props.slider_height.clone(),
716                    custom_thumb_css: props.custom_thumb_css,
717                    custom_thumb_html: props.custom_thumb_html.clone(),
718                    keyboard_step: props.keyboard_step,
719                }
720                {double_input}
721                {props.icon_end.clone().unwrap_or(rsx!{})}
722            }
723        }
724    } else {
725        rsx! {
726            div {
727                style: "display: flex; align-items: center; width: 100%;",
728                {props.icon_start.clone().unwrap_or(rsx!{})}
729                Input {
730                    input_ref: input_ref1,
731                    min: props.min,
732                    max: props.max,
733                    step: props.step,
734                    value: val1(),
735                    orientation: props.orientation.clone(),
736                    disabled: props.disabled,
737                    size: props.size.clone(),
738                    color: props.color.clone(),
739                    cursor_style: props.cursor_style.clone(),
740                    input_class: props.input_class,
741                    input_style: input_style1,
742                    on_input: on_input1,
743                    on_focus: on_focus_cb,
744                    on_blur: on_blur_cb,
745                    datalist_id: Some(list_id().clone()),
746                    aria_label: props.aria_label,
747                    aria_describedby: props.aria_describedby,
748                    width: props.slider_width.clone(),
749                    height: props.slider_height.clone(),
750                    custom_thumb_css: props.custom_thumb_css,
751                    custom_thumb_html: props.custom_thumb_html.clone(),
752                    keyboard_step: props.keyboard_step,
753                }
754                {props.icon_end.clone().unwrap_or(rsx!{})}
755            }
756        }
757    };
758
759    let horizontal_steps = if props.show_steps && !props.orientation.is_vertical() {
760        rsx! {
761            Steps {
762                min: props.min,
763                max: props.max,
764                step: props.step,
765                steps_style: props.steps_style,
766                orientation: props.orientation.clone()
767            }
768        }
769    } else {
770        rsx! {}
771    };
772
773    rsx! {
774        div {
775            class: "{props.container_class}",
776            style: "{props.container_style}",
777            role: "group",
778            aria_orientation: "{orientation_attr}",
779            aria_disabled: "{props.disabled}",
780            Label {
781                label: props.label,
782                label_class: props.label_class,
783                label_style: props.label_style
784            }
785            {input_group}
786            Ticks { id: list_id().clone(), min: props.min, max: props.max, step: props.step }
787            {value_display}
788            {horizontal_steps}
789        }
790    }
791}