radix_leptos_primitives/components/
button.rs1use leptos::callback::Callback;
2use leptos::children::Children;
3use leptos::prelude::*;
4use crate::utils::{merge_optional_classes, generate_id};
5
6#[derive(Debug, Clone, Copy, PartialEq)]
41pub enum ButtonVariant {
42 Default,
43 Destructive,
44 Outline,
45 Secondary,
46 Ghost,
47 Link,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq)]
51pub enum ButtonSize {
52 Default,
53 Small,
54 Large,
55 Icon,
56}
57
58impl ButtonVariant {
59 pub fn as_str(&self) -> &'static str {
60 match self {
61 ButtonVariant::Default => "default",
62 ButtonVariant::Destructive => "destructive",
63 ButtonVariant::Outline => "outline",
64 ButtonVariant::Secondary => "secondary",
65 ButtonVariant::Ghost => "ghost",
66 ButtonVariant::Link => "link",
67 }
68 }
69}
70
71impl ButtonSize {
72 pub fn as_str(&self) -> &'static str {
73 match self {
74 ButtonSize::Default => "default",
75 ButtonSize::Small => "sm",
76 ButtonSize::Large => "lg",
77 ButtonSize::Icon => "icon",
78 }
79 }
80}
81
82#[component]
84pub fn Button(
85 #[prop(optional, default = ButtonVariant::Default)]
87 variant: ButtonVariant,
88 #[prop(optional, default = ButtonSize::Default)]
90 size: ButtonSize,
91 #[prop(optional, default = false)]
93 disabled: bool,
94 #[prop(optional, default = false)]
96 loading: bool,
97 #[prop(optional, into)]
99 button_type: Option<String>,
100 #[prop(optional)]
102 class: Option<String>,
103 #[prop(optional)]
105 style: Option<String>,
106 #[prop(optional)]
108 on_click: Option<Callback<web_sys::MouseEvent>>,
109 #[prop(optional)]
111 on_focus: Option<Callback<web_sys::FocusEvent>>,
112 #[prop(optional)]
114 on_blur: Option<Callback<web_sys::FocusEvent>>,
115 children: Children,
117) -> impl IntoView {
118 let button_id = generate_id("button");
119
120 let data_variant = variant.as_str();
122 let data_size = size.as_str();
123
124 let base_classes = "radix-button";
126 let combined_class = merge_optional_classes(Some(base_classes), class.as_deref())
127 .unwrap_or_else(|| base_classes.to_string());
128
129 let handle_click = move |e: web_sys::MouseEvent| {
131 if !disabled && !loading {
132 if let Some(on_click) = on_click {
133 on_click.run(e);
134 }
135 }
136 };
137
138 let handle_focus = move |e: web_sys::FocusEvent| {
140 if let Some(on_focus) = on_focus {
141 on_focus.run(e);
142 }
143 };
144
145 let handle_blur = move |e: web_sys::FocusEvent| {
147 if let Some(on_blur) = on_blur {
148 on_blur.run(e);
149 }
150 };
151
152 view! {
153 <button
154 id=button_id
155 class=combined_class
156 style=style
157 type=button_type.unwrap_or_else(|| "button".to_string())
158 disabled=disabled || loading
159 data-variant=data_variant
160 data-size=data_size
161 data-loading=loading
162 aria-disabled=disabled || loading
163 on:click=handle_click
164 on:focus=handle_focus
165 on:blur=handle_blur
166 >
167 <Show when=move || loading>
168 <span class="button-spinner" aria-hidden="true">
169 "⟳"
170 </span>
171 </Show>
172 {children()}
173 </button>
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use crate::{ButtonSize, ButtonVariant};
180 use proptest::prelude::*;
181 use wasm_bindgen_test::*;
182
183 wasm_bindgen_test_configure!(run_in_browser);
184
185 #[test]
187 fn test_button_variants() {
188 run_test(|| {
189 let variants = [
191 ButtonVariant::Default,
192 ButtonVariant::Destructive,
193 ButtonVariant::Outline,
194 ButtonVariant::Secondary,
195 ButtonVariant::Ghost,
196 ButtonVariant::Link,
197 ];
198
199 for variant in variants {
200 assert!(!variant.as_str().is_empty());
202 }
203 });
204 }
205
206 #[test]
207 fn test_button_sizes() {
208 run_test(|| {
209 let sizes = [
210 ButtonSize::Default,
211 ButtonSize::Small,
212 ButtonSize::Large,
213 ButtonSize::Icon,
214 ];
215
216 for size in sizes {
217 assert!(!size.as_str().is_empty());
219 }
220 });
221 }
222
223 #[test]
225 fn test_buttondisabled_state() {
226 run_test(|| {
227 let disabled = true;
229 let loading = false;
230
231 assert!(disabled);
233 assert!(!loading);
234 });
235 }
236
237 #[test]
238 fn test_buttonloading_state() {
239 run_test(|| {
240 let loading = true;
242 let disabled = false;
243
244 assert!(loading);
246 assert!(!disabled);
247 });
248 }
249
250 #[test]
252 fn test_button_click_handling() {
253 run_test(|| {
254 let mut click_count = 0;
256
257 assert_eq!(click_count, 0);
259
260 click_count += 1;
262 assert_eq!(click_count, 1);
263 });
264 }
265
266 #[test]
268 fn test_button_focus_events() {
269 run_test(|| {
270 let mut focus_count = 0;
272
273 assert_eq!(focus_count, 0);
275
276 focus_count += 1;
278 assert_eq!(focus_count, 1);
279 });
280 }
281
282 #[test]
284 fn test_button_accessibility() {
285 run_test(|| {
286 let disabled = true;
288 let loading = false;
289
290 assert!(disabled);
292 assert!(!loading);
293 });
294 }
295
296 #[test]
298 fn test_button_empty_content() {
299 run_test(|| {
300 let content = "";
302
303 assert!(content.is_empty());
305 });
306 }
307
308 #[test]
309 fn test_button_long_content() {
310 run_test(|| {
311 let long_content = "x".repeat(1000);
313
314 assert_eq!(long_content.len(), 1000);
316 });
317 }
318
319 #[test]
320 fn test_button_special_characters() {
321 run_test(|| {
322 let special_content = "🚀 Test with émojis & spéciál chars";
324
325 assert!(!special_content.is_empty());
327 assert!(special_content.contains("🚀"));
328 });
329 }
330
331 proptest! {
333 #[test]
334 fn test_button_properties(
335 variant in prop::sample::select(&[
336 ButtonVariant::Default,
337 ButtonVariant::Destructive,
338 ButtonVariant::Outline,
339 ButtonVariant::Secondary,
340 ButtonVariant::Ghost,
341 ButtonVariant::Link,
342 ]),
343 size in prop::sample::select(&[
344 ButtonSize::Default,
345 ButtonSize::Small,
346 ButtonSize::Large,
347 ButtonSize::Icon,
348 ]),
349 disabled in prop::bool::ANY,
350 loading in prop::bool::ANY,
351 content in ".*"
352 ) {
353 if disabled && loading {
356 }
359
360 assert!(!variant.as_str().is_empty());
362 assert!(!size.as_str().is_empty());
363 }
364 }
365
366 fn run_test<F>(f: F)
368 where
369 F: FnOnce(),
370 {
371 f();
374 }
375}