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#[derive(Props, Clone, PartialEq)]
18pub struct CheckboxProps {
19 #[props(optional)]
21 pub checked: Option<bool>,
22 #[props(default)]
24 pub default_checked: bool,
25 #[props(default)]
27 pub indeterminate: bool,
28 #[props(default)]
29 pub disabled: bool,
30 #[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 #[props(optional)]
43 pub children: Element,
44}
45
46#[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 if let Some(group) = group_ctx {
167 if let Some(val) = value {
168 let next = {
170 let current = group.selected.read();
171 toggle_membership(¤t, 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 return;
187 }
188
189 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
210fn 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(¤t, "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(¤t, "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(¤t, "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(¤t, "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(¤t, "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(¤t, "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(¤t, "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(¤t, "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(¤t, "中文");
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(¤t, "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(¤t, "");
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(¤t, "50");
327 assert_eq!(next.len(), 99);
328 assert!(!next.contains(&"50".to_string()));
329 }
330}
331
332#[derive(Props, Clone, PartialEq)]
334pub struct CheckboxGroupProps {
335 #[props(optional)]
337 pub value: Option<Vec<String>>,
338 #[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#[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 {
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}