Skip to main content

dioxus_ui_system/atoms/
button.rs

1//! Button atom component
2//!
3//! A highly customizable button component with multiple variants and sizes.
4//! Uses Rust-native hover/active states (no CSS pseudo-classes).
5
6use crate::styles::Style;
7use crate::theme::tokens::Color;
8use crate::theme::{use_style, use_theme};
9use dioxus::prelude::*;
10
11/// Button variant styles
12#[derive(Default, Clone, PartialEq, Debug)]
13pub enum ButtonVariant {
14    /// Primary action button
15    #[default]
16    Primary,
17    /// Secondary action button
18    Secondary,
19    /// Ghost/outline button
20    Ghost,
21    /// Destructive action button
22    Destructive,
23    /// Link-style button
24    Link,
25}
26
27/// Button sizes
28#[derive(Default, Clone, PartialEq, Debug)]
29pub enum ButtonSize {
30    /// Small button
31    Sm,
32    /// Medium (default) button
33    #[default]
34    Md,
35    /// Large button
36    Lg,
37    /// Icon-only button
38    Icon,
39}
40
41/// Button properties
42#[derive(Props, Clone, PartialEq)]
43pub struct ButtonProps {
44    /// Button content
45    pub children: Element,
46    /// Visual variant
47    #[props(default)]
48    pub variant: ButtonVariant,
49    /// Button size
50    #[props(default)]
51    pub size: ButtonSize,
52    /// Disabled state
53    #[props(default)]
54    pub disabled: bool,
55    /// Full width button
56    #[props(default)]
57    pub full_width: bool,
58    /// Click handler
59    #[props(default)]
60    pub onclick: Option<EventHandler<MouseEvent>>,
61    /// Custom inline styles
62    #[props(default)]
63    pub style: Option<String>,
64    /// Custom class name
65    #[props(default)]
66    pub class: Option<String>,
67    /// Button type
68    #[props(default)]
69    pub button_type: ButtonType,
70}
71
72/// HTML button type
73#[derive(Default, Clone, PartialEq)]
74pub enum ButtonType {
75    #[default]
76    Button,
77    Submit,
78    Reset,
79}
80
81impl ButtonType {
82    pub fn as_str(&self) -> &'static str {
83        match self {
84            ButtonType::Button => "button",
85            ButtonType::Submit => "submit",
86            ButtonType::Reset => "reset",
87        }
88    }
89}
90
91/// Button atom component
92///
93/// # Example
94/// ```rust,ignore
95/// use dioxus::prelude::*;
96/// use dioxus_ui_system::atoms::{Button, ButtonVariant, ButtonSize};
97///
98/// rsx! {
99///     Button {
100///         variant: ButtonVariant::Primary,
101///         size: ButtonSize::Md,
102///         onclick: move |_| println!("Clicked!"),
103///         "Click me"
104///     }
105/// }
106/// ```
107#[component]
108pub fn Button(props: ButtonProps) -> Element {
109    let _theme = use_theme();
110
111    let variant = props.variant.clone();
112    let size = props.size.clone();
113    let disabled = props.disabled;
114    let full_width = props.full_width;
115
116    // Interactive states
117    let mut is_hovered = use_signal(|| false);
118    let mut is_pressed = use_signal(|| false);
119    let mut is_focused = use_signal(|| false);
120
121    // Memoized styles
122    let style = use_style(move |t| {
123        let base = Style::new()
124            .inline_flex()
125            .items_center()
126            .justify_center()
127            .gap(&t.spacing, "sm")
128            .rounded(&t.radius, "md")
129            .text(&t.typography, "sm")
130            .font_weight(500)
131            .line_height(1.0)
132            .transition("all 150ms cubic-bezier(0.4, 0, 0.2, 1)")
133            .select_none()
134            .whitespace_nowrap()
135            .cursor(if disabled { "not-allowed" } else { "pointer" });
136
137        // Apply opacity for disabled state
138        let base = if disabled {
139            base.opacity(0.5)
140        } else {
141            base.opacity(1.0)
142        };
143
144        // Full width
145        let base = if full_width { base.w_full() } else { base };
146
147        // Size styles
148        let sized = match size {
149            ButtonSize::Sm => base.p(&t.spacing, "sm").h_px(32),
150            ButtonSize::Md => base.p(&t.spacing, "md").h_px(40),
151            ButtonSize::Lg => base.p(&t.spacing, "lg").h_px(48),
152            ButtonSize::Icon => base.p(&t.spacing, "md").rounded(&t.radius, "md"),
153        };
154
155        // Variant styles with hover states
156        let (bg, fg, border, shadow) = match variant {
157            ButtonVariant::Primary => {
158                let bg = if is_hovered() && !disabled {
159                    t.colors.primary.darken(0.1)
160                } else {
161                    t.colors.primary.clone()
162                };
163                let shadow = if is_focused() && !disabled {
164                    format!("0 0 0 2px {}", t.colors.background.to_rgba())
165                } else {
166                    String::new()
167                };
168                (bg, t.colors.primary_foreground.clone(), None, shadow)
169            }
170            ButtonVariant::Secondary => {
171                let bg = if is_hovered() && !disabled {
172                    t.colors.secondary.darken(0.05)
173                } else {
174                    t.colors.secondary.clone()
175                };
176                (
177                    bg,
178                    t.colors.secondary_foreground.clone(),
179                    None,
180                    String::new(),
181                )
182            }
183            ButtonVariant::Ghost => {
184                let bg = if is_hovered() && !disabled {
185                    t.colors.accent.clone()
186                } else {
187                    Color::new_rgba(0, 0, 0, 0.0)
188                };
189                let border = if is_focused() && !disabled {
190                    Some(format!("1px solid {}", t.colors.ring.to_rgba()))
191                } else {
192                    None
193                };
194                (bg, t.colors.foreground.clone(), border, String::new())
195            }
196            ButtonVariant::Destructive => {
197                let bg = if is_hovered() && !disabled {
198                    t.colors.destructive.darken(0.1)
199                } else {
200                    t.colors.destructive.clone()
201                };
202                (bg, Color::new(255, 255, 255), None, String::new())
203            }
204            ButtonVariant::Link => {
205                let fg = if is_hovered() && !disabled {
206                    t.colors.primary.darken(0.1)
207                } else {
208                    t.colors.primary.clone()
209                };
210                (Color::new_rgba(0, 0, 0, 0.0), fg, None, String::new())
211            }
212        };
213
214        let mut final_style = sized.bg(&bg).text_color(&fg);
215
216        if let Some(b) = border {
217            final_style = Style {
218                border: Some(b),
219                ..final_style
220            };
221        }
222
223        if !shadow.is_empty() {
224            final_style = Style {
225                box_shadow: Some(shadow),
226                ..final_style
227            };
228        }
229
230        final_style.build()
231    });
232
233    // Transform for pressed state
234    let transform = if is_pressed() && !disabled {
235        "transform: scale(0.98);"
236    } else {
237        ""
238    };
239
240    // Combine with custom styles
241    let final_style = if let Some(custom) = &props.style {
242        format!("{} {}{}", style(), custom, transform)
243    } else {
244        format!("{}{}", style(), transform)
245    };
246
247    let class = props.class.clone().unwrap_or_default();
248
249    rsx! {
250        button {
251            r#type: props.button_type.as_str(),
252            style: "{final_style}",
253            class: "{class}",
254            disabled: disabled,
255            onmouseenter: move |_| if !disabled { is_hovered.set(true) },
256            onmouseleave: move |_| { is_hovered.set(false); is_pressed.set(false); },
257            onmousedown: move |_| if !disabled { is_pressed.set(true) },
258            onmouseup: move |_| if !disabled { is_pressed.set(false) },
259            onfocus: move |_| is_focused.set(true),
260            onblur: move |_| is_focused.set(false),
261            onclick: move |e| {
262                if let Some(handler) = &props.onclick {
263                    if !disabled {
264                        handler.call(e);
265                    }
266                }
267            },
268            {props.children}
269        }
270    }
271}
272
273/// Icon button component (convenience wrapper)
274#[component]
275pub fn IconButton(
276    icon: Element,
277    #[props(default)] variant: ButtonVariant,
278    #[props(default)] size: ButtonSize,
279    #[props(default)] disabled: bool,
280    #[props(default)] onclick: Option<EventHandler<MouseEvent>>,
281) -> Element {
282    rsx! {
283        Button {
284            variant: variant,
285            size: ButtonSize::Icon,
286            disabled: disabled,
287            onclick: onclick,
288            {icon}
289        }
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn test_button_variant_equality() {
299        assert_eq!(ButtonVariant::Primary, ButtonVariant::Primary);
300        assert_ne!(ButtonVariant::Primary, ButtonVariant::Secondary);
301    }
302
303    #[test]
304    fn test_button_size_equality() {
305        assert_eq!(ButtonSize::Md, ButtonSize::Md);
306        assert_ne!(ButtonSize::Sm, ButtonSize::Lg);
307    }
308}