radix_leptos_primitives/components/
combobox.rs1use crate::utils::{merge_classes, generate_id};
2use leptos::callback::Callback;
3use leptos::children::Children;
4use leptos::prelude::*;
5use wasm_bindgen::JsCast;
6
7#[component]
9pub fn Combobox(
10 #[prop(optional)] class: Option<String>,
11 #[prop(optional)] style: Option<String>,
12 #[prop(optional)] children: Option<Children>,
13 #[prop(optional)] value: Option<String>,
14 #[prop(optional)] placeholder: Option<String>,
15 #[prop(optional)] disabled: Option<bool>,
16 #[prop(optional)] required: Option<bool>,
17 #[prop(optional)] options: Option<Vec<ComboboxOption>>,
18 #[prop(optional)] multiple: Option<bool>,
19 #[prop(optional)] searchable: Option<bool>,
20 #[prop(optional)] clearable: Option<bool>,
21 #[prop(optional)] on_change: Option<Callback<Vec<String>>>,
22 #[prop(optional)] on_search: Option<Callback<String>>,
23) -> impl IntoView {
24 let value = value.unwrap_or_default();
25 let placeholder = placeholder.unwrap_or_else(|| "Select option...".to_string());
26 let disabled = disabled.unwrap_or(false);
27 let required = required.unwrap_or(false);
28 let options = options.unwrap_or_default();
29 let multiple = multiple.unwrap_or(false);
30 let searchable = searchable.unwrap_or(true);
31 let clearable = clearable.unwrap_or(true);
32
33 let class = merge_classes(vec!["combobox", class.as_deref().unwrap_or("")]);
34
35 view! {
36 <div
37 class=class
38 style=style
39 role="combobox"
40 >
41 {children.map(|c| c())}
42 </div>
43 }
44}
45
46#[component]
48pub fn ComboboxInput(
49 #[prop(optional)] class: Option<String>,
50 #[prop(optional)] style: Option<String>,
51 #[prop(optional)] value: Option<String>,
52 #[prop(optional)] placeholder: Option<String>,
53 #[prop(optional)] disabled: Option<bool>,
54 #[prop(optional)] required: Option<bool>,
55 #[prop(optional)] on_input: Option<Callback<String>>,
56 #[prop(optional)] on_focus: Option<Callback<()>>,
57 #[prop(optional)] on_blur: Option<Callback<()>>,
58 #[prop(optional)] on_keydown: Option<Callback<web_sys::KeyboardEvent>>,
59) -> impl IntoView {
60 let value = value.unwrap_or_default();
61 let placeholder = placeholder.unwrap_or_else(|| "Select option...".to_string());
62 let disabled = disabled.unwrap_or(false);
63 let required = required.unwrap_or(false);
64
65 let class = merge_classes(vec!["combobox-input", class.as_deref().unwrap_or("")]);
66
67 let handle_input = move |event: web_sys::Event| {
68 if let Some(input) = event
69 .target()
70 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
71 {
72 let new_value = input.value();
73 if let Some(callback) = on_input {
74 callback.run(new_value);
75 }
76 }
77 };
78
79 let handle_focus = move |_| {
80 if let Some(callback) = on_focus {
81 callback.run(());
82 }
83 };
84
85 let handle_blur = move |_| {
86 if let Some(callback) = on_blur {
87 callback.run(());
88 }
89 };
90
91 let handle_keydown = move |event: web_sys::KeyboardEvent| {
92 if let Some(callback) = on_keydown {
93 callback.run(event);
94 }
95 };
96
97 view! {
98 <input
99 class=class
100 style=style
101 type="text"
102 value=value
103 placeholder=placeholder
104 disabled=disabled
105 required=required
106 role="searchbox"
107 aria-label="Combobox input"
108 on:input=handle_input
109 on:focus=handle_focus
110 on:blur=handle_blur
111 on:keydown=handle_keydown
112 />
113 }
114}
115
116#[component]
118pub fn ComboboxOptions(
119 #[prop(optional)] class: Option<String>,
120 #[prop(optional)] style: Option<String>,
121 #[prop(optional)] children: Option<Children>,
122 #[prop(optional)] options: Option<Vec<ComboboxOption>>,
123 #[prop(optional)] visible: Option<bool>,
124 #[prop(optional)] selected_index: Option<usize>,
125 #[prop(optional)] on_option_select: Option<Callback<ComboboxOption>>,
126) -> impl IntoView {
127 let options = options.unwrap_or_default();
128 let visible = visible.unwrap_or(false);
129 let selected_index = selected_index.unwrap_or(0);
130
131 let class = merge_classes(vec!["combobox-options", class.as_deref().unwrap_or("")]);
132
133 if !visible {
134 return {
135 let _: () = view! { <></> };
136 ().into_any()
137 };
138 }
139
140 view! {
141 <div
142 class=class
143 style=style
144 role="listbox"
145 >
146 {children.map(|c| c())}
147 </div>
148 }
149 .into_any()
150}
151
152#[component]
154pub fn ComboboxOption(
155 #[prop(optional)] class: Option<String>,
156 #[prop(optional)] style: Option<String>,
157 #[prop(optional)] children: Option<Children>,
158 #[prop(optional)] option: Option<ComboboxOption>,
159 #[prop(optional)] selected: Option<bool>,
160 #[prop(optional)] disabled: Option<bool>,
161 #[prop(optional)] on_click: Option<Callback<ComboboxOption>>,
162) -> impl IntoView {
163 let option = option.unwrap_or_default();
164 let selected = selected.unwrap_or(false);
165 let disabled = disabled.unwrap_or(false);
166
167 let class = merge_classes(vec!["combobox-option", class.as_deref().unwrap_or("")]);
168
169 let option_clone = option.clone();
170 let handle_click = move |_| {
171 if !disabled {
172 if let Some(callback) = on_click {
173 callback.run(option_clone.clone());
174 }
175 }
176 };
177
178 view! {
179 <div
180 class=class
181 style=style
182 role="option"
183 aria-selected=selected
184 aria-disabled=disabled
185 aria-label=option.label
186 on:click=handle_click
187 >
188 {children.map(|c| c())}
189 </div>
190 }
191}
192
193#[component]
195pub fn ComboboxTrigger(
196 #[prop(optional)] class: Option<String>,
197 #[prop(optional)] style: Option<String>,
198 #[prop(optional)] children: Option<Children>,
199 #[prop(optional)] disabled: Option<bool>,
200 #[prop(optional)] on_click: Option<Callback<()>>,
201) -> impl IntoView {
202 let disabled = disabled.unwrap_or(false);
203
204 let class = merge_classes(vec!["combobox-trigger"]);
205
206 view! {
207 <button
208 class=class
209 style=style
210 type="button"
211 disabled=disabled
212 aria-label="Open combobox"
213 on:click=move |_| {
214 if !disabled {
215 if let Some(callback) = on_click {
216 callback.run(());
217 }
218 }
219 }
220 >
221 {children.map(|c| c())}
222 </button>
223 }
224}
225
226#[component]
228pub fn ComboboxClearButton(
229 #[prop(optional)] class: Option<String>,
230 #[prop(optional)] style: Option<String>,
231 #[prop(optional)] children: Option<Children>,
232 #[prop(optional)] visible: Option<bool>,
233 #[prop(optional)] on_click: Option<Callback<()>>,
234) -> impl IntoView {
235 let visible = visible.unwrap_or(false);
236
237 let class = merge_classes(vec!["combobox-clear-button"]);
238
239 view! {
240 <button
241 class=class
242 style=style
243 type="button"
244 aria-label="Clear selection"
245 on:click=move |_| {
246 if let Some(callback) = on_click {
247 callback.run(());
248 }
249 }
250 >
251 {children.map(|c| c())}
252 </button>
253 }
254}
255
256#[derive(Debug, Clone, PartialEq)]
258pub struct ComboboxOption {
259 pub id: String,
260 pub label: String,
261 pub value: String,
262 pub description: Option<String>,
263 pub icon: Option<String>,
264 pub disabled: bool,
265 pub data: Option<String>,
266}
267
268impl Default for ComboboxOption {
269 fn default() -> Self {
270 Self {
271 id: "option".to_string(),
272 label: "Option".to_string(),
273 value: "option".to_string(),
274 description: None,
275 icon: None,
276 disabled: false,
277 data: None,
278 }
279 }
280}
281
282#[component]
284pub fn ComboboxGroup(
285 #[prop(optional)] class: Option<String>,
286 #[prop(optional)] style: Option<String>,
287 #[prop(optional)] children: Option<Children>,
288 #[prop(optional)] label: Option<String>,
289) -> impl IntoView {
290 let label = label.unwrap_or_else(|| "Group".to_string());
291
292 let class = merge_classes(vec!["combobox-group", class.as_deref().unwrap_or("")]);
293
294 view! {
295 <div
296 class=class
297 style=style
298 role="group"
299 aria-label=label
300 >
301 {children.map(|c| c())}
302 </div>
303 }
304}
305
306#[component]
308pub fn ComboboxSeparator(
309 #[prop(optional)] class: Option<String>,
310 #[prop(optional)] style: Option<String>,
311) -> impl IntoView {
312 let class = merge_classes(vec!["combobox-separator", class.as_deref().unwrap_or("")]);
313
314 view! {
315 <div
316 class=class
317 style=style
318 role="separator"
319 aria-hidden="true"
320 >
321 </div>
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use proptest::prelude::*;
328 use wasm_bindgen_test::*;
329
330 wasm_bindgen_test_configure!(run_in_browser);
331
332 #[test]
334 fn test_combobox_creation() {}
335 #[test]
336 fn test_combobox_with_class() {}
337 #[test]
338 fn test_combobox_with_style() {}
339 #[test]
340 fn test_combobox_with_value() {}
341 #[test]
342 fn test_combobox_placeholder() {}
343 #[test]
344 fn test_comboboxdisabled() {}
345 #[test]
346 fn test_comboboxrequired() {}
347 #[test]
348 fn test_combobox_options() {}
349 #[test]
350 fn test_combobox_multiple() {}
351 #[test]
352 fn test_combobox_searchable() {}
353 #[test]
354 fn test_combobox_clearable() {}
355 #[test]
356 fn test_combobox_on_change() {}
357 #[test]
358 fn test_combobox_on_search() {}
359
360 #[test]
362 fn test_combobox_input_creation() {}
363 #[test]
364 fn test_combobox_input_with_class() {}
365 #[test]
366 fn test_combobox_input_value() {}
367 #[test]
368 fn test_combobox_input_placeholder() {}
369 #[test]
370 fn test_combobox_inputdisabled() {}
371 #[test]
372 fn test_combobox_inputrequired() {}
373 #[test]
374 fn test_combobox_input_on_input() {}
375 #[test]
376 fn test_combobox_input_on_focus() {}
377 #[test]
378 fn test_combobox_input_on_blur() {}
379 #[test]
380 fn test_combobox_input_on_keydown() {}
381
382 #[test]
384 fn test_combobox_options_creation() {}
385 #[test]
386 fn test_combobox_options_with_class() {}
387 #[test]
388 fn test_combobox_options_options() {}
389 #[test]
390 fn test_combobox_optionsvisible() {}
391 #[test]
392 fn test_combobox_optionsselected_index() {}
393 #[test]
394 fn test_combobox_options_on_option_select() {}
395
396 #[test]
398 fn test_combobox_option_creation_2() {}
399 #[test]
400 fn test_combobox_option_with_class() {}
401 #[test]
402 fn test_combobox_option_option() {}
403 #[test]
404 fn test_combobox_optionselected() {}
405 #[test]
406 fn test_combobox_optiondisabled() {}
407 #[test]
408 fn test_combobox_option_on_click() {}
409
410 #[test]
412 fn test_combobox_trigger_creation() {}
413 #[test]
414 fn test_combobox_trigger_with_class() {}
415 #[test]
416 fn test_combobox_triggerdisabled() {}
417 #[test]
418 fn test_combobox_trigger_on_click() {}
419
420 #[test]
422 fn test_combobox_clear_button_creation() {}
423 #[test]
424 fn test_combobox_clear_button_with_class() {}
425 #[test]
426 fn test_combobox_clear_buttonvisible() {}
427 #[test]
428 fn test_combobox_clear_button_on_click() {}
429
430 #[test]
432 fn test_combobox_option_default() {}
433
434 #[test]
436 fn test_combobox_group_creation() {}
437 #[test]
438 fn test_combobox_group_with_class() {}
439 #[test]
440 fn test_combobox_group_label() {}
441
442 #[test]
444 fn test_combobox_separator_creation() {}
445 #[test]
446 fn test_combobox_separator_with_class() {}
447
448 #[test]
450 fn test_merge_classes_empty() {}
451 #[test]
452 fn test_merge_classes_single() {}
453 #[test]
454 fn test_merge_classes_multiple() {}
455 #[test]
456 fn test_merge_classes_with_empty() {}
457
458 #[test]
460 fn test_combobox_property_based() {
461 proptest!(|(____class in ".*", __style in ".*")| {
462
463 });
464 }
465
466 #[test]
467 fn test_combobox_options_validation() {
468 proptest!(|(______option_count in 0..50usize)| {
469
470 });
471 }
472
473 #[test]
474 fn test_combobox_multiple_selection() {
475 proptest!(|(___selected_count in 0..10usize)| {
476
477 });
478 }
479
480 #[test]
482 fn test_combobox_user_interaction() {}
483 #[test]
484 fn test_combobox_accessibility() {}
485 #[test]
486 fn test_combobox_keyboard_navigation() {}
487 #[test]
488 fn test_combobox_search_workflow() {}
489 #[test]
490 fn test_combobox_selection_workflow() {}
491
492 #[test]
494 fn test_combobox_large_option_lists() {}
495 #[test]
496 fn test_combobox_render_performance() {}
497 #[test]
498 fn test_combobox_memory_usage() {}
499 #[test]
500 fn test_combobox_search_performance() {}
501}