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 dioxus::prelude::*;
7use crate::theme::{use_theme, use_style};
8use crate::styles::Style;
9use crate::theme::tokens::Color;
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 {
146            base.w_full()
147        } else {
148            base
149        };
150        
151        // Size styles
152        let sized = match size {
153            ButtonSize::Sm => base.p(&t.spacing, "sm").h_px(32),
154            ButtonSize::Md => base.p(&t.spacing, "md").h_px(40),
155            ButtonSize::Lg => base.p(&t.spacing, "lg").h_px(48),
156            ButtonSize::Icon => base.p(&t.spacing, "md").rounded(&t.radius, "md"),
157        };
158        
159        // Variant styles with hover states
160        let (bg, fg, border, shadow) = match variant {
161            ButtonVariant::Primary => {
162                let bg = if is_hovered() && !disabled {
163                    t.colors.primary.darken(0.1)
164                } else {
165                    t.colors.primary.clone()
166                };
167                let shadow = if is_focused() && !disabled {
168                    format!("0 0 0 2px {}", t.colors.background.to_rgba())
169                } else {
170                    String::new()
171                };
172                (bg, t.colors.primary_foreground.clone(), None, shadow)
173            }
174            ButtonVariant::Secondary => {
175                let bg = if is_hovered() && !disabled {
176                    t.colors.secondary.darken(0.05)
177                } else {
178                    t.colors.secondary.clone()
179                };
180                (bg, t.colors.secondary_foreground.clone(), None, String::new())
181            }
182            ButtonVariant::Ghost => {
183                let bg = if is_hovered() && !disabled {
184                    t.colors.accent.clone()
185                } else {
186                    Color::new_rgba(0, 0, 0, 0.0)
187                };
188                let border = if is_focused() && !disabled {
189                    Some(format!("1px solid {}", t.colors.ring.to_rgba()))
190                } else {
191                    None
192                };
193                (bg, t.colors.foreground.clone(), border, String::new())
194            }
195            ButtonVariant::Destructive => {
196                let bg = if is_hovered() && !disabled {
197                    t.colors.destructive.darken(0.1)
198                } else {
199                    t.colors.destructive.clone()
200                };
201                (bg, Color::new(255, 255, 255), None, String::new())
202            }
203            ButtonVariant::Link => {
204                let fg = if is_hovered() && !disabled {
205                    t.colors.primary.darken(0.1)
206                } else {
207                    t.colors.primary.clone()
208                };
209                (Color::new_rgba(0, 0, 0, 0.0), fg, None, String::new())
210            }
211        };
212        
213        let mut final_style = sized
214            .bg(&bg)
215            .text_color(&fg);
216            
217        if let Some(b) = border {
218            final_style = Style {
219                border: Some(b),
220                ..final_style
221            };
222        }
223        
224        if !shadow.is_empty() {
225            final_style = Style {
226                box_shadow: Some(shadow),
227                ..final_style
228            };
229        }
230        
231        final_style.build()
232    });
233    
234    // Transform for pressed state
235    let transform = if is_pressed() && !disabled {
236        "transform: scale(0.98);"
237    } else {
238        ""
239    };
240    
241    // Combine with custom styles
242    let final_style = if let Some(custom) = &props.style {
243        format!("{} {}{}", style(), custom, transform)
244    } else {
245        format!("{}{}", style(), transform)
246    };
247    
248    let class = props.class.clone().unwrap_or_default();
249    
250    rsx! {
251        button {
252            r#type: props.button_type.as_str(),
253            style: "{final_style}",
254            class: "{class}",
255            disabled: disabled,
256            onmouseenter: move |_| if !disabled { is_hovered.set(true) },
257            onmouseleave: move |_| { is_hovered.set(false); is_pressed.set(false); },
258            onmousedown: move |_| if !disabled { is_pressed.set(true) },
259            onmouseup: move |_| if !disabled { is_pressed.set(false) },
260            onfocus: move |_| is_focused.set(true),
261            onblur: move |_| is_focused.set(false),
262            onclick: move |e| {
263                if let Some(handler) = &props.onclick {
264                    if !disabled {
265                        handler.call(e);
266                    }
267                }
268            },
269            {props.children}
270        }
271    }
272}
273
274/// Icon button component (convenience wrapper)
275#[component]
276pub fn IconButton(
277    icon: Element,
278    #[props(default)]
279    variant: ButtonVariant,
280    #[props(default)]
281    size: ButtonSize,
282    #[props(default)]
283    disabled: bool,
284    #[props(default)]
285    onclick: Option<EventHandler<MouseEvent>>,
286) -> Element {
287    rsx! {
288        Button {
289            variant: variant,
290            size: ButtonSize::Icon,
291            disabled: disabled,
292            onclick: onclick,
293            {icon}
294        }
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn test_button_variant_equality() {
304        assert_eq!(ButtonVariant::Primary, ButtonVariant::Primary);
305        assert_ne!(ButtonVariant::Primary, ButtonVariant::Secondary);
306    }
307
308    #[test]
309    fn test_button_size_equality() {
310        assert_eq!(ButtonSize::Md, ButtonSize::Md);
311        assert_ne!(ButtonSize::Sm, ButtonSize::Lg);
312    }
313}