Skip to main content

stratum_components/forms/
input.rs

1//! Styled Input component.
2
3use crate::common::{Size, merge_classes};
4use stratum_core::aria::{AriaAttributes, AriaRole};
5use stratum_core::render::{AttrValue, RenderOutput};
6
7/// Visual variant of the input.
8#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
9pub enum InputVariant {
10    #[default]
11    Default,
12    /// Shows a destructive/error border.
13    Error,
14    /// Shows a success border.
15    Success,
16}
17
18/// Properties for the styled Input component.
19#[derive(Debug, Clone, PartialEq, Default)]
20pub struct InputProps {
21    pub variant: InputVariant,
22    pub size: Size,
23    pub disabled: bool,
24    pub readonly: bool,
25    pub required: bool,
26    pub placeholder: Option<String>,
27    pub input_type: Option<String>,
28    pub class: Option<String>,
29    pub aria_label: Option<String>,
30    pub id: Option<String>,
31}
32
33/// Styled Input component.
34pub struct Input;
35
36impl Input {
37    const BASE: &'static str = "flex w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50";
38
39    pub fn classes(props: &InputProps) -> String {
40        let variant = match props.variant {
41            InputVariant::Default => "",
42            InputVariant::Error => "border-destructive focus-visible:ring-destructive",
43            InputVariant::Success => "border-green-500 focus-visible:ring-green-500",
44        };
45
46        let size = match props.size {
47            Size::Xs => "h-7 text-xs",
48            Size::Sm => "h-8 text-xs",
49            Size::Md => "h-9 text-sm",
50            Size::Lg => "h-10 text-base",
51            Size::Xl => "h-12 text-lg",
52        };
53
54        let computed = format!("{} {} {}", Self::BASE, variant, size);
55        merge_classes(&computed, &props.class)
56    }
57
58    pub fn render(props: &InputProps) -> RenderOutput {
59        let classes = Self::classes(props);
60        let mut aria = AriaAttributes::new().with_role(AriaRole::TextBox);
61
62        if let Some(ref label) = props.aria_label {
63            aria = aria.with_label(label.clone());
64        }
65        if props.disabled {
66            aria = aria.with_disabled(true);
67        }
68        if props.required {
69            aria.required = Some(true);
70        }
71        if props.readonly {
72            aria.readonly = Some(true);
73        }
74        if props.variant == InputVariant::Error {
75            aria.invalid = Some(true);
76        }
77
78        let mut output = RenderOutput::new()
79            .with_tag("input")
80            .with_class(classes)
81            .with_aria(aria);
82
83        if let Some(ref t) = props.input_type {
84            output = output.with_attr("type", AttrValue::String(t.clone()));
85        }
86        if let Some(ref p) = props.placeholder {
87            output = output.with_attr("placeholder", AttrValue::String(p.clone()));
88        }
89        if props.disabled {
90            output = output.with_attr("disabled", AttrValue::Bool(true));
91        }
92        if props.readonly {
93            output = output.with_attr("readonly", AttrValue::Bool(true));
94        }
95        if props.required {
96            output = output.with_attr("required", AttrValue::Bool(true));
97        }
98        if let Some(ref id) = props.id {
99            output = output.with_attr("id", AttrValue::String(id.clone()));
100        }
101
102        output
103    }
104}
105
106/// InputGroup wraps an input with optional leading/trailing addons.
107pub struct InputGroup;
108
109impl InputGroup {
110    pub fn classes() -> String {
111        "flex items-center".to_string()
112    }
113
114    pub fn render() -> RenderOutput {
115        RenderOutput::new()
116            .with_tag("div")
117            .with_class(Self::classes())
118            .with_aria(AriaAttributes::new().with_role(AriaRole::Group))
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn default_input_classes() {
128        let props = InputProps::default();
129        let classes = Input::classes(&props);
130        assert!(classes.contains("flex"));
131        assert!(classes.contains("rounded-md"));
132        assert!(classes.contains("h-9"));
133    }
134
135    #[test]
136    fn error_variant() {
137        let props = InputProps {
138            variant: InputVariant::Error,
139            ..Default::default()
140        };
141        let classes = Input::classes(&props);
142        assert!(classes.contains("border-destructive"));
143    }
144
145    #[test]
146    fn render_tag_is_input() {
147        let props = InputProps::default();
148        let output = Input::render(&props);
149        assert_eq!(output.effective_tag(), "input");
150    }
151
152    #[test]
153    fn render_required_input() {
154        let props = InputProps {
155            required: true,
156            ..Default::default()
157        };
158        let output = Input::render(&props);
159        assert_eq!(output.aria.required, Some(true));
160    }
161
162    #[test]
163    fn render_error_sets_invalid() {
164        let props = InputProps {
165            variant: InputVariant::Error,
166            ..Default::default()
167        };
168        let output = Input::render(&props);
169        assert_eq!(output.aria.invalid, Some(true));
170    }
171
172    #[test]
173    fn input_group_render() {
174        let output = InputGroup::render();
175        assert_eq!(output.effective_tag(), "div");
176    }
177}