radix_leptos_primitives/components/
multi_select.rs

1use leptos::callback::Callback;
2use leptos::children::Children;
3use leptos::prelude::*;
4
5/// Multi-Select component for selecting multiple options with search functionality
6#[component]
7pub fn MultiSelect(
8    /// Selected values
9    #[prop(optional)]
10    value: Option<Vec<String>>,
11    /// Available options
12    #[prop(optional)]
13    options: Option<Vec<MultiSelectOption>>,
14    /// Placeholder text
15    #[prop(optional)]
16    placeholder: Option<String>,
17    /// Whether the component is disabled
18    #[prop(optional)]
19    disabled: Option<bool>,
20    /// Whether the component is required
21    #[prop(optional)]
22    required: Option<bool>,
23    /// Maximum number of selections allowed
24    #[prop(optional)]
25    max_selections: Option<usize>,
26    /// Whether to show search functionality
27    #[prop(optional)]
28    searchable: Option<bool>,
29    /// Callback when selection changes
30    #[prop(optional)]
31    on_change: Option<Callback<Vec<String>>>,
32    /// Callback when search query changes
33    #[prop(optional)]
34    on_search: Option<Callback<String>>,
35    /// Callback when option is selected
36    #[prop(optional)]
37    on_option_select: Option<Callback<MultiSelectOption>>,
38    /// Callback when option is deselected
39    #[prop(optional)]
40    on_option_deselect: Option<Callback<MultiSelectOption>>,
41    /// Additional CSS classes
42    #[prop(optional)]
43    class: Option<String>,
44    /// Inline styles
45    #[prop(optional)]
46    style: Option<String>,
47    /// Children content
48    children: Option<Children>,
49) -> impl IntoView {
50    let _value = value.unwrap_or_default();
51    let _options = options.unwrap_or_default();
52    let _placeholder = placeholder.unwrap_or_else(|| "Select options...".to_string());
53    let _disabled = disabled.unwrap_or(false);
54    let _required = required.unwrap_or(false);
55    let _max_selections = max_selections.unwrap_or(usize::MAX);
56    let _searchable = searchable.unwrap_or(true);
57
58    let _class = format!(
59        "multi-select {} {}",
60        class.as_deref().unwrap_or(""),
61        style.as_deref().unwrap_or("")
62    );
63
64    view! {
65        <div
66            class=_class
67            role="combobox"
68            aria-multiselectable=true
69        >
70            {children.map(|c| c())}
71        </div>
72    }
73}
74
75/// Multi-Select option structure
76#[derive(Debug, Clone, PartialEq, Default)]
77pub struct MultiSelectOption {
78    pub value: String,
79    pub label: String,
80    pub _disabled: bool,
81    pub description: Option<String>,
82    pub group: Option<String>,
83}
84
85/// Multi-Select trigger component
86#[component]
87pub fn MultiSelectTrigger(
88    /// Whether the dropdown is open
89    #[prop(optional)]
90    open: Option<bool>,
91    /// Callback when trigger is clicked
92    #[prop(optional)]
93    on_click: Option<Callback<()>>,
94    /// Additional CSS classes
95    #[prop(optional)]
96    class: Option<String>,
97    /// Inline styles
98    #[prop(optional)]
99    style: Option<String>,
100    /// Children content
101    children: Option<Children>,
102) -> impl IntoView {
103    let _open = open.unwrap_or(false);
104    let _class = format!(
105        "multi-select-trigger {} {}",
106        class.as_deref().unwrap_or(""),
107        style.as_deref().unwrap_or("")
108    );
109
110    view! {
111        <button
112            class=_class
113            role="button"
114            aria-expanded=_open
115            on:click=move |_| {
116                if let Some(callback) = on_click {
117                    callback.run(());
118                }
119            }
120        >
121            {children.map(|c| c())}
122        </button>
123    }
124}
125
126/// Multi-Select content component
127#[component]
128pub fn MultiSelectContent(
129    /// Whether the content is visible
130    #[prop(optional)]
131    visible: Option<bool>,
132    /// Additional CSS classes
133    #[prop(optional)]
134    class: Option<String>,
135    /// Inline styles
136    #[prop(optional)]
137    style: Option<String>,
138    /// Children content
139    children: Option<Children>,
140) -> impl IntoView {
141    let _visible = visible.unwrap_or(false);
142    let _class = format!(
143        "multi-select-content {} {}",
144        class.as_deref().unwrap_or(""),
145        style.as_deref().unwrap_or("")
146    );
147
148    view! {
149        <div
150            class=_class
151            role="listbox"
152            aria-hidden=!_visible
153        >
154            {children.map(|c| c())}
155        </div>
156    }
157}
158
159/// Multi-Select option component
160#[component]
161pub fn MultiSelectOption(
162    /// Option data
163    option: MultiSelectOption,
164    /// Whether the option is selected
165    #[prop(optional)]
166    selected: Option<bool>,
167    /// Whether the option is disabled
168    #[prop(optional)]
169    disabled: Option<bool>,
170    /// Callback when option is clicked
171    #[prop(optional)]
172    on_click: Option<Callback<MultiSelectOption>>,
173    /// Additional CSS classes
174    #[prop(optional)]
175    class: Option<String>,
176    /// Inline styles
177    #[prop(optional)]
178    style: Option<String>,
179    /// Children content
180    children: Option<Children>,
181) -> impl IntoView {
182    let selected = selected.unwrap_or(false);
183    let disabled = disabled.unwrap_or(option._disabled);
184    let class = format!("multi-select-option {}", class.unwrap_or_default());
185
186    let style = style.unwrap_or_default();
187
188    let option_clone = option.clone();
189    let handle_click = move |_| {
190        if !disabled {
191            if let Some(callback) = on_click {
192                callback.run(option_clone.clone());
193            }
194        }
195    };
196
197    view! {
198        <div
199            class=class
200            style=style
201            role="option"
202            aria-selected=selected
203            aria-disabled=disabled
204            on:click=handle_click
205        >
206            {children.map(|c| c())}
207        </div>
208    }
209}
210
211/// Multi-Select search component
212#[component]
213pub fn MultiSelectSearch(
214    /// Search query value
215    #[prop(optional)]
216    value: Option<String>,
217    /// Placeholder text
218    #[prop(optional)]
219    placeholder: Option<String>,
220    /// Whether the search is disabled
221    #[prop(optional)]
222    disabled: Option<bool>,
223    /// Callback when search query changes
224    #[prop(optional)]
225    on_change: Option<Callback<String>>,
226    /// Callback when search is cleared
227    #[prop(optional)]
228    on_clear: Option<Callback<()>>,
229    /// Additional CSS classes
230    #[prop(optional)]
231    class: Option<String>,
232    /// Inline styles
233    #[prop(optional)]
234    style: Option<String>,
235) -> impl IntoView {
236    let value = value.unwrap_or_default();
237    let placeholder = placeholder.unwrap_or_else(|| "Search options...".to_string());
238    let disabled = disabled.unwrap_or(false);
239    let class = format!(
240        "multi-select-search {} {}",
241        class.as_deref().unwrap_or(""),
242        style.as_deref().unwrap_or("")
243    );
244
245    view! {
246        <input
247            class=class
248            style=style
249            type="text"
250            placeholder=placeholder
251            value=value
252            disabled=disabled
253            on:input=move |ev| {
254                if let Some(callback) = on_change {
255                    callback.run(event_target_value(&ev));
256                }
257            }
258        />
259    }
260}
261
262/// Multi-Select tag component for selected items
263#[component]
264pub fn MultiSelectTag(
265    /// Option data
266    option: MultiSelectOption,
267    /// Callback when tag is removed
268    #[prop(optional)]
269    on_remove: Option<Callback<MultiSelectOption>>,
270    /// Additional CSS classes
271    #[prop(optional)]
272    class: Option<String>,
273    /// Inline styles
274    #[prop(optional)]
275    style: Option<String>,
276) -> impl IntoView {
277    let class = format!("multi-select-tag {}", class.unwrap_or_default());
278    let style = style.unwrap_or_default();
279
280    let option_clone = option.clone();
281    let handle_remove = move |_: web_sys::MouseEvent| {
282        if let Some(callback) = on_remove {
283            callback.run(option_clone.clone());
284        }
285    };
286
287    view! {
288        <span class=class style=style>
289            <span class="tag-label">{option.label.clone()}</span>
290            <button
291                class="tag-remove"
292                type="button"
293                aria-label=format!("Remove {}", option.label)
294                on:click=handle_remove
295            >
296                "×"
297            </button>
298        </span>
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use crate::MultiSelectOption;
305use crate::utils::{merge_optional_classes, generate_id};
306
307    // Component structure tests
308    #[test]
309    fn test_multiselect_component_creation() {}
310
311    #[test]
312    fn test_multiselect_trigger_component_creation() {}
313
314    #[test]
315    fn test_multiselect_content_component_creation() {}
316
317    #[test]
318    fn test_multiselect_option_component_creation() {}
319
320    #[test]
321    fn test_multiselect_search_component_creation() {}
322
323    #[test]
324    fn test_multiselect_tag_component_creation() {}
325
326    // Data structure tests
327    #[test]
328    fn test_multiselect_option_struct() {
329        let option = MultiSelectOption {
330            value: "test".to_string(),
331            label: "Test Option".to_string(),
332            _disabled: false,
333            description: Some("Test description".to_string()),
334            group: Some("test-group".to_string()),
335        };
336        assert_eq!(option.value, "test");
337        assert_eq!(option.label, "Test Option");
338        assert!(!option._disabled);
339        assert!(option.description.is_some());
340        assert!(option.group.is_some());
341    }
342
343    #[test]
344    fn test_multiselect_option_default() {
345        let option = MultiSelectOption::default();
346        assert_eq!(option.value, "");
347        assert_eq!(option.label, "");
348        assert!(!option._disabled);
349        assert!(option.description.is_none());
350        assert!(option.group.is_none());
351    }
352
353    // Props and state tests
354    #[test]
355    fn test_multiselect_props_handling() {}
356
357    #[test]
358    fn test_multiselect_value_handling() {}
359
360    #[test]
361    fn test_multiselect_options_handling() {}
362
363    #[test]
364    fn test_multiselectdisabled_state() {}
365
366    #[test]
367    fn test_multiselectrequired_state() {}
368
369    #[test]
370    fn test_multiselect_max_selections() {}
371
372    #[test]
373    fn test_multiselect_searchable_prop() {}
374
375    // Event handling tests
376    #[test]
377    fn test_multiselect_change_callback() {}
378
379    #[test]
380    fn test_multiselect_search_callback() {}
381
382    #[test]
383    fn test_multiselect_option_select_callback() {}
384
385    #[test]
386    fn test_multiselect_option_deselect_callback() {}
387
388    #[test]
389    fn test_multiselect_trigger_click() {}
390
391    #[test]
392    fn test_multiselect_option_click() {}
393
394    #[test]
395    fn test_multiselect_search_input() {}
396
397    #[test]
398    fn test_multiselect_tag_remove() {}
399
400    // Accessibility tests
401    #[test]
402    fn test_multiselect_aria_attributes() {}
403
404    #[test]
405    fn test_multiselect_keyboard_navigation() {}
406
407    #[test]
408    fn test_multiselect_screen_reader_support() {}
409
410    #[test]
411    fn test_multiselect_focus_management() {}
412
413    // Search functionality tests
414    #[test]
415    fn test_multiselect_search_filtering() {}
416
417    #[test]
418    fn test_multiselect_search_clear() {}
419
420    #[test]
421    fn test_multiselect_search_placeholder() {}
422
423    // Selection management tests
424    #[test]
425    fn test_multiselect_multiple_selection() {}
426
427    #[test]
428    fn test_multiselect_selection_limit() {}
429
430    #[test]
431    fn test_multiselect_selection_validation() {}
432
433    #[test]
434    fn test_multiselect_deselection() {}
435
436    // Grouping tests
437    #[test]
438    fn test_multiselect_option_grouping() {}
439
440    #[test]
441    fn test_multiselect_group_display() {}
442
443    // Performance tests
444    #[test]
445    fn test_multiselect_large_option_list() {}
446
447    #[test]
448    fn test_multiselect_search_performance() {}
449
450    // Integration tests
451    #[test]
452    fn test_multiselect_full_workflow() {}
453
454    #[test]
455    fn test_multiselect_with_form_integration() {}
456
457    // Edge case tests
458    #[test]
459    fn test_multiselect_empty_options() {}
460
461    #[test]
462    fn test_multiselect_all_optionsdisabled() {}
463
464    #[test]
465    fn test_multiselect_duplicate_values() {}
466
467    // Styling tests
468    #[test]
469    fn test_multiselect_custom_classes() {}
470
471    #[test]
472    fn test_multiselect_custom_styles() {}
473
474    #[test]
475    fn test_multiselect_responsive_design() {}
476}