adui_dioxus/components/
slider.rs

1use crate::components::config_provider::use_config;
2use crate::components::form::use_form_item_control;
3#[cfg(target_arch = "wasm32")]
4use crate::components::interaction::start_pointer;
5use crate::components::interaction::{
6    PointerState, as_pointer_event, end_pointer, is_active_pointer,
7};
8#[cfg(target_arch = "wasm32")]
9use crate::components::slider_base::ratio_from_pointer_event;
10#[cfg(target_arch = "wasm32")]
11use crate::components::slider_base::ratio_to_value;
12use crate::components::slider_base::{
13    SliderMath, SliderOrientation, apply_keyboard_action, keyboard_action_for_key, snap_value,
14    value_to_ratio,
15};
16use dioxus::events::{KeyboardEvent, PointerData};
17use dioxus::prelude::*;
18use serde_json::{Number, Value};
19use std::rc::Rc;
20#[cfg(target_arch = "wasm32")]
21use wasm_bindgen::JsCast;
22
23/// A labeled mark rendered along the slider track.
24#[derive(Clone, PartialEq)]
25pub struct SliderMark {
26    pub value: f64,
27    pub label: String,
28}
29
30/// Supported slider value shapes (single vs. range).
31#[derive(Clone, PartialEq, Debug)]
32pub enum SliderValue {
33    Single(f64),
34    Range(f64, f64),
35}
36
37impl SliderValue {
38    pub fn as_single(&self) -> f64 {
39        match *self {
40            SliderValue::Single(v) => v,
41            SliderValue::Range(_, end) => end,
42        }
43    }
44
45    pub fn as_range(&self) -> (f64, f64) {
46        match *self {
47            SliderValue::Single(v) => (v, v),
48            SliderValue::Range(start, end) => (start, end),
49        }
50    }
51
52    fn ensure_range(self) -> Self {
53        match self {
54            SliderValue::Single(v) => SliderValue::Range(v, v),
55            SliderValue::Range(start, end) => {
56                if start <= end {
57                    SliderValue::Range(start, end)
58                } else {
59                    SliderValue::Range(end, start)
60                }
61            }
62        }
63    }
64}
65
66/// Slider component supporting single and range values.
67#[derive(Props, Clone, PartialEq)]
68pub struct SliderProps {
69    /// Controlled value; use `Range(_, _)` when `range = true`.
70    #[props(optional)]
71    pub value: Option<SliderValue>,
72    /// Default value in uncontrolled mode.
73    #[props(optional)]
74    pub default_value: Option<SliderValue>,
75    /// Whether to render two handles.
76    #[props(default)]
77    pub range: bool,
78    #[props(default = 0.0)]
79    pub min: f64,
80    #[props(default = 100.0)]
81    pub max: f64,
82    /// Step granularity; when `None` the slider is continuous.
83    #[props(optional)]
84    pub step: Option<f64>,
85    /// Decimal precision used for snapping.
86    #[props(optional)]
87    pub precision: Option<u32>,
88    /// Reverse the visual direction (RTL or top-down).
89    #[props(default)]
90    pub reverse: bool,
91    /// Vertical orientation.
92    #[props(default)]
93    pub vertical: bool,
94    /// Disable interactions.
95    #[props(default)]
96    pub disabled: bool,
97    /// Render tick dots for marks.
98    #[props(default)]
99    pub dots: bool,
100    /// Optional labeled marks along the track.
101    #[props(optional)]
102    pub marks: Option<Vec<SliderMark>>,
103    #[props(optional)]
104    pub class: Option<String>,
105    #[props(optional)]
106    pub style: Option<String>,
107    /// Fired on every value change (drag/keyboard/track click).
108    #[props(optional)]
109    pub on_change: Option<EventHandler<SliderValue>>,
110    /// Fired when user finishes interaction (pointer up / Enter / blur).
111    #[props(optional)]
112    pub on_change_complete: Option<EventHandler<SliderValue>>,
113}
114
115#[component]
116pub fn Slider(props: SliderProps) -> Element {
117    let SliderProps {
118        value,
119        default_value,
120        range,
121        min,
122        max,
123        step,
124        precision,
125        reverse,
126        vertical,
127        disabled,
128        dots,
129        marks,
130        class,
131        style,
132        on_change,
133        on_change_complete,
134    } = props;
135
136    let math = SliderMath {
137        min,
138        max,
139        step,
140        precision,
141        reverse,
142        orientation: if vertical {
143            SliderOrientation::Vertical
144        } else {
145            SliderOrientation::Horizontal
146        },
147    };
148
149    let config = use_config();
150    let form_control = use_form_item_control();
151    let controlled_by_prop = value.is_some();
152
153    let initial_value = normalize_value(
154        range,
155        value
156            .clone()
157            .or_else(|| {
158                form_control
159                    .as_ref()
160                    .and_then(|ctx| slider_value_from_form(ctx.value(), range))
161            })
162            .or(default_value.clone())
163            .unwrap_or_else(|| default_slider_value(range, &math)),
164        &math,
165    );
166
167    let current = use_signal(|| initial_value.clone());
168    let mut active_handle = use_signal(|| None::<usize>);
169    let active_pointer = use_signal::<PointerState>(PointerState::default);
170    // Sync to controlled/form value changes.
171    {
172        let mut current_signal = current.clone();
173        let form_ctx = form_control.clone();
174        let value_prop = value.clone();
175        let default_val = default_value.clone();
176        use_effect(move || {
177            let next = normalize_value(
178                range,
179                value_prop
180                    .clone()
181                    .or_else(|| {
182                        form_ctx
183                            .as_ref()
184                            .and_then(|ctx| slider_value_from_form(ctx.value(), range))
185                    })
186                    .or(default_val.clone())
187                    .unwrap_or_else(|| default_slider_value(range, &math)),
188                &math,
189            );
190            current_signal.set(next);
191        });
192    }
193
194    let is_disabled =
195        disabled || config.disabled || form_control.as_ref().is_some_and(|ctx| ctx.is_disabled());
196
197    let mut class_list = vec!["adui-slider".to_string()];
198    if is_disabled {
199        class_list.push("adui-slider-disabled".into());
200    }
201    if vertical {
202        class_list.push("adui-slider-vertical".into());
203    }
204    if dots {
205        class_list.push("adui-slider-dots".into());
206    }
207    if let Some(extra) = class {
208        class_list.push(extra);
209    }
210    let class_attr = class_list.join(" ");
211    let style_attr = style.unwrap_or_default();
212
213    let on_change_cb = on_change;
214    let on_change_complete_cb = on_change_complete;
215    let form_ctx_for_apply = form_control.clone();
216    let current_for_apply = current.clone();
217
218    let apply_value = move |next: SliderValue, fire_change: bool| {
219        let normalized = normalize_value(range, next, &math);
220        if !controlled_by_prop {
221            let mut state = current_for_apply;
222            state.set(normalized.clone());
223        }
224
225        if let Some(ctx) = form_ctx_for_apply.as_ref() {
226            ctx.set_value(slider_value_to_form(&normalized));
227        }
228
229        if fire_change {
230            if let Some(cb) = on_change_cb.as_ref() {
231                cb.call(normalized.clone());
232            }
233        }
234        normalized
235    };
236
237    let handle_pointer_move = {
238        #[allow(unused_variables)]
239        let current_for_move = current.clone();
240        #[allow(unused_variables)]
241        let active_handle_for_move = active_handle.clone();
242        #[allow(unused_variables)]
243        let active_pointer_for_move = active_pointer.clone();
244        #[allow(unused_variables)]
245        let apply_for_move = apply_value.clone();
246        move |evt: Event<PointerData>| {
247            #[cfg(target_arch = "wasm32")]
248            {
249                if is_disabled {
250                    return;
251                }
252                let Some(pevt) = as_pointer_event(&evt) else {
253                    return;
254                };
255                if !is_active_pointer(&active_pointer_for_move, &pevt) {
256                    return;
257                }
258                let rect = pevt
259                    .current_target()
260                    .and_then(|t| t.dyn_into::<web_sys::Element>().ok())
261                    .map(|el| el.get_bounding_client_rect());
262                let Some(rect) = rect else {
263                    return;
264                };
265                let Some(ratio) = pointer_ratio(&pevt, &rect, &math) else {
266                    return;
267                };
268                let target_value = ratio_to_value(ratio, &math);
269
270                let handle_idx = active_handle_for_move.read().unwrap_or(0);
271                let next = update_handle_value(
272                    &current_for_move.read(),
273                    handle_idx,
274                    target_value,
275                    range,
276                    &math,
277                );
278                apply_for_move(next, true);
279            }
280            #[cfg(not(target_arch = "wasm32"))]
281            {
282                let _ = evt;
283            }
284        }
285    };
286
287    let mut pointer_state_for_up = active_pointer.clone();
288    let mut active_handle_for_up = active_handle.clone();
289    let current_for_up = current.clone();
290    let on_change_complete_for_up = on_change_complete_cb.clone();
291
292    let handle_pointer_up = move |evt: Event<PointerData>| {
293        let Some(pevt) = as_pointer_event(&evt) else {
294            return;
295        };
296        if !is_active_pointer(&pointer_state_for_up, &pevt) {
297            return;
298        }
299        end_pointer(&mut pointer_state_for_up, &pevt);
300        active_handle_for_up.set(None);
301        if let Some(cb) = on_change_complete_for_up.as_ref() {
302            cb.call(current_for_up.read().clone());
303        }
304    };
305
306    let apply_for_key = apply_value.clone();
307
308    let handle_track_pointer_down = {
309        #[allow(unused_variables)]
310        let apply_for_track = apply_value.clone();
311        move |evt: Event<PointerData>| {
312            #[cfg(target_arch = "wasm32")]
313            {
314                if is_disabled {
315                    return;
316                }
317                let Some(pevt) = as_pointer_event(&evt) else {
318                    return;
319                };
320                let rect = pevt
321                    .current_target()
322                    .and_then(|t| t.dyn_into::<web_sys::Element>().ok())
323                    .map(|el| el.get_bounding_client_rect());
324                let Some(rect) = rect else {
325                    return;
326                };
327                let Some(ratio) = pointer_ratio(&pevt, &rect, &math) else {
328                    return;
329                };
330                let target_value = ratio_to_value(ratio, &math);
331
332                let current_value = current.read();
333                let handle_idx = choose_handle(&current_value, target_value);
334                active_handle.set(Some(handle_idx));
335                let mut pointer_for_down = active_pointer.clone();
336                start_pointer(&mut pointer_for_down, &pevt);
337
338                let next =
339                    update_handle_value(&current_value, handle_idx, target_value, range, &math);
340                let normalized = apply_for_track(next, true);
341                if let Some(cb) = on_change_complete_cb.as_ref() {
342                    cb.call(normalized);
343                }
344            }
345            #[cfg(not(target_arch = "wasm32"))]
346            {
347                let _ = evt;
348            }
349        }
350    };
351
352    let handle_key: Rc<dyn Fn(usize, KeyboardEvent)> = Rc::new({
353        let current_signal = current.clone();
354        move |idx: usize, evt: KeyboardEvent| {
355            if is_disabled {
356                return;
357            }
358            if let Some(action) = keyboard_action_for_key(&evt.key(), math.reverse) {
359                let current_value = current_signal.read();
360                let handle_value = match *current_value {
361                    SliderValue::Single(v) => v,
362                    SliderValue::Range(start, end) => {
363                        if idx == 0 {
364                            start
365                        } else {
366                            end
367                        }
368                    }
369                };
370                let stepped = apply_keyboard_action(handle_value, action, &math);
371                let next = update_handle_value(&current_value, idx, stepped, range, &math);
372                apply_for_key(next, true);
373            }
374        }
375    });
376
377    let value_now = current.read().clone();
378    let (ratios, handle_values): (Vec<f64>, Vec<f64>) = match value_now {
379        SliderValue::Single(v) => (vec![value_to_ratio(v, &math)], vec![v]),
380        SliderValue::Range(a, b) => (
381            vec![value_to_ratio(a, &math), value_to_ratio(b, &math)],
382            vec![a, b],
383        ),
384    };
385    let track_range = track_range_style(&ratios, math.orientation);
386
387    let marks_view = marks.map(|items| {
388        let dots_enabled = dots;
389        items
390            .into_iter()
391            .map(|mark| {
392                let pos = value_to_ratio(mark.value, &math) * 100.0;
393                rsx! {
394                    div { class: "adui-slider-mark", style: match math.orientation {
395                        SliderOrientation::Vertical => format!("bottom:{pos:.2}%;"),
396                        SliderOrientation::Horizontal => format!("left:{pos:.2}%;"),
397                    },
398                        if dots_enabled {
399                            span { class: "adui-slider-dot" }
400                        }
401                        span { class: "adui-slider-mark-label", {mark.label} }
402                    }
403                }
404            })
405            .collect::<Vec<_>>()
406    });
407
408    rsx! {
409        div {
410            class: "{class_attr}",
411            style: "{style_attr}",
412            onpointerdown: handle_track_pointer_down,
413            onpointermove: handle_pointer_move,
414            onpointerup: handle_pointer_up,
415            onpointercancel: handle_pointer_up,
416            onpointerleave: handle_pointer_up,
417            div { class: "adui-slider-rail" }
418            div { class: "adui-slider-track", style: "{track_range}" }
419            {handles_view(&ratios, &handle_values, range, math, is_disabled, handle_key.clone())}
420            if let Some(marks) = marks_view {
421                div { class: "adui-slider-marks",
422                    for mark in marks {
423                        {mark}
424                    }
425                }
426            }
427        }
428    }
429}
430
431fn handles_view(
432    ratios: &[f64],
433    values: &[f64],
434    range: bool,
435    math: SliderMath,
436    disabled: bool,
437    on_key: Rc<dyn Fn(usize, KeyboardEvent)>,
438) -> Element {
439    let count = if range {
440        ratios.len()
441    } else {
442        1.min(ratios.len())
443    };
444    let iter = ratios.iter().zip(values.iter()).take(count).enumerate();
445    rsx! {
446        Fragment {
447            for (idx, (ratio, value_now)) in iter {
448                button {
449                    class: "adui-slider-handle",
450                    role: "slider",
451                    tabindex: 0,
452                    aria_disabled: disabled,
453                    aria_valuemin: math.min,
454                    aria_valuemax: math.max,
455                    aria_valuenow: *value_now,
456                    style: "{handle_position_style(*ratio, math.orientation)}",
457                    onkeydown: { let cb = on_key.clone(); move |evt| cb(idx, evt) },
458                }
459            }
460        }
461    }
462}
463
464fn track_range_style(ratios: &[f64], orientation: SliderOrientation) -> String {
465    let (start, end) = if ratios.len() >= 2 {
466        let a = ratios[0];
467        let b = ratios[1];
468        (a.min(b), a.max(b))
469    } else {
470        (0.0, *ratios.get(0).unwrap_or(&0.0))
471    };
472    let start_pct = start * 100.0;
473    let length_pct = (end - start).abs() * 100.0;
474    match orientation {
475        SliderOrientation::Horizontal => {
476            format!("left:{start_pct:.2}%;width:{length_pct:.2}%;")
477        }
478        SliderOrientation::Vertical => {
479            format!("bottom:{start_pct:.2}%;height:{length_pct:.2}%;")
480        }
481    }
482}
483
484fn handle_position_style(ratio: f64, orientation: SliderOrientation) -> String {
485    let pct = (ratio * 100.0).clamp(0.0, 100.0);
486    match orientation {
487        SliderOrientation::Horizontal => format!("left:{pct:.2}%;"),
488        SliderOrientation::Vertical => format!("bottom:{pct:.2}%;"),
489    }
490}
491
492fn normalize_value(range: bool, value: SliderValue, math: &SliderMath) -> SliderValue {
493    let mut normalized = match value {
494        SliderValue::Single(v) => SliderValue::Single(snap_value(v, math)),
495        SliderValue::Range(a, b) => {
496            let a = snap_value(a, math);
497            let b = snap_value(b, math);
498            SliderValue::Range(a.min(b), a.max(b))
499        }
500    };
501
502    if range {
503        normalized = normalized.ensure_range();
504    } else {
505        normalized = SliderValue::Single(normalized.as_single());
506    }
507    normalized
508}
509
510fn default_slider_value(range: bool, math: &SliderMath) -> SliderValue {
511    if range {
512        SliderValue::Range(math.min, math.max)
513    } else {
514        SliderValue::Single(math.min)
515    }
516}
517
518#[allow(dead_code)]
519fn choose_handle(current: &SliderValue, target: f64) -> usize {
520    match current {
521        SliderValue::Single(_) => 0,
522        SliderValue::Range(a, b) => {
523            let dist_a = (target - *a).abs();
524            let dist_b = (target - *b).abs();
525            if dist_a <= dist_b { 0 } else { 1 }
526        }
527    }
528}
529
530fn update_handle_value(
531    current: &SliderValue,
532    handle_idx: usize,
533    target: f64,
534    range: bool,
535    math: &SliderMath,
536) -> SliderValue {
537    let next = match (range, current) {
538        (false, _) => SliderValue::Single(target),
539        (true, SliderValue::Range(start, end)) => {
540            if handle_idx == 0 {
541                SliderValue::Range(target, *end)
542            } else {
543                SliderValue::Range(*start, target)
544            }
545        }
546        (true, SliderValue::Single(v)) => {
547            if handle_idx == 0 {
548                SliderValue::Range(target, *v)
549            } else {
550                SliderValue::Range(*v, target)
551            }
552        }
553    };
554    normalize_value(range, next, math)
555}
556
557fn slider_value_from_form(val: Option<Value>, range: bool) -> Option<SliderValue> {
558    if range {
559        match val {
560            Some(Value::Array(items)) if items.len() >= 2 => {
561                let first = items.get(0).and_then(|v| v.as_f64())?;
562                let second = items.get(1).and_then(|v| v.as_f64())?;
563                Some(SliderValue::Range(first, second))
564            }
565            Some(Value::Number(n)) => n.as_f64().map(|v| SliderValue::Range(v, v)),
566            _ => None,
567        }
568    } else {
569        match val {
570            Some(Value::Number(n)) => n.as_f64().map(SliderValue::Single),
571            Some(Value::Array(items)) if !items.is_empty() => items
572                .get(0)
573                .and_then(|v| v.as_f64())
574                .map(SliderValue::Single),
575            Some(Value::String(s)) => s.parse::<f64>().ok().map(SliderValue::Single),
576            _ => None,
577        }
578    }
579}
580
581fn slider_value_to_form(value: &SliderValue) -> Value {
582    match value {
583        SliderValue::Single(v) => Number::from_f64(*v)
584            .map(Value::Number)
585            .unwrap_or(Value::Null),
586        SliderValue::Range(a, b) => Value::Array(vec![
587            Number::from_f64(*a)
588                .map(Value::Number)
589                .unwrap_or(Value::Null),
590            Number::from_f64(*b)
591                .map(Value::Number)
592                .unwrap_or(Value::Null),
593        ]),
594    }
595}
596
597#[cfg(target_arch = "wasm32")]
598#[allow(dead_code)]
599fn pointer_ratio(
600    evt: &web_sys::PointerEvent,
601    rect: &web_sys::DomRect,
602    math: &SliderMath,
603) -> Option<f64> {
604    ratio_from_pointer_event(evt, rect, math)
605}
606
607#[cfg(not(target_arch = "wasm32"))]
608#[allow(dead_code)]
609fn pointer_ratio(
610    _evt: &web_sys::PointerEvent,
611    _rect: &web_sys::DomRect,
612    _math: &SliderMath,
613) -> Option<f64> {
614    None
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620
621    #[test]
622    fn normalize_single_respects_bounds() {
623        let math = SliderMath {
624            min: 0.0,
625            max: 10.0,
626            step: Some(1.0),
627            precision: None,
628            reverse: false,
629            orientation: SliderOrientation::Horizontal,
630        };
631        let val = normalize_value(false, SliderValue::Single(11.2), &math);
632        assert_eq!(val.as_single(), 10.0);
633    }
634
635    #[test]
636    fn normalize_single_below_min() {
637        let math = SliderMath {
638            min: 5.0,
639            max: 10.0,
640            step: None,
641            precision: None,
642            reverse: false,
643            orientation: SliderOrientation::Horizontal,
644        };
645        let val = normalize_value(false, SliderValue::Single(3.0), &math);
646        assert_eq!(val.as_single(), 5.0);
647    }
648
649    #[test]
650    fn normalize_range_orders_and_snaps() {
651        let math = SliderMath {
652            min: 0.0,
653            max: 5.0,
654            step: Some(0.5),
655            precision: Some(1),
656            reverse: false,
657            orientation: SliderOrientation::Horizontal,
658        };
659        let val = normalize_value(true, SliderValue::Range(3.3, 1.0), &math);
660        assert_eq!(val.as_range(), (1.0, 3.5));
661    }
662
663    #[test]
664    fn normalize_range_out_of_bounds() {
665        let math = SliderMath {
666            min: 0.0,
667            max: 10.0,
668            step: Some(1.0),
669            precision: None,
670            reverse: false,
671            orientation: SliderOrientation::Horizontal,
672        };
673        let val = normalize_value(true, SliderValue::Range(-5.0, 15.0), &math);
674        let (start, end) = val.as_range();
675        assert_eq!(start, 0.0);
676        assert_eq!(end, 10.0);
677    }
678
679    #[test]
680    fn normalize_single_with_step() {
681        let math = SliderMath {
682            min: 0.0,
683            max: 10.0,
684            step: Some(2.0),
685            precision: None,
686            reverse: false,
687            orientation: SliderOrientation::Horizontal,
688        };
689        let val = normalize_value(false, SliderValue::Single(7.3), &math);
690        assert_eq!(val.as_single(), 8.0);
691    }
692
693    #[test]
694    fn normalize_single_with_precision() {
695        let math = SliderMath {
696            min: 0.0,
697            max: 10.0,
698            step: None,
699            precision: Some(2),
700            reverse: false,
701            orientation: SliderOrientation::Horizontal,
702        };
703        let val = normalize_value(false, SliderValue::Single(3.456789), &math);
704        assert_eq!(val.as_single(), 3.46);
705    }
706
707    #[test]
708    fn choose_handle_picks_nearest() {
709        let val = SliderValue::Range(10.0, 20.0);
710        assert_eq!(choose_handle(&val, 12.0), 0);
711        assert_eq!(choose_handle(&val, 18.0), 1);
712    }
713
714    #[test]
715    fn choose_handle_equal_distance() {
716        let val = SliderValue::Range(10.0, 20.0);
717        assert_eq!(choose_handle(&val, 15.0), 0);
718    }
719
720    #[test]
721    fn choose_handle_single_value() {
722        let val = SliderValue::Single(15.0);
723        assert_eq!(choose_handle(&val, 20.0), 0);
724    }
725
726    #[test]
727    fn slider_value_as_single() {
728        let single = SliderValue::Single(5.0);
729        assert_eq!(single.as_single(), 5.0);
730
731        let range = SliderValue::Range(10.0, 20.0);
732        assert_eq!(range.as_single(), 20.0);
733    }
734
735    #[test]
736    fn slider_value_as_range() {
737        let single = SliderValue::Single(5.0);
738        assert_eq!(single.as_range(), (5.0, 5.0));
739
740        let range = SliderValue::Range(10.0, 20.0);
741        assert_eq!(range.as_range(), (10.0, 20.0));
742    }
743
744    #[test]
745    fn slider_value_ensure_range() {
746        let single = SliderValue::Single(5.0);
747        let range = single.ensure_range();
748        assert_eq!(range.as_range(), (5.0, 5.0));
749
750        let reversed = SliderValue::Range(20.0, 10.0);
751        let fixed = reversed.ensure_range();
752        assert_eq!(fixed.as_range(), (10.0, 20.0));
753    }
754
755    #[test]
756    fn default_slider_value_single() {
757        let math = SliderMath {
758            min: 0.0,
759            max: 100.0,
760            step: None,
761            precision: None,
762            reverse: false,
763            orientation: SliderOrientation::Horizontal,
764        };
765        let val = default_slider_value(false, &math);
766        assert_eq!(val.as_single(), 0.0);
767    }
768
769    #[test]
770    fn default_slider_value_range() {
771        let math = SliderMath {
772            min: 0.0,
773            max: 100.0,
774            step: None,
775            precision: None,
776            reverse: false,
777            orientation: SliderOrientation::Horizontal,
778        };
779        let val = default_slider_value(true, &math);
780        assert_eq!(val.as_range(), (0.0, 100.0));
781    }
782}