Skip to main content

dioxus_ui_system/molecules/
toggle_group.rs

1//! Toggle Group molecule component
2//!
3//! A group of two-state toggle buttons. Supports single selection (like text alignment)
4//! or multiple selection (like bold/italic/underline).
5
6use crate::styles::Style;
7use crate::theme::{use_style, use_theme};
8use dioxus::prelude::*;
9
10/// Toggle group selection type
11#[derive(Default, Clone, PartialEq, Debug)]
12pub enum ToggleGroupType {
13    /// Only one toggle can be selected at a time (like radio buttons)
14    #[default]
15    Single,
16    /// Multiple toggles can be selected (like checkboxes)
17    Multiple,
18}
19
20/// Toggle group properties
21#[derive(Props, Clone, PartialEq)]
22pub struct ToggleGroupProps {
23    /// Selection type - Single or Multiple
24    #[props(default)]
25    pub group_type: ToggleGroupType,
26    /// Currently selected values
27    #[props(default)]
28    pub value: Vec<String>,
29    /// Callback when selection changes
30    #[props(default)]
31    pub on_value_change: Option<EventHandler<Vec<String>>>,
32    /// Toggle group children (ToggleItem components)
33    pub children: Element,
34    /// Custom inline styles
35    #[props(default)]
36    pub style: Option<String>,
37    /// Custom class name
38    #[props(default)]
39    pub class: Option<String>,
40}
41
42/// Toggle item properties (for use within ToggleGroup)
43#[derive(Props, Clone, PartialEq)]
44pub struct ToggleItemProps {
45    /// Toggle item content
46    pub children: Element,
47    /// Unique value for this toggle item
48    pub value: String,
49    /// Whether this item is disabled
50    #[props(default)]
51    pub disabled: bool,
52    /// Custom inline styles
53    #[props(default)]
54    pub style: Option<String>,
55    /// Custom class name
56    #[props(default)]
57    pub class: Option<String>,
58}
59
60/// Toggle group context for managing state
61#[derive(Clone)]
62pub struct ToggleGroupContext {
63    /// Currently selected values
64    pub selected_values: Signal<Vec<String>>,
65    /// Selection type
66    pub group_type: ToggleGroupType,
67    /// Callback when selection changes
68    pub on_value_change: Option<EventHandler<Vec<String>>>,
69}
70
71impl ToggleGroupContext {
72    /// Check if a value is selected
73    pub fn is_selected(&self, value: &str) -> bool {
74        self.selected_values.read().iter().any(|v| v == value)
75    }
76
77    /// Toggle a value
78    pub fn toggle_value(&mut self, value: &str) {
79        let mut values = self.selected_values.write();
80
81        match self.group_type {
82            ToggleGroupType::Single => {
83                // In single mode, select only this value (or deselect if already selected)
84                if values.iter().any(|v| v == value) {
85                    values.clear();
86                } else {
87                    values.clear();
88                    values.push(value.to_string());
89                }
90            }
91            ToggleGroupType::Multiple => {
92                // In multiple mode, toggle the value
93                if let Some(pos) = values.iter().position(|v| v == value) {
94                    values.remove(pos);
95                } else {
96                    values.push(value.to_string());
97                }
98            }
99        }
100
101        // Notify parent of change
102        if let Some(handler) = &self.on_value_change {
103            handler.call(values.clone());
104        }
105    }
106}
107
108/// Toggle group molecule component
109///
110/// Manages a group of toggle buttons with single or multiple selection.
111///
112/// # Example
113/// ```rust,ignore
114/// use dioxus::prelude::*;
115/// use dioxus_ui_system::molecules::{ToggleGroup, ToggleItem, ToggleGroupType};
116///
117/// rsx! {
118///     ToggleGroup {
119///         group_type: ToggleGroupType::Single,
120///         value: vec!["left".to_string()],
121///         on_value_change: move |values| println!("Selected: {:?}", values),
122///         ToggleItem { value: "left", "Left" }
123///         ToggleItem { value: "center", "Center" }
124///         ToggleItem { value: "right", "Right" }
125///     }
126/// }
127/// ```
128#[component]
129pub fn ToggleGroup(props: ToggleGroupProps) -> Element {
130    let _theme = use_theme();
131
132    let mut selected_values = use_signal(|| props.value.clone());
133
134    // Sync with prop changes
135    use_effect(move || {
136        selected_values.set(props.value.clone());
137    });
138
139    // Container style
140    let container_style = use_style(move |t| {
141        Style::new()
142            .inline_flex()
143            .items_center()
144            .gap(&t.spacing, "xs")
145            .build()
146    });
147
148    // Combine with custom styles
149    let final_style = if let Some(custom) = &props.style {
150        format!("{} {}", container_style(), custom)
151    } else {
152        container_style()
153    };
154
155    let class = props.class.clone().unwrap_or_default();
156
157    // Provide context to children
158    use_context_provider(|| ToggleGroupContext {
159        selected_values,
160        group_type: props.group_type.clone(),
161        on_value_change: props.on_value_change.clone(),
162    });
163
164    rsx! {
165        div {
166            role: "group",
167            style: "{final_style}",
168            class: "{class}",
169            {props.children}
170        }
171    }
172}
173
174/// Toggle item component for use within ToggleGroup
175///
176/// A single toggle button that participates in a ToggleGroup.
177/// Must be used within a ToggleGroup component.
178///
179/// # Example
180/// ```rust,ignore
181/// use dioxus::prelude::*;
182/// use dioxus_ui_system::molecules::{ToggleGroup, ToggleItem};
183///
184/// rsx! {
185///     ToggleGroup {
186///         ToggleItem { value: "bold", "B" }
187///         ToggleItem { value: "italic", "I" }
188///     }
189/// }
190/// ```
191#[component]
192pub fn ToggleItem(props: ToggleItemProps) -> Element {
193    let _theme = use_theme();
194
195    // Get context from parent ToggleGroup
196    let mut context = use_context::<ToggleGroupContext>();
197
198    let mut is_hovered = use_signal(|| false);
199    let mut is_focused = use_signal(|| false);
200
201    let value = props.value.clone();
202    let disabled = props.disabled;
203
204    // Check if this item is selected from context
205    let is_selected = context.is_selected(&value);
206
207    // Memoized styles
208    let style = use_style(move |t| {
209        let base = Style::new()
210            .inline_flex()
211            .items_center()
212            .justify_center()
213            .px(&t.spacing, "md")
214            .h_px(40)
215            .rounded(&t.radius, "md")
216            .text(&t.typography, "sm")
217            .font_weight(500)
218            .line_height(1.0)
219            .transition("all 150ms cubic-bezier(0.4, 0, 0.2, 1)")
220            .select_none()
221            .whitespace_nowrap()
222            .cursor(if disabled { "not-allowed" } else { "pointer" });
223
224        // Apply opacity for disabled state
225        let base = if disabled {
226            base.opacity(0.5)
227        } else {
228            base.opacity(1.0)
229        };
230
231        // Style based on selected state
232        let (bg, fg, border) = if is_selected {
233            let bg = if is_hovered() && !disabled {
234                t.colors.primary.darken(0.1)
235            } else {
236                t.colors.primary.clone()
237            };
238            (bg, t.colors.primary_foreground.clone(), None)
239        } else {
240            let bg = if is_hovered() && !disabled {
241                t.colors.accent.clone()
242            } else {
243                Color::new_rgba(0, 0, 0, 0.0)
244            };
245            (
246                bg,
247                t.colors.foreground.clone(),
248                Some(t.colors.border.clone()),
249            )
250        };
251
252        let mut final_style = base.bg(&bg).text_color(&fg);
253
254        // Add border for unselected state
255        if let Some(border_color) = border {
256            final_style = final_style.border(1, &border_color);
257        }
258
259        // Add focus ring
260        if is_focused() && !disabled {
261            final_style = Style {
262                box_shadow: Some(format!("0 0 0 2px {}", t.colors.ring.to_rgba())),
263                ..final_style
264            };
265        }
266
267        final_style.build()
268    });
269
270    // Combine with custom styles
271    let final_style = if let Some(custom) = &props.style {
272        format!("{} {}", style(), custom)
273    } else {
274        style()
275    };
276
277    let class = props.class.clone().unwrap_or_default();
278
279    let handle_click = move |_| {
280        if !disabled {
281            context.toggle_value(&value);
282        }
283    };
284
285    rsx! {
286        button {
287            r#type: "button",
288            role: "switch",
289            aria_pressed: "{is_selected}",
290            disabled: disabled,
291            style: "{final_style}",
292            class: "{class}",
293            onclick: handle_click,
294            onmouseenter: move |_| if !disabled { is_hovered.set(true) },
295            onmouseleave: move |_| is_hovered.set(false),
296            onfocus: move |_| is_focused.set(true),
297            onblur: move |_| is_focused.set(false),
298            {props.children}
299        }
300    }
301}
302
303/// Convenience component for a single-select toggle group with icon buttons
304///
305/// Similar to ToggleGroup but optimized for icon-only toggles.
306#[component]
307pub fn IconToggleGroup(
308    #[props(default)] group_type: ToggleGroupType,
309    #[props(default)] value: Vec<String>,
310    #[props(default)] on_value_change: Option<EventHandler<Vec<String>>>,
311    children: Element,
312    #[props(default)] style: Option<String>,
313    #[props(default)] class: Option<String>,
314) -> Element {
315    rsx! {
316        ToggleGroup {
317            group_type: group_type,
318            value: value,
319            on_value_change: on_value_change,
320            style: style,
321            class: class,
322            {children}
323        }
324    }
325}
326
327/// Convenience component for an icon-only toggle item
328///
329/// Similar to ToggleItem but styled for icon-only content.
330#[component]
331pub fn IconToggleItem(
332    value: String,
333    icon: Element,
334    #[props(default)] disabled: bool,
335    #[props(default)] style: Option<String>,
336    #[props(default)] class: Option<String>,
337) -> Element {
338    let _theme = use_theme();
339
340    // Get context from parent ToggleGroup
341    let mut context = use_context::<ToggleGroupContext>();
342
343    let mut is_hovered = use_signal(|| false);
344    let mut is_focused = use_signal(|| false);
345
346    let is_selected = context.is_selected(&value);
347    let item_disabled = disabled;
348
349    // Memoized styles - square icon button style
350    let item_style = use_style(move |t| {
351        let base = Style::new()
352            .inline_flex()
353            .items_center()
354            .justify_center()
355            .w_px(40)
356            .h_px(40)
357            .rounded(&t.radius, "md")
358            .text(&t.typography, "base")
359            .font_weight(500)
360            .transition("all 150ms cubic-bezier(0.4, 0, 0.2, 1)")
361            .select_none()
362            .cursor(if item_disabled {
363                "not-allowed"
364            } else {
365                "pointer"
366            });
367
368        // Apply opacity for disabled state
369        let base = if item_disabled {
370            base.opacity(0.5)
371        } else {
372            base.opacity(1.0)
373        };
374
375        // Style based on selected state
376        let (bg, fg, border) = if is_selected {
377            let bg = if is_hovered() && !item_disabled {
378                t.colors.primary.darken(0.1)
379            } else {
380                t.colors.primary.clone()
381            };
382            (bg, t.colors.primary_foreground.clone(), None)
383        } else {
384            let bg = if is_hovered() && !item_disabled {
385                t.colors.accent.clone()
386            } else {
387                Color::new_rgba(0, 0, 0, 0.0)
388            };
389            (
390                bg,
391                t.colors.foreground.clone(),
392                Some(t.colors.border.clone()),
393            )
394        };
395
396        let mut final_style = base.bg(&bg).text_color(&fg);
397
398        // Add border for unselected state
399        if let Some(border_color) = border {
400            final_style = final_style.border(1, &border_color);
401        }
402
403        // Add focus ring
404        if is_focused() && !item_disabled {
405            final_style = Style {
406                box_shadow: Some(format!("0 0 0 2px {}", t.colors.ring.to_rgba())),
407                ..final_style
408            };
409        }
410
411        final_style.build()
412    });
413
414    // Combine with custom styles
415    let final_style = if let Some(custom) = &style {
416        format!("{} {}", item_style(), custom)
417    } else {
418        item_style()
419    };
420
421    let class_name = class.clone().unwrap_or_default();
422
423    let handle_click = move |_| {
424        if !disabled {
425            context.toggle_value(&value);
426        }
427    };
428
429    rsx! {
430        button {
431            r#type: "button",
432            role: "switch",
433            aria_pressed: "{is_selected}",
434            disabled: disabled,
435            style: "{final_style}",
436            class: "{class_name}",
437            onclick: handle_click,
438            onmouseenter: move |_| if !disabled { is_hovered.set(true) },
439            onmouseleave: move |_| is_hovered.set(false),
440            onfocus: move |_| is_focused.set(true),
441            onblur: move |_| is_focused.set(false),
442            {icon}
443        }
444    }
445}
446
447use crate::theme::tokens::Color;
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    #[test]
454    fn test_toggle_group_type_equality() {
455        assert_eq!(ToggleGroupType::Single, ToggleGroupType::Single);
456        assert_eq!(ToggleGroupType::Multiple, ToggleGroupType::Multiple);
457        assert_ne!(ToggleGroupType::Single, ToggleGroupType::Multiple);
458    }
459}