Skip to main content

dioxus_ui_system/atoms/
toggle.rs

1//! Toggle atom component
2//!
3//! A two-state button that can be either on or off. Used for features like
4//! bold/italic in text editors, or any feature that requires a simple on/off state.
5//! Similar to a checkbox but styled as a button.
6
7use crate::styles::Style;
8use crate::theme::tokens::Color;
9use crate::theme::{use_style, use_theme};
10use dioxus::prelude::*;
11
12/// Toggle variant styles
13#[derive(Default, Clone, PartialEq, Debug)]
14pub enum ToggleVariant {
15    /// Default filled style
16    #[default]
17    Default,
18    /// Outlined style with border
19    Outline,
20    /// Subtle ghost style
21    Ghost,
22}
23
24/// Toggle sizes
25#[derive(Default, Clone, PartialEq, Debug)]
26pub enum ToggleSize {
27    /// Small toggle
28    Sm,
29    /// Medium (default) toggle
30    #[default]
31    Md,
32    /// Large toggle
33    Lg,
34}
35
36/// Toggle properties
37#[derive(Props, Clone, PartialEq)]
38pub struct ToggleProps {
39    /// Toggle content
40    pub children: Element,
41    /// Whether the toggle is pressed/activated
42    #[props(default)]
43    pub pressed: bool,
44    /// Callback when pressed state changes
45    #[props(default)]
46    pub on_pressed_change: Option<EventHandler<bool>>,
47    /// Visual variant
48    #[props(default)]
49    pub variant: ToggleVariant,
50    /// Toggle size
51    #[props(default)]
52    pub size: ToggleSize,
53    /// Disabled state
54    #[props(default)]
55    pub disabled: bool,
56    /// Custom inline styles
57    #[props(default)]
58    pub style: Option<String>,
59    /// Custom class name
60    #[props(default)]
61    pub class: Option<String>,
62}
63
64/// Toggle atom component
65///
66/// A two-state button that can be either pressed (on) or not pressed (off).
67///
68/// # Example
69/// ```rust,ignore
70/// use dioxus::prelude::*;
71/// use dioxus_ui_system::atoms::{Toggle, ToggleVariant, ToggleSize};
72///
73/// rsx! {
74///     Toggle {
75///         pressed: true,
76///         on_pressed_change: move |pressed| println!("Pressed: {}", pressed),
77///         variant: ToggleVariant::Outline,
78///         size: ToggleSize::Md,
79///         "Bold"
80///     }
81/// }
82/// ```
83#[component]
84pub fn Toggle(props: ToggleProps) -> Element {
85    let _theme = use_theme();
86
87    let mut is_pressed = use_signal(|| props.pressed);
88    let mut is_hovered = use_signal(|| false);
89    let mut is_focused = use_signal(|| false);
90
91    // Sync with prop changes
92    use_effect(move || {
93        is_pressed.set(props.pressed);
94    });
95
96    let pressed = is_pressed();
97    let disabled = props.disabled;
98    let variant = props.variant.clone();
99    let size = props.size.clone();
100
101    // Memoized styles
102    let style = use_style(move |t| {
103        let base = Style::new()
104            .inline_flex()
105            .items_center()
106            .justify_center()
107            .gap(&t.spacing, "sm")
108            .rounded(&t.radius, "md")
109            .text(&t.typography, "sm")
110            .font_weight(500)
111            .line_height(1.0)
112            .transition("all 150ms cubic-bezier(0.4, 0, 0.2, 1)")
113            .select_none()
114            .whitespace_nowrap()
115            .cursor(if disabled { "not-allowed" } else { "pointer" });
116
117        // Apply opacity for disabled state
118        let base = if disabled {
119            base.opacity(0.5)
120        } else {
121            base.opacity(1.0)
122        };
123
124        // Size styles
125        let sized = match size {
126            ToggleSize::Sm => base.px(&t.spacing, "sm").h_px(32),
127            ToggleSize::Md => base.px(&t.spacing, "md").h_px(40),
128            ToggleSize::Lg => base.px(&t.spacing, "lg").h_px(48),
129        };
130
131        // Variant styles based on pressed state
132        let (bg, fg, border) = match variant {
133            ToggleVariant::Default => {
134                if pressed {
135                    let bg = if is_hovered() && !disabled {
136                        t.colors.primary.darken(0.1)
137                    } else {
138                        t.colors.primary.clone()
139                    };
140                    (bg, t.colors.primary_foreground.clone(), None)
141                } else {
142                    let bg = if is_hovered() && !disabled {
143                        t.colors.muted.darken(0.05)
144                    } else {
145                        t.colors.muted.clone()
146                    };
147                    (bg, t.colors.muted_foreground.clone(), None)
148                }
149            }
150            ToggleVariant::Outline => {
151                if pressed {
152                    let bg = if is_hovered() && !disabled {
153                        t.colors.accent.darken(0.05)
154                    } else {
155                        t.colors.accent.clone()
156                    };
157                    let border_color = if is_hovered() && !disabled {
158                        t.colors.primary.darken(0.1)
159                    } else {
160                        t.colors.primary.clone()
161                    };
162                    (bg, t.colors.foreground.clone(), Some(border_color))
163                } else {
164                    let bg = if is_hovered() && !disabled {
165                        t.colors.accent.clone()
166                    } else {
167                        Color::new_rgba(0, 0, 0, 0.0)
168                    };
169                    (
170                        bg,
171                        t.colors.foreground.clone(),
172                        Some(t.colors.border.clone()),
173                    )
174                }
175            }
176            ToggleVariant::Ghost => {
177                if pressed {
178                    let bg = if is_hovered() && !disabled {
179                        t.colors.accent.darken(0.05)
180                    } else {
181                        t.colors.accent.clone()
182                    };
183                    (bg, t.colors.foreground.clone(), None)
184                } else {
185                    let bg = if is_hovered() && !disabled {
186                        t.colors.accent.clone()
187                    } else {
188                        Color::new_rgba(0, 0, 0, 0.0)
189                    };
190                    (bg, t.colors.muted_foreground.clone(), None)
191                }
192            }
193        };
194
195        let mut final_style = sized.bg(&bg).text_color(&fg);
196
197        // Add border for outline variant
198        if let Some(border_color) = border {
199            final_style = final_style.border(1, &border_color);
200        }
201
202        // Add focus ring
203        if is_focused() && !disabled {
204            final_style = Style {
205                box_shadow: Some(format!("0 0 0 2px {}", t.colors.ring.to_rgba())),
206                ..final_style
207            };
208        }
209
210        final_style.build()
211    });
212
213    // Combine with custom styles
214    let final_style = if let Some(custom) = &props.style {
215        format!("{} {}", style(), custom)
216    } else {
217        style()
218    };
219
220    let class = props.class.clone().unwrap_or_default();
221
222    let handle_click = move |_| {
223        if !disabled {
224            let new_pressed = !is_pressed();
225            is_pressed.set(new_pressed);
226            if let Some(handler) = &props.on_pressed_change {
227                handler.call(new_pressed);
228            }
229        }
230    };
231
232    rsx! {
233        button {
234            r#type: "button",
235            role: "switch",
236            aria_pressed: "{pressed}",
237            disabled: disabled,
238            style: "{final_style}",
239            class: "{class}",
240            onclick: handle_click,
241            onmouseenter: move |_| if !disabled { is_hovered.set(true) },
242            onmouseleave: move |_| is_hovered.set(false),
243            onfocus: move |_| is_focused.set(true),
244            onblur: move |_| is_focused.set(false),
245            {props.children}
246        }
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn test_toggle_variant_equality() {
256        assert_eq!(ToggleVariant::Default, ToggleVariant::Default);
257        assert_ne!(ToggleVariant::Default, ToggleVariant::Outline);
258        assert_ne!(ToggleVariant::Outline, ToggleVariant::Ghost);
259    }
260
261    #[test]
262    fn test_toggle_size_equality() {
263        assert_eq!(ToggleSize::Md, ToggleSize::Md);
264        assert_ne!(ToggleSize::Sm, ToggleSize::Lg);
265    }
266}