Skip to main content

stratum_components/forms/
radio.rs

1//! Styled Radio and RadioGroup components.
2
3use crate::common::{Size, merge_classes};
4use stratum_core::aria::{AriaAttributes, AriaRole};
5use stratum_core::render::{AttrValue, RenderOutput};
6
7/// Properties for the styled Radio button.
8#[derive(Debug, Clone, PartialEq, Default)]
9pub struct RadioProps {
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    pub value: Option<String>,
17}
18
19pub struct Radio;
20
21impl Radio {
22    const BASE: &'static str = "aspect-square rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50";
23
24    pub fn classes(props: &RadioProps) -> String {
25        let size_cls = match props.size {
26            Size::Xs => "h-3 w-3",
27            Size::Sm => "h-3.5 w-3.5",
28            Size::Md => "h-4 w-4",
29            Size::Lg => "h-5 w-5",
30            Size::Xl => "h-6 w-6",
31        };
32
33        let computed = format!("{} {}", Self::BASE, size_cls);
34        merge_classes(&computed, &props.class)
35    }
36
37    pub fn render(props: &RadioProps) -> RenderOutput {
38        let classes = Self::classes(props);
39        let mut aria = AriaAttributes::new().with_role(AriaRole::Radio);
40        aria.checked = Some(if props.checked {
41            stratum_core::aria::TriState::True
42        } else {
43            stratum_core::aria::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        if let Some(ref value) = props.value {
74            output = output.with_attr("value", AttrValue::String(value.clone()));
75        }
76
77        output
78    }
79}
80
81/// Properties for RadioGroup.
82#[derive(Debug, Clone, PartialEq, Default)]
83pub struct RadioGroupProps {
84    pub orientation: Option<stratum_core::aria::Orientation>,
85    pub class: Option<String>,
86    pub aria_label: Option<String>,
87}
88
89pub struct RadioGroup;
90
91impl RadioGroup {
92    pub fn classes(props: &RadioGroupProps) -> String {
93        let base = match props.orientation {
94            Some(stratum_core::aria::Orientation::Horizontal) => "flex flex-row gap-2",
95            _ => "flex flex-col gap-2",
96        };
97        merge_classes(base, &props.class)
98    }
99
100    pub fn render(props: &RadioGroupProps) -> RenderOutput {
101        let classes = Self::classes(props);
102        let mut aria = AriaAttributes::new().with_role(AriaRole::RadioGroup);
103        if let Some(ref label) = props.aria_label {
104            aria = aria.with_label(label.clone());
105        }
106        if let Some(orientation) = props.orientation {
107            aria = aria.with_orientation(orientation);
108        }
109
110        RenderOutput::new()
111            .with_tag("div")
112            .with_class(classes)
113            .with_aria(aria)
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn default_radio_classes() {
123        let props = RadioProps::default();
124        let classes = Radio::classes(&props);
125        assert!(classes.contains("rounded-full"));
126        assert!(classes.contains("h-4 w-4"));
127    }
128
129    #[test]
130    fn render_checked_radio() {
131        let props = RadioProps {
132            checked: true,
133            ..Default::default()
134        };
135        let output = Radio::render(&props);
136        assert!(
137            output
138                .data_attrs
139                .iter()
140                .any(|(k, v)| k == "state" && v == "checked")
141        );
142    }
143
144    #[test]
145    fn radio_group_vertical() {
146        let props = RadioGroupProps::default();
147        let classes = RadioGroup::classes(&props);
148        assert!(classes.contains("flex-col"));
149    }
150
151    #[test]
152    fn radio_group_horizontal() {
153        let props = RadioGroupProps {
154            orientation: Some(stratum_core::aria::Orientation::Horizontal),
155            ..Default::default()
156        };
157        let classes = RadioGroup::classes(&props);
158        assert!(classes.contains("flex-row"));
159    }
160}