dioxus_ui_system/molecules/
toggle_group.rs1use crate::styles::Style;
7use crate::theme::{use_style, use_theme};
8use dioxus::prelude::*;
9
10#[derive(Default, Clone, PartialEq, Debug)]
12pub enum ToggleGroupType {
13 #[default]
15 Single,
16 Multiple,
18}
19
20#[derive(Props, Clone, PartialEq)]
22pub struct ToggleGroupProps {
23 #[props(default)]
25 pub group_type: ToggleGroupType,
26 #[props(default)]
28 pub value: Vec<String>,
29 #[props(default)]
31 pub on_value_change: Option<EventHandler<Vec<String>>>,
32 pub children: Element,
34 #[props(default)]
36 pub style: Option<String>,
37 #[props(default)]
39 pub class: Option<String>,
40}
41
42#[derive(Props, Clone, PartialEq)]
44pub struct ToggleItemProps {
45 pub children: Element,
47 pub value: String,
49 #[props(default)]
51 pub disabled: bool,
52 #[props(default)]
54 pub style: Option<String>,
55 #[props(default)]
57 pub class: Option<String>,
58}
59
60#[derive(Clone)]
62pub struct ToggleGroupContext {
63 pub selected_values: Signal<Vec<String>>,
65 pub group_type: ToggleGroupType,
67 pub on_value_change: Option<EventHandler<Vec<String>>>,
69}
70
71impl ToggleGroupContext {
72 pub fn is_selected(&self, value: &str) -> bool {
74 self.selected_values.read().iter().any(|v| v == value)
75 }
76
77 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 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 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 if let Some(handler) = &self.on_value_change {
103 handler.call(values.clone());
104 }
105 }
106}
107
108#[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 use_effect(move || {
136 selected_values.set(props.value.clone());
137 });
138
139 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 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 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#[component]
192pub fn ToggleItem(props: ToggleItemProps) -> Element {
193 let _theme = use_theme();
194
195 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 let is_selected = context.is_selected(&value);
206
207 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 let base = if disabled {
226 base.opacity(0.5)
227 } else {
228 base.opacity(1.0)
229 };
230
231 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 if let Some(border_color) = border {
256 final_style = final_style.border(1, &border_color);
257 }
258
259 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 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#[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#[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 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 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 let base = if item_disabled {
370 base.opacity(0.5)
371 } else {
372 base.opacity(1.0)
373 };
374
375 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 if let Some(border_color) = border {
400 final_style = final_style.border(1, &border_color);
401 }
402
403 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 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}