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#[derive(PartialEq, Clone, Props)]
308pub struct SliderProps {
309 #[props(default)]
311 pub label: &'static str,
312
313 #[props(default = 0.0)]
315 pub min: f64,
316
317 #[props(default = 10.0)]
319 pub max: f64,
320
321 #[props(default = 1.0)]
323 pub step: f64,
324
325 #[props(default)]
327 pub value: Option<f64>,
328
329 #[props(default)]
331 pub range: Option<(f64, f64)>,
332
333 #[props(default = false)]
335 pub double: bool,
336
337 #[props(default)]
339 pub orientation: Orientation,
340
341 #[props(default)]
343 pub size: Size,
344
345 #[props(default)]
347 pub color: Color,
348
349 #[props(default)]
351 pub cursor_style: Cursor,
352
353 #[props(default = false)]
355 pub show_value: bool,
356
357 #[props(default = false)]
359 pub show_steps: bool,
360
361 #[props(default = false)]
363 pub show_tooltip: bool,
364
365 #[props(default = false)]
367 pub disabled: bool,
368
369 #[props(default)]
371 pub on_change: Callback<f64>,
372
373 #[props(default)]
375 pub on_change_range: Callback<(f64, f64)>,
376
377 #[props(default)]
379 pub on_focus: Callback<()>,
380
381 #[props(default)]
383 pub on_blur: Callback<()>,
384
385 #[props(default)]
387 pub aria_label: Option<&'static str>,
388
389 #[props(default)]
391 pub aria_describedby: Option<&'static str>,
392
393 #[props(default = "slider-container")]
395 pub container_class: &'static str,
396
397 #[props(
399 default = "display: flex; flex-direction: column; align-items: center; margin: 20px; position: relative;"
400 )]
401 pub container_style: &'static str,
402
403 #[props(default = "slider-label")]
405 pub label_class: &'static str,
406
407 #[props(default = "font-size: 14px; margin-bottom: 8px;")]
409 pub label_style: &'static str,
410
411 #[props(default = "slider-input")]
413 pub input_class: &'static str,
414
415 #[props(default = "border-radius: 8px; appearance: none; outline: none;")]
417 pub input_style: &'static str,
418
419 #[props(default = "slider-output")]
421 pub output_class: &'static str,
422
423 #[props(default = "font-size: 12px; margin-top: 8px;")]
425 pub output_style: &'static str,
426
427 #[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 #[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 #[props(default)]
441 pub slider_width: Width,
442
443 #[props(default)]
445 pub slider_height: Height,
446
447 #[props(default)]
449 pub custom_thumb_css: Option<&'static str>,
450
451 #[props(default)]
453 pub custom_thumb_html: Option<Element>,
454
455 #[props(default = 1.0)]
457 pub keyboard_step: f64,
458
459 #[props(default)]
461 pub icon_start: Option<Element>,
462
463 #[props(default)]
465 pub icon_end: Option<Element>,
466}
467
468#[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}