radix_leptos_primitives/components/
select.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)]
62pub enum SelectVariant {
63 Default,
64 Destructive,
65 Ghost,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq)]
69pub enum SelectSize {
70 Default,
71 Sm,
72 Lg,
73}
74
75impl SelectVariant {
76 pub fn as_str(&self) -> &'static str {
77 match self {
78 SelectVariant::Default => "default",
79 SelectVariant::Destructive => "destructive",
80 SelectVariant::Ghost => "ghost",
81 }
82 }
83}
84
85impl SelectSize {
86 pub fn as_str(&self) -> &'static str {
87 match self {
88 SelectSize::Default => "default",
89 SelectSize::Sm => "sm",
90 SelectSize::Lg => "lg",
91 }
92 }
93}
94
95
96#[component]
98pub fn Select(
99 #[prop(optional)]
101 value: Option<String>,
102 #[prop(optional, default = false)]
104 open: bool,
105 #[prop(optional, default = false)]
107 disabled: bool,
108 #[prop(optional, default = SelectVariant::Default)]
110 variant: SelectVariant,
111 #[prop(optional, default = SelectSize::Default)]
113 size: SelectSize,
114 #[prop(optional)]
116 class: Option<String>,
117 #[prop(optional)]
119 style: Option<String>,
120 #[prop(optional)]
122 on_value_change: Option<Callback<String>>,
123 #[prop(optional)]
125 onopen_change: Option<Callback<bool>>,
126 children: Children,
128) -> impl IntoView {
129 let __select_id = generate_id("select");
130 let __trigger_id = generate_id("select-trigger");
131 let __content_id = generate_id("select-content");
132
133 let data_variant = variant.as_str();
135 let data_size = size.as_str();
136
137 let base_classes = "radix-select";
139 let combined_class = merge_optional_classes(Some(base_classes), class.as_deref())
140 .unwrap_or_else(|| base_classes.to_string());
141
142 let handle_keydown = move |e: web_sys::KeyboardEvent| match e.key().as_str() {
144 "ArrowDown" | "ArrowUp" => {
145 e.prevent_default();
146 if !open {
147 if let Some(onopen_change) = onopen_change {
148 onopen_change.run(true);
149 }
150 }
151 }
152 "Enter" | " " => {
153 e.prevent_default();
154 if let Some(onopen_change) = onopen_change {
155 onopen_change.run(!open);
156 }
157 }
158 "Escape" => {
159 e.prevent_default();
160 if let Some(onopen_change) = onopen_change {
161 onopen_change.run(false);
162 }
163 }
164 _ => {}
165 };
166
167 view! {
168 <div
169 class=combined_class
170 style=style
171 data-variant=data_variant
172 data-size=data_size
173 data-open=open
174 data-disabled=disabled
175 on:keydown=handle_keydown
176 >
177 {children()}
178 </div>
179 }
180}
181
182#[component]
184pub fn SelectTrigger(
185 #[prop(optional)]
187 class: Option<String>,
188 #[prop(optional)]
190 style: Option<String>,
191 children: Children,
193) -> impl IntoView {
194 let base_classes = "radix-select-trigger";
195 let combined_class = merge_optional_classes(Some(base_classes), class.as_deref())
196 .unwrap_or_else(|| base_classes.to_string());
197
198 view! {
199 <button
200 class=combined_class
201 style=style
202 type="button"
203 role="combobox"
204 aria-expanded="false"
205 aria-haspopup="listbox"
206 >
207 {children()}
208 </button>
209 }
210}
211
212#[component]
214pub fn SelectValue(
215 #[prop(optional)]
217 placeholder: Option<String>,
218 #[prop(optional)]
220 class: Option<String>,
221 #[prop(optional)]
223 style: Option<String>,
224) -> impl IntoView {
225 let base_classes = "radix-select-value";
226 let combined_class = merge_optional_classes(Some(base_classes), class.as_deref())
227 .unwrap_or_else(|| base_classes.to_string());
228
229 view! {
230 <span class=combined_class style=style>
231 {placeholder.unwrap_or_else(|| "Select an option".to_string())}
232 </span>
233 }
234}
235
236#[component]
238pub fn SelectContent(
239 #[prop(optional)]
241 class: Option<String>,
242 #[prop(optional)]
244 style: Option<String>,
245 children: Children,
247) -> impl IntoView {
248 let base_classes = "radix-select-content";
249 let combined_class = merge_optional_classes(Some(base_classes), class.as_deref())
250 .unwrap_or_else(|| base_classes.to_string());
251
252 view! {
253 <div
254 class=combined_class
255 style=style
256 role="listbox"
257 tabindex="-1"
258 >
259 {children()}
260 </div>
261 }
262}
263
264#[component]
266pub fn SelectItem(
267 value: String,
269 #[prop(optional, default = false)]
271 disabled: bool,
272 #[prop(optional)]
274 class: Option<String>,
275 #[prop(optional)]
277 style: Option<String>,
278 children: Children,
280) -> impl IntoView {
281 let base_classes = "radix-select-item";
282 let combined_class = merge_optional_classes(Some(base_classes), class.as_deref())
283 .unwrap_or_else(|| base_classes.to_string());
284
285 let handle_click = move |e: web_sys::MouseEvent| {
287 e.prevent_default();
288 };
290
291 view! {
292 <div
293 class=combined_class
294 style=style
295 data-value=value
296 data-disabled=disabled
297 role="option"
298 >
299 </div>
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use crate::{SelectSize, SelectVariant};
306 use proptest::prelude::*;
307use crate::utils::{merge_optional_classes, generate_id};
308
309 #[test]
311 fn test_select_variants() {
312 run_test(|| {
313 let variants = [
315 SelectVariant::Default,
316 SelectVariant::Destructive,
317 SelectVariant::Ghost,
318 ];
319
320 for variant in variants {
321 assert!(!variant.as_str().is_empty());
323 }
324 });
325 }
326
327 #[test]
328 fn test_select_sizes() {
329 run_test(|| {
330 let sizes = [SelectSize::Default, SelectSize::Sm, SelectSize::Lg];
331
332 for size in sizes {
333 assert!(!size.as_str().is_empty());
335 }
336 });
337 }
338
339 #[test]
341 fn test_selectopen_state() {
342 run_test(|| {
343 let open = true;
345 let disabled = false;
346 let variant = SelectVariant::Default;
347 let size = SelectSize::Default;
348
349 assert!(open);
351 assert!(!disabled);
352 assert_eq!(variant, SelectVariant::Default);
353 assert_eq!(size, SelectSize::Default);
354 });
355 }
356
357 #[test]
358 fn test_select_closed_state() {
359 run_test(|| {
360 let open = false;
362 let disabled = true;
363 let variant = SelectVariant::Destructive;
364 let size = SelectSize::Lg;
365
366 assert!(!open);
368 assert!(disabled);
369 assert_eq!(variant, SelectVariant::Destructive);
370 assert_eq!(size, SelectSize::Lg);
371 });
372 }
373
374 #[test]
376 fn test_select_state_changes() {
377 run_test(|| {
378 let mut open = false;
380 let mut selected_value = None;
381 let disabled = false;
382
383 assert!(!open);
385 assert!(selected_value.is_none());
386 assert!(!disabled);
387
388 open = true;
390 selected_value = Some("option1".to_string());
391
392 assert!(open);
393 assert_eq!(selected_value, Some("option1".to_string()));
394 assert!(!disabled);
395
396 open = false;
398
399 assert!(!open);
400 assert_eq!(selected_value, Some("option1".to_string()));
401 assert!(!disabled);
402 });
403 }
404
405 #[test]
407 fn test_select_keyboard_navigation() {
408 run_test(|| {
409 let mut open = false;
411 let arrow_down_pressed = true;
412 let enter_pressed = false;
413 let escape_pressed = false;
414
415 assert!(!open);
417 assert!(arrow_down_pressed);
418
419 if arrow_down_pressed {
421 open = true;
422 }
423
424 assert!(open);
425
426 if enter_pressed {
428 open = !open;
429 }
430
431 assert!(open); if escape_pressed {
435 open = false;
436 }
437
438 assert!(open); });
440 }
441
442 #[test]
443 fn test_select_item_selection() {
444 run_test(|| {
445 let mut selected_value = None;
447 let item_value = "option1".to_string();
448 let itemdisabled = false;
449
450 assert!(selected_value.is_none());
452 assert_eq!(item_value, "option1");
453 assert!(!itemdisabled);
454
455 if !itemdisabled {
457 selected_value = Some(item_value.clone());
458 }
459
460 assert_eq!(selected_value, Some("option1".to_string()));
461 });
462 }
463
464 #[test]
466 fn test_select_accessibility() {
467 run_test(|| {
468 let open = true;
470 let disabled = false;
471 let role = "combobox";
472 let aria_expanded = "true";
473 let aria_haspopup = "listbox";
474
475 assert!(open);
477 assert!(!disabled);
478 assert_eq!(role, "combobox");
479 assert_eq!(aria_expanded, "true");
480 assert_eq!(aria_haspopup, "listbox");
481 });
482 }
483
484 #[test]
486 fn test_select_edge_cases() {
487 run_test(|| {
488 let open = true;
490 let has_options = false;
491 let selected_value: Option<String> = None;
492
493 assert!(open);
495 assert!(!has_options);
496 assert!(selected_value.is_none());
497 });
498 }
499
500 proptest! {
502 #[test]
503 fn test_select_properties(
504 variant in prop::sample::select(&[
505 SelectVariant::Default,
506 SelectVariant::Destructive,
507 SelectVariant::Ghost,
508 ]),
509 size in prop::sample::select(&[
510 SelectSize::Default,
511 SelectSize::Sm,
512 SelectSize::Lg,
513 ]),
514 open in prop::bool::ANY,
515 disabled in prop::bool::ANY,
516 value in prop::option::of("[a-zA-Z0-9_]+")
517 ) {
518 assert!(!variant.as_str().is_empty());
521 assert!(!size.as_str().is_empty());
522
523 assert!(matches!(open, true | false));
525 assert!(matches!(disabled, true | false));
526
527 match &value {
529 Some(v) => assert!(!v.is_empty()),
530 None => assert!(true), }
532
533 if disabled {
535 }
538 }
539 }
540
541 fn run_test<F>(f: F)
543 where
544 F: FnOnce(),
545 {
546 f();
548 }
549}