Skip to main content

stratum_components/forms/
switch.rs

1//! Styled Switch toggle component.
2
3use crate::common::{Size, merge_classes};
4use stratum_core::aria::{AriaAttributes, AriaRole, TriState};
5use stratum_core::render::{AttrValue, RenderOutput};
6
7/// Properties for the styled Switch.
8#[derive(Debug, Clone, PartialEq, Default)]
9pub struct SwitchProps {
10    pub size: Size,
11    pub checked: bool,
12    pub disabled: bool,
13    pub class: Option<String>,
14    pub aria_label: Option<String>,
15    pub id: Option<String>,
16}
17
18pub struct Switch;
19
20impl Switch {
21    const BASE: &'static str = "peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input";
22
23    pub fn classes(props: &SwitchProps) -> String {
24        let size_cls = match props.size {
25            Size::Xs => "h-4 w-7",
26            Size::Sm => "h-4 w-8",
27            Size::Md => "h-5 w-9",
28            Size::Lg => "h-6 w-11",
29            Size::Xl => "h-7 w-14",
30        };
31
32        let computed = format!("{} {}", Self::BASE, size_cls);
33        merge_classes(&computed, &props.class)
34    }
35
36    pub fn render(props: &SwitchProps) -> RenderOutput {
37        let classes = Self::classes(props);
38        let mut aria = AriaAttributes::new()
39            .with_role(AriaRole::Switch)
40            .with_checked(if props.checked {
41                TriState::True
42            } else {
43                TriState::False
44            });
45
46        if let Some(ref label) = props.aria_label {
47            aria = aria.with_label(label.clone());
48        }
49        if props.disabled {
50            aria = aria.with_disabled(true);
51        }
52
53        let mut output = RenderOutput::new()
54            .with_tag("button")
55            .with_class(classes)
56            .with_aria(aria)
57            .with_attr("type", AttrValue::String("button".to_string()))
58            .with_data(
59                "state",
60                if props.checked {
61                    "checked"
62                } else {
63                    "unchecked"
64                },
65            );
66
67        if props.disabled {
68            output = output.with_attr("disabled", AttrValue::Bool(true));
69        }
70        if let Some(ref id) = props.id {
71            output = output.with_attr("id", AttrValue::String(id.clone()));
72        }
73
74        output
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn default_switch_classes() {
84        let props = SwitchProps::default();
85        let classes = Switch::classes(&props);
86        assert!(classes.contains("rounded-full"));
87        assert!(classes.contains("h-5 w-9"));
88    }
89
90    #[test]
91    fn render_checked_switch() {
92        let props = SwitchProps {
93            checked: true,
94            ..Default::default()
95        };
96        let output = Switch::render(&props);
97        assert_eq!(output.aria.checked, Some(TriState::True));
98        assert!(
99            output
100                .data_attrs
101                .iter()
102                .any(|(k, v)| k == "state" && v == "checked")
103        );
104    }
105
106    #[test]
107    fn render_unchecked_switch() {
108        let props = SwitchProps::default();
109        let output = Switch::render(&props);
110        assert_eq!(output.aria.checked, Some(TriState::False));
111    }
112
113    #[test]
114    fn render_tag_is_button() {
115        let props = SwitchProps::default();
116        let output = Switch::render(&props);
117        assert_eq!(output.effective_tag(), "button");
118    }
119}