stratum_components/forms/
input.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 InputVariant {
10 #[default]
11 Default,
12 Error,
14 Success,
16}
17
18#[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
33pub 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
106pub 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}