adui_dioxus/components/
rate.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::as_pointer_event;
5use crate::components::number_utils::{NumberRules, apply_step};
6use dioxus::events::{KeyboardEvent, PointerData};
7use dioxus::prelude::Key;
8use dioxus::prelude::*;
9use serde_json::{Number, Value};
10#[cfg(target_arch = "wasm32")]
11use wasm_bindgen::JsCast;
12
13/// Rate component for collecting evaluations with stars (supports half).
14#[derive(Props, Clone, PartialEq)]
15pub struct RateProps {
16    /// Controlled numeric value.
17    #[props(optional)]
18    pub value: Option<f64>,
19    /// Uncontrolled initial value.
20    #[props(optional)]
21    pub default_value: Option<f64>,
22    /// Total count of items.
23    #[props(default = 5)]
24    pub count: usize,
25    /// Allow selecting half steps (0.5 increments).
26    #[props(default)]
27    pub allow_half: bool,
28    /// Allow clearing when clicking the same value again.
29    #[props(default = true)]
30    pub allow_clear: bool,
31    /// Disable interactions.
32    #[props(default)]
33    pub disabled: bool,
34    /// Optional custom character for each item.
35    #[props(optional)]
36    pub character: Option<Element>,
37    /// Optional tooltips per item (aligned by index).
38    #[props(optional)]
39    pub tooltips: Option<Vec<String>>,
40    #[props(optional)]
41    pub class: Option<String>,
42    #[props(optional)]
43    pub style: Option<String>,
44    /// Change callback (after click/keyboard). None means cleared.
45    #[props(optional)]
46    pub on_change: Option<EventHandler<Option<f64>>>,
47    /// Hover value callback.
48    #[props(optional)]
49    pub on_hover_change: Option<EventHandler<Option<f64>>>,
50    #[props(optional)]
51    pub on_focus: Option<EventHandler<()>>,
52    #[props(optional)]
53    pub on_blur: Option<EventHandler<()>>,
54}
55
56#[component]
57pub fn Rate(props: RateProps) -> Element {
58    let RateProps {
59        value,
60        default_value,
61        count,
62        allow_half,
63        allow_clear,
64        disabled,
65        character,
66        tooltips,
67        class,
68        style,
69        on_change,
70        on_hover_change,
71        on_focus,
72        on_blur,
73    } = props;
74
75    let config = use_config();
76    let form_control = use_form_item_control();
77    let controlled = value.is_some();
78
79    let rules = NumberRules {
80        min: Some(0.0),
81        max: Some(count as f64),
82        step: Some(if allow_half { 0.5 } else { 1.0 }),
83        precision: Some(if allow_half { 1 } else { 0 }),
84    };
85
86    let inner_value = use_signal(|| default_value);
87
88    // Sync controlled/form value into local state when it changes.
89    {
90        let form_ctx = form_control.clone();
91        let prop_val = value.clone();
92        let mut inner_signal = inner_value.clone();
93        use_effect(move || {
94            let next = resolve_value(prop_val.clone(), &form_ctx, &inner_signal);
95            inner_signal.set(next);
96        });
97    }
98
99    let hover_value = use_signal(|| None::<f64>);
100
101    let is_disabled =
102        disabled || config.disabled || form_control.as_ref().is_some_and(|ctx| ctx.is_disabled());
103
104    let mut class_list = vec!["adui-rate".to_string()];
105    if allow_half {
106        class_list.push("adui-rate-half".into());
107    }
108    if is_disabled {
109        class_list.push("adui-rate-disabled".into());
110    }
111    if let Some(extra) = class {
112        class_list.push(extra);
113    }
114    let class_attr = class_list.join(" ");
115    let style_attr = style.unwrap_or_default();
116
117    let on_hover_cb = on_hover_change;
118    let mut hover_setter = hover_value.clone();
119    let hover_cb = on_hover_cb;
120
121    let current_value = resolve_value(value, &form_control, &inner_value);
122    let display_value = hover_value.read().or(current_value).unwrap_or(0.0);
123
124    let handle_keyboard = {
125        let mut hover_signal = hover_value.clone();
126        let form_for_key = form_control.clone();
127        let mut inner_for_key = inner_value.clone();
128        let on_change_for_key = on_change.clone();
129        move |evt: KeyboardEvent| {
130            if is_disabled {
131                return;
132            }
133            let current = resolve_value(value, &form_for_key, &inner_for_key).unwrap_or(0.0);
134            let next = match evt.key() {
135                Key::ArrowRight | Key::ArrowUp => Some(apply_step(current, 1, &rules)),
136                Key::ArrowLeft | Key::ArrowDown => Some(apply_step(current, -1, &rules)),
137                Key::Home => Some(0.0),
138                Key::End => Some(count as f64),
139                Key::Enter => Some(current),
140                Key::Character(c) if c == " " => Some(current),
141                _ => None,
142            };
143            if let Some(val) = next {
144                apply_rate(
145                    Some(val),
146                    true,
147                    controlled,
148                    &mut inner_for_key,
149                    &form_for_key,
150                    &on_change_for_key,
151                );
152                hover_signal.set(None);
153            }
154        }
155    };
156
157    rsx! {
158        div {
159            class: "{class_attr}",
160            style: "{style_attr}",
161            role: "slider",
162            tabindex: if is_disabled { -1 } else { 0 },
163            aria_valuemin: 0,
164            aria_valuemax: count,
165            aria_valuenow: display_value,
166            onkeydown: handle_keyboard,
167            onfocus: move |_| if let Some(cb) = on_focus.as_ref() { cb.call(()); },
168            onblur: {
169                let mut hover_for_blur = hover_value.clone();
170                move |_| {
171                    hover_for_blur.set(None);
172                    if let Some(cb) = on_blur.as_ref() { cb.call(()); }
173                }
174            },
175            onpointerleave: move |_| {
176                hover_setter.set(None);
177                if let Some(cb) = hover_cb.as_ref() { cb.call(None); }
178            },
179            {(0..count).map(|idx| {
180                let star_index = idx + 1;
181                let tooltip = tooltips.as_ref().and_then(|t| t.get(idx).cloned());
182                let char_node = character.clone().unwrap_or(rsx! { span { class: "adui-rate-star-default", "★" } });
183                let is_full = display_value + f64::EPSILON >= star_index as f64;
184                let is_half = allow_half && !is_full && display_value + f64::EPSILON >= star_index as f64 - 0.5;
185                let mut star_classes = vec!["adui-rate-star".to_string()];
186                if is_full {
187                    star_classes.push("adui-rate-star-full".into());
188                } else if is_half {
189                    star_classes.push("adui-rate-star-half".into());
190                }
191
192                let on_pointer_move = {
193                    let mut hover_signal = hover_value.clone();
194                    move |evt: Event<PointerData>| {
195                        if is_disabled {
196                            return;
197                        }
198                        let val = pointer_value(&evt, star_index, allow_half).unwrap_or(star_index as f64);
199                        hover_signal.set(Some(val));
200                        if let Some(cb) = hover_cb.as_ref() {
201                            cb.call(Some(val));
202                        }
203                    }
204                };
205
206                let on_pointer_down = {
207                    let form_for_click = form_control.clone();
208                    let mut inner_for_click = inner_value.clone();
209                    let on_change_for_click = on_change.clone();
210                    move |evt: Event<PointerData>| {
211                        if is_disabled {
212                            return;
213                        }
214                        let current = resolve_value(value, &form_for_click, &inner_for_click);
215                        let mut val = pointer_value(&evt, star_index, allow_half).unwrap_or(star_index as f64);
216                        if allow_clear && current.is_some_and(|v| (v - val).abs() < f64::EPSILON) {
217                            val = 0.0;
218                        }
219                        let next = if val == 0.0 { None } else { Some(val) };
220                        apply_rate(
221                            next,
222                            true,
223                            controlled,
224                            &mut inner_for_click,
225                            &form_for_click,
226                            &on_change_for_click,
227                        );
228                        hover_setter.set(None);
229                        if let Some(cb) = hover_cb.as_ref() {
230                            cb.call(next);
231                        }
232                    }
233                };
234
235                rsx! {
236                    span {
237                        class: "{star_classes.join(\" \")}",
238                        title: tooltip.unwrap_or_default(),
239                        onpointermove: on_pointer_move,
240                        onpointerdown: on_pointer_down,
241                        onpointerenter: on_pointer_move,
242                        {char_node}
243                    }
244                }
245            })}
246        }
247    }
248}
249
250fn resolve_value(
251    value: Option<f64>,
252    form_control: &Option<crate::components::form::FormItemControlContext>,
253    inner: &Signal<Option<f64>>,
254) -> Option<f64> {
255    value
256        .or_else(|| {
257            form_control
258                .as_ref()
259                .and_then(|ctx| value_from_form(ctx.value()))
260        })
261        .or_else(|| *inner.read())
262}
263
264fn value_from_form(val: Option<Value>) -> Option<f64> {
265    match val {
266        Some(Value::Number(n)) => n.as_f64(),
267        Some(Value::String(s)) => s.parse::<f64>().ok(),
268        _ => None,
269    }
270}
271
272fn apply_rate(
273    next: Option<f64>,
274    fire_change: bool,
275    controlled: bool,
276    inner: &mut Signal<Option<f64>>,
277    form_control: &Option<crate::components::form::FormItemControlContext>,
278    on_change: &Option<EventHandler<Option<f64>>>,
279) {
280    if !controlled {
281        inner.set(next);
282    }
283
284    if let Some(ctx) = form_control.as_ref() {
285        let val = match next.and_then(Number::from_f64) {
286            Some(num) => Value::Number(num),
287            None => Value::Null,
288        };
289        ctx.set_value(val);
290    }
291
292    if fire_change {
293        if let Some(cb) = on_change.as_ref() {
294            cb.call(next);
295        }
296    }
297}
298
299#[cfg(target_arch = "wasm32")]
300fn pointer_value(evt: &Event<PointerData>, star_index: usize, allow_half: bool) -> Option<f64> {
301    if let Some(p_evt) = as_pointer_event(evt) {
302        if let Some(target) = p_evt
303            .current_target()
304            .and_then(|t| t.dyn_into::<web_sys::Element>().ok())
305        {
306            let rect = target.get_bounding_client_rect();
307            if allow_half {
308                let mid = rect.width() / 2.0;
309                let left = p_evt.client_x() as f64 - rect.x();
310                if left < mid {
311                    return Some(star_index as f64 - 0.5);
312                }
313            }
314        }
315    }
316    Some(star_index as f64)
317}
318
319#[cfg(not(target_arch = "wasm32"))]
320fn pointer_value(_evt: &Event<PointerData>, star_index: usize, _allow_half: bool) -> Option<f64> {
321    Some(star_index as f64)
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use serde_json::{Number, Value};
328
329    #[test]
330    fn rate_props_defaults() {
331        // RateProps requires no mandatory fields
332        // count defaults to 5
333        // allow_half defaults to false
334        // allow_clear defaults to true
335        // disabled defaults to false
336    }
337
338    // Note: resolve_value tests require Dioxus runtime for Signal creation
339    // These are tested in integration tests or component behavior tests
340
341    #[test]
342    fn value_from_form_number() {
343        let val = Some(Value::Number(Number::from_f64(4.5).unwrap()));
344        assert_eq!(value_from_form(val), Some(4.5));
345    }
346
347    #[test]
348    fn value_from_form_string_valid() {
349        let val = Some(Value::String("3.5".to_string()));
350        assert_eq!(value_from_form(val), Some(3.5));
351    }
352
353    #[test]
354    fn value_from_form_string_invalid() {
355        let val = Some(Value::String("not a number".to_string()));
356        assert_eq!(value_from_form(val), None);
357    }
358
359    #[test]
360    fn value_from_form_other_types() {
361        assert_eq!(value_from_form(Some(Value::Bool(true))), None);
362        assert_eq!(value_from_form(Some(Value::Null)), None);
363        assert_eq!(value_from_form(Some(Value::Array(vec![]))), None);
364        assert_eq!(value_from_form(None), None);
365    }
366
367    #[test]
368    fn value_from_form_number_zero() {
369        let val = Some(Value::Number(Number::from_f64(0.0).unwrap()));
370        assert_eq!(value_from_form(val), Some(0.0));
371    }
372
373    #[test]
374    fn value_from_form_number_negative() {
375        let val = Some(Value::Number(Number::from_f64(-1.5).unwrap()));
376        assert_eq!(value_from_form(val), Some(-1.5));
377    }
378
379    #[test]
380    fn value_from_form_number_integer() {
381        let val = Some(Value::Number(Number::from(5)));
382        assert_eq!(value_from_form(val), Some(5.0));
383    }
384
385    #[test]
386    fn value_from_form_number_large() {
387        let val = Some(Value::Number(Number::from_f64(100.0).unwrap()));
388        assert_eq!(value_from_form(val), Some(100.0));
389    }
390
391    #[test]
392    fn value_from_form_string_zero() {
393        let val = Some(Value::String("0".to_string()));
394        assert_eq!(value_from_form(val), Some(0.0));
395    }
396
397    #[test]
398    fn value_from_form_string_negative() {
399        let val = Some(Value::String("-2.5".to_string()));
400        assert_eq!(value_from_form(val), Some(-2.5));
401    }
402
403    #[test]
404    fn value_from_form_string_integer() {
405        let val = Some(Value::String("10".to_string()));
406        assert_eq!(value_from_form(val), Some(10.0));
407    }
408
409    #[test]
410    fn value_from_form_string_with_whitespace() {
411        let val = Some(Value::String("  5.5  ".to_string()));
412        // Note: parse::<f64> does NOT trim whitespace, so this should fail
413        assert_eq!(value_from_form(val), None);
414    }
415
416    #[test]
417    fn value_from_form_string_empty() {
418        let val = Some(Value::String(String::new()));
419        assert_eq!(value_from_form(val), None);
420    }
421
422    #[test]
423    fn value_from_form_string_scientific_notation() {
424        let val = Some(Value::String("1e2".to_string()));
425        assert_eq!(value_from_form(val), Some(100.0));
426    }
427
428    #[test]
429    fn value_from_form_string_decimal_point_only() {
430        let val = Some(Value::String(".".to_string()));
431        assert_eq!(value_from_form(val), None);
432    }
433
434    #[test]
435    fn value_from_form_string_multiple_decimal_points() {
436        let val = Some(Value::String("1.2.3".to_string()));
437        assert_eq!(value_from_form(val), None);
438    }
439
440    #[test]
441    fn value_from_form_string_leading_plus() {
442        let val = Some(Value::String("+5.5".to_string()));
443        assert_eq!(value_from_form(val), Some(5.5));
444    }
445
446    #[test]
447    fn value_from_form_string_with_letters() {
448        let val = Some(Value::String("abc123".to_string()));
449        assert_eq!(value_from_form(val), None);
450    }
451
452    #[test]
453    fn value_from_form_string_partial_number() {
454        let val = Some(Value::String("123abc".to_string()));
455        // parse::<f64> will fail on this
456        assert_eq!(value_from_form(val), None);
457    }
458
459    #[test]
460    fn value_from_form_number_precision() {
461        let val = Some(Value::Number(Number::from_f64(4.999999).unwrap()));
462        let result = value_from_form(val);
463        assert!(result.is_some());
464        assert!((result.unwrap() - 4.999999).abs() < f64::EPSILON);
465    }
466
467    // Note: apply_rate tests require Dioxus runtime for Signal creation
468    // These are tested in integration tests or component behavior tests
469
470    #[test]
471    fn pointer_value_non_wasm() {
472        // On non-wasm targets, pointer_value should return star_index as f64
473        // We can't easily create a real Event in tests, so we test the logic directly
474        // The function always returns Some(star_index as f64) on non-wasm targets
475        // This is tested implicitly through the component behavior
476    }
477}