Skip to main content

stratum_components/forms/
button.rs

1//! Styled Button component wrapping the button primitive.
2
3use crate::common::{Size, merge_classes};
4use stratum_core::aria::{AriaAttributes, AriaRole};
5use stratum_core::render::AttrValue;
6use stratum_core::render::RenderOutput;
7
8/// Visual variant of the button.
9#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
10pub enum ButtonVariant {
11    /// Primary action button.
12    #[default]
13    Default,
14    /// Destructive / danger action.
15    Destructive,
16    /// Outline / bordered button.
17    Outline,
18    /// Secondary / muted button.
19    Secondary,
20    /// Ghost / transparent button.
21    Ghost,
22    /// Link-styled button.
23    Link,
24}
25
26/// Properties for the styled Button component.
27#[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
38/// Styled Button component.
39pub 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    /// Get the CSS classes for this button configuration.
45    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    /// Get the RenderOutput for this button.
74    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}