1#![doc = include_str!("../YEW.md")]
2
3use crate::common::{Color, Cursor, Height, Orientation, Size, Width};
4use uuid::Uuid;
5use web_sys::{FocusEvent, HtmlInputElement, InputEvent, KeyboardEvent};
6use yew::prelude::*;
7
8#[derive(Properties, PartialEq)]
9pub struct LabelProps {
10 #[prop_or_default]
11 pub label: &'static str,
12 #[prop_or("font-size: 14px; margin-bottom: 8px; text-align: center;")]
13 pub label_style: &'static str,
14 #[prop_or("slider-label")]
15 pub label_class: &'static str,
16}
17
18#[function_component(Label)]
19fn slider_label(props: &LabelProps) -> Html {
20 html! { <label class={props.label_class} style={props.label_style}>{ props.label }</label> }
21}
22
23#[derive(Properties, PartialEq)]
24pub struct StepsProps {
25 #[prop_or(0.0)]
26 pub min: f64,
27 #[prop_or(10.0)]
28 pub max: f64,
29 #[prop_or(1.0)]
30 pub step: f64,
31 #[prop_or(
32 "width: 100%; display: flex; justify-content: space-between; margin-top: 8px; font-size: 10px;"
33 )]
34 pub steps_style: &'static str,
35 #[prop_or_default]
36 pub orientation: Orientation,
37}
38
39#[function_component(Steps)]
40fn slider_steps(props: &StepsProps) -> Html {
41 let count = ((props.max - props.min) / props.step).floor() as usize;
42
43 let steps = (0..=count)
44 .map(|i| {
45 let val = props.min + (i as f64 * props.step);
46 html! {
47 <span
48 style={if props.orientation.is_vertical() {
49 "margin: 4px 0; writing-mode: vertical-rl; text-align: center;"
50 } else {
51 "text-align: center;"
52 }}
53 >
54 { format!("{:.0}", val) }
55 </span>
56 }
57 })
58 .collect::<Html>();
59
60 let style = if props.orientation.is_vertical() {
61 "display: flex; flex-direction: column; align-items: center; height: 100%; font-size: 10px;"
62 } else {
63 props.steps_style
64 };
65
66 html! { <div style={style}>{ steps }</div> }
67}
68
69#[derive(Properties, PartialEq)]
70pub struct OutputProps {
71 #[prop_or_default]
72 pub value_display: String,
73 #[prop_or("font-size: 12px; margin-top: 8px; text-align: center;")]
74 pub output_style: &'static str,
75 #[prop_or("slider-output")]
76 pub output_class: &'static str,
77 #[prop_or(
78 "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;"
79 )]
80 pub tooltip_style: &'static str,
81 #[prop_or(false)]
82 pub show_tooltip: bool,
83 #[prop_or_default]
84 pub tooltip_left: String,
85}
86
87#[function_component(Output)]
88fn slider_output(props: &OutputProps) -> Html {
89 html! {
90 <>
91 <output class={props.output_class} style={props.output_style} aria-live="polite">
92 { &props.value_display }
93 </output>
94 { if props.show_tooltip {
95 let mut style = props.tooltip_style.to_string();
96 style.push_str(&format!(" left: {}; ", props.tooltip_left));
97 html! {
98 <div class={props.output_class} style={style}>
99 { &props.value_display }
100 </div>
101 }
102 } else {
103 html! {}
104 } }
105 </>
106 }
107}
108
109#[derive(Properties, PartialEq)]
110pub struct TicksProps {
111 #[prop_or_default]
112 pub id: String,
113 #[prop_or(0.0)]
114 pub min: f64,
115 #[prop_or(10.0)]
116 pub max: f64,
117 #[prop_or(1.0)]
118 pub step: f64,
119}
120
121#[function_component(Ticks)]
122fn slider_ticks(props: &TicksProps) -> Html {
123 let mut current = props.min;
124 let mut children = vec![];
125
126 while current <= props.max {
127 children.push(html! { <option value={current.to_string()} /> });
128 current += props.step;
129 }
130
131 html! { <datalist id={props.id.clone()}>{ for children }</datalist> }
132}
133
134#[derive(Properties, PartialEq)]
135pub struct InputProps {
136 #[prop_or_default]
137 pub input_ref: NodeRef,
138 #[prop_or(0.0)]
139 pub min: f64,
140 #[prop_or(10.0)]
141 pub max: f64,
142 #[prop_or(1.0)]
143 pub step: f64,
144 #[prop_or(0.0)]
145 pub value: f64,
146 #[prop_or_default]
147 pub orientation: Orientation,
148 #[prop_or_default]
149 pub size: Size,
150 #[prop_or_default]
151 pub width: Width,
152 #[prop_or_default]
153 pub height: Height,
154 #[prop_or_default]
155 pub color: Color,
156 #[prop_or_default]
157 pub cursor_style: Cursor,
158 #[prop_or(false)]
159 pub disabled: bool,
160 #[prop_or_default]
161 pub on_input: Callback<InputEvent>,
162 #[prop_or_default]
163 pub on_focus: Callback<FocusEvent>,
164 #[prop_or_default]
165 pub on_blur: Callback<FocusEvent>,
166 #[prop_or_default]
167 pub aria_label: Option<&'static str>,
168 #[prop_or_default]
169 pub aria_describedby: Option<&'static str>,
170 #[prop_or_default]
171 pub datalist_id: Option<String>,
172 #[prop_or("slider-input")]
173 pub input_class: &'static str,
174 #[prop_or("border-radius: 8px; appearance: none; outline: none;")]
175 pub input_style: &'static str,
176 #[prop_or(true)]
177 pub use_gradient: bool,
178 #[prop_or_default]
179 pub custom_thumb_css: Option<&'static str>,
180 #[prop_or_default]
181 pub custom_thumb_html: Option<Html>,
182 #[prop_or(1.0)]
183 pub keyboard_step: f64,
184 #[prop_or(false)]
185 pub rtl_fill: bool,
186}
187
188#[function_component(Input)]
189fn slider_input(props: &InputProps) -> Html {
190 let value_percent = ((props.value - props.min) / (props.max - props.min)) * 100.0;
191 let fill_color = props.color.to_color_code();
192 let gradient = if props.use_gradient {
193 if props.orientation.is_vertical() {
194 if props.rtl_fill {
195 format!(
196 "background: linear-gradient(to top, {} 0%, {} {:.2}%, #ccc {:.2}%, #ccc 100%);",
197 fill_color, fill_color, value_percent, value_percent
198 )
199 } else {
200 format!(
201 "background: linear-gradient(to bottom, {} 0%, {} {:.2}%, #ccc {:.2}%, #ccc 100%);",
202 fill_color, fill_color, value_percent, value_percent
203 )
204 }
205 } else if props.rtl_fill {
206 format!(
207 "background: linear-gradient(to left, {} 0%, {} {:.2}%, #ccc {:.2}%, #ccc 100%);",
208 fill_color, fill_color, value_percent, value_percent
209 )
210 } else {
211 format!(
212 "background: linear-gradient(to right, {} 0%, {} {:.2}%, #ccc {:.2}%, #ccc 100%);",
213 fill_color, fill_color, value_percent, value_percent
214 )
215 }
216 } else {
217 format!("background: {};", fill_color)
218 };
219
220 let base_style = format!(
221 "cursor: pointer; transition: background 0.3s; {} {} {} {} {} {}",
222 props.input_style,
223 props.width.to_style(),
224 props.height.to_style(),
225 gradient,
226 props.orientation.to_style(),
227 props.size.to_style(),
228 );
229
230 let thumb_style = props.custom_thumb_css.unwrap_or("");
231
232 let on_key_down = {
233 let on_input = props.on_input.clone();
234 let input_ref = props.input_ref.clone();
235 let keyboard_step = props.keyboard_step;
236 let min = props.min;
237 let max = props.max;
238 Callback::from(move |e: KeyboardEvent| {
239 if let Some(input) = input_ref.cast::<HtmlInputElement>() {
240 let current = input.value().parse::<f64>().unwrap_or(0.0);
241 let new_val = match e.key().as_str() {
242 "ArrowLeft" | "ArrowDown" => current - keyboard_step,
243 "ArrowRight" | "ArrowUp" => current + keyboard_step,
244 _ => current,
245 }
246 .clamp(min, max);
247 input.set_value(&new_val.to_string());
248
249 on_input.emit(InputEvent::new("input").unwrap());
250 }
251 })
252 };
253
254 html! {
255 <>
256 <input
257 ref={props.input_ref.clone()}
258 type="range"
259 class={props.input_class}
260 min={props.min.to_string()}
261 max={props.max.to_string()}
262 step={if props.step == 0.0 { "any".to_string() } else { props.step.to_string() }}
263 value={props.value.to_string()}
264 list={props.datalist_id.clone()}
265 oninput={props.on_input.clone()}
266 onfocus={props.on_focus.clone()}
267 onblur={props.on_blur.clone()}
268 onkeydown={on_key_down}
269 style={format!("{} {}", base_style, thumb_style)}
270 orient={props.orientation.to_orient()}
271 disabled={props.disabled}
272 aria-valuemin={props.min.to_string()}
273 aria-valuemax={props.max.to_string()}
274 aria-valuenow={props.value.to_string()}
275 aria-orientation={if props.orientation.is_vertical() { "vertical" } else { "horizontal" }}
276 aria-disabled={props.disabled.to_string()}
277 aria-label={props.aria_label.unwrap_or_default()}
278 aria-describedby={props.aria_describedby.unwrap_or_default()}
279 />
280 { props.custom_thumb_html.clone().unwrap_or(html! {}) }
281 </>
282 }
283}
284
285#[derive(Properties, PartialEq, Clone)]
297pub struct Props {
298 #[prop_or_default]
300 pub label: &'static str,
301
302 #[prop_or(0.0)]
304 pub min: f64,
305
306 #[prop_or(10.0)]
308 pub max: f64,
309
310 #[prop_or(1.0)]
312 pub step: f64,
313
314 #[prop_or_default]
316 pub value: Option<f64>,
317
318 #[prop_or_default]
320 pub range: Option<(f64, f64)>,
321
322 #[prop_or(false)]
324 pub double: bool,
325
326 #[prop_or_default]
328 pub orientation: Orientation,
329
330 #[prop_or_default]
332 pub size: Size,
333
334 #[prop_or_default]
336 pub color: Color,
337
338 #[prop_or_default]
340 pub cursor_style: Cursor,
341
342 #[prop_or(false)]
344 pub show_value: bool,
345
346 #[prop_or(false)]
348 pub show_steps: bool,
349
350 #[prop_or(false)]
352 pub show_tooltip: bool,
353
354 #[prop_or(false)]
356 pub disabled: bool,
357
358 #[prop_or_default]
360 pub on_change: Callback<f64>,
361
362 #[prop_or_default]
364 pub on_change_range: Callback<(f64, f64)>,
365
366 #[prop_or_default]
368 pub on_focus: Callback<()>,
369
370 #[prop_or_default]
372 pub on_blur: Callback<()>,
373
374 #[prop_or_default]
376 pub aria_label: Option<&'static str>,
377
378 #[prop_or_default]
380 pub aria_describedby: Option<&'static str>,
381
382 #[prop_or("slider-container")]
384 pub container_class: &'static str,
385
386 #[prop_or(
388 "display: flex; flex-direction: column; align-items: center; margin: 20px; position: relative;"
389 )]
390 pub container_style: &'static str,
391
392 #[prop_or("slider-label")]
394 pub label_class: &'static str,
395
396 #[prop_or("font-size: 14px; margin-bottom: 8px;")]
398 pub label_style: &'static str,
399
400 #[prop_or("slider-input")]
402 pub input_class: &'static str,
403
404 #[prop_or("border-radius: 8px; appearance: none; outline: none;")]
406 pub input_style: &'static str,
407
408 #[prop_or("slider-output")]
410 pub output_class: &'static str,
411
412 #[prop_or("font-size: 12px; margin-top: 8px;")]
414 pub output_style: &'static str,
415
416 #[prop_or(
418 "background-color: #333; color: #fff; padding: 4px 8px; border-radius: 4px; font-size: 12px; display: none;"
419 )]
420 pub tooltip_style: &'static str,
421
422 #[prop_or(
424 "width: 100%; display: flex; justify-content: space-between; margin-top: 8px; font-size: 10px;"
425 )]
426 pub steps_style: &'static str,
427
428 #[prop_or_default]
430 pub slider_width: Width,
431
432 #[prop_or_default]
434 pub slider_height: Height,
435
436 #[prop_or_default]
438 pub custom_thumb_css: Option<&'static str>,
439
440 #[prop_or_default]
442 pub custom_thumb_html: Option<Html>,
443
444 #[prop_or(1.0)]
446 pub keyboard_step: f64,
447
448 #[prop_or_default]
450 pub icon_start: Option<Html>,
451
452 #[prop_or_default]
454 pub icon_end: Option<Html>,
455}
456
457#[function_component(Slider)]
525pub fn slider(props: &Props) -> Html {
526 let input_ref1 = use_node_ref();
527 let input_ref2 = use_node_ref();
528 let (initial_val1, initial_val2) = props.range.unwrap_or((props.min, props.max));
529 let val1 = use_state(|| initial_val1);
530 let val2 = use_state(|| initial_val2);
531
532 let list_id = format!("slider-list-{}", Uuid::new_v4());
533
534 let update_range = {
535 let val1 = val1.clone();
536 let val2 = val2.clone();
537 let on_change_range = props.on_change_range.clone();
538 let on_change = props.on_change.clone();
539 Callback::from(move |_| {
540 on_change_range.emit((*val1, *val2));
541 on_change.emit(*val1);
542 })
543 };
544
545 let on_input1 = {
546 let val1 = val1.clone();
547 let update_range = update_range.clone();
548 let on_change = props.on_change.clone();
549 Callback::from(move |e: InputEvent| {
550 if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
551 if let Ok(v) = input.value().parse::<f64>() {
552 val1.set(v);
553 update_range.emit(());
554 on_change.emit(v);
555 }
556 }
557 })
558 };
559
560 let on_input2 = {
561 let val2 = val2.clone();
562 let update_range = update_range.clone();
563 let on_change = props.on_change.clone();
564 Callback::from(move |e: InputEvent| {
565 if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
566 if let Ok(v) = input.value().parse::<f64>() {
567 val2.set(v);
568 update_range.emit(());
569 on_change.emit(v);
570 }
571 }
572 })
573 };
574
575 let on_focus_cb = {
576 let cb = props.on_focus.clone();
577 Callback::from(move |_| cb.emit(()))
578 };
579
580 let on_blur_cb = {
581 let cb = props.on_blur.clone();
582 Callback::from(move |_| cb.emit(()))
583 };
584
585 let (input_style1, input_style2): (&'static str, &'static str) = if props.double {
586 let flipped_style = Box::leak(Box::new(format!(
587 "{}; transform: rotate(0deg); direction: rtl; z-index: 3; position: relative; flex: 1;",
588 props.input_style
589 )));
590 let normal_style = Box::leak(Box::new(format!(
591 "{}; z-index: 2; position: relative; flex: 1;",
592 props.input_style
593 )));
594 (flipped_style, normal_style)
595 } else {
596 (props.input_style, props.input_style)
597 };
598
599 html! {
600 <div
601 class={props.container_class}
602 style={props.container_style}
603 role="group"
604 aria-orientation={if props.orientation.is_vertical() { "vertical" } else { "horizontal" }}
605 aria-disabled={props.disabled.to_string()}
606 >
607 <Label
608 label={props.label}
609 label_class={props.label_class}
610 label_style={props.label_style}
611 />
612 { if props.orientation.is_vertical() {
613 html! {
614 <div style="display: flex; flex-direction: row; align-items: flex-start;">
615 { props.icon_start.clone().unwrap_or_default() }
616 <Input
617 input_ref={input_ref1}
618 min={props.min}
619 max={props.max}
620 step={props.step}
621 value={*val1}
622 orientation={props.orientation.clone()}
623 disabled={props.disabled}
624 size={props.size.clone()}
625 color={props.color.clone()}
626 cursor_style={props.cursor_style.clone()}
627 input_class={props.input_class}
628 on_input={on_input1}
629 on_focus={on_focus_cb.clone()}
630 on_blur={on_blur_cb.clone()}
631 datalist_id={Some(list_id.clone())}
632 aria_label={props.aria_label}
633 aria_describedby={props.aria_describedby}
634 width={props.slider_width.clone()}
635 height={props.slider_height.clone()}
636 input_style={input_style1}
637 custom_thumb_css={props.custom_thumb_css}
638 custom_thumb_html={props.custom_thumb_html.clone()}
639 keyboard_step={props.keyboard_step}
640 />
641 {
642 if props.double {
643 html! {
644 <Input
645 input_ref={input_ref2}
646 min={props.min}
647 max={props.max}
648 step={props.step}
649 value={*val2}
650 orientation={props.orientation.clone()}
651 disabled={props.disabled}
652 size={props.size.clone()}
653 color={props.color.clone()}
654 cursor_style={props.cursor_style.clone()}
655 input_class={props.input_class}
656 on_input={on_input2}
657 on_focus={on_focus_cb}
658 on_blur={on_blur_cb}
659 datalist_id={Some(list_id.clone())}
660 aria_label={props.aria_label}
661 aria_describedby={props.aria_describedby}
662 width={props.slider_width.clone()}
663 height={props.slider_height.clone()}
664 input_style={input_style2}
665 custom_thumb_css={props.custom_thumb_css}
666 custom_thumb_html={props.custom_thumb_html.clone()}
667 keyboard_step={props.keyboard_step}
668 />
669 }
670 } else {
671 html! {}
672 }
673 }
674 { props.icon_end.clone().unwrap_or_default() }
675 {if props.show_steps {
676 html! {
677 <>
678 <Ticks id={list_id.clone()} min={props.min} max={props.max} step={props.step} />
679 <Steps
680 min={props.min}
681 max={props.max}
682 step={props.step}
683 steps_style={props.steps_style}
684 orientation={props.orientation.clone()}
685 />
686 </>
687 }
688 }
689 else {
690 html!{}
691 }
692 }
693 </div>
694 }
695 } else if props.double {
696 html! {
697 <div style="position: relative; width: 100%; display: flex; align-items: center;">
698 { props.icon_start.clone().unwrap_or_default() }
699 <Input
700 input_ref={input_ref1}
701 min={props.min}
702 max={props.max}
703 step={props.step}
704 value={*val1}
705 rtl_fill={true}
706 orientation={props.orientation.clone()}
707 disabled={props.disabled}
708 size={props.size.clone()}
709 color={props.color.clone()}
710 cursor_style={props.cursor_style.clone()}
711 input_class={props.input_class}
712 on_input={on_input1}
713 on_focus={on_focus_cb.clone()}
714 on_blur={on_blur_cb.clone()}
715 datalist_id={Some(list_id.clone())}
716 aria_label={props.aria_label}
717 aria_describedby={props.aria_describedby}
718 width={props.slider_width.clone()}
719 height={props.slider_height.clone()}
720 input_style={input_style1}
721 custom_thumb_css={props.custom_thumb_css}
722 custom_thumb_html={props.custom_thumb_html.clone()}
723 keyboard_step={props.keyboard_step}
724 />
725 <Input
726 input_ref={input_ref2}
727 min={props.min}
728 max={props.max}
729 step={props.step}
730 value={*val2}
731 orientation={props.orientation.clone()}
732 disabled={props.disabled}
733 size={props.size.clone()}
734 color={props.color.clone()}
735 cursor_style={props.cursor_style.clone()}
736 input_class={props.input_class}
737 on_input={on_input2}
738 on_focus={on_focus_cb}
739 on_blur={on_blur_cb}
740 datalist_id={Some(list_id.clone())}
741 aria_label={props.aria_label}
742 aria_describedby={props.aria_describedby}
743 width={props.slider_width.clone()}
744 height={props.slider_height.clone()}
745 input_style={input_style2}
746 custom_thumb_css={props.custom_thumb_css}
747 custom_thumb_html={props.custom_thumb_html.clone()}
748 keyboard_step={props.keyboard_step}
749 />
750 { props.icon_end.clone().unwrap_or_default() }
751 </div>
752 }
753 } else {
754 html! {
755 <div style="display: flex; align-items: center; width: 100%;">
756 { props.icon_start.clone().unwrap_or_default() }
757 <Input
758 input_ref={input_ref1}
759 min={props.min}
760 max={props.max}
761 step={props.step}
762 value={*val1}
763 orientation={props.orientation.clone()}
764 disabled={props.disabled}
765 size={props.size.clone()}
766 color={props.color.clone()}
767 cursor_style={props.cursor_style.clone()}
768 input_class={props.input_class}
769 on_input={on_input1}
770 on_focus={on_focus_cb.clone()}
771 on_blur={on_blur_cb.clone()}
772 datalist_id={Some(list_id.clone())}
773 aria_label={props.aria_label}
774 aria_describedby={props.aria_describedby}
775 width={props.slider_width.clone()}
776 height={props.slider_height.clone()}
777 input_style={input_style1}
778 custom_thumb_css={props.custom_thumb_css}
779 custom_thumb_html={props.custom_thumb_html.clone()}
780 keyboard_step={props.keyboard_step}
781 />
782 { props.icon_end.clone().unwrap_or_default() }
783 </div>
784 }
785 } }
786 <Ticks id={list_id.clone()} min={props.min} max={props.max} step={props.step} />
787 { if props.show_value {
788 html! {
789 <Output
790 value_display={format!("{:.1}", *val1)}
791 output_class={props.output_class}
792 output_style={props.output_style}
793 tooltip_style={props.tooltip_style}
794 show_tooltip={props.show_tooltip}
795 tooltip_left={format!("{:.2}%", ((*val1 - props.min) / (props.max - props.min)) * 100.0)}
796 />
797 }
798 } else {
799 html! {}
800 } }
801 { if props.show_steps && !props.orientation.is_vertical() {
802 html! {
803 <Steps
804 min={props.min}
805 max={props.max}
806 step={props.step}
807 steps_style={props.steps_style}
808 orientation={props.orientation.clone()}
809 />
810 }
811 } else {
812 html! {}
813 } }
814 </div>
815 }
816}