skill_web/components/
searchable_select.rs

1use yew::prelude::*;
2use web_sys::{HtmlInputElement, KeyboardEvent};
3use wasm_bindgen::JsCast;
4
5#[derive(Clone, PartialEq, Properties)]
6pub struct SearchableSelectProps {
7    pub options: Vec<String>,
8    pub selected: Option<String>,
9    pub on_select: Callback<String>,
10    #[prop_or_default]
11    pub placeholder: String,
12    #[prop_or_default]
13    pub disabled: bool,
14    #[prop_or_default]
15    pub loading: bool,
16}
17
18#[function_component(SearchableSelect)]
19pub fn searchable_select(props: &SearchableSelectProps) -> Html {
20    let is_open = use_state(|| false);
21    let search_term = use_state(|| String::new());
22    let wrapper_ref = use_node_ref();
23    let input_ref = use_node_ref();
24
25    // Close dropdown when clicking outside
26    {
27        let is_open = is_open.clone();
28        let wrapper_ref = wrapper_ref.clone();
29        
30        use_effect_with(wrapper_ref, move |wrapper_ref| {
31            let wrapper_ref = wrapper_ref.clone();
32            let is_open = is_open.clone();
33            
34            let listener = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::MouseEvent)>::new(
35                move |event: web_sys::MouseEvent| {
36                    if *is_open {
37                        if let Some(target) = event.target() {
38                            if let Some(wrapper) = wrapper_ref.cast::<web_sys::HtmlElement>() {
39                                if !wrapper.contains(Some(&target.dyn_into().unwrap())) {
40                                    is_open.set(false);
41                                }
42                            }
43                        }
44                    }
45                },
46            );
47
48            if let Some(window) = web_sys::window() {
49                let _ = window.add_event_listener_with_callback(
50                    "mousedown",
51                    listener.as_ref().unchecked_ref(),
52                );
53            }
54
55            move || {
56                if let Some(window) = web_sys::window() {
57                    let _ = window.remove_event_listener_with_callback(
58                        "mousedown",
59                        listener.as_ref().unchecked_ref(),
60                    );
61                }
62            }
63        });
64    }
65
66    let filtered_options = props.options.iter()
67        .filter(|opt| {
68            opt.to_lowercase().contains(&search_term.to_lowercase())
69        })
70        .collect::<Vec<_>>();
71
72    let on_toggle = {
73        let is_open = is_open.clone();
74        let disabled = props.disabled;
75        let input_ref = input_ref.clone();
76        
77        Callback::from(move |e: MouseEvent| {
78            e.prevent_default(); // Prevent focus loss issues
79            if !disabled {
80                let new_state = !*is_open;
81                is_open.set(new_state);
82                // Focus input when opening
83                if new_state {
84                     if let Some(input) = input_ref.cast::<HtmlInputElement>() {
85                        let _ = input.focus();
86                    }
87                }
88            }
89        })
90    };
91
92    let on_input = {
93        let search_term = search_term.clone();
94        let is_open = is_open.clone();
95        Callback::from(move |e: InputEvent| {
96            let input: HtmlInputElement = e.target_unchecked_into();
97            search_term.set(input.value());
98            is_open.set(true); // Ensure open when typing
99        })
100    };
101
102    let on_select_option = {
103        let on_select = props.on_select.clone();
104        let is_open = is_open.clone();
105        let search_term = search_term.clone();
106        
107        move |option: String| {
108            let on_select = on_select.clone();
109            let is_open = is_open.clone();
110            let search_term = search_term.clone();
111            
112            Callback::from(move |e: MouseEvent| {
113                e.stop_propagation(); // Prevent outside click handler
114                on_select.emit(option.clone());
115                is_open.set(false);
116                search_term.set(String::new()); // Reset search on select
117            })
118        }
119    };
120
121    let display_value = props.selected.as_deref().unwrap_or(&props.placeholder);
122
123    html! {
124        <div class="relative" ref={wrapper_ref}>
125            // Trigger
126            <div
127                class={classes!(
128                    "flex", "items-center", "justify-between",
129                    "w-full", "px-3", "py-2.5", "text-sm",
130                    "bg-white", "dark:bg-gray-700",
131                    "border", "rounded-lg", "shadow-sm",
132                    "cursor-pointer", "transition-colors",
133                    if props.disabled {
134                        "bg-gray-100 dark:bg-gray-800 text-gray-500 cursor-not-allowed border-gray-200 dark:border-gray-700"
135                    } else if *is_open {
136                        "border-primary-500 ring-1 ring-primary-500"
137                    } else {
138                        "border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500"
139                    },
140                    if props.selected.is_none() { "text-gray-500 dark:text-gray-400" } else { "text-gray-900 dark:text-white" }
141                )}
142                onclick={on_toggle}
143            >
144                <div class="truncate mr-2">
145                    { display_value }
146                </div>
147                
148                if props.loading {
149                    <div class="animate-spin h-4 w-4 border-2 border-primary-500 border-t-transparent rounded-full flex-shrink-0"></div>
150                } else {
151                    <svg
152                        class={classes!(
153                            "w-4", "h-4", "text-gray-400", "transition-transform",
154                            is_open.then(|| "transform rotate-180")
155                        )}
156                        fill="none"
157                        viewBox="0 0 24 24"
158                        stroke="currentColor"
159                    >
160                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
161                    </svg>
162                }
163            </div>
164
165            // Dropdown
166            if *is_open && !props.disabled {
167                <div class="absolute z-50 w-full mt-1 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg overflow-hidden animate-in fade-in zoom-in-95 duration-100">
168                    // Search input
169                    <div class="p-2 border-b border-gray-200 dark:border-gray-600">
170                        <input
171                            ref={input_ref}
172                            type="text"
173                            class="w-full px-2 py-1.5 text-sm bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500 text-gray-900 dark:text-white placeholder-gray-500"
174                            placeholder="Search..."
175                            value={(*search_term).clone()}
176                            oninput={on_input}
177                            onclick={Callback::from(|e: MouseEvent| e.stop_propagation())} // Prevent closing when clicking input
178                        />
179                    </div>
180
181                    // Options list
182                    <div class="max-h-60 overflow-y-auto">
183                        if filtered_options.is_empty() {
184                            <div class="px-3 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
185                                { "No results found" }
186                            </div>
187                        } else {
188                            { for filtered_options.iter().map(|option| {
189                                let is_selected = props.selected.as_ref() == Some(option);
190                                html! {
191                                    <div
192                                        class={classes!(
193                                            "px-3", "py-2", "text-sm", "cursor-pointer",
194                                            "flex", "items-center", "justify-between",
195                                            if is_selected {
196                                                "bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300"
197                                            } else {
198                                                "text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600"
199                                            }
200                                        )}
201                                        onclick={on_select_option(option.to_string())}
202                                    >
203                                        <span class="truncate">{ option }</span>
204                                        if is_selected {
205                                            <svg class="w-4 h-4 text-primary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
206                                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
207                                            </svg>
208                                        }
209                                    </div>
210                                }
211                            })}
212                        }
213                    </div>
214                </div>
215            }
216        </div>
217    }
218}