skill_web/components/
searchable_select.rs1use 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 {
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(); if !disabled {
80 let new_state = !*is_open;
81 is_open.set(new_state);
82 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); })
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(); on_select.emit(option.clone());
115 is_open.set(false);
116 search_term.set(String::new()); })
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 <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 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 <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())} />
179 </div>
180
181 <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}