radix_leptos_primitives/components/
switch.rs

1use leptos::*;
2use leptos::prelude::*;
3
4/// Switch component with proper accessibility and styling variants
5#[derive(Debug, Clone, Copy, PartialEq)]
6pub enum SwitchVariant {
7    Default,
8    Destructive,
9    Ghost,
10}
11
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub enum SwitchSize {
14    Default,
15    Sm,
16    Lg,
17}
18
19impl SwitchVariant {
20    pub fn as_str(&self) -> &'static str {
21        match self {
22            SwitchVariant::Default => "default",
23            SwitchVariant::Destructive => "destructive",
24            SwitchVariant::Ghost => "ghost",
25        }
26    }
27}
28
29impl SwitchSize {
30    pub fn as_str(&self) -> &'static str {
31        match self {
32            SwitchSize::Default => "default",
33            SwitchSize::Sm => "sm",
34            SwitchSize::Lg => "lg",
35        }
36    }
37}
38
39/// Generate a simple unique ID for components
40fn generate_id(prefix: &str) -> String {
41    static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
42    let id = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
43    format!("{}-{}", prefix, id)
44}
45
46/// Merge CSS classes
47fn merge_classes(existing: Option<&str>, additional: Option<&str>) -> Option<String> {
48    match (existing, additional) {
49        (Some(a), Some(b)) => Some(format!("{} {}", a, b)),
50        (Some(a), None) => Some(a.to_string()),
51        (None, Some(b)) => Some(b.to_string()),
52        (None, None) => None,
53    }
54}
55
56/// Switch root component
57#[component]
58pub fn Switch(
59    /// Whether the switch is on
60    #[prop(optional, default = false)]
61    checked: bool,
62    /// Whether the switch is disabled
63    #[prop(optional, default = false)]
64    disabled: bool,
65    /// Switch styling variant
66    #[prop(optional, default = SwitchVariant::Default)]
67    variant: SwitchVariant,
68    /// Switch size
69    #[prop(optional, default = SwitchSize::Default)]
70    size: SwitchSize,
71    /// CSS classes
72    #[prop(optional)]
73    class: Option<String>,
74    /// CSS styles
75    #[prop(optional)]
76    style: Option<String>,
77    /// Checked change event handler
78    #[prop(optional)]
79    on_checked_change: Option<Callback<bool>>,
80    /// Child content
81    children: Children,
82) -> impl IntoView {
83    let switch_id = generate_id("switch");
84    let thumb_id = generate_id("switch-thumb");
85    
86    // Build data attributes for styling
87    let data_variant = variant.as_str();
88    let data_size = size.as_str();
89    
90    // Merge classes with data attributes for CSS targeting
91    let base_classes = "radix-switch";
92    let combined_class = merge_classes(Some(base_classes), class.as_deref())
93        .unwrap_or_else(|| base_classes.to_string());
94    
95    // Handle keyboard navigation
96    let handle_keydown = move |e: web_sys::KeyboardEvent| {
97        match e.key().as_str() {
98            " " | "Enter" => {
99                e.prevent_default();
100                if !disabled {
101                    if let Some(on_checked_change) = on_checked_change {
102                        on_checked_change.run(!checked);
103                    }
104                }
105            }
106            _ => {}
107        }
108    };
109    
110    // Handle click
111    let handle_click = move |e: web_sys::MouseEvent| {
112        e.prevent_default();
113        if !disabled {
114            if let Some(on_checked_change) = on_checked_change {
115                on_checked_change.run(!checked);
116            }
117        }
118    };
119    
120    view! {
121        <div 
122            class=combined_class
123            style=style
124            data-variant=data_variant
125            data-size=data_size
126            data-checked=checked
127            data-disabled=disabled
128            role="switch"
129            aria-checked=checked
130            aria-disabled=disabled
131            tabindex=if disabled { "-1" } else { "0" }
132            on:keydown=handle_keydown
133            on:click=handle_click
134        >
135            <input
136                id=switch_id.clone()
137                type="checkbox"
138                checked=checked
139                disabled=disabled
140                tabindex="-1"
141                aria-hidden="true"
142            />
143            <div class="radix-switch-track">
144                <div 
145                    id=thumb_id
146                    class="radix-switch-thumb"
147                    data-state=if checked { "checked" } else { "unchecked" }
148                >
149                </div>
150            </div>
151            {children()}
152        </div>
153    }
154}
155
156/// Switch Thumb component
157#[component]
158pub fn SwitchThumb(
159    /// CSS classes
160    #[prop(optional)]
161    class: Option<String>,
162    /// CSS styles
163    #[prop(optional)]
164    style: Option<String>,
165) -> impl IntoView {
166    let base_classes = "radix-switch-thumb";
167    let combined_class = merge_classes(Some(base_classes), class.as_deref())
168        .unwrap_or_else(|| base_classes.to_string());
169    
170    view! {
171        <div 
172            class=combined_class
173            style=style
174        >
175        </div>
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use proptest::prelude::*;
183    
184    // 1. Basic Rendering Tests
185    #[test]
186    fn test_switch_variants() {
187        run_test(|| {
188            let variants = vec![
189                SwitchVariant::Default,
190                SwitchVariant::Destructive,
191                SwitchVariant::Ghost,
192            ];
193            
194            for variant in variants {
195                assert!(!variant.as_str().is_empty());
196            }
197        });
198    }
199    
200    #[test]
201    fn test_switch_sizes() {
202        run_test(|| {
203            let sizes = vec![
204                SwitchSize::Default,
205                SwitchSize::Sm,
206                SwitchSize::Lg,
207            ];
208            
209            for size in sizes {
210                assert!(!size.as_str().is_empty());
211            }
212        });
213    }
214    
215    // 2. Props Validation Tests
216    #[test]
217    fn test_switch_on_state() {
218        run_test(|| {
219            let checked = true;
220            let disabled = false;
221            let variant = SwitchVariant::Default;
222            let size = SwitchSize::Default;
223            
224            assert!(checked);
225            assert!(!disabled);
226            assert_eq!(variant, SwitchVariant::Default);
227            assert_eq!(size, SwitchSize::Default);
228        });
229    }
230    
231    #[test]
232    fn test_switch_off_state() {
233        run_test(|| {
234            let checked = false;
235            let disabled = false;
236            let variant = SwitchVariant::Destructive;
237            let size = SwitchSize::Lg;
238            
239            assert!(!checked);
240            assert!(!disabled);
241            assert_eq!(variant, SwitchVariant::Destructive);
242            assert_eq!(size, SwitchSize::Lg);
243        });
244    }
245    
246    #[test]
247    fn test_switch_disabled_state() {
248        run_test(|| {
249            let checked = false;
250            let disabled = true;
251            let variant = SwitchVariant::Ghost;
252            let size = SwitchSize::Sm;
253            
254            assert!(!checked);
255            assert!(disabled);
256            assert_eq!(variant, SwitchVariant::Ghost);
257            assert_eq!(size, SwitchSize::Sm);
258        });
259    }
260    
261    // 3. State Management Tests
262    #[test]
263    fn test_switch_state_changes() {
264        run_test(|| {
265            let mut checked = false;
266            let disabled = false;
267            
268            // Initial state
269            assert!(!checked);
270            assert!(!disabled);
271            
272            // Turn on switch
273            checked = true;
274            
275            assert!(checked);
276            assert!(!disabled);
277            
278            // Turn off switch
279            checked = false;
280            
281            assert!(!checked);
282            assert!(!disabled);
283        });
284    }
285    
286    // 4. Event Handling Tests
287    #[test]
288    fn test_switch_keyboard_navigation() {
289        run_test(|| {
290            let space_pressed = true;
291            let enter_pressed = false;
292            let disabled = false;
293            let checked = false;
294            
295            assert!(space_pressed);
296            assert!(!enter_pressed);
297            assert!(!disabled);
298            assert!(!checked);
299            
300            if space_pressed && !disabled {
301                assert!(true);
302            }
303            
304            if enter_pressed && !disabled {
305                assert!(false);
306            }
307        });
308    }
309    
310    #[test]
311    fn test_switch_click_handling() {
312        run_test(|| {
313            let clicked = true;
314            let disabled = false;
315            let checked = false;
316            
317            assert!(clicked);
318            assert!(!disabled);
319            assert!(!checked);
320            
321            if clicked && !disabled {
322                assert!(true);
323            }
324        });
325    }
326    
327    // 5. Accessibility Tests
328    #[test]
329    fn test_switch_accessibility() {
330        run_test(|| {
331            let role = "switch";
332            let aria_checked = "false";
333            let aria_disabled = "false";
334            let tabindex = "0";
335            
336            assert_eq!(role, "switch");
337            assert_eq!(aria_checked, "false");
338            assert_eq!(aria_disabled, "false");
339            assert_eq!(tabindex, "0");
340        });
341    }
342    
343    // 6. Edge Case Tests
344    #[test]
345    fn test_switch_edge_cases() {
346        run_test(|| {
347            let checked = true;
348            let disabled = true;
349            
350            assert!(checked);
351            assert!(disabled);
352        });
353    }
354    
355    #[test]
356    fn test_switch_toggle_behavior() {
357        run_test(|| {
358            let mut checked = false;
359            let disabled = false;
360            
361            assert!(!checked);
362            assert!(!disabled);
363            
364            // Toggle on
365            checked = !checked;
366            
367            assert!(checked);
368            assert!(!disabled);
369            
370            // Toggle off
371            checked = !checked;
372            
373            assert!(!checked);
374            assert!(!disabled);
375        });
376    }
377    
378    // 7. Property-Based Tests
379    proptest! {
380        #[test]
381        fn test_switch_properties(
382            variant in prop::sample::select(vec![
383                SwitchVariant::Default,
384                SwitchVariant::Destructive,
385                SwitchVariant::Ghost,
386            ]),
387            size in prop::sample::select(vec![
388                SwitchSize::Default,
389                SwitchSize::Sm,
390                SwitchSize::Lg,
391            ]),
392            checked in prop::bool::ANY,
393            disabled in prop::bool::ANY
394        ) {
395            assert!(!variant.as_str().is_empty());
396            assert!(!size.as_str().is_empty());
397            
398            assert!(checked == true || checked == false);
399            assert!(disabled == true || disabled == false);
400            
401            if disabled {
402                // Disabled switch should not be interactive
403            }
404        }
405    }
406    
407    // Helper function for running tests
408    fn run_test<F>(f: F) where F: FnOnce() {
409        f();
410    }
411}