Skip to main content

stratum_components/forms/
checkbox.rs

1//! Styled Checkbox and CheckboxGroup components.
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 Checkbox.
8#[derive(Debug, Clone, PartialEq)]
9pub struct CheckboxProps {
10    pub size: Size,
11    pub checked: TriState,
12    pub disabled: bool,
13    pub required: bool,
14    pub class: Option<String>,
15    pub aria_label: Option<String>,
16    pub id: Option<String>,
17}
18
19impl Default for CheckboxProps {
20    fn default() -> Self {
21        Self {
22            size: Size::default(),
23            checked: TriState::False,
24            disabled: false,
25            required: false,
26            class: None,
27            aria_label: None,
28            id: None,
29        }
30    }
31}
32
33pub struct Checkbox;
34
35impl Checkbox {
36    const BASE: &'static str = "peer shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground";
37
38    pub fn classes(props: &CheckboxProps) -> String {
39        let size_cls = match props.size {
40            Size::Xs => "h-3 w-3",
41            Size::Sm => "h-3.5 w-3.5",
42            Size::Md => "h-4 w-4",
43            Size::Lg => "h-5 w-5",
44            Size::Xl => "h-6 w-6",
45        };
46
47        let computed = format!("{} {}", Self::BASE, size_cls);
48        merge_classes(&computed, &props.class)
49    }
50
51    pub fn render(props: &CheckboxProps) -> RenderOutput {
52        let classes = Self::classes(props);
53        let mut aria = AriaAttributes::new()
54            .with_role(AriaRole::Checkbox)
55            .with_checked(props.checked);
56
57        if let Some(ref label) = props.aria_label {
58            aria = aria.with_label(label.clone());
59        }
60        if props.disabled {
61            aria = aria.with_disabled(true);
62        }
63        if props.required {
64            aria.required = Some(true);
65        }
66
67        let state_str = match props.checked {
68            TriState::True => "checked",
69            TriState::False => "unchecked",
70            TriState::Mixed => "indeterminate",
71        };
72
73        let mut output = RenderOutput::new()
74            .with_tag("button")
75            .with_class(classes)
76            .with_aria(aria)
77            .with_attr("type", AttrValue::String("button".to_string()))
78            .with_data("state", state_str);
79
80        if props.disabled {
81            output = output.with_attr("disabled", AttrValue::Bool(true));
82        }
83        if let Some(ref id) = props.id {
84            output = output.with_attr("id", AttrValue::String(id.clone()));
85        }
86
87        output
88    }
89}
90
91/// Properties for CheckboxGroup.
92#[derive(Debug, Clone, PartialEq, Default)]
93pub struct CheckboxGroupProps {
94    pub class: Option<String>,
95    pub aria_label: Option<String>,
96}
97
98pub struct CheckboxGroup;
99
100impl CheckboxGroup {
101    pub fn classes(props: &CheckboxGroupProps) -> String {
102        let computed = "flex flex-col gap-2";
103        merge_classes(computed, &props.class)
104    }
105
106    pub fn render(props: &CheckboxGroupProps) -> RenderOutput {
107        let classes = Self::classes(props);
108        let mut aria = AriaAttributes::new().with_role(AriaRole::Group);
109        if let Some(ref label) = props.aria_label {
110            aria = aria.with_label(label.clone());
111        }
112
113        RenderOutput::new()
114            .with_tag("div")
115            .with_class(classes)
116            .with_aria(aria)
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn default_checkbox_classes() {
126        let props = CheckboxProps::default();
127        let classes = Checkbox::classes(&props);
128        assert!(classes.contains("rounded-sm"));
129        assert!(classes.contains("h-4 w-4"));
130    }
131
132    #[test]
133    fn render_checked_state() {
134        let props = CheckboxProps {
135            checked: TriState::True,
136            ..Default::default()
137        };
138        let output = Checkbox::render(&props);
139        assert_eq!(output.aria.checked, Some(TriState::True));
140        assert!(
141            output
142                .data_attrs
143                .iter()
144                .any(|(k, v)| k == "state" && v == "checked")
145        );
146    }
147
148    #[test]
149    fn render_indeterminate() {
150        let props = CheckboxProps {
151            checked: TriState::Mixed,
152            ..Default::default()
153        };
154        let output = Checkbox::render(&props);
155        assert_eq!(output.aria.checked, Some(TriState::Mixed));
156    }
157
158    #[test]
159    fn checkbox_group_render() {
160        let props = CheckboxGroupProps::default();
161        let output = CheckboxGroup::render(&props);
162        assert_eq!(output.effective_tag(), "div");
163        assert_eq!(output.aria.role, Some(AriaRole::Group));
164    }
165}