stratum_components/forms/
button.rs1use crate::common::{Size, merge_classes};
4use stratum_core::aria::{AriaAttributes, AriaRole};
5use stratum_core::render::AttrValue;
6use stratum_core::render::RenderOutput;
7
8#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
10pub enum ButtonVariant {
11 #[default]
13 Default,
14 Destructive,
16 Outline,
18 Secondary,
20 Ghost,
22 Link,
24}
25
26#[derive(Debug, Clone, PartialEq, Default)]
28pub struct ButtonProps {
29 pub variant: ButtonVariant,
30 pub size: Size,
31 pub disabled: bool,
32 pub loading: bool,
33 pub class: Option<String>,
34 pub aria_label: Option<String>,
35 pub id: Option<String>,
36}
37
38pub struct Button;
40
41impl Button {
42 const BASE: &'static str = "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50";
43
44 pub fn classes(props: &ButtonProps) -> String {
46 let variant = match props.variant {
47 ButtonVariant::Default => "bg-primary text-primary-foreground hover:bg-primary/90",
48 ButtonVariant::Destructive => {
49 "bg-destructive text-destructive-foreground hover:bg-destructive/90"
50 }
51 ButtonVariant::Outline => {
52 "border border-input bg-background hover:bg-accent hover:text-accent-foreground"
53 }
54 ButtonVariant::Secondary => {
55 "bg-secondary text-secondary-foreground hover:bg-secondary/80"
56 }
57 ButtonVariant::Ghost => "hover:bg-accent hover:text-accent-foreground",
58 ButtonVariant::Link => "text-primary underline-offset-4 hover:underline",
59 };
60
61 let size = match props.size {
62 Size::Xs => "h-7 px-2 text-xs",
63 Size::Sm => "h-8 rounded-md px-3 text-xs",
64 Size::Md => "h-9 px-4 py-2",
65 Size::Lg => "h-10 rounded-md px-8",
66 Size::Xl => "h-12 rounded-md px-10 text-lg",
67 };
68
69 let computed = format!("{} {} {}", Self::BASE, variant, size);
70 merge_classes(&computed, &props.class)
71 }
72
73 pub fn render(props: &ButtonProps) -> RenderOutput {
75 let classes = Self::classes(props);
76 let mut aria = AriaAttributes::new().with_role(AriaRole::Button);
77
78 if let Some(ref label) = props.aria_label {
79 aria = aria.with_label(label.clone());
80 }
81 if props.disabled || props.loading {
82 aria = aria.with_disabled(true);
83 }
84 if props.loading {
85 aria.busy = Some(true);
86 }
87
88 let mut output = RenderOutput::new()
89 .with_tag("button")
90 .with_class(classes)
91 .with_aria(aria)
92 .with_attr("type", AttrValue::String("button".to_string()));
93
94 if props.disabled || props.loading {
95 output = output.with_attr("disabled", AttrValue::Bool(true));
96 }
97 if let Some(ref id) = props.id {
98 output = output.with_attr("id", AttrValue::String(id.clone()));
99 }
100 if props.loading {
101 output = output.with_data("loading", "true");
102 }
103
104 output
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 #[test]
113 fn default_button_classes() {
114 let props = ButtonProps::default();
115 let classes = Button::classes(&props);
116 assert!(classes.contains("inline-flex"));
117 assert!(classes.contains("bg-primary"));
118 assert!(classes.contains("h-9"));
119 }
120
121 #[test]
122 fn destructive_variant_classes() {
123 let props = ButtonProps {
124 variant: ButtonVariant::Destructive,
125 ..Default::default()
126 };
127 let classes = Button::classes(&props);
128 assert!(classes.contains("bg-destructive"));
129 }
130
131 #[test]
132 fn small_size_classes() {
133 let props = ButtonProps {
134 size: Size::Sm,
135 ..Default::default()
136 };
137 let classes = Button::classes(&props);
138 assert!(classes.contains("h-8"));
139 assert!(classes.contains("px-3"));
140 }
141
142 #[test]
143 fn user_class_override() {
144 let props = ButtonProps {
145 class: Some("my-custom-class".to_string()),
146 ..Default::default()
147 };
148 let classes = Button::classes(&props);
149 assert!(classes.contains("my-custom-class"));
150 }
151
152 #[test]
153 fn render_output_tag_is_button() {
154 let props = ButtonProps::default();
155 let output = Button::render(&props);
156 assert_eq!(output.effective_tag(), "button");
157 }
158
159 #[test]
160 fn render_disabled_button() {
161 let props = ButtonProps {
162 disabled: true,
163 ..Default::default()
164 };
165 let output = Button::render(&props);
166 assert_eq!(output.aria.disabled, Some(true));
167 }
168
169 #[test]
170 fn render_loading_button() {
171 let props = ButtonProps {
172 loading: true,
173 ..Default::default()
174 };
175 let output = Button::render(&props);
176 assert_eq!(output.aria.busy, Some(true));
177 assert_eq!(output.aria.disabled, Some(true));
178 assert!(
179 output
180 .data_attrs
181 .iter()
182 .any(|(k, v)| k == "loading" && v == "true")
183 );
184 }
185
186 #[test]
187 fn render_with_aria_label() {
188 let props = ButtonProps {
189 aria_label: Some("Save document".to_string()),
190 ..Default::default()
191 };
192 let output = Button::render(&props);
193 assert_eq!(output.aria.label, Some("Save document".to_string()));
194 }
195
196 #[test]
197 fn all_variants_produce_classes() {
198 let variants = [
199 ButtonVariant::Default,
200 ButtonVariant::Destructive,
201 ButtonVariant::Outline,
202 ButtonVariant::Secondary,
203 ButtonVariant::Ghost,
204 ButtonVariant::Link,
205 ];
206 for variant in variants {
207 let props = ButtonProps {
208 variant,
209 ..Default::default()
210 };
211 let classes = Button::classes(&props);
212 assert!(classes.contains("inline-flex"));
213 }
214 }
215
216 #[test]
217 fn all_sizes_produce_classes() {
218 let sizes = [Size::Xs, Size::Sm, Size::Md, Size::Lg, Size::Xl];
219 for size in sizes {
220 let props = ButtonProps {
221 size,
222 ..Default::default()
223 };
224 let classes = Button::classes(&props);
225 assert!(!classes.is_empty());
226 }
227 }
228}