radix_leptos_primitives/components/
radio_group.rs1use leptos::*;
2use leptos::prelude::*;
3
4#[derive(Debug, Clone, Copy, PartialEq)]
6pub enum RadioGroupVariant {
7 Default,
8 Destructive,
9 Ghost,
10}
11
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub enum RadioGroupSize {
14 Default,
15 Sm,
16 Lg,
17}
18
19impl RadioGroupVariant {
20 pub fn as_str(&self) -> &'static str {
21 match self {
22 RadioGroupVariant::Default => "default",
23 RadioGroupVariant::Destructive => "destructive",
24 RadioGroupVariant::Ghost => "ghost",
25 }
26 }
27}
28
29impl RadioGroupSize {
30 pub fn as_str(&self) -> &'static str {
31 match self {
32 RadioGroupSize::Default => "default",
33 RadioGroupSize::Sm => "sm",
34 RadioGroupSize::Lg => "lg",
35 }
36 }
37}
38
39fn generate_id(prefix: &str) -> String {
41 static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
42 let id = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
43 format!("{}-{}", prefix, id)
44}
45
46fn merge_classes(existing: Option<&str>, additional: Option<&str>) -> Option<String> {
48 match (existing, additional) {
49 (Some(a), Some(b)) => Some(format!("{} {}", a, b)),
50 (Some(a), None) => Some(a.to_string()),
51 (None, Some(b)) => Some(b.to_string()),
52 (None, None) => None,
53 }
54}
55
56#[component]
58pub fn RadioGroup(
59 #[prop(optional)]
61 value: Option<String>,
62 #[prop(optional, default = false)]
64 disabled: bool,
65 #[prop(optional, default = RadioGroupVariant::Default)]
67 variant: RadioGroupVariant,
68 #[prop(optional, default = RadioGroupSize::Default)]
70 size: RadioGroupSize,
71 #[prop(optional)]
73 class: Option<String>,
74 #[prop(optional)]
76 style: Option<String>,
77 #[prop(optional)]
79 on_value_change: Option<Callback<String>>,
80 children: Children,
82) -> impl IntoView {
83 let radio_group_id = generate_id("radio-group");
84
85 let data_variant = variant.as_str();
87 let data_size = size.as_str();
88
89 let base_classes = "radix-radio-group";
91 let combined_class = merge_classes(Some(base_classes), class.as_deref())
92 .unwrap_or_else(|| base_classes.to_string());
93
94 let handle_keydown = move |e: web_sys::KeyboardEvent| {
96 match e.key().as_str() {
97 "ArrowDown" | "ArrowUp" => {
98 e.prevent_default();
99 }
101 "Home" => {
102 e.prevent_default();
103 }
105 "End" => {
106 e.prevent_default();
107 }
109 _ => {}
110 }
111 };
112
113 view! {
114 <div
115 class=combined_class
116 style=style
117 data-variant=data_variant
118 data-size=data_size
119 data-disabled=disabled
120 role="radiogroup"
121 on:keydown=handle_keydown
122 >
123 {children()}
124 </div>
125 }
126}
127
128#[component]
130pub fn RadioGroupItem(
131 value: String,
133 #[prop(optional, default = false)]
135 disabled: bool,
136 #[prop(optional)]
138 class: Option<String>,
139 #[prop(optional)]
141 style: Option<String>,
142 children: Children,
144) -> impl IntoView {
145 let item_id = generate_id(&format!("radio-item-{}", value));
146
147 let base_classes = "radix-radio-group-item";
148 let combined_class = merge_classes(Some(base_classes), class.as_deref())
149 .unwrap_or_else(|| base_classes.to_string());
150
151 let handle_click = move |e: web_sys::MouseEvent| {
153 e.prevent_default();
154 };
156
157 let handle_keydown = move |e: web_sys::KeyboardEvent| {
159 match e.key().as_str() {
160 "Enter" | " " => {
161 e.prevent_default();
162 }
164 _ => {}
165 }
166 };
167
168 view! {
169 <div
170 class=combined_class
171 style=style
172 data-value=value
173 data-disabled=disabled
174 role="radio"
175 tabindex=if disabled { "-1" } else { "0" }
176 aria-checked="false"
177 on:click=handle_click
178 on:keydown=handle_keydown
179 >
180 {children()}
181 </div>
182 }
183}
184
185#[component]
187pub fn RadioGroupIndicator(
188 #[prop(optional)]
190 class: Option<String>,
191 #[prop(optional)]
193 style: Option<String>,
194) -> impl IntoView {
195 let base_classes = "radix-radio-group-indicator";
196 let combined_class = merge_classes(Some(base_classes), class.as_deref())
197 .unwrap_or_else(|| base_classes.to_string());
198
199 view! {
200 <div
201 class=combined_class
202 style=style
203 aria-hidden="true"
204 >
205 </div>
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use proptest::prelude::*;
213
214 #[test]
216 fn test_radio_group_variants() {
217 run_test(|| {
218 let variants = vec![
219 RadioGroupVariant::Default,
220 RadioGroupVariant::Destructive,
221 RadioGroupVariant::Ghost,
222 ];
223
224 for variant in variants {
225 assert!(!variant.as_str().is_empty());
226 }
227 });
228 }
229
230 #[test]
231 fn test_radio_group_sizes() {
232 run_test(|| {
233 let sizes = vec![
234 RadioGroupSize::Default,
235 RadioGroupSize::Sm,
236 RadioGroupSize::Lg,
237 ];
238
239 for size in sizes {
240 assert!(!size.as_str().is_empty());
241 }
242 });
243 }
244
245 #[test]
247 fn test_radio_group_selected_state() {
248 run_test(|| {
249 let value = Some("option1".to_string());
250 let disabled = false;
251 let variant = RadioGroupVariant::Default;
252 let size = RadioGroupSize::Default;
253
254 assert_eq!(value, Some("option1".to_string()));
255 assert!(!disabled);
256 assert_eq!(variant, RadioGroupVariant::Default);
257 assert_eq!(size, RadioGroupSize::Default);
258 });
259 }
260
261 #[test]
262 fn test_radio_group_unselected_state() {
263 run_test(|| {
264 let value: Option<String> = None;
265 let disabled = false;
266 let variant = RadioGroupVariant::Destructive;
267 let size = RadioGroupSize::Lg;
268
269 assert!(value.is_none());
270 assert!(!disabled);
271 assert_eq!(variant, RadioGroupVariant::Destructive);
272 assert_eq!(size, RadioGroupSize::Lg);
273 });
274 }
275
276 #[test]
277 fn test_radio_group_disabled_state() {
278 run_test(|| {
279 let value = Some("option1".to_string());
280 let disabled = true;
281 let variant = RadioGroupVariant::Ghost;
282 let size = RadioGroupSize::Sm;
283
284 assert_eq!(value, Some("option1".to_string()));
285 assert!(disabled);
286 assert_eq!(variant, RadioGroupVariant::Ghost);
287 assert_eq!(size, RadioGroupSize::Sm);
288 });
289 }
290
291 #[test]
293 fn test_radio_group_state_changes() {
294 run_test(|| {
295 let mut value: Option<String> = None;
296 let disabled = false;
297
298 assert!(value.is_none());
300 assert!(!disabled);
301
302 value = Some("option1".to_string());
304
305 assert_eq!(value, Some("option1".to_string()));
306 assert!(!disabled);
307
308 value = Some("option2".to_string());
310
311 assert_eq!(value, Some("option2".to_string()));
312 assert!(!disabled);
313
314 value = None;
316
317 assert!(value.is_none());
318 assert!(!disabled);
319 });
320 }
321
322 #[test]
324 fn test_radio_group_keyboard_navigation() {
325 run_test(|| {
326 let arrow_down_pressed = true;
327 let arrow_up_pressed = false;
328 let home_pressed = false;
329 let end_pressed = false;
330 let enter_pressed = false;
331 let space_pressed = false;
332 let disabled = false;
333
334 assert!(arrow_down_pressed);
335 assert!(!arrow_up_pressed);
336 assert!(!home_pressed);
337 assert!(!end_pressed);
338 assert!(!enter_pressed);
339 assert!(!space_pressed);
340 assert!(!disabled);
341
342 if arrow_down_pressed && !disabled {
343 assert!(true);
344 }
345
346 if arrow_up_pressed && !disabled {
347 assert!(false);
348 }
349
350 if home_pressed && !disabled {
351 assert!(false);
352 }
353
354 if end_pressed && !disabled {
355 assert!(false);
356 }
357
358 if (enter_pressed || space_pressed) && !disabled {
359 assert!(false);
360 }
361 });
362 }
363
364 #[test]
365 fn test_radio_group_item_selection() {
366 run_test(|| {
367 let item_clicked = true;
368 let item_value = "option1".to_string();
369 let item_disabled = false;
370 let current_value: Option<String> = None;
371
372 assert!(item_clicked);
373 assert_eq!(item_value, "option1");
374 assert!(!item_disabled);
375 assert!(current_value.is_none());
376
377 if item_clicked && !item_disabled {
378 assert!(true);
379 }
380 });
381 }
382
383 #[test]
385 fn test_radio_group_accessibility() {
386 run_test(|| {
387 let role = "radiogroup";
388 let item_role = "radio";
389 let aria_checked = "false";
390 let tabindex = "0";
391
392 assert_eq!(role, "radiogroup");
393 assert_eq!(item_role, "radio");
394 assert_eq!(aria_checked, "false");
395 assert_eq!(tabindex, "0");
396 });
397 }
398
399 #[test]
401 fn test_radio_group_edge_cases() {
402 run_test(|| {
403 let value: Option<String> = None;
404 let disabled = false;
405 let has_items = false;
406
407 assert!(value.is_none());
408 assert!(!disabled);
409 assert!(!has_items);
410 });
411 }
412
413 #[test]
414 fn test_radio_group_single_selection() {
415 run_test(|| {
416 let mut value = Some("option1".to_string());
417 let new_value = "option2".to_string();
418 let disabled = false;
419
420 assert_eq!(value, Some("option1".to_string()));
421 assert_eq!(new_value, "option2");
422 assert!(!disabled);
423
424 value = Some(new_value);
426
427 assert_eq!(value, Some("option2".to_string()));
428 });
429 }
430
431 proptest! {
433 #[test]
434 fn test_radio_group_properties(
435 variant in prop::sample::select(vec![
436 RadioGroupVariant::Default,
437 RadioGroupVariant::Destructive,
438 RadioGroupVariant::Ghost,
439 ]),
440 size in prop::sample::select(vec![
441 RadioGroupSize::Default,
442 RadioGroupSize::Sm,
443 RadioGroupSize::Lg,
444 ]),
445 disabled in prop::bool::ANY,
446 value in prop::option::of("[a-zA-Z0-9_]+")
447 ) {
448 assert!(!variant.as_str().is_empty());
449 assert!(!size.as_str().is_empty());
450
451 assert!(disabled == true || disabled == false);
452
453 match &value {
454 Some(v) => assert!(!v.is_empty()),
455 None => assert!(true),
456 }
457
458 if disabled {
459 }
461 }
462 }
463
464 fn run_test<F>(f: F) where F: FnOnce() {
466 f();
467 }
468}