adui_dioxus/components/
switch.rs

1use crate::components::control::{ControlStatus, push_status_class};
2use crate::components::form::use_form_item_control;
3use dioxus::events::KeyboardEvent;
4use dioxus::prelude::Key;
5use dioxus::prelude::*;
6use serde_json::Value;
7
8/// Visual size of the switch.
9#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
10pub enum SwitchSize {
11    #[default]
12    Default,
13    Small,
14}
15
16/// Props for the Switch component.
17#[derive(Props, Clone, PartialEq)]
18pub struct SwitchProps {
19    /// Controlled checked state.
20    #[props(optional)]
21    pub checked: Option<bool>,
22    /// Initial value in uncontrolled mode.
23    #[props(default)]
24    pub default_checked: bool,
25    #[props(default)]
26    pub disabled: bool,
27    #[props(default)]
28    pub size: SwitchSize,
29    #[props(optional)]
30    pub checked_children: Option<Element>,
31    #[props(optional)]
32    pub un_checked_children: Option<Element>,
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    /// Change event with the next checked state.
40    #[props(optional)]
41    pub on_change: Option<EventHandler<bool>>,
42}
43
44/// Ant Design flavored switch component.
45#[component]
46pub fn Switch(props: SwitchProps) -> Element {
47    let SwitchProps {
48        checked,
49        default_checked,
50        disabled,
51        size,
52        checked_children,
53        un_checked_children,
54        status,
55        class,
56        style,
57        on_change,
58    } = props;
59
60    let form_control = use_form_item_control();
61    let controlled_by_prop = checked.is_some();
62
63    let inner_checked = use_signal(|| default_checked);
64
65    // 同步内部状态与 Form 字段:
66    // - 若 Form 中有布尔值,则以 Form 为准;
67    // - 若 Form 中无值,则回退到 default_checked。
68    if let Some(ctx) = &form_control {
69        let form_value = ctx.value();
70        let mut inner_signal = inner_checked;
71        if let Some(Value::Bool(b)) = form_value {
72            if *inner_signal.read() != b {
73                inner_signal.set(b);
74            }
75        } else if form_value.is_none() && *inner_signal.read() != default_checked {
76            inner_signal.set(default_checked);
77        }
78    }
79
80    let is_disabled = disabled || form_control.as_ref().is_some_and(|ctx| ctx.is_disabled());
81
82    let is_checked = *inner_checked.read();
83
84    let mut class_list = vec!["adui-switch".to_string()];
85    if size == SwitchSize::Small {
86        class_list.push("adui-switch-small".into());
87    }
88    if is_checked {
89        class_list.push("adui-switch-checked".into());
90    }
91    if is_disabled {
92        class_list.push("adui-switch-disabled".into());
93    }
94    push_status_class(&mut class_list, status);
95    if let Some(extra) = class {
96        class_list.push(extra);
97    }
98    let class_attr = class_list.join(" ");
99    let style_attr = style.unwrap_or_default();
100
101    let inner_for_toggle = inner_checked;
102    let form_for_toggle = form_control.clone();
103    let on_change_cb = on_change;
104    let disabled_flag = disabled;
105
106    rsx! {
107        button {
108            class: "{class_attr}",
109            style: "{style_attr}",
110            role: "switch",
111            "aria-checked": is_checked,
112            "aria-disabled": is_disabled,
113            r#type: "button",
114            disabled: is_disabled,
115            onclick: {
116                let mut inner_for_toggle = inner_for_toggle;
117                let form_for_toggle = form_for_toggle.clone();
118                move |_| {
119                    let is_disabled_now = disabled_flag || form_for_toggle.as_ref().is_some_and(|ctx| ctx.is_disabled());
120                    if is_disabled_now {
121                        return;
122                    }
123                    handle_switch_toggle(
124                        &form_for_toggle,
125                        controlled_by_prop,
126                        &mut inner_for_toggle,
127                        on_change_cb,
128                    );
129                }
130            },
131            onkeydown: {
132                let mut inner_for_toggle = inner_for_toggle;
133                let form_for_toggle = form_for_toggle.clone();
134                move |evt: KeyboardEvent| {
135                    let is_disabled_now = disabled_flag || form_for_toggle.as_ref().is_some_and(|ctx| ctx.is_disabled());
136                    if !is_disabled_now && key_triggers_toggle(&evt.key()) {
137                        handle_switch_toggle(
138                            &form_for_toggle,
139                            controlled_by_prop,
140                            &mut inner_for_toggle,
141                            on_change_cb,
142                        );
143                    }
144                }
145            },
146            span { class: "adui-switch-handle" }
147            span {
148                class: "adui-switch-inner",
149                if is_checked {
150                    if let Some(content) = checked_children {
151                        {content}
152                    }
153                } else {
154                    if let Some(content) = un_checked_children {
155                        {content}
156                    }
157                }
158            }
159        }
160    }
161}
162
163fn handle_switch_toggle(
164    form_control: &Option<crate::components::form::FormItemControlContext>,
165    controlled_by_prop: bool,
166    inner: &mut Signal<bool>,
167    on_change: Option<EventHandler<bool>>,
168) {
169    let current = *inner.read();
170    let next = !current;
171
172    // 始终尝试写回 FormStore(如果存在),确保提交时能拿到 newsletter 字段
173    if let Some(ctx) = form_control {
174        ctx.set_value(Value::Bool(next));
175    }
176
177    // 在非受控模式下更新内部状态,驱动 UI 立即切换
178    if !controlled_by_prop {
179        let mut state = *inner;
180        state.set(next);
181    }
182
183    if let Some(cb) = on_change {
184        cb.call(next);
185    }
186}
187
188fn key_triggers_toggle(key: &Key) -> bool {
189    match key {
190        Key::Enter => true,
191        Key::Character(text) if text == " " => true,
192        _ => false,
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn switch_size_default_value() {
202        assert_eq!(SwitchSize::Default, SwitchSize::default());
203    }
204
205    #[test]
206    fn switch_size_all_variants() {
207        assert_eq!(SwitchSize::Default, SwitchSize::Default);
208        assert_eq!(SwitchSize::Small, SwitchSize::Small);
209        assert_ne!(SwitchSize::Default, SwitchSize::Small);
210    }
211
212    #[test]
213    fn switch_size_equality() {
214        let size1 = SwitchSize::Default;
215        let size2 = SwitchSize::Default;
216        let size3 = SwitchSize::Small;
217        assert_eq!(size1, size2);
218        assert_ne!(size1, size3);
219    }
220
221    #[test]
222    fn switch_size_clone() {
223        let original = SwitchSize::Small;
224        let cloned = original;
225        assert_eq!(original, cloned);
226    }
227
228    #[test]
229    fn switch_size_debug() {
230        let size = SwitchSize::Default;
231        let debug_str = format!("{:?}", size);
232        assert!(debug_str.contains("Default") || debug_str.contains("Small"));
233    }
234
235    #[test]
236    fn switch_props_defaults() {
237        // SwitchProps requires no mandatory fields
238        // default_checked defaults to false
239        // disabled defaults to false
240        // size defaults to SwitchSize::Default
241    }
242
243    #[test]
244    fn key_triggers_toggle_enter() {
245        assert!(key_triggers_toggle(&Key::Enter));
246    }
247
248    #[test]
249    fn key_triggers_toggle_space() {
250        assert!(key_triggers_toggle(&Key::Character(" ".into())));
251    }
252
253    #[test]
254    fn key_triggers_toggle_other_keys() {
255        assert!(!key_triggers_toggle(&Key::ArrowLeft));
256        assert!(!key_triggers_toggle(&Key::ArrowRight));
257        assert!(!key_triggers_toggle(&Key::Character("a".into())));
258        assert!(!key_triggers_toggle(&Key::Escape));
259    }
260
261    #[test]
262    fn key_triggers_toggle_space_variations() {
263        // Space should trigger
264        assert!(key_triggers_toggle(&Key::Character(" ".into())));
265        // Other whitespace should not
266        assert!(!key_triggers_toggle(&Key::Character("\t".into())));
267        assert!(!key_triggers_toggle(&Key::Character("\n".into())));
268    }
269
270    #[test]
271    fn key_triggers_toggle_arrow_keys() {
272        assert!(!key_triggers_toggle(&Key::ArrowUp));
273        assert!(!key_triggers_toggle(&Key::ArrowDown));
274    }
275
276    #[test]
277    fn key_triggers_toggle_modifier_keys() {
278        assert!(!key_triggers_toggle(&Key::Control));
279        assert!(!key_triggers_toggle(&Key::Shift));
280        assert!(!key_triggers_toggle(&Key::Alt));
281        assert!(!key_triggers_toggle(&Key::Meta));
282    }
283
284    #[test]
285    fn key_triggers_toggle_function_keys() {
286        assert!(!key_triggers_toggle(&Key::F1));
287        assert!(!key_triggers_toggle(&Key::F12));
288    }
289
290    #[test]
291    fn key_triggers_toggle_navigation_keys() {
292        assert!(!key_triggers_toggle(&Key::Home));
293        assert!(!key_triggers_toggle(&Key::End));
294        assert!(!key_triggers_toggle(&Key::PageUp));
295        assert!(!key_triggers_toggle(&Key::PageDown));
296    }
297
298    #[test]
299    fn key_triggers_toggle_other_special_keys() {
300        assert!(!key_triggers_toggle(&Key::Backspace));
301        assert!(!key_triggers_toggle(&Key::Delete));
302        assert!(!key_triggers_toggle(&Key::Tab));
303        assert!(!key_triggers_toggle(&Key::CapsLock));
304    }
305
306    #[test]
307    fn key_triggers_toggle_character_keys() {
308        // Only space should trigger, other characters should not
309        assert!(!key_triggers_toggle(&Key::Character("0".into())));
310        assert!(!key_triggers_toggle(&Key::Character("9".into())));
311        assert!(!key_triggers_toggle(&Key::Character("A".into())));
312        assert!(!key_triggers_toggle(&Key::Character("z".into())));
313        assert!(!key_triggers_toggle(&Key::Character("!".into())));
314        assert!(!key_triggers_toggle(&Key::Character("@".into())));
315    }
316
317    #[test]
318    fn key_triggers_toggle_enter_vs_space() {
319        // Both Enter and Space should trigger
320        assert!(key_triggers_toggle(&Key::Enter));
321        assert!(key_triggers_toggle(&Key::Character(" ".into())));
322    }
323
324    #[test]
325    fn handle_switch_toggle_logic() {
326        // This function requires Signal and EventHandler which are hard to test in isolation
327        // But we can verify the logic conceptually:
328        // - If not controlled, updates inner signal
329        // - Always updates form control if present
330        // - Calls on_change callback if present
331    }
332
333    #[test]
334    fn switch_size_all_variants_equality() {
335        let sizes = [SwitchSize::Default, SwitchSize::Small];
336        for (i, size1) in sizes.iter().enumerate() {
337            for (j, size2) in sizes.iter().enumerate() {
338                if i == j {
339                    assert_eq!(size1, size2);
340                } else {
341                    assert_ne!(size1, size2);
342                }
343            }
344        }
345    }
346
347    #[test]
348    fn switch_size_copy_semantics() {
349        // SwitchSize should be Copy
350        let size = SwitchSize::Small;
351        let size2 = size;
352        assert_eq!(size, size2);
353    }
354
355    #[test]
356    fn key_triggers_toggle_unicode_space() {
357        // Only regular space should trigger, not other unicode spaces
358        assert!(key_triggers_toggle(&Key::Character(" ".into())));
359        // Non-breaking space should not trigger
360        assert!(!key_triggers_toggle(&Key::Character("\u{00A0}".into())));
361    }
362
363    #[test]
364    fn key_triggers_toggle_empty_string() {
365        // Empty string should not trigger
366        assert!(!key_triggers_toggle(&Key::Character("".into())));
367    }
368
369    #[test]
370    fn key_triggers_toggle_multiple_spaces() {
371        // Only single space should trigger
372        assert!(key_triggers_toggle(&Key::Character(" ".into())));
373        // Multiple spaces should not trigger (treated as different string)
374        assert!(!key_triggers_toggle(&Key::Character("  ".into())));
375    }
376}