Skip to main content

maud_ui/primitives/
textarea.rs

1//! Textarea component — multi-line text input field.
2
3use maud::{html, Markup, PreEscaped};
4
5/// Resize behavior for textarea
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum Resize {
8    None,
9    Vertical,
10    Horizontal,
11    Both,
12}
13
14impl Resize {
15    fn class(&self) -> &'static str {
16        match self {
17            Self::None => "mui-textarea--resize-none",
18            Self::Vertical => "mui-textarea--resize-vertical",
19            Self::Horizontal => "mui-textarea--resize-horizontal",
20            Self::Both => "mui-textarea--resize-both",
21        }
22    }
23}
24
25/// Textarea rendering properties
26#[derive(Debug, Clone)]
27pub struct Props {
28    /// Form field name
29    pub name: String,
30    /// Placeholder text
31    pub placeholder: String,
32    /// Text content inside textarea
33    pub value: String,
34    /// Number of rows (default 4)
35    pub rows: u32,
36    /// Unique identifier
37    pub id: String,
38    /// Whether field is disabled
39    pub disabled: bool,
40    /// Whether field is required
41    pub required: bool,
42    /// Whether field shows invalid state
43    pub invalid: bool,
44    /// Whether field is read-only
45    pub readonly: bool,
46    /// Resize behavior
47    pub resize: Resize,
48}
49
50impl Default for Props {
51    fn default() -> Self {
52        Self {
53            name: String::new(),
54            placeholder: String::new(),
55            value: String::new(),
56            rows: 4,
57            id: String::new(),
58            disabled: false,
59            required: false,
60            invalid: false,
61            readonly: false,
62            resize: Resize::Vertical,
63        }
64    }
65}
66
67/// Render a single textarea with the given properties
68pub fn render(props: Props) -> Markup {
69    let mut attrs = String::new();
70
71    if props.required {
72        attrs.push_str(" required");
73    }
74    if props.disabled {
75        attrs.push_str(" disabled");
76    }
77    if props.readonly {
78        attrs.push_str(" readonly");
79    }
80    if props.invalid {
81        attrs.push_str(r#" aria-invalid="true""#);
82    }
83
84    let class = format!("mui-textarea {}", props.resize.class());
85    let html_string = format!(
86        r#"<textarea class="{}" name="{}" id="{}" placeholder="{}" rows="{}"{}>{}</textarea>"#,
87        escape_html(&class),
88        escape_html(&props.name),
89        escape_html(&props.id),
90        escape_html(&props.placeholder),
91        props.rows,
92        attrs,
93        escape_html(&props.value)
94    );
95
96    PreEscaped(html_string)
97}
98
99fn escape_html(s: &str) -> String {
100    s.replace('&', "&amp;")
101        .replace('<', "&lt;")
102        .replace('>', "&gt;")
103        .replace('"', "&quot;")
104        .replace('\'', "&#39;")
105}
106
107/// Showcase all textarea variants and use cases
108pub fn showcase() -> Markup {
109    html! {
110        div.mui-showcase__grid {
111            // Feedback form with character counter
112            section {
113                h2 { "Share your feedback" }
114                p.mui-showcase__caption { "Tell us what worked and what didn't. We read every response." }
115                div style="display:flex;flex-direction:column;gap:0.5rem;max-width:28rem;" {
116                    label for="feedback-message" style="font-size:0.875rem;font-weight:500;" { "Your feedback" }
117                    (render(Props {
118                        name: "feedback".into(),
119                        id: "feedback-message".into(),
120                        placeholder: "What's on your mind?".into(),
121                        rows: 5,
122                        ..Default::default()
123                    }))
124                    div style="display:flex;justify-content:space-between;font-size:0.75rem;color:var(--mui-text-muted);" {
125                        span { "Min 20 characters" }
126                        span { "0 / 500" }
127                    }
128                }
129            }
130
131            // Bio field
132            section {
133                h2 { "Profile" }
134                p.mui-showcase__caption { "Shown on your public profile and attribution lines." }
135                div style="display:flex;flex-direction:column;gap:0.5rem;max-width:28rem;" {
136                    label for="profile-bio" style="font-size:0.875rem;font-weight:500;" { "Bio" }
137                    (render(Props {
138                        name: "bio".into(),
139                        id: "profile-bio".into(),
140                        placeholder: "Write a short intro about yourself".into(),
141                        rows: 4,
142                        ..Default::default()
143                    }))
144                    p style="font-size:0.75rem;color:var(--mui-text-muted);margin:0;" {
145                        "Tip: mention where you work, what you build, and where folks can find you."
146                    }
147                }
148            }
149
150            // Admin notes — read-only
151            section {
152                h2 { "Admin notes" }
153                p.mui-showcase__caption { "Read-only. Changes require a support ticket." }
154                div style="display:flex;flex-direction:column;gap:0.5rem;max-width:28rem;" {
155                    label for="admin-notes" style="font-size:0.875rem;font-weight:500;color:var(--mui-text-muted);" {
156                        "Admin notes \u{2014} read only"
157                    }
158                    (render(Props {
159                        name: "admin-notes".into(),
160                        id: "admin-notes".into(),
161                        value: "Account flagged for manual review on 2026-04-10 by Sofia M. (billing). Reason: chargeback window open until 2026-05-10. Do not issue refunds without approval from #finance-ops.".into(),
162                        readonly: true,
163                        rows: 4,
164                        ..Default::default()
165                    }))
166                    p style="font-size:0.75rem;color:var(--mui-text-muted);margin:0;" {
167                        "Last updated by Sofia M. \u{00B7} 6 days ago"
168                    }
169                }
170            }
171        }
172    }
173}