Skip to main content

maud_ui/primitives/
button.rs

1//! Button component — maud-ui Wave 1
2
3use maud::{html, Markup, PreEscaped};
4
5/// Inline SVG plus icon (16x16, stroke=currentColor) for use in leading_icon.
6fn icon_plus() -> Markup {
7    PreEscaped(r#"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>"#.to_string())
8}
9
10/// Inline SVG GitHub icon (16x16, stroke=currentColor).
11fn icon_github() -> Markup {
12    PreEscaped(r#"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.4-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/></svg>"#.to_string())
13}
14
15/// Inline SVG loader spinner (16x16, stroke=currentColor) — self-animates via `.mui-spin` class.
16fn icon_spinner() -> Markup {
17    PreEscaped(r#"<svg xmlns="http://www.w3.org/2000/svg" class="mui-spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>"#.to_string())
18}
19
20#[derive(Clone, Debug)]
21pub struct Props {
22    pub label: String,
23    pub variant: Variant,
24    pub size: Size,
25    pub disabled: bool,
26    pub button_type: &'static str,
27    /// Optional leading icon (SVG markup). Use `stroke="currentColor"` so it
28    /// inherits the button's text color — emoji characters do NOT inherit
29    /// color and will render in OS system colors.
30    pub leading_icon: Option<Markup>,
31    /// aria-label override. Required for icon-only buttons (where `label` is
32    /// empty) so screen readers announce the button's purpose.
33    pub aria_label: Option<String>,
34}
35
36impl Default for Props {
37    fn default() -> Self {
38        Self {
39            label: "Button".to_string(),
40            variant: Variant::Default,
41            size: Size::Md,
42            disabled: false,
43            button_type: "button",
44            leading_icon: None,
45            aria_label: None,
46        }
47    }
48}
49
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum Variant {
52    Default,
53    Primary,
54    Secondary,
55    Outline,
56    Ghost,
57    Danger,
58    Link,
59}
60
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub enum Size {
63    Sm,
64    Md,
65    Lg,
66    Icon,
67}
68
69impl Variant {
70    fn class_name(self) -> &'static str {
71        match self {
72            Variant::Default => "mui-btn--default",
73            Variant::Primary => "mui-btn--primary",
74            Variant::Secondary => "mui-btn--secondary",
75            Variant::Outline => "mui-btn--outline",
76            Variant::Ghost => "mui-btn--ghost",
77            Variant::Danger => "mui-btn--danger",
78            Variant::Link => "mui-btn--link",
79        }
80    }
81}
82
83impl Size {
84    fn class_name(self) -> &'static str {
85        match self {
86            Size::Sm => "mui-btn--sm",
87            Size::Md => "mui-btn--md",
88            Size::Lg => "mui-btn--lg",
89            Size::Icon => "mui-btn--icon",
90        }
91    }
92}
93
94pub fn render(props: Props) -> Markup {
95    let disabled_attr = if props.disabled {
96        "true"
97    } else {
98        "false"
99    };
100
101    let class = format!(
102        "mui-btn {} {}",
103        props.variant.class_name(),
104        props.size.class_name()
105    );
106
107    html! {
108        @if let Some(label) = &props.aria_label {
109            button class=(class) type=(props.button_type) aria-disabled=(disabled_attr) aria-label=(label) {
110                @if let Some(icon) = &props.leading_icon {
111                    span.mui-btn__icon aria-hidden="true" { (icon) }
112                }
113                (props.label)
114            }
115        } @else {
116            button class=(class) type=(props.button_type) aria-disabled=(disabled_attr) {
117                @if let Some(icon) = &props.leading_icon {
118                    span.mui-btn__icon aria-hidden="true" { (icon) }
119                }
120                (props.label)
121            }
122        }
123    }
124}
125
126pub fn showcase() -> Markup {
127    html! {
128        div.mui-showcase__grid {
129            section {
130                h2 { "Form actions" }
131                p.mui-showcase__caption { "Primary/secondary pairing for settings, onboarding, checkout." }
132                div.mui-showcase__row {
133                    (render(Props {
134                        label: "Save changes".to_string(),
135                        variant: Variant::Primary,
136                        size: Size::Md,
137                        disabled: false,
138                        button_type: "submit",
139                        leading_icon: None,
140                        aria_label: None,
141                    }))
142                    (render(Props {
143                        label: "Continue to billing".to_string(),
144                        variant: Variant::Primary,
145                        size: Size::Md,
146                        disabled: false,
147                        button_type: "button",
148                        leading_icon: None,
149                        aria_label: None,
150                    }))
151                    (render(Props {
152                        label: "Cancel".to_string(),
153                        variant: Variant::Outline,
154                        size: Size::Md,
155                        disabled: false,
156                        button_type: "button",
157                        leading_icon: None,
158                        aria_label: None,
159                    }))
160                }
161            }
162            section {
163                h2 { "Destructive" }
164                p.mui-showcase__caption { "Irreversible actions — only after a confirm dialog." }
165                div.mui-showcase__row {
166                    (render(Props {
167                        label: "Delete account".to_string(),
168                        variant: Variant::Danger,
169                        size: Size::Md,
170                        disabled: false,
171                        button_type: "button",
172                        leading_icon: None,
173                        aria_label: None,
174                    }))
175                    (render(Props {
176                        label: "Revoke API key".to_string(),
177                        variant: Variant::Danger,
178                        size: Size::Sm,
179                        disabled: false,
180                        button_type: "button",
181                        leading_icon: None,
182                        aria_label: None,
183                    }))
184                }
185            }
186            section {
187                h2 { "Loading state" }
188                p.mui-showcase__caption { "Disabled + spinner icon while awaiting a response." }
189                div.mui-showcase__row {
190                    (render(Props {
191                        label: "Signing in\u{2026}".to_string(),
192                        variant: Variant::Primary,
193                        size: Size::Md,
194                        disabled: true,
195                        button_type: "button",
196                        leading_icon: Some(icon_spinner()),
197                        aria_label: None,
198                    }))
199                    (render(Props {
200                        label: "Deploying\u{2026}".to_string(),
201                        variant: Variant::Secondary,
202                        size: Size::Md,
203                        disabled: true,
204                        button_type: "button",
205                        leading_icon: Some(icon_spinner()),
206                        aria_label: None,
207                    }))
208                }
209            }
210            section {
211                h2 { "Icon + text" }
212                p.mui-showcase__caption { "Leading glyph for recognition at a glance." }
213                div.mui-showcase__row {
214                    (render(Props {
215                        label: "Invite teammate".to_string(),
216                        variant: Variant::Primary,
217                        size: Size::Md,
218                        disabled: false,
219                        button_type: "button",
220                        leading_icon: Some(icon_plus()),
221                        aria_label: None,
222                    }))
223                    (render(Props {
224                        label: "GitHub".to_string(),
225                        variant: Variant::Outline,
226                        size: Size::Md,
227                        disabled: false,
228                        button_type: "button",
229                        leading_icon: Some(icon_github()),
230                        aria_label: None,
231                    }))
232                    (render(Props {
233                        label: String::new(),
234                        variant: Variant::Outline,
235                        size: Size::Icon,
236                        disabled: false,
237                        button_type: "button",
238                        leading_icon: Some(icon_plus()),
239                        aria_label: Some("Add item".to_string()),
240                    }))
241                }
242            }
243        }
244    }
245}