/ libs / primitives / src / toggle_group.rs
toggle_group.rs
  1  //! Defines the [`ToggleGroup`] component and its sub-components, which manage a group of toggle buttons with single or multiple selection.
  2  
  3  use crate::{
  4      focus::{use_focus_controlled_item, use_focus_provider, FocusState},
  5      toggle::Toggle,
  6      use_controlled,
  7  };
  8  use dioxus::prelude::*;
  9  use std::collections::HashSet;
 10  
 11  // Todo: docs, test controlled version
 12  
 13  #[derive(Clone, Copy)]
 14  struct ToggleGroupCtx {
 15      // State
 16      disabled: ReadSignal<bool>,
 17      pressed: Memo<HashSet<usize>>,
 18      set_pressed: Callback<HashSet<usize>>,
 19  
 20      allow_multiple_pressed: ReadSignal<bool>,
 21  
 22      // Focus state
 23      focus: FocusState,
 24  
 25      horizontal: ReadSignal<bool>,
 26      roving_loop: ReadSignal<bool>,
 27  }
 28  
 29  impl ToggleGroupCtx {
 30      fn orientation(&self) -> &'static str {
 31          match (self.horizontal)() {
 32              true => "horizontal",
 33              false => "vertical",
 34          }
 35      }
 36  
 37      fn is_pressed(&self, id: usize) -> bool {
 38          let pressed = (self.pressed)();
 39          pressed.contains(&id)
 40      }
 41  
 42      fn set_pressed(&self, id: usize, pressed: bool) {
 43          let mut new_pressed = (self.pressed)();
 44          match pressed {
 45              false => new_pressed.remove(&id),
 46              true => {
 47                  if !(self.allow_multiple_pressed)() {
 48                      new_pressed.clear();
 49                  }
 50                  new_pressed.insert(id)
 51              }
 52          };
 53          self.set_pressed.call(new_pressed);
 54      }
 55  
 56      fn is_horizontal(&self) -> bool {
 57          (self.horizontal)()
 58      }
 59  
 60      fn focus_next(&mut self) {
 61          if !(self.roving_loop)() {
 62              return;
 63          }
 64  
 65          self.focus.focus_next();
 66      }
 67  
 68      fn focus_prev(&mut self) {
 69          if !(self.roving_loop)() {
 70              return;
 71          }
 72  
 73          self.focus.focus_prev();
 74      }
 75  
 76      fn is_roving_loop(&self) -> bool {
 77          (self.roving_loop)()
 78      }
 79  }
 80  
 81  /// The props for the [`ToggleGroup`] component
 82  #[derive(Props, Clone, PartialEq)]
 83  pub struct ToggleGroupProps {
 84      /// The default pressed items if the component is not controlled.
 85      #[props(default)]
 86      pub default_pressed: HashSet<usize>,
 87  
 88      /// The currently pressed items. This can be used to drive the component when controlled.
 89      pub pressed: ReadSignal<Option<HashSet<usize>>>,
 90  
 91      /// Callback to handle changes in pressed state
 92      #[props(default)]
 93      pub on_pressed_change: Callback<HashSet<usize>>,
 94  
 95      /// Whether the toggle group is disabled
 96      #[props(default)]
 97      pub disabled: ReadSignal<bool>,
 98  
 99      /// If multiple items can be pressed at the same time. If this is false, only one item can be pressed at a time (radio-style).
100      #[props(default)]
101      pub allow_multiple_pressed: ReadSignal<bool>,
102  
103      /// Whether the toggle group is horizontal or vertical.
104      #[props(default)]
105      pub horizontal: ReadSignal<bool>,
106  
107      /// Whether focus should loop around when reaching the end.
108      #[props(default = ReadSignal::new(Signal::new(true)))]
109      pub roving_loop: ReadSignal<bool>,
110  
111      /// Additional attributes to apply to the toggle group element
112      #[props(extends = GlobalAttributes)]
113      pub attributes: Vec<Attribute>,
114  
115      /// The children of the toggle group, which should include multiple [`ToggleItem`] components.
116      pub children: Element,
117  }
118  
119  /// # ToggleGroup
120  ///
121  /// The `ToggleGroup` component manages a group of toggle buttons. It supports both single (radio-style) and multiple selection modes with keyboard navigation.
122  ///
123  /// ## Example
124  ///
125  /// ```rust
126  /// use dioxus::prelude::*;
127  /// use dioxus_primitives::toggle_group::{ToggleGroup, ToggleItem};
128  /// #[component]
129  /// fn Demo() -> Element {
130  ///     rsx! {
131  ///         ToggleGroup { horizontal: true, allow_multiple_pressed: true,
132  ///             ToggleItem { index: 0usize, em { "B" } }
133  ///             ToggleItem { index: 1usize, i { "I" } }
134  ///             ToggleItem { index: 2usize, u { "U" } }
135  ///         }
136  ///     }
137  /// }
138  /// ```
139  ///
140  /// ## Styling
141  ///
142  /// The [`ToggleGroup`] component defines the following data attributes you can use to control styling:
143  /// - `data-orientation`: Indicates the orientation of the toggle group. Values are `horizontal` or `vertical`.
144  /// - `data-allow-multiple-pressed`: Indicates if multiple items can be pressed at the same time. Values are `true` or `false`.
145  #[component]
146  pub fn ToggleGroup(props: ToggleGroupProps) -> Element {
147      let (pressed, set_pressed) = use_controlled(
148          props.pressed,
149          props.default_pressed,
150          props.on_pressed_change,
151      );
152  
153      let focus = use_focus_provider(props.roving_loop);
154      let mut ctx = use_context_provider(|| ToggleGroupCtx {
155          pressed,
156          set_pressed,
157          allow_multiple_pressed: props.allow_multiple_pressed,
158          disabled: props.disabled,
159  
160          focus,
161          horizontal: props.horizontal,
162          roving_loop: props.roving_loop,
163      });
164  
165      rsx! {
166          div {
167              onfocusout: move |_| ctx.focus.set_focus(None),
168  
169              "data-orientation": ctx.orientation(),
170              "data-allow-multiple-pressed": ctx.allow_multiple_pressed,
171              ..props.attributes,
172  
173              {props.children}
174          }
175      }
176  }
177  
178  /// The props for the [`ToggleItem`] component
179  #[derive(Props, Clone, PartialEq)]
180  pub struct ToggleItemProps {
181      /// The index of the item within the [`ToggleGroup`]. This is used to order the items for keyboard navigation.
182      pub index: ReadSignal<usize>,
183  
184      /// Whether the toggle item is disabled.
185      #[props(default)]
186      pub disabled: ReadSignal<bool>,
187  
188      /// Additional attributes to apply to the toggle item element
189      #[props(extends = GlobalAttributes)]
190      pub attributes: Vec<Attribute>,
191  
192      /// The children of the toggle item
193      pub children: Element,
194  }
195  
196  /// # ToggleItem
197  ///
198  /// An individual toggle button within a [`ToggleGroup`] component.
199  ///
200  /// This must be used inside a [`ToggleGroup`] component.
201  ///
202  /// ## Example
203  ///
204  /// ```rust
205  /// use dioxus::prelude::*;
206  /// use dioxus_primitives::toggle_group::{ToggleGroup, ToggleItem};
207  /// #[component]
208  /// fn Demo() -> Element {
209  ///     rsx! {
210  ///         ToggleGroup { horizontal: true, allow_multiple_pressed: true,
211  ///             ToggleItem { index: 0usize, em { "B" } }
212  ///             ToggleItem { index: 1usize, i { "I" } }
213  ///             ToggleItem { index: 2usize, u { "U" } }
214  ///         }
215  ///     }
216  /// }
217  /// ```
218  ///
219  /// ## Styling
220  ///
221  /// The [`ToggleItem`] component defines the following data attributes you can use to control styling:
222  /// - `data-state`: Indicates the state of the toggle. Values are `on` or `off`.
223  /// - `data-disabled`: Indicates if the toggle is disabled. Values are `true` or `false`.
224  /// - `data-orientation`: Indicates the orientation of the toggle group. Values are `horizontal` or `vertical`.
225  #[component]
226  pub fn ToggleItem(props: ToggleItemProps) -> Element {
227      let mut ctx: ToggleGroupCtx = use_context();
228  
229      // We need a kept-alive signal to control the toggle.
230      let mut pressed = use_signal(|| ctx.is_pressed(props.index.cloned()));
231      use_effect(move || {
232          let is_pressed = ctx.is_pressed(props.index.cloned());
233          pressed.set(is_pressed);
234      });
235  
236      // Tab index for roving index
237      let tab_index = use_memo(move || {
238          if !ctx.is_roving_loop() {
239              return "0";
240          }
241  
242          match ctx.focus.recent_focus_or_default() == props.index.cloned() {
243              true => "0",
244              false => "-1",
245          }
246      });
247  
248      // Handle settings focus
249      let onmounted = use_focus_controlled_item(props.index);
250  
251      rsx! {
252          Toggle {
253              onmounted,
254              onfocus: move |_| ctx.focus.set_focus(Some(props.index.cloned())),
255              onkeydown: move |event: Event<KeyboardData>| {
256                  let key = event.key();
257                  let horizontal = ctx.is_horizontal();
258                  let mut prevent_default = true;
259  
260                  match key {
261                      Key::ArrowUp if !horizontal => ctx.focus_prev(),
262                      Key::ArrowDown if !horizontal => ctx.focus_next(),
263                      Key::ArrowLeft if horizontal => ctx.focus_prev(),
264                      Key::ArrowRight if horizontal => ctx.focus_next(),
265                      Key::Home => ctx.focus.focus_first(),
266                      Key::End => ctx.focus.focus_last(),
267                      _ => prevent_default = false,
268                  };
269  
270                  if prevent_default {
271                      event.prevent_default();
272                  }
273              },
274  
275              tabindex: tab_index,
276              disabled: (ctx.disabled)() || (props.disabled)(),
277              "data-orientation": ctx.orientation(),
278  
279              pressed: pressed(),
280              on_pressed_change: move |pressed| {
281                  ctx.set_pressed(props.index.cloned(), pressed);
282              },
283  
284              attributes: props.attributes.clone(),
285  
286              {props.children}
287          }
288      }
289  }