radix_leptos_primitives/components/
combobox.rs

1use crate::utils::{merge_classes, generate_id};
2use leptos::callback::Callback;
3use leptos::children::Children;
4use leptos::prelude::*;
5use wasm_bindgen::JsCast;
6
7/// Combobox component - Searchable select component with autocomplete
8#[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/// Combobox Input component
47#[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/// Combobox Options component
117#[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/// Combobox Option component
153#[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/// Combobox Trigger component
194#[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/// Combobox Clear Button component
227#[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/// Combobox Option structure
257#[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/// Combobox Group component
283#[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/// Combobox Separator component
307#[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    // Unit Tests
333    #[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    // Combobox Input tests
361    #[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    // Combobox Options tests
383    #[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    // Combobox Option tests
397    #[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    // Combobox Trigger tests
411    #[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    // Combobox Clear Button tests
421    #[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    // Combobox Option tests
431    #[test]
432    fn test_combobox_option_default() {}
433
434    // Combobox Group tests
435    #[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    // Combobox Separator tests
443    #[test]
444    fn test_combobox_separator_creation() {}
445    #[test]
446    fn test_combobox_separator_with_class() {}
447
448    // Helper function tests
449    #[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    // Property-based Tests
459    #[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    // Integration Tests
481    #[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    // Performance Tests
493    #[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}