Skip to main content

stratum_components/forms/
form.rs

1//! Form layout components: Form, FormField, FormLabel, FormError, FormHelperText.
2
3use crate::common::merge_classes;
4use stratum_core::aria::{AriaAttributes, AriaRole};
5use stratum_core::render::{AttrValue, ChildrenSpec, RenderOutput};
6
7// --- Form ---
8
9#[derive(Debug, Clone, PartialEq, Default)]
10pub struct FormProps {
11    pub class: Option<String>,
12    pub id: Option<String>,
13}
14
15pub struct Form;
16
17impl Form {
18    pub fn classes(props: &FormProps) -> String {
19        merge_classes("space-y-6", &props.class)
20    }
21
22    pub fn render(props: &FormProps) -> RenderOutput {
23        let classes = Self::classes(props);
24        let mut output = RenderOutput::new()
25            .with_tag("form")
26            .with_class(classes)
27            .with_aria(AriaAttributes::new().with_role(AriaRole::Form));
28        if let Some(ref id) = props.id {
29            output = output.with_attr("id", AttrValue::String(id.clone()));
30        }
31        output
32    }
33}
34
35// --- FormField ---
36
37#[derive(Debug, Clone, PartialEq, Default)]
38pub struct FormFieldProps {
39    pub class: Option<String>,
40}
41
42pub struct FormField;
43
44impl FormField {
45    pub fn classes(props: &FormFieldProps) -> String {
46        merge_classes("space-y-2", &props.class)
47    }
48
49    pub fn render(props: &FormFieldProps) -> RenderOutput {
50        RenderOutput::new()
51            .with_tag("div")
52            .with_class(Self::classes(props))
53    }
54}
55
56// --- FormLabel ---
57
58#[derive(Debug, Clone, PartialEq, Default)]
59pub struct FormLabelProps {
60    pub html_for: Option<String>,
61    pub required: bool,
62    pub class: Option<String>,
63}
64
65pub struct FormLabel;
66
67impl FormLabel {
68    const BASE: &'static str = "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70";
69
70    pub fn classes(props: &FormLabelProps) -> String {
71        merge_classes(Self::BASE, &props.class)
72    }
73
74    pub fn render(props: &FormLabelProps) -> RenderOutput {
75        let mut output = RenderOutput::new()
76            .with_tag("label")
77            .with_class(Self::classes(props));
78        if let Some(ref f) = props.html_for {
79            output = output.with_attr("for", AttrValue::String(f.clone()));
80        }
81        output
82    }
83}
84
85// --- FormError ---
86
87#[derive(Debug, Clone, PartialEq)]
88pub struct FormErrorProps {
89    pub message: String,
90    pub class: Option<String>,
91    pub id: Option<String>,
92}
93
94pub struct FormError;
95
96impl FormError {
97    const BASE: &'static str = "text-[0.8rem] font-medium text-destructive";
98
99    pub fn classes(props: &FormErrorProps) -> String {
100        merge_classes(Self::BASE, &props.class)
101    }
102
103    pub fn render(props: &FormErrorProps) -> RenderOutput {
104        let mut output = RenderOutput::new()
105            .with_tag("p")
106            .with_class(Self::classes(props))
107            .with_aria(AriaAttributes::new().with_role(AriaRole::Alert))
108            .with_children(ChildrenSpec::Text(props.message.clone()));
109        if let Some(ref id) = props.id {
110            output = output.with_attr("id", AttrValue::String(id.clone()));
111        }
112        output
113    }
114}
115
116// --- FormHelperText ---
117
118#[derive(Debug, Clone, PartialEq)]
119pub struct FormHelperTextProps {
120    pub text: String,
121    pub class: Option<String>,
122    pub id: Option<String>,
123}
124
125pub struct FormHelperText;
126
127impl FormHelperText {
128    const BASE: &'static str = "text-[0.8rem] text-muted-foreground";
129
130    pub fn classes(props: &FormHelperTextProps) -> String {
131        merge_classes(Self::BASE, &props.class)
132    }
133
134    pub fn render(props: &FormHelperTextProps) -> RenderOutput {
135        let mut output = RenderOutput::new()
136            .with_tag("p")
137            .with_class(Self::classes(props))
138            .with_children(ChildrenSpec::Text(props.text.clone()));
139        if let Some(ref id) = props.id {
140            output = output.with_attr("id", AttrValue::String(id.clone()));
141        }
142        output
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn form_render_tag() {
152        let props = FormProps::default();
153        let output = Form::render(&props);
154        assert_eq!(output.effective_tag(), "form");
155        assert_eq!(output.aria.role, Some(AriaRole::Form));
156    }
157
158    #[test]
159    fn form_field_classes() {
160        let props = FormFieldProps::default();
161        let classes = FormField::classes(&props);
162        assert!(classes.contains("space-y-2"));
163    }
164
165    #[test]
166    fn form_label_render() {
167        let props = FormLabelProps {
168            html_for: Some("email".to_string()),
169            ..Default::default()
170        };
171        let output = FormLabel::render(&props);
172        assert_eq!(output.effective_tag(), "label");
173        assert!(output.attrs.iter().any(|(k, _)| k == "for"));
174    }
175
176    #[test]
177    fn form_error_render() {
178        let props = FormErrorProps {
179            message: "Required field".to_string(),
180            class: None,
181            id: Some("email-error".to_string()),
182        };
183        let output = FormError::render(&props);
184        assert_eq!(output.effective_tag(), "p");
185        assert_eq!(output.aria.role, Some(AriaRole::Alert));
186        assert_eq!(
187            output.children,
188            ChildrenSpec::Text("Required field".to_string())
189        );
190    }
191
192    #[test]
193    fn form_helper_text_render() {
194        let props = FormHelperTextProps {
195            text: "Enter your email".to_string(),
196            class: None,
197            id: None,
198        };
199        let output = FormHelperText::render(&props);
200        assert_eq!(output.effective_tag(), "p");
201        assert!(output.class_string().contains("text-muted-foreground"));
202    }
203}