/ libs / ui / src / components / button.rs
button.rs
  1  use crate::{use_id_or, use_unique_id};
  2  use dioxus::prelude::*;
  3  use lucide_dioxus::LoaderCircle;
  4  
  5  /// Button variant types
  6  #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
  7  pub enum ButtonVariant {
  8      #[default]
  9      Primary,
 10      Secondary,
 11      Outline,
 12      Ghost,
 13      Link,
 14      Destructive,
 15  }
 16  
 17  /// Button size options
 18  #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
 19  pub enum ButtonSize {
 20      Small,
 21      #[default]
 22      Medium,
 23      Large,
 24  }
 25  
 26  #[derive(Props, Clone, PartialEq)]
 27  pub struct ButtonProps {
 28      /// Optional extra classes appended to the component defaults
 29      #[props(default)]
 30      class: String,
 31  
 32      /// The button type (submit, reset, button)
 33      #[props(default = String::from("button"))]
 34      button_type: String,
 35  
 36      /// The variant of the button
 37      #[props(default)]
 38      variant: ButtonVariant,
 39  
 40      /// The size of the button
 41      #[props(default)]
 42      size: ButtonSize,
 43  
 44      /// Whether the button is disabled
 45      #[props(default)]
 46      disabled: bool,
 47  
 48      /// Whether the button is in a loading state
 49      #[props(default)]
 50      loading: bool,
 51  
 52      /// Whether the button is displayed as a full width block
 53      #[props(default)]
 54      full_width: bool,
 55  
 56      /// Whether the button is an icon-only button (square with centered icon)
 57      /// Note: When using icon-only buttons, providing an aria-label is strongly recommended
 58      /// for accessibility purposes as there is no visible text to identify the button.
 59      #[props(default)]
 60      is_icon_button: bool,
 61  
 62      /// Callback when the button is clicked
 63      #[props(default)]
 64      on_click: Option<Callback<MouseEvent>>,
 65  
 66      /// Name of the button for form submission
 67      #[props(default)]
 68      name: String,
 69  
 70      /// Value of the button for form submission
 71      #[props(default)]
 72      value: String,
 73  
 74      /// Optional ID for the button
 75      #[props(default)]
 76      id: Option<String>,
 77  
 78      /// Optional icon to display before the button text
 79      #[props(default)]
 80      icon_left: Option<Element>,
 81  
 82      /// Optional icon to display after the button text
 83      #[props(default)]
 84      icon_right: Option<Element>,
 85  
 86      /// Optional aria-label for the button (for accessibility)
 87      #[props(default)]
 88      aria_label: Option<String>,
 89  
 90      /// Optional ID of the element that labels this button (for accessibility)
 91      #[props(default)]
 92      aria_labelledby: Option<String>,
 93  
 94      /// Optional ID of the element that describes this button (for accessibility)
 95      #[props(default)]
 96      aria_describedby: Option<String>,
 97  
 98      /// Optional aria-controls attribute
 99      #[props(default)]
100      aria_controls: Option<String>,
101  
102      /// Optional aria-expanded attribute
103      #[props(default)]
104      aria_expanded: Option<bool>,
105  
106      /// Optional aria-pressed attribute
107      #[props(default)]
108      aria_pressed: Option<bool>,
109  
110      #[props(extends = GlobalAttributes)]
111      attributes: Vec<Attribute>,
112  
113      children: Element,
114  }
115  
116  #[component]
117  pub fn Button(props: ButtonProps) -> Element {
118      // Generate unique ID if not provided
119      let button_id = use_unique_id();
120      let props_id_signal = use_signal(|| props.id);
121      let id_value = use_id_or(button_id, props_id_signal.into());
122  
123      // Check if icon button has aria label for accessibility
124      #[cfg(debug_assertions)]
125      {
126          if props.is_icon_button && props.aria_label.is_none() && props.aria_labelledby.is_none() {
127              log::warn!(
128                  "Icon button missing aria-label or aria-labelledby attribute. This may cause accessibility issues."
129              );
130          }
131      }
132  
133      // Determine base classes for button based on variant
134      let variant_classes = match props.variant {
135          ButtonVariant::Primary => {
136              "bg-primary text-primary-foreground hover:bg-primary/90 border-transparent focus:ring-ring"
137          }
138          ButtonVariant::Secondary => {
139              "bg-secondary text-secondary-foreground hover:bg-secondary/80 border-transparent focus:ring-ring"
140          }
141          ButtonVariant::Outline => {
142              "bg-background text-foreground hover:bg-muted border-border focus:ring-ring"
143          }
144          ButtonVariant::Ghost => {
145              "bg-transparent text-foreground hover:bg-muted border-transparent focus:ring-ring"
146          }
147          ButtonVariant::Link => {
148              "bg-transparent text-primary underline-offset-4 underline border-transparent p-0 shadow-none focus:ring-ring"
149          }
150          ButtonVariant::Destructive => {
151              "bg-destructive text-primary-foreground dark:text-foreground hover:bg-destructive/90 border-transparent focus:ring-ring"
152          }
153      };
154  
155      // Determine size classes based on whether it's an icon button or regular button
156      let size_classes = if props.is_icon_button {
157          match props.size {
158              ButtonSize::Small => "p-1.5 text-sm",
159              ButtonSize::Medium => "p-2 text-base",
160              ButtonSize::Large => "p-3 text-lg",
161          }
162      } else {
163          match props.size {
164              ButtonSize::Small => "text-xs px-2.5 py-1",
165              ButtonSize::Medium => "text-sm px-4 py-1.5",
166              ButtonSize::Large => "text-base px-6 py-2",
167          }
168      };
169  
170      // Determine if the button should be full width (only for non-icon buttons)
171      let width_class = if props.is_icon_button {
172          "w-auto" // Icon buttons should never be full width
173      } else if props.full_width {
174          "w-full"
175      } else {
176          "w-auto"
177      };
178  
179      // Determine disabled and loading state classes
180      let state_class = if props.disabled || props.loading {
181          "opacity-50 cursor-not-allowed"
182      } else {
183          "cursor-pointer"
184      };
185  
186      // Generate all the classes in a more maintainable way
187      let button_classes = vec![
188          // Base classes that apply to all buttons
189          "inline-flex items-center justify-center font-medium rounded border",
190          "transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2",
191          // Variant-specific classes
192          variant_classes,
193          // Size-specific classes
194          size_classes,
195          // Width class
196          width_class,
197          // Icon button gets aspect-square class
198          if props.is_icon_button {
199              "aspect-square"
200          } else {
201              ""
202          },
203          // State class (disabled/loading)
204          state_class,
205          props.class.as_str(),
206      ]
207      .into_iter()
208      .filter(|s| !s.is_empty())
209      .collect::<Vec<_>>()
210      .join(" ");
211  
212      // Handle click event
213      let handle_click = move |event: MouseEvent| {
214          if let Some(callback) = &props.on_click {
215              callback.call(event);
216          }
217      };
218  
219      rsx! {
220          button {
221              // Standard HTML attributes
222              id: id_value,
223              type: props.button_type.clone(),
224              name: props.name,
225              value: props.value,
226              disabled: props.disabled || props.loading,
227              class: button_classes,
228              onclick: handle_click,
229  
230              // ARIA attributes
231              aria_label: if props.is_icon_button && props.aria_label.is_none() {
232                  // Fallback for icon buttons without aria-label
233                  Some("Button".to_string())
234              } else {
235                  props.aria_label.clone()
236              },
237              aria_labelledby: props.aria_labelledby.clone(),
238              aria_describedby: props.aria_describedby.clone(),
239              aria_controls: props.aria_controls.clone(),
240              aria_expanded: props.aria_expanded.map(|v| v.to_string()),
241              aria_pressed: props.aria_pressed.map(|v| v.to_string()),
242              aria_disabled: (props.disabled || props.loading).to_string(),
243  
244              // Pass through other attributes
245              ..props.attributes,
246  
247              if props.is_icon_button {
248                  // Icon button content
249                  if props.loading {
250                      // Loading spinner for icon button
251                      span {
252                          class: "animate-spin inline-block",
253                          aria_hidden: "true",
254                          LoaderCircle {
255                              class: "h-4 w-4",
256                          }
257                      }
258                  } else {
259                      // Icon only when not loading
260                      {props.children}
261                  }
262              } else {
263                  // Standard button content
264                  if props.loading {
265                      // Loading spinner for standard button
266                      span {
267                          LoaderCircle {
268                              class: "mr-1 inline-block animate-spin h-4",
269                          }
270                      }
271                  }
272  
273                  // Left icon if provided
274                  if let Some(icon) = &props.icon_left {
275                      span {
276                          class: "mr-2",
277                          aria_hidden: "true",
278                          {icon.clone()}
279                      }
280                  }
281  
282                  // Button content (always shown for standard buttons)
283                  {props.children}
284  
285                  // Right icon if provided
286                  if let Some(icon) = &props.icon_right {
287                      span {
288                          class: "ml-2",
289                          aria_hidden: "true",
290                          {icon.clone()}
291                      }
292                  }
293              }
294          }
295      }
296  }