adui_dioxus/components/
segmented.rs

1use crate::components::config_provider::use_config;
2use crate::components::form::use_form_item_control;
3use dioxus::events::KeyboardEvent;
4use dioxus::prelude::*;
5use serde_json::Value;
6
7/// Segmented option with label/value/icon/tooltip.
8#[derive(Clone, Debug, PartialEq)]
9pub struct SegmentedOption {
10    pub label: String,
11    pub value: String,
12    pub icon: Option<Element>,
13    pub tooltip: Option<String>,
14    pub disabled: bool,
15}
16
17impl SegmentedOption {
18    pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
19        Self {
20            label: label.into(),
21            value: value.into(),
22            icon: None,
23            tooltip: None,
24            disabled: false,
25        }
26    }
27}
28
29/// Props for segmented control (single selection).
30#[derive(Props, Clone, PartialEq)]
31pub struct SegmentedProps {
32    pub options: Vec<SegmentedOption>,
33    /// Controlled value.
34    #[props(optional)]
35    pub value: Option<String>,
36    /// Uncontrolled initial value.
37    #[props(optional)]
38    pub default_value: Option<String>,
39    /// Fill parent's width.
40    #[props(default)]
41    pub block: bool,
42    /// Rounded shape.
43    #[props(default)]
44    pub round: bool,
45    #[props(default)]
46    pub disabled: bool,
47    #[props(optional)]
48    pub class: Option<String>,
49    #[props(optional)]
50    pub style: Option<String>,
51    #[props(optional)]
52    pub on_change: Option<EventHandler<String>>,
53}
54
55#[component]
56pub fn Segmented(props: SegmentedProps) -> Element {
57    let SegmentedProps {
58        options,
59        value,
60        default_value,
61        block,
62        round,
63        disabled,
64        class,
65        style,
66        on_change,
67    } = props;
68
69    let config = use_config();
70    let form_control = use_form_item_control();
71    let controlled = value.is_some();
72
73    let inner = use_signal(|| default_value.clone());
74
75    // Sync external changes into local state.
76    {
77        let form_ctx = form_control.clone();
78        let prop_val = value.clone();
79        let mut inner_signal = inner.clone();
80        use_effect(move || {
81            let next = resolve_value(prop_val.clone(), &form_ctx, &inner_signal);
82            inner_signal.set(next);
83        });
84    }
85
86    let is_disabled =
87        disabled || config.disabled || form_control.as_ref().is_some_and(|ctx| ctx.is_disabled());
88
89    let mut class_list = vec!["adui-segmented".to_string()];
90    if block {
91        class_list.push("adui-segmented-block".into());
92    }
93    if round {
94        class_list.push("adui-segmented-round".into());
95    }
96    if is_disabled {
97        class_list.push("adui-segmented-disabled".into());
98    }
99    if let Some(extra) = class {
100        class_list.push(extra);
101    }
102    let class_attr = class_list.join(" ");
103    let style_attr = style.unwrap_or_default();
104
105    let current_value = resolve_value(value.clone(), &form_control, &inner).unwrap_or_else(|| {
106        options
107            .iter()
108            .find(|opt| !opt.disabled)
109            .map(|opt| opt.value.clone())
110            .unwrap_or_default()
111    });
112
113    let handle_key = {
114        let opts = options.clone();
115        let form_for_key = form_control.clone();
116        let mut inner_for_key = inner.clone();
117        let on_change_for_key = on_change.clone();
118        let value_for_key = value.clone();
119        let fallback_value = current_value.clone();
120        move |evt: KeyboardEvent| {
121            if is_disabled {
122                return;
123            }
124            let current = resolve_value(value_for_key.clone(), &form_for_key, &inner_for_key)
125                .unwrap_or_else(|| fallback_value.clone());
126            let idx = opts.iter().position(|opt| opt.value == current);
127            let len = opts.len();
128            if len == 0 {
129                return;
130            }
131            let next_idx = match evt.key() {
132                Key::ArrowRight | Key::ArrowDown => idx.map(|i| (i + 1) % len).unwrap_or(0),
133                Key::ArrowLeft | Key::ArrowUp => idx
134                    .map(|i| if i == 0 { len - 1 } else { i - 1 })
135                    .unwrap_or(0),
136                _ => return,
137            };
138            let target = &opts[next_idx];
139            if target.disabled {
140                return;
141            }
142            apply_segmented(
143                target.value.clone(),
144                controlled,
145                &mut inner_for_key,
146                &form_for_key,
147                &on_change_for_key,
148            );
149        }
150    };
151    rsx! {
152        div {
153            class: "{class_attr}",
154            style: "{style_attr}",
155            role: "tablist",
156            tabindex: if is_disabled { -1 } else { 0 },
157            onkeydown: handle_key,
158            {options.into_iter().map(|opt| {
159                let active = opt.value == current_value;
160                let mut item_class = vec!["adui-segmented-item".to_string()];
161                if active { item_class.push("adui-segmented-item-active".into()); }
162                if opt.disabled { item_class.push("adui-segmented-item-disabled".into()); }
163
164                let tooltip_text = opt.tooltip.clone().unwrap_or_default();
165
166                let on_click = {
167                    let value = opt.value.clone();
168                    let form_for_click = form_control.clone();
169                    let mut inner_for_click = inner.clone();
170                    let on_change_for_click = on_change.clone();
171                    move |_| {
172                        if is_disabled || opt.disabled {
173                            return;
174                        }
175                        apply_segmented(
176                            value.clone(),
177                            controlled,
178                            &mut inner_for_click,
179                            &form_for_click,
180                            &on_change_for_click,
181                        );
182                    }
183                };
184
185                rsx! {
186                    button {
187                        class: "{item_class.join(\" \")}",
188                        title: tooltip_text,
189                        aria_pressed: active,
190                        disabled: is_disabled || opt.disabled,
191                        onclick: on_click,
192                        if let Some(icon) = opt.icon.clone() {
193                            span { class: "adui-segmented-item-icon", {icon} }
194                        }
195                        span { class: "adui-segmented-item-label", {opt.label.clone()} }
196                    }
197                }
198            })}
199        }
200    }
201}
202
203fn resolve_value(
204    value: Option<String>,
205    form_control: &Option<crate::components::form::FormItemControlContext>,
206    inner: &Signal<Option<String>>,
207) -> Option<String> {
208    value
209        .or_else(|| {
210            form_control
211                .as_ref()
212                .and_then(|ctx| value_from_form(ctx.value()))
213        })
214        .or_else(|| (*inner.read()).clone())
215}
216
217fn value_from_form(val: Option<Value>) -> Option<String> {
218    match val {
219        Some(Value::String(s)) => Some(s),
220        Some(Value::Number(n)) => Some(n.to_string()),
221        Some(Value::Bool(b)) => Some(b.to_string()),
222        _ => None,
223    }
224}
225
226fn apply_segmented(
227    next: String,
228    controlled: bool,
229    inner: &mut Signal<Option<String>>,
230    form_control: &Option<crate::components::form::FormItemControlContext>,
231    on_change: &Option<EventHandler<String>>,
232) {
233    if !controlled {
234        inner.set(Some(next.clone()));
235    }
236
237    if let Some(ctx) = form_control.as_ref() {
238        ctx.set_value(Value::String(next.clone()));
239    }
240
241    if let Some(cb) = on_change.as_ref() {
242        cb.call(next);
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use serde_json::Value;
250
251    #[test]
252    fn segmented_option_new() {
253        let option = SegmentedOption::new("Label", "value1");
254        assert_eq!(option.label, "Label");
255        assert_eq!(option.value, "value1");
256        assert_eq!(option.icon, None);
257        assert_eq!(option.tooltip, None);
258        assert_eq!(option.disabled, false);
259    }
260
261    #[test]
262    fn segmented_option_clone() {
263        let option = SegmentedOption::new("Label", "value1");
264        let cloned = option.clone();
265        assert_eq!(option.label, cloned.label);
266        assert_eq!(option.value, cloned.value);
267        assert_eq!(option.disabled, cloned.disabled);
268    }
269
270    #[test]
271    fn segmented_option_equality() {
272        let option1 = SegmentedOption::new("Label", "value1");
273        let option2 = SegmentedOption::new("Label", "value1");
274        let option3 = SegmentedOption::new("Different", "value1");
275        assert_eq!(option1, option2);
276        assert_ne!(option1, option3);
277    }
278
279    #[test]
280    fn segmented_props_defaults() {
281        // SegmentedProps requires options
282        // block defaults to false
283        // round defaults to false
284        // disabled defaults to false
285    }
286
287    // Note: resolve_value tests require Dioxus runtime for Signal creation
288    // These are tested in integration tests or component behavior tests
289
290    #[test]
291    fn value_from_form_string() {
292        let val = Some(Value::String("test".to_string()));
293        assert_eq!(value_from_form(val), Some("test".to_string()));
294    }
295
296    #[test]
297    fn value_from_form_number() {
298        let val = Some(Value::Number(serde_json::Number::from(42)));
299        assert_eq!(value_from_form(val), Some("42".to_string()));
300    }
301
302    #[test]
303    fn value_from_form_bool() {
304        let val = Some(Value::Bool(true));
305        assert_eq!(value_from_form(val), Some("true".to_string()));
306        let val_false = Some(Value::Bool(false));
307        assert_eq!(value_from_form(val_false), Some("false".to_string()));
308    }
309
310    #[test]
311    fn value_from_form_other_types() {
312        assert_eq!(value_from_form(Some(Value::Null)), None);
313        assert_eq!(value_from_form(Some(Value::Array(vec![]))), None);
314        assert_eq!(value_from_form(None), None);
315    }
316
317    #[test]
318    fn value_from_form_string_empty() {
319        let val = Some(Value::String(String::new()));
320        assert_eq!(value_from_form(val), Some(String::new()));
321    }
322
323    #[test]
324    fn value_from_form_string_with_spaces() {
325        let val = Some(Value::String("  test  ".to_string()));
326        assert_eq!(value_from_form(val), Some("  test  ".to_string()));
327    }
328
329    #[test]
330    fn value_from_form_number_zero() {
331        let val = Some(Value::Number(serde_json::Number::from(0)));
332        assert_eq!(value_from_form(val), Some("0".to_string()));
333    }
334
335    #[test]
336    fn value_from_form_number_negative() {
337        let val = Some(Value::Number(serde_json::Number::from(-42)));
338        assert_eq!(value_from_form(val), Some("-42".to_string()));
339    }
340
341    #[test]
342    fn value_from_form_number_large() {
343        let val = Some(Value::Number(serde_json::Number::from(999999)));
344        assert_eq!(value_from_form(val), Some("999999".to_string()));
345    }
346
347    #[test]
348    fn value_from_form_number_float() {
349        let val = Some(Value::Number(serde_json::Number::from_f64(3.14).unwrap()));
350        assert_eq!(value_from_form(val), Some("3.14".to_string()));
351    }
352
353    #[test]
354    fn value_from_form_number_negative_float() {
355        let val = Some(Value::Number(serde_json::Number::from_f64(-2.5).unwrap()));
356        assert_eq!(value_from_form(val), Some("-2.5".to_string()));
357    }
358
359    #[test]
360    fn segmented_option_debug() {
361        let option = SegmentedOption::new("Label", "value1");
362        let debug_str = format!("{:?}", option);
363        assert!(debug_str.contains("Label") || debug_str.contains("value1"));
364    }
365
366    #[test]
367    fn segmented_option_with_all_fields() {
368        let option = SegmentedOption {
369            label: "Test Label".to_string(),
370            value: "test_value".to_string(),
371            icon: None,
372            tooltip: Some("Tooltip text".to_string()),
373            disabled: true,
374        };
375        assert_eq!(option.label, "Test Label");
376        assert_eq!(option.value, "test_value");
377        assert_eq!(option.tooltip, Some("Tooltip text".to_string()));
378        assert_eq!(option.disabled, true);
379    }
380
381    #[test]
382    fn segmented_option_with_tooltip() {
383        let mut option = SegmentedOption::new("Label", "value1");
384        option.tooltip = Some("Help text".to_string());
385        assert_eq!(option.tooltip, Some("Help text".to_string()));
386    }
387
388    #[test]
389    fn segmented_option_disabled() {
390        let mut option = SegmentedOption::new("Label", "value1");
391        option.disabled = true;
392        assert_eq!(option.disabled, true);
393    }
394
395    #[test]
396    fn segmented_option_equality_with_different_fields() {
397        let option1 = SegmentedOption {
398            label: "Label".to_string(),
399            value: "value1".to_string(),
400            icon: None,
401            tooltip: None,
402            disabled: false,
403        };
404        let option2 = SegmentedOption {
405            label: "Label".to_string(),
406            value: "value1".to_string(),
407            icon: None,
408            tooltip: Some("Tooltip".to_string()),
409            disabled: true,
410        };
411        // Equality is based on all fields, so these should be different
412        assert_ne!(option1, option2);
413    }
414
415    #[test]
416    fn segmented_option_empty_strings() {
417        let option = SegmentedOption::new("", "");
418        assert_eq!(option.label, "");
419        assert_eq!(option.value, "");
420    }
421
422    #[test]
423    fn segmented_option_long_strings() {
424        let long_label = "a".repeat(100);
425        let long_value = "b".repeat(100);
426        let option = SegmentedOption::new(&long_label, &long_value);
427        assert_eq!(option.label, long_label);
428        assert_eq!(option.value, long_value);
429    }
430
431    // Note: apply_segmented tests require Dioxus runtime for Signal creation
432    // These are tested in integration tests or component behavior tests
433}