Skip to main content

stratum_components/forms/
textarea.rs

1//! Styled Textarea component.
2
3use crate::common::{Size, merge_classes};
4use stratum_core::aria::{AriaAttributes, AriaRole};
5use stratum_core::render::{AttrValue, RenderOutput};
6
7/// Resize behavior for the textarea.
8#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
9pub enum ResizeMode {
10    /// No resizing allowed.
11    None,
12    /// Resize vertically only (default).
13    #[default]
14    Vertical,
15    /// Resize horizontally only.
16    Horizontal,
17    /// Resize in both directions.
18    Both,
19}
20
21/// Properties for the styled Textarea component.
22#[derive(Debug, Clone, PartialEq, Default)]
23pub struct TextareaProps {
24    pub size: Size,
25    pub resize: ResizeMode,
26    pub disabled: bool,
27    pub readonly: bool,
28    pub required: bool,
29    pub placeholder: Option<String>,
30    pub rows: Option<u32>,
31    pub class: Option<String>,
32    pub aria_label: Option<String>,
33    pub id: Option<String>,
34}
35
36pub struct Textarea;
37
38impl Textarea {
39    const BASE: &'static str = "flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50";
40
41    pub fn classes(props: &TextareaProps) -> String {
42        let size_cls = match props.size {
43            Size::Xs => "text-xs",
44            Size::Sm => "text-xs",
45            Size::Md => "text-sm",
46            Size::Lg => "text-base",
47            Size::Xl => "text-lg",
48        };
49
50        let resize_cls = match props.resize {
51            ResizeMode::None => "resize-none",
52            ResizeMode::Vertical => "resize-y",
53            ResizeMode::Horizontal => "resize-x",
54            ResizeMode::Both => "resize",
55        };
56
57        let computed = format!("{} {} {}", Self::BASE, size_cls, resize_cls);
58        merge_classes(&computed, &props.class)
59    }
60
61    pub fn render(props: &TextareaProps) -> RenderOutput {
62        let classes = Self::classes(props);
63        let mut aria = AriaAttributes::new().with_role(AriaRole::TextBox);
64
65        if let Some(ref label) = props.aria_label {
66            aria = aria.with_label(label.clone());
67        }
68        if props.disabled {
69            aria = aria.with_disabled(true);
70        }
71        if props.required {
72            aria.required = Some(true);
73        }
74        if props.readonly {
75            aria.readonly = Some(true);
76        }
77
78        let mut output = RenderOutput::new()
79            .with_tag("textarea")
80            .with_class(classes)
81            .with_aria(aria);
82
83        if props.required {
84            output = output.with_attr("required", AttrValue::Bool(true));
85        }
86        if props.readonly {
87            output = output.with_attr("readonly", AttrValue::Bool(true));
88        }
89
90        if let Some(ref p) = props.placeholder {
91            output = output.with_attr("placeholder", AttrValue::String(p.clone()));
92        }
93        if let Some(rows) = props.rows {
94            output = output.with_attr("rows", AttrValue::Number(rows as f64));
95        }
96        if props.disabled {
97            output = output.with_attr("disabled", AttrValue::Bool(true));
98        }
99        if props.readonly {
100            output = output.with_attr("readonly", AttrValue::Bool(true));
101        }
102        if let Some(ref id) = props.id {
103            output = output.with_attr("id", AttrValue::String(id.clone()));
104        }
105
106        output
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn default_textarea_classes() {
116        let props = TextareaProps::default();
117        let classes = Textarea::classes(&props);
118        assert!(classes.contains("rounded-md"));
119        assert!(classes.contains("resize-y"));
120    }
121
122    #[test]
123    fn resize_none() {
124        let props = TextareaProps {
125            resize: ResizeMode::None,
126            ..Default::default()
127        };
128        let classes = Textarea::classes(&props);
129        assert!(classes.contains("resize-none"));
130    }
131
132    #[test]
133    fn render_tag_is_textarea() {
134        let props = TextareaProps::default();
135        let output = Textarea::render(&props);
136        assert_eq!(output.effective_tag(), "textarea");
137    }
138
139    #[test]
140    fn render_with_rows() {
141        let props = TextareaProps {
142            rows: Some(5),
143            ..Default::default()
144        };
145        let output = Textarea::render(&props);
146        assert!(output.attrs.iter().any(|(k, _)| k == "rows"));
147    }
148}