stratum_components/forms/
textarea.rs1use crate::common::{Size, merge_classes};
4use stratum_core::aria::{AriaAttributes, AriaRole};
5use stratum_core::render::{AttrValue, RenderOutput};
6
7#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
9pub enum ResizeMode {
10 None,
12 #[default]
14 Vertical,
15 Horizontal,
17 Both,
19}
20
21#[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}