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 }