1
use std::cell::RefCell;
2
use std::collections::HashMap;
3
use std::rc::Rc;
4
use wasm_bindgen::JsCast;
5
use wasm_bindgen::prelude::*;
6
use web_sys::{Element, HtmlElement, HtmlInputElement, KeyboardEvent};
7

            
8
use super::Suggestion;
9
use crate::data_attrs::AutocompleteAttr;
10

            
11
/// HTTP method for fetching autocomplete data.
12
#[derive(Debug, Clone, Copy, Default)]
13
pub enum FetchMethod {
14
    #[default]
15
    Get,
16
    Post,
17
}
18

            
19
/// Callback type for post-selection actions.
20
pub type OnSelectCallback = Rc<dyn Fn(&HtmlInputElement, &str, &str)>;
21

            
22
/// Configuration for autocomplete behavior.
23
pub struct AutocompleteConfig {
24
    pub fetch_url: String,
25
    pub fetch_method: FetchMethod,
26
    pub post_body_builder: Option<fn(&str) -> String>,
27
    pub enable_cache: bool,
28
    pub dependency_input: Option<HtmlInputElement>,
29
    pub on_select: Option<OnSelectCallback>,
30
}
31

            
32
impl AutocompleteConfig {
33
    #[must_use]
34
2
    pub fn get(url: impl Into<String>) -> Self {
35
2
        Self {
36
2
            fetch_url: url.into(),
37
2
            fetch_method: FetchMethod::Get,
38
2
            post_body_builder: None,
39
2
            enable_cache: true,
40
2
            dependency_input: None,
41
2
            on_select: None,
42
2
        }
43
2
    }
44

            
45
    #[must_use]
46
1
    pub fn post(url: impl Into<String>, body_builder: fn(&str) -> String) -> Self {
47
1
        Self {
48
1
            fetch_url: url.into(),
49
1
            fetch_method: FetchMethod::Post,
50
1
            post_body_builder: Some(body_builder),
51
1
            enable_cache: true,
52
1
            dependency_input: None,
53
1
            on_select: None,
54
1
        }
55
1
    }
56

            
57
    #[must_use]
58
1
    pub fn with_cache(mut self, enable: bool) -> Self {
59
1
        self.enable_cache = enable;
60
1
        self
61
1
    }
62

            
63
    #[must_use]
64
    pub fn with_dependency(mut self, input: HtmlInputElement) -> Self {
65
        self.dependency_input = Some(input);
66
        self
67
    }
68

            
69
    #[must_use]
70
    pub fn with_on_select(mut self, callback: OnSelectCallback) -> Self {
71
        self.on_select = Some(callback);
72
        self
73
    }
74
}
75

            
76
/// Builds a cache key from URL and optional dependency value.
77
#[must_use]
78
2
pub fn build_cache_key(url: &str, dependency_value: Option<&str>) -> String {
79
2
    match dependency_value {
80
1
        Some(dep) => format!("{url}?dep={dep}"),
81
1
        None => url.to_string(),
82
    }
83
2
}
84

            
85
pub struct Autocomplete<T: Suggestion> {
86
    input: HtmlInputElement,
87
    hidden_primary: HtmlInputElement,
88
    hidden_secondary: Option<HtmlInputElement>,
89
    container: Element,
90
    items: Vec<T>,
91
    filtered_indices: Vec<usize>,
92
    selected_index: Option<usize>,
93
    is_open: bool,
94
    on_select: Option<OnSelectCallback>,
95
}
96

            
97
impl<T: Suggestion> Autocomplete<T> {
98
    #[must_use]
99
    pub fn new(
100
        input: HtmlInputElement,
101
        hidden_primary: HtmlInputElement,
102
        hidden_secondary: Option<HtmlInputElement>,
103
        container: Element,
104
    ) -> Self {
105
        Self {
106
            input,
107
            hidden_primary,
108
            hidden_secondary,
109
            container,
110
            items: Vec::new(),
111
            filtered_indices: Vec::new(),
112
            selected_index: None,
113
            is_open: false,
114
            on_select: None,
115
        }
116
    }
117

            
118
    pub fn set_on_select(&mut self, callback: OnSelectCallback) {
119
        self.on_select = Some(callback);
120
    }
121

            
122
    pub fn set_items(&mut self, items: Vec<T>) {
123
        self.items = items;
124
        self.filter(&self.input.value());
125
    }
126

            
127
    pub fn filter(&mut self, query: &str) {
128
        self.filtered_indices = self
129
            .items
130
            .iter()
131
            .enumerate()
132
            .filter(|(_, item)| query.is_empty() || item.matches(query))
133
            .map(|(i, _)| i)
134
            .collect();
135
        self.selected_index = if self.filtered_indices.is_empty() {
136
            None
137
        } else {
138
            Some(0)
139
        };
140
        self.render_suggestions();
141
    }
142

            
143
    pub fn select(&mut self, index: usize) {
144
        if let Some(&item_idx) = self.filtered_indices.get(index)
145
            && let Some(item) = self.items.get(item_idx)
146
        {
147
            let display_text = item.display_text().to_string();
148
            let id = item.get_id().to_string();
149
            let secondary = item.get_secondary_value().unwrap_or("").to_string();
150

            
151
            self.input.set_value(&display_text);
152
            self.hidden_primary.set_value(&id);
153

            
154
            if let Some(ref secondary_input) = self.hidden_secondary {
155
                secondary_input.set_value(&secondary);
156
            }
157

            
158
            self.trigger_input_event(&self.input);
159
            self.trigger_change_event(&self.hidden_primary);
160
            if let Some(ref secondary_input) = self.hidden_secondary {
161
                self.trigger_change_event(secondary_input);
162
            }
163

            
164
            if let Some(ref callback) = self.on_select {
165
                callback(&self.input, &id, &secondary);
166
            }
167
        }
168
        self.close();
169
    }
170

            
171
    pub fn open(&mut self) {
172
        self.is_open = true;
173
        self.show_container();
174
        self.filter(&self.input.value());
175
    }
176

            
177
    pub fn close(&mut self) {
178
        self.is_open = false;
179
        self.hide_container();
180
        self.container.set_inner_html("");
181
    }
182

            
183
    fn show_container(&self) {
184
        if let Ok(el) = self.container.clone().dyn_into::<HtmlElement>() {
185
            let _ = el.style().set_property("display", "block");
186
        }
187
    }
188

            
189
    fn hide_container(&self) {
190
        if let Ok(el) = self.container.clone().dyn_into::<HtmlElement>() {
191
            let _ = el.style().set_property("display", "none");
192
        }
193
    }
194

            
195
    pub fn move_selection(&mut self, delta: i32) {
196
        if self.filtered_indices.is_empty() {
197
            return;
198
        }
199

            
200
        let len = self.filtered_indices.len();
201
        let current = self.selected_index.unwrap_or(0) as i32;
202
        let new_idx = (current + delta).rem_euclid(len as i32) as usize;
203
        self.selected_index = Some(new_idx);
204
        self.render_suggestions();
205
    }
206

            
207
    pub fn select_current(&mut self) {
208
        if let Some(idx) = self.selected_index {
209
            self.select(idx);
210
        }
211
    }
212

            
213
    fn render_suggestions(&mut self) {
214
        if !self.is_open {
215
            self.container.set_inner_html("");
216
            return;
217
        }
218

            
219
        let mut html = String::new();
220
        for (display_idx, &item_idx) in self.filtered_indices.iter().enumerate() {
221
            if let Some(item) = self.items.get(item_idx) {
222
                let selected_class = if self.selected_index == Some(display_idx) {
223
                    " selected"
224
                } else {
225
                    ""
226
                };
227
                html.push_str(&format!(
228
                    r#"<div class="autocomplete-item{}" {}="{}">{}</div>"#,
229
                    selected_class,
230
                    AutocompleteAttr::Index.as_str(),
231
                    display_idx,
232
                    html_escape(item.display_text())
233
                ));
234
            }
235
        }
236

            
237
        if html.is_empty() && !self.input.value().is_empty() {
238
            html = r#"<div class="no-results">No matches found</div>"#.to_string();
239
        }
240

            
241
        self.container.set_inner_html(&html);
242
    }
243

            
244
    fn trigger_input_event(&self, element: &HtmlInputElement) {
245
        let init = web_sys::EventInit::new();
246
        init.set_bubbles(true);
247
        if let Ok(event) = web_sys::Event::new_with_event_init_dict("input", &init) {
248
            let _ = element.dispatch_event(&event);
249
        }
250
    }
251

            
252
    fn trigger_change_event(&self, element: &HtmlInputElement) {
253
        let init = web_sys::EventInit::new();
254
        init.set_bubbles(true);
255
        if let Ok(event) = web_sys::Event::new_with_event_init_dict("change", &init) {
256
            let _ = element.dispatch_event(&event);
257
        }
258
    }
259
}
260

            
261
fn html_escape(s: &str) -> String {
262
    s.replace('&', "&amp;")
263
        .replace('<', "&lt;")
264
        .replace('>', "&gt;")
265
        .replace('"', "&quot;")
266
}
267

            
268
pub fn attach_autocomplete<T: Suggestion>(
269
    ac: Rc<RefCell<Autocomplete<T>>>,
270
    config: AutocompleteConfig,
271
) {
272
    let input = ac.borrow().input.clone();
273
    let container = ac.borrow().container.clone();
274

            
275
    if let Some(ref callback) = config.on_select {
276
        ac.borrow_mut().set_on_select(Rc::clone(callback));
277
    }
278

            
279
    let config = Rc::new(config);
280
    let cache: Rc<RefCell<HashMap<String, Vec<T>>>> = Rc::new(RefCell::new(HashMap::new()));
281

            
282
    let ac_input = Rc::clone(&ac);
283
    let input_callback = Closure::wrap(Box::new(move |_: web_sys::InputEvent| {
284
        // Use try_borrow_mut to skip if already borrowed (e.g., during programmatic selection)
285
        let Ok(mut ac) = ac_input.try_borrow_mut() else {
286
            return;
287
        };
288
        let query = ac.input.value();
289
        ac.open();
290
        ac.filter(&query);
291
    }) as Box<dyn FnMut(_)>);
292
    input
293
        .add_event_listener_with_callback("input", input_callback.as_ref().unchecked_ref())
294
        .unwrap();
295
    input_callback.forget();
296

            
297
    let ac_focus = Rc::clone(&ac);
298
    let config_focus = Rc::clone(&config);
299
    let cache_focus = Rc::clone(&cache);
300
    let focus_callback = Closure::wrap(Box::new(move |_: web_sys::FocusEvent| {
301
        ac_focus.borrow_mut().open();
302

            
303
        if let Some(ref dep_input) = config_focus.dependency_input {
304
            let dep_value = dep_input.value();
305
            if dep_value.is_empty() {
306
                return;
307
            }
308

            
309
            let ac_fetch = Rc::clone(&ac_focus);
310
            let config_fetch = Rc::clone(&config_focus);
311
            let cache_fetch = Rc::clone(&cache_focus);
312
            let dep_value_clone = dep_value.clone();
313

            
314
            wasm_bindgen_futures::spawn_local(async move {
315
                let url = format!("{}?name={}", config_fetch.fetch_url, dep_value_clone);
316
                let cache_key = build_cache_key(&config_fetch.fetch_url, Some(&dep_value_clone));
317

            
318
                if config_fetch.enable_cache
319
                    && let Some(cached) = cache_fetch.borrow().get(&cache_key)
320
                {
321
                    ac_fetch.borrow_mut().set_items(cached.clone());
322
                    return;
323
                }
324

            
325
                let fetch_config = AutocompleteConfig {
326
                    fetch_url: url,
327
                    fetch_method: config_fetch.fetch_method,
328
                    post_body_builder: config_fetch.post_body_builder,
329
                    enable_cache: config_fetch.enable_cache,
330
                    dependency_input: None,
331
                    on_select: None,
332
                };
333

            
334
                if let Ok(items) = fetch_items(&fetch_config, "").await {
335
                    if config_fetch.enable_cache {
336
                        cache_fetch.borrow_mut().insert(cache_key, items.clone());
337
                    }
338
                    ac_fetch.borrow_mut().set_items(items);
339
                }
340
            });
341
        }
342
    }) as Box<dyn FnMut(_)>);
343
    input
344
        .add_event_listener_with_callback("focus", focus_callback.as_ref().unchecked_ref())
345
        .unwrap();
346
    focus_callback.forget();
347

            
348
    let ac_blur = Rc::clone(&ac);
349
    let blur_callback = Closure::wrap(Box::new(move |_: web_sys::FocusEvent| {
350
        let window = web_sys::window().unwrap();
351
        let ac_delayed = Rc::clone(&ac_blur);
352
        let delayed_close = Closure::once(Box::new(move || {
353
            ac_delayed.borrow_mut().close();
354
        }) as Box<dyn FnOnce()>);
355
        window
356
            .set_timeout_with_callback_and_timeout_and_arguments_0(
357
                delayed_close.as_ref().unchecked_ref(),
358
                150,
359
            )
360
            .unwrap();
361
        delayed_close.forget();
362
    }) as Box<dyn FnMut(_)>);
363
    input
364
        .add_event_listener_with_callback("blur", blur_callback.as_ref().unchecked_ref())
365
        .unwrap();
366
    blur_callback.forget();
367

            
368
    let ac_keydown = Rc::clone(&ac);
369
    let keydown_callback = Closure::wrap(Box::new(move |e: KeyboardEvent| {
370
        let mut ac = ac_keydown.borrow_mut();
371
        match e.key().as_str() {
372
            "ArrowDown" => {
373
                e.prevent_default();
374
                ac.move_selection(1);
375
            }
376
            "ArrowUp" => {
377
                e.prevent_default();
378
                ac.move_selection(-1);
379
            }
380
            "Enter" => {
381
                if ac.is_open && ac.selected_index.is_some() {
382
                    e.prevent_default();
383
                    ac.select_current();
384
                }
385
            }
386
            "Escape" => {
387
                ac.close();
388
            }
389
            _ => {}
390
        }
391
    }) as Box<dyn FnMut(_)>);
392
    input
393
        .add_event_listener_with_callback("keydown", keydown_callback.as_ref().unchecked_ref())
394
        .unwrap();
395
    keydown_callback.forget();
396

            
397
    // Prevent blur when clicking on suggestions (important for mobile Safari)
398
    let mousedown_callback = Closure::wrap(Box::new(move |e: web_sys::MouseEvent| {
399
        e.prevent_default();
400
    }) as Box<dyn FnMut(_)>);
401
    container
402
        .add_event_listener_with_callback("mousedown", mousedown_callback.as_ref().unchecked_ref())
403
        .unwrap();
404
    mousedown_callback.forget();
405

            
406
    let ac_click = Rc::clone(&ac);
407
    let click_callback = Closure::wrap(Box::new(move |e: web_sys::MouseEvent| {
408
        if let Some(target) = e.target()
409
            && let Ok(el) = target.dyn_into::<HtmlElement>()
410
            && el.class_list().contains("autocomplete-item")
411
            && let Some(idx_str) = el.get_attribute(AutocompleteAttr::Index.as_str())
412
            && let Ok(idx) = idx_str.parse::<usize>()
413
        {
414
            ac_click.borrow_mut().select(idx);
415
        }
416
    }) as Box<dyn FnMut(_)>);
417
    container
418
        .add_event_listener_with_callback("click", click_callback.as_ref().unchecked_ref())
419
        .unwrap();
420
    click_callback.forget();
421

            
422
    // Skip initial fetch if there's a dependency - fetch on focus instead
423
    if config.dependency_input.is_none() {
424
        let ac_fetch = Rc::clone(&ac);
425
        let config_fetch = Rc::clone(&config);
426
        let cache_fetch = Rc::clone(&cache);
427
        wasm_bindgen_futures::spawn_local(async move {
428
            let cache_key = build_cache_key(&config_fetch.fetch_url, None);
429

            
430
            if config_fetch.enable_cache
431
                && let Some(cached) = cache_fetch.borrow().get(&cache_key)
432
            {
433
                ac_fetch.borrow_mut().set_items(cached.clone());
434
                return;
435
            }
436

            
437
            if let Ok(items) = fetch_items::<T>(&config_fetch, "").await {
438
                if config_fetch.enable_cache {
439
                    cache_fetch.borrow_mut().insert(cache_key, items.clone());
440
                }
441
                ac_fetch.borrow_mut().set_items(items);
442
            }
443
        });
444
    }
445
}
446

            
447
async fn fetch_items<T: Suggestion>(
448
    config: &AutocompleteConfig,
449
    query: &str,
450
) -> Result<Vec<T>, JsValue> {
451
    let window = web_sys::window().ok_or("no window")?;
452
    let opts = web_sys::RequestInit::new();
453

            
454
    let url = match config.fetch_method {
455
        FetchMethod::Get => {
456
            opts.set_method("GET");
457
            config.fetch_url.clone()
458
        }
459
        FetchMethod::Post => {
460
            opts.set_method("POST");
461
            if let Some(body_builder) = config.post_body_builder {
462
                let body = body_builder(query);
463
                opts.set_body(&JsValue::from_str(&body));
464
            }
465
            config.fetch_url.clone()
466
        }
467
    };
468

            
469
    let request = web_sys::Request::new_with_str_and_init(&url, &opts)?;
470
    request.headers().set("Accept", "application/json")?;
471
    if matches!(config.fetch_method, FetchMethod::Post) {
472
        request.headers().set("Content-Type", "application/json")?;
473
    }
474

            
475
    let resp_value =
476
        wasm_bindgen_futures::JsFuture::from(window.fetch_with_request(&request)).await?;
477
    let resp: web_sys::Response = resp_value.dyn_into()?;
478

            
479
    let json = wasm_bindgen_futures::JsFuture::from(resp.json()?).await?;
480
    let items: Vec<T> = serde_wasm_bindgen::from_value(json)?;
481

            
482
    Ok(items)
483
}
484

            
485
#[cfg(test)]
486
mod tests {
487
    use super::*;
488

            
489
    #[test]
490
1
    fn cache_key_without_dependency() {
491
1
        let key = build_cache_key("/api/accounts", None);
492
1
        assert_eq!(key, "/api/accounts");
493
1
    }
494

            
495
    #[test]
496
1
    fn cache_key_with_dependency() {
497
1
        let key = build_cache_key("/api/tags/values", Some("category"));
498
1
        assert_eq!(key, "/api/tags/values?dep=category");
499
1
    }
500

            
501
    #[test]
502
1
    fn config_get_creates_get_request() {
503
1
        let config = AutocompleteConfig::get("/api/test");
504
1
        assert!(matches!(config.fetch_method, FetchMethod::Get));
505
1
        assert_eq!(config.fetch_url, "/api/test");
506
1
        assert!(config.enable_cache);
507
1
        assert!(config.post_body_builder.is_none());
508
1
    }
509

            
510
    #[test]
511
1
    fn config_post_creates_post_request() {
512
1
        let config = AutocompleteConfig::post("/api/search", |q| format!("{{\"q\":\"{q}\"}}"));
513
1
        assert!(matches!(config.fetch_method, FetchMethod::Post));
514
1
        assert_eq!(config.fetch_url, "/api/search");
515
1
        assert!(config.post_body_builder.is_some());
516

            
517
1
        let body = (config.post_body_builder.unwrap())("test");
518
1
        assert_eq!(body, r#"{"q":"test"}"#);
519
1
    }
520

            
521
    #[test]
522
1
    fn config_with_cache_disabled() {
523
1
        let config = AutocompleteConfig::get("/api/test").with_cache(false);
524
1
        assert!(!config.enable_cache);
525
1
    }
526
}