adui_dioxus/components/
checkbox.rs

1use crate::components::control::{ControlStatus, push_status_class};
2use crate::components::form::{
3    form_value_to_bool, form_value_to_string_vec, use_form_item_control,
4};
5use dioxus::prelude::*;
6use serde_json::Value;
7
8#[derive(Clone)]
9struct CheckboxGroupContext {
10    selected: Signal<Vec<String>>,
11    disabled: bool,
12    controlled: bool,
13    on_change: Option<EventHandler<Vec<String>>>,
14}
15
16/// Props for a single checkbox.
17#[derive(Props, Clone, PartialEq)]
18pub struct CheckboxProps {
19    /// Controlled checked state.
20    #[props(optional)]
21    pub checked: Option<bool>,
22    /// Initial state in uncontrolled mode.
23    #[props(default)]
24    pub default_checked: bool,
25    /// Visual indeterminate state (mainly for \"select all\" scenarios).
26    #[props(default)]
27    pub indeterminate: bool,
28    #[props(default)]
29    pub disabled: bool,
30    /// Value used when inside a CheckboxGroup.
31    #[props(optional)]
32    pub value: Option<String>,
33    #[props(optional)]
34    pub status: Option<ControlStatus>,
35    #[props(optional)]
36    pub class: Option<String>,
37    #[props(optional)]
38    pub style: Option<String>,
39    #[props(optional)]
40    pub on_change: Option<EventHandler<bool>>,
41    /// Optional label content rendered to the right of the box.
42    #[props(optional)]
43    pub children: Element,
44}
45
46/// Ant Design flavored checkbox.
47#[component]
48pub fn Checkbox(props: CheckboxProps) -> Element {
49    let CheckboxProps {
50        checked,
51        default_checked,
52        indeterminate,
53        disabled,
54        value,
55        status,
56        class,
57        style,
58        on_change,
59        children,
60    } = props;
61
62    let form_control = use_form_item_control();
63    let group_ctx = try_use_context::<CheckboxGroupContext>();
64
65    let controlled_by_prop = checked.is_some();
66    let inner_checked = use_signal(|| default_checked);
67
68    let is_disabled = disabled
69        || group_ctx.as_ref().is_some_and(|ctx| ctx.disabled)
70        || form_control.as_ref().is_some_and(|ctx| ctx.is_disabled());
71
72    let is_checked = resolve_checked(
73        &group_ctx,
74        &form_control,
75        checked,
76        value.as_deref(),
77        inner_checked,
78    );
79
80    let mut class_list = vec!["adui-checkbox".to_string()];
81    if is_checked {
82        class_list.push("adui-checkbox-checked".into());
83    }
84    if indeterminate && !is_checked {
85        class_list.push("adui-checkbox-indeterminate".into());
86    }
87    if is_disabled {
88        class_list.push("adui-checkbox-disabled".into());
89    }
90    push_status_class(&mut class_list, status);
91    if let Some(extra) = class {
92        class_list.push(extra);
93    }
94    let class_attr = class_list.join(" ");
95    let style_attr = style.unwrap_or_default();
96
97    rsx! {
98        label {
99            class: "{class_attr}",
100            style: "{style_attr}",
101            role: "checkbox",
102            "aria-checked": is_checked,
103            "aria-disabled": is_disabled,
104            input {
105                class: "adui-checkbox-input",
106                r#type: "checkbox",
107                checked: is_checked,
108                disabled: is_disabled,
109                onclick: {
110                    let group_for_click = group_ctx.clone();
111                    let form_for_click = form_control.clone();
112                    let value_for_click = value.clone();
113                    let on_change_cb = on_change;
114                    let mut inner_for_click = inner_checked;
115                    move |_| {
116                        if is_disabled {
117                            return;
118                        }
119                        handle_checkbox_toggle(
120                            &group_for_click,
121                            &form_for_click,
122                            controlled_by_prop,
123                            &mut inner_for_click,
124                            value_for_click.as_deref(),
125                            on_change_cb,
126                        );
127                    }
128                },
129            }
130            span { class: "adui-checkbox-inner" }
131            span { {children} }
132        }
133    }
134}
135
136fn resolve_checked(
137    group_ctx: &Option<CheckboxGroupContext>,
138    form_control: &Option<crate::components::form::FormItemControlContext>,
139    prop_checked: Option<bool>,
140    value: Option<&str>,
141    inner: Signal<bool>,
142) -> bool {
143    if let Some(group) = group_ctx
144        && let Some(val) = value
145    {
146        return group.selected.read().contains(&val.to_string());
147    }
148    if let Some(ctx) = form_control {
149        return form_value_to_bool(ctx.value(), false);
150    }
151    if let Some(c) = prop_checked {
152        return c;
153    }
154    *inner.read()
155}
156
157fn handle_checkbox_toggle(
158    group_ctx: &Option<CheckboxGroupContext>,
159    form_control: &Option<crate::components::form::FormItemControlContext>,
160    controlled_by_prop: bool,
161    inner: &mut Signal<bool>,
162    value: Option<&str>,
163    on_change: Option<EventHandler<bool>>,
164) {
165    // Group mode: toggle membership in selected set.
166    if let Some(group) = group_ctx {
167        if let Some(val) = value {
168            // Limit the scope of the read borrow so we can safely write later.
169            let next = {
170                let current = group.selected.read();
171                toggle_membership(&current, val)
172            };
173            if let Some(cb) = group.on_change {
174                cb.call(next.clone());
175            }
176            if let Some(ctx) = form_control {
177                let json = Value::Array(next.iter().cloned().map(Value::String).collect());
178                ctx.set_value(json);
179            }
180            if !group.controlled {
181                let mut signal = group.selected;
182                signal.set(next);
183            }
184        }
185        // Group mode does not call individual on_change; the group callback is primary.
186        return;
187    }
188
189    // Simple checkbox: Form 模式下以 FormStore 为真相源,其他情况使用内部 state。
190    let next = if let Some(ctx) = form_control {
191        let current = form_value_to_bool(ctx.value(), false);
192        let next = !current;
193        ctx.set_value(Value::Bool(next));
194        next
195    } else {
196        let current = *inner.read();
197        let next = !current;
198        if !controlled_by_prop {
199            let mut state = *inner;
200            state.set(next);
201        }
202        next
203    };
204
205    if let Some(cb) = on_change {
206        cb.call(next);
207    }
208}
209
210/// Toggle a single value within a set of string values.
211/// If the value already exists it is removed, otherwise it is appended.
212fn toggle_membership(current: &[String], value: &str) -> Vec<String> {
213    let mut next = current.to_vec();
214    if let Some(pos) = next.iter().position(|v| v == value) {
215        next.remove(pos);
216    } else {
217        next.push(value.to_string());
218    }
219    next
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn toggle_membership_adds_when_missing_and_removes_when_present() {
228        let current: Vec<String> = vec![];
229        let next = toggle_membership(&current, "a");
230        assert_eq!(next, vec!["a".to_string()]);
231
232        let next2 = toggle_membership(&next, "a");
233        assert!(next2.is_empty());
234    }
235
236    #[test]
237    fn toggle_membership_multiple_values() {
238        let current = vec!["a".to_string(), "b".to_string()];
239        let next = toggle_membership(&current, "c");
240        assert_eq!(
241            next,
242            vec!["a".to_string(), "b".to_string(), "c".to_string()]
243        );
244    }
245
246    #[test]
247    fn toggle_membership_remove_middle_value() {
248        let current = vec!["a".to_string(), "b".to_string(), "c".to_string()];
249        let next = toggle_membership(&current, "b");
250        assert_eq!(next, vec!["a".to_string(), "c".to_string()]);
251    }
252
253    #[test]
254    fn toggle_membership_remove_first_value() {
255        let current = vec!["a".to_string(), "b".to_string()];
256        let next = toggle_membership(&current, "a");
257        assert_eq!(next, vec!["b".to_string()]);
258    }
259
260    #[test]
261    fn toggle_membership_remove_last_value() {
262        let current = vec!["a".to_string(), "b".to_string()];
263        let next = toggle_membership(&current, "b");
264        assert_eq!(next, vec!["a".to_string()]);
265    }
266
267    #[test]
268    fn toggle_membership_empty_after_removal() {
269        let current = vec!["a".to_string()];
270        let next = toggle_membership(&current, "a");
271        assert!(next.is_empty());
272    }
273
274    #[test]
275    fn toggle_membership_preserves_order() {
276        let current = vec!["a".to_string(), "b".to_string(), "c".to_string()];
277        let next = toggle_membership(&current, "d");
278        assert_eq!(
279            next,
280            vec![
281                "a".to_string(),
282                "b".to_string(),
283                "c".to_string(),
284                "d".to_string()
285            ]
286        );
287    }
288
289    #[test]
290    fn toggle_membership_case_sensitive() {
291        let current = vec!["a".to_string()];
292        let next = toggle_membership(&current, "A");
293        assert_eq!(next, vec!["a".to_string(), "A".to_string()]);
294    }
295
296    #[test]
297    fn toggle_membership_unicode_values() {
298        let current = vec!["中文".to_string()];
299        let next = toggle_membership(&current, "中文");
300        assert!(next.is_empty());
301
302        let next2 = toggle_membership(&next, "中文");
303        assert_eq!(next2, vec!["中文".to_string()]);
304    }
305
306    #[test]
307    fn toggle_membership_special_characters() {
308        let current = vec!["a-b".to_string()];
309        let next = toggle_membership(&current, "a-b");
310        assert!(next.is_empty());
311    }
312
313    #[test]
314    fn toggle_membership_empty_string() {
315        let current = vec!["".to_string()];
316        let next = toggle_membership(&current, "");
317        assert!(next.is_empty());
318    }
319
320    #[test]
321    fn toggle_membership_large_list() {
322        let mut current = Vec::new();
323        for i in 0..100 {
324            current.push(i.to_string());
325        }
326        let next = toggle_membership(&current, "50");
327        assert_eq!(next.len(), 99);
328        assert!(!next.contains(&"50".to_string()));
329    }
330}
331
332/// Checkbox group props.
333#[derive(Props, Clone, PartialEq)]
334pub struct CheckboxGroupProps {
335    /// Controlled set of selected values.
336    #[props(optional)]
337    pub value: Option<Vec<String>>,
338    /// Initial selection in uncontrolled mode.
339    #[props(default)]
340    pub default_value: Vec<String>,
341    #[props(default)]
342    pub disabled: bool,
343    #[props(optional)]
344    pub class: Option<String>,
345    #[props(optional)]
346    pub style: Option<String>,
347    #[props(optional)]
348    pub on_change: Option<EventHandler<Vec<String>>>,
349    pub children: Element,
350}
351
352/// Group container for multiple checkboxes.
353#[component]
354pub fn CheckboxGroup(props: CheckboxGroupProps) -> Element {
355    let CheckboxGroupProps {
356        value,
357        default_value,
358        disabled,
359        class,
360        style,
361        on_change,
362        children,
363    } = props;
364
365    let form_control = crate::components::form::use_form_item_control();
366
367    let controlled = value.is_some();
368    let selected = use_signal(|| {
369        if let Some(external) = value.clone() {
370            external
371        } else if let Some(ctx) = form_control.as_ref() {
372            form_value_to_string_vec(ctx.value())
373        } else {
374            default_value.clone()
375        }
376    });
377
378    // Sync internal signal when controlled value or form value changes.
379    {
380        let mut selected_signal = selected;
381        let external = value.clone();
382        let form_ctx = form_control.clone();
383        use_effect(move || {
384            if let Some(external_value) = external.clone() {
385                selected_signal.set(external_value);
386            } else if let Some(ctx) = form_ctx.as_ref() {
387                let next = form_value_to_string_vec(ctx.value());
388                selected_signal.set(next);
389            }
390        });
391    }
392
393    let ctx = CheckboxGroupContext {
394        selected,
395        disabled,
396        controlled,
397        on_change,
398    };
399    use_context_provider(|| ctx);
400
401    let mut class_list = vec!["adui-checkbox-group".to_string()];
402    if let Some(extra) = class {
403        class_list.push(extra);
404    }
405    let class_attr = class_list.join(" ");
406    let style_attr = style.unwrap_or_default();
407
408    rsx! {
409        div {
410            class: "{class_attr}",
411            style: "{style_attr}",
412            {children}
413        }
414    }
415}