patternfly_yew/components/
slider.rs1use gloo_events::{EventListener, EventListenerOptions};
3use gloo_utils::document;
4use std::fmt::{Display, Formatter};
5use wasm_bindgen::JsCast;
6use web_sys::HtmlElement;
7use yew::html::IntoPropValue;
8use yew::prelude::*;
9
10#[derive(Clone, PartialEq)]
11pub struct Step {
12 pub value: f64,
13 pub label: Option<String>,
14}
15
16impl From<f64> for Step {
17 fn from(value: f64) -> Self {
18 Self { value, label: None }
19 }
20}
21
22impl IntoPropValue<Step> for f64 {
23 fn into_prop_value(self) -> Step {
24 self.into()
25 }
26}
27
28impl<S> IntoPropValue<Step> for (f64, S)
29where
30 S: Into<String>,
31{
32 fn into_prop_value(self) -> Step {
33 Step {
34 value: self.0,
35 label: Some(self.1.into()),
36 }
37 }
38}
39
40impl<S> From<(f64, S)> for Step
41where
42 S: Into<String>,
43{
44 fn from((value, label): (f64, S)) -> Self {
45 Step {
46 value,
47 label: Some(label.into()),
48 }
49 }
50}
51
52impl Display for Step {
53 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
54 match &self.label {
55 Some(label) => f.write_str(label),
56 None => write!(f, "{}", self.value),
57 }
58 }
59}
60
61#[derive(Clone, PartialEq, Properties)]
63pub struct SliderProperties {
64 pub min: Step,
66 pub max: Step,
68
69 #[prop_or_default]
71 pub value: Option<f64>,
72
73 #[prop_or_default]
75 pub hide_labels: bool,
76
77 #[prop_or(2)]
79 pub label_precision: usize,
80
81 #[prop_or_default]
82 pub ticks: Vec<Step>,
83
84 #[prop_or_default]
86 pub suppress_initial_change: bool,
87
88 #[prop_or_default]
90 pub onchange: Callback<f64>,
91
92 #[prop_or_default]
93 pub snap_mode: SnapMode,
94}
95
96#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
97pub enum SnapMode {
98 #[default]
99 None,
100 Nearest,
101}
102
103#[doc(hidden)]
104pub enum SliderMsg {
105 SetValue(f64),
107 Start(Input, i32),
108 Move(i32),
109 Stop,
110}
111
112#[derive(Clone, Copy, Debug, PartialEq, Eq)]
113pub enum Input {
114 Mouse,
115 Touch,
116}
117
118pub struct Slider {
128 value: f64,
130
131 mousemove: Option<EventListener>,
132 mouseup: Option<EventListener>,
133 touchmove: Option<EventListener>,
134 touchend: Option<EventListener>,
135 touchcancel: Option<EventListener>,
136
137 refs: Refs,
138 snap_mode: SnapMode,
139 ticks: Vec<f64>,
140}
141
142#[derive(Default)]
143struct Refs {
144 rail: NodeRef,
145}
146
147impl Component for Slider {
148 type Message = SliderMsg;
149 type Properties = SliderProperties;
150
151 fn create(ctx: &Context<Self>) -> Self {
152 let ticks = Self::value_ticks(ctx.props());
153
154 let value = match ctx.props().value {
155 Some(value) => value,
156 None => ctx.props().min.value,
157 };
158
159 if !ctx.props().suppress_initial_change {
160 ctx.props().onchange.emit(value);
162 }
163
164 let snap_mode = ctx.props().snap_mode;
165
166 Self {
167 value,
168 refs: Default::default(),
169
170 mousemove: None,
171 mouseup: None,
172 touchmove: None,
173 touchend: None,
174 touchcancel: None,
175
176 snap_mode,
177 ticks,
178 }
179 }
180
181 fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
182 match msg {
183 SliderMsg::SetValue(value) => {
184 if self.value != value {
185 self.value = value;
186 ctx.props().onchange.emit(self.value);
187 } else {
188 return false;
189 }
190 }
191 SliderMsg::Start(input, x) => {
192 log::debug!("Start: {x}");
193 match input {
194 Input::Mouse => self.start_mouse(ctx),
195 Input::Touch => self.start_touch(ctx),
196 }
197 }
198 SliderMsg::Move(x) => {
199 log::debug!("Move: {x}");
200 self.r#move(ctx, x);
201 }
202 SliderMsg::Stop => {
203 log::debug!("Stop");
204 self.mousemove = None;
205 self.mouseup = None;
206 self.touchmove = None;
207 self.touchend = None;
208 self.touchcancel = None;
209 }
210 }
211 true
212 }
213
214 fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool {
215 let props = ctx.props();
216 if old_props != props {
217 if old_props.value != props.value {
218 if let Some(value) = props.value {
219 ctx.link().send_message(SliderMsg::SetValue(value));
220 }
221 };
222 true
223 } else {
224 false
225 }
226 }
227
228 fn view(&self, ctx: &Context<Self>) -> Html {
229 let classes = Classes::from("pf-v5-c-slider");
230 let valuestr = format!("{0:.1$}", self.value, ctx.props().label_precision);
231 let valuestr = valuestr.trim_end_matches('0').to_string();
232
233 let onmousedown = ctx.link().callback(|e: MouseEvent| {
234 e.stop_propagation();
235 e.prevent_default();
236 SliderMsg::Start(Input::Mouse, e.client_x())
237 });
238
239 let ontouchstart = ctx.link().batch_callback(|e: TouchEvent| {
240 e.stop_propagation();
241 if let Some(t) = e.touches().get(0) {
242 vec![SliderMsg::Start(Input::Touch, t.client_x())]
243 } else {
244 vec![]
245 }
246 });
247 let percent = Self::calc_percent(self.value, ctx.props()) * 100f64;
248 let min = &ctx.props().min;
249 let max = &ctx.props().max;
250
251 html!(
252 <div class={classes} style={format!("--pf-v5-c-slider--value: {}%", percent)}>
253 <div class="pf-v5-c-slider__main">
254 <div class="pf-v5-c-slider__rail" ref={self.refs.rail.clone()}>
255 <div class="pf-v5-c-slider__rail-track"></div>
256 </div>
257 if !ctx.props().hide_labels {
258 <div class="pf-v5-c-slider__steps" aria-hidden="true">
259 { self.render_step(min, ctx.props()) }
260 { for ctx.props().ticks.iter()
261 .filter(|t| t.value>min.value && t.value<max.value)
262 .map(|t| self.render_step(t,ctx.props()))}
263 { self.render_step(max, ctx.props()) }
264 </div>
265 }
266 <div class="pf-v5-c-slider__thumb"
267 {onmousedown}
268 {ontouchstart}
269 role="slider"
270 aria-valuemin={ctx.props().min.value.to_string()}
271 aria-valuemax={ctx.props().max.value.to_string()}
272 aria-valuenow={valuestr}
273 aria-label="Value"
274 tabindex="0"
275 >
276 </div>
277 </div>
278 </div>
279 )
280 }
281}
282
283impl Slider {
284 fn start_mouse(&mut self, ctx: &Context<Self>) {
285 let onmove = ctx.link().callback(SliderMsg::Move);
286 let onstop = ctx.link().callback(|_: ()| SliderMsg::Stop);
287
288 let mousemove = {
289 let onmove = onmove;
290 EventListener::new_with_options(
291 &document(),
292 "mousemove",
293 EventListenerOptions::enable_prevent_default(),
294 move |event| {
295 if let Some(e) = event.dyn_ref::<MouseEvent>() {
296 e.stop_propagation();
297 e.prevent_default();
298 onmove.emit(e.client_x());
299 }
300 },
301 )
302 };
303 self.mousemove = Some(mousemove);
304
305 let mouseup = EventListener::new_with_options(
306 &document(),
307 "mouseup",
308 EventListenerOptions::default(),
309 move |_| {
310 onstop.emit(());
311 },
312 );
313 self.mouseup = Some(mouseup);
314 }
315
316 fn start_touch(&mut self, ctx: &Context<Self>) {
317 let onmove = ctx.link().callback(SliderMsg::Move);
318 let onstop = ctx.link().callback(|_: ()| SliderMsg::Stop);
319
320 let touchmove = EventListener::new_with_options(
321 &document(),
322 "touchmove",
323 EventListenerOptions::enable_prevent_default(),
324 move |event| {
325 if let Some(e) = event.dyn_ref::<TouchEvent>() {
326 e.prevent_default();
327 e.stop_immediate_propagation();
328 if let Some(t) = e.touches().get(0) {
329 onmove.emit(t.client_x());
330 }
331 }
332 },
333 );
334 self.touchmove = Some(touchmove);
335
336 let touchend = {
337 let onstop = onstop.clone();
338 EventListener::new_with_options(
339 &document(),
340 "touchend",
341 EventListenerOptions::default(),
342 move |_| {
343 onstop.emit(());
344 },
345 )
346 };
347 self.touchend = Some(touchend);
348
349 let touchcancel = EventListener::new_with_options(
350 &document(),
351 "touchcancel",
352 EventListenerOptions::default(),
353 move |_| {
354 onstop.emit(());
355 },
356 );
357 self.touchcancel = Some(touchcancel);
358 }
359
360 fn r#move(&mut self, ctx: &Context<Self>, x: i32) {
361 if let Some(ele) = self.refs.rail.cast::<HtmlElement>() {
362 let bounding = ele.get_bounding_client_rect();
363
364 let left = bounding.left();
365 let width = bounding.width();
366
367 let value = x as f64 - left;
368
369 let value = if value <= 0f64 {
370 0f64
371 } else if value >= width {
372 1f64
373 } else {
374 value / width
375 };
376
377 let value = Self::calc_value(value, ctx.props());
378 let value = self.snap(value);
379
380 ctx.link().send_message(SliderMsg::SetValue(value))
381 }
382 }
383
384 fn calc_percent(value: f64, props: &SliderProperties) -> f64 {
385 let delta = props.max.value - props.min.value;
386 let p = (value - props.min.value) / delta;
387 p.clamp(0f64, 1f64)
388 }
389
390 fn calc_value(p: f64, props: &SliderProperties) -> f64 {
391 let delta = props.max.value - props.min.value;
392 props.min.value + delta * p
393 }
394
395 fn render_step(&self, step: &Step, props: &SliderProperties) -> Html {
396 let active = step.value <= self.value;
397
398 let mut classes = classes!("pf-v5-c-slider__step");
399 if active {
400 classes.push(classes!("pf-m-active"));
401 }
402 let label = if let Some(label) = &step.label {
403 label.clone()
404 } else {
405 format!("{:.1$}", step.value, props.label_precision)
406 };
407
408 let position = Self::calc_percent(step.value, props) * 100f64;
409 html!(
410 <div class={classes} style={format!("--pf-v5-c-slider__step--Left: {}%", position)}>
411 <div class="pf-v5-c-slider__step-tick"></div>
412 <div class="pf-v5-c-slider__step-label">{ label }</div>
413 </div>
414 )
415 }
416
417 fn snap(&self, value: f64) -> f64 {
418 match &self.snap_mode {
419 SnapMode::None => value,
420 SnapMode::Nearest => snap_nearest(value, &self.ticks),
421 }
422 }
423
424 fn value_ticks(props: &SliderProperties) -> Vec<f64> {
425 let mut ticks = vec![props.min.value, props.max.value];
426 ticks.extend(
427 props
428 .ticks
429 .iter()
430 .map(|t| t.value)
431 .filter(|v| v.is_finite()),
432 );
433 ticks.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap());
434 ticks
435 }
436}
437
438fn snap_nearest(value: f64, ticks: &[f64]) -> f64 {
439 let mut best = None;
441 for t in ticks {
442 match best {
443 None => best = Some((*t, (t - value).abs())),
444 Some((_, cd)) => {
445 let nd = (t - value).abs();
446 if nd < cd {
447 best = Some((*t, nd));
448 } else {
449 break;
452 }
453 }
454 }
455 }
456
457 best.map(|(value, _delta)| value).unwrap_or_default()
459}
460
461#[cfg(test)]
462mod test {
463 use super::*;
464
465 #[test]
466 fn test_snap_nearest() {
467 let ticks = [0f64, 25.0, 50.0, 100.0];
468
469 assert_eq!(snap_nearest(-1.0, &ticks), 0.0);
470
471 assert_eq!(snap_nearest(0.0, &ticks), 0.0);
472 assert_eq!(snap_nearest(25.0, &ticks), 25.0);
473 assert_eq!(snap_nearest(49.0, &ticks), 50.0);
474 assert_eq!(snap_nearest(51.0, &ticks), 50.0);
475 assert_eq!(snap_nearest(75.0, &ticks), 50.0);
476 assert_eq!(snap_nearest(75.1, &ticks), 100.0);
477 assert_eq!(snap_nearest(100.0, &ticks), 100.0);
478
479 assert_eq!(snap_nearest(101.0, &ticks), 100.0);
480 }
481}