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
/// The `data-index` of the `.autocomplete-item` under a pointer event,
269
/// if any. The event target may be the item itself or a descendant, so
270
/// it resolves via `closest`.
271
fn item_index_at(e: &web_sys::MouseEvent) -> Option<usize> {
272
    let target = e.target()?;
273
    let el = target.dyn_into::<Element>().ok()?;
274
    let item = el.closest(".autocomplete-item").ok().flatten()?;
275
    item.get_attribute(AutocompleteAttr::Index.as_str())?
276
        .parse::<usize>()
277
        .ok()
278
}
279

            
280
pub fn attach_autocomplete<T: Suggestion>(
281
    ac: Rc<RefCell<Autocomplete<T>>>,
282
    config: AutocompleteConfig,
283
) {
284
    let input = ac.borrow().input.clone();
285
    let container = ac.borrow().container.clone();
286

            
287
    if let Some(ref callback) = config.on_select {
288
        ac.borrow_mut().set_on_select(Rc::clone(callback));
289
    }
290

            
291
    let config = Rc::new(config);
292
    let cache: Rc<RefCell<HashMap<String, Vec<T>>>> = Rc::new(RefCell::new(HashMap::new()));
293

            
294
    let ac_input = Rc::clone(&ac);
295
    let input_callback = Closure::wrap(Box::new(move |_: web_sys::InputEvent| {
296
        // Use try_borrow_mut to skip if already borrowed (e.g., during programmatic selection)
297
        let Ok(mut ac) = ac_input.try_borrow_mut() else {
298
            return;
299
        };
300
        let query = ac.input.value();
301
        ac.open();
302
        ac.filter(&query);
303
    }) as Box<dyn FnMut(_)>);
304
    input
305
        .add_event_listener_with_callback("input", input_callback.as_ref().unchecked_ref())
306
        .unwrap();
307
    input_callback.forget();
308

            
309
    let ac_focus = Rc::clone(&ac);
310
    let config_focus = Rc::clone(&config);
311
    let cache_focus = Rc::clone(&cache);
312
    let focus_callback = Closure::wrap(Box::new(move |_: web_sys::FocusEvent| {
313
        ac_focus.borrow_mut().open();
314

            
315
        if let Some(ref dep_input) = config_focus.dependency_input {
316
            let dep_value = dep_input.value();
317
            if dep_value.is_empty() {
318
                return;
319
            }
320

            
321
            let ac_fetch = Rc::clone(&ac_focus);
322
            let config_fetch = Rc::clone(&config_focus);
323
            let cache_fetch = Rc::clone(&cache_focus);
324
            let dep_value_clone = dep_value.clone();
325

            
326
            wasm_bindgen_futures::spawn_local(async move {
327
                let url = format!("{}?name={}", config_fetch.fetch_url, dep_value_clone);
328
                let cache_key = build_cache_key(&config_fetch.fetch_url, Some(&dep_value_clone));
329

            
330
                if config_fetch.enable_cache
331
                    && let Some(cached) = cache_fetch.borrow().get(&cache_key)
332
                {
333
                    ac_fetch.borrow_mut().set_items(cached.clone());
334
                    return;
335
                }
336

            
337
                let fetch_config = AutocompleteConfig {
338
                    fetch_url: url,
339
                    fetch_method: config_fetch.fetch_method,
340
                    post_body_builder: config_fetch.post_body_builder,
341
                    enable_cache: config_fetch.enable_cache,
342
                    dependency_input: None,
343
                    on_select: None,
344
                };
345

            
346
                if let Ok(items) = fetch_items(&fetch_config, "").await {
347
                    if config_fetch.enable_cache {
348
                        cache_fetch.borrow_mut().insert(cache_key, items.clone());
349
                    }
350
                    ac_fetch.borrow_mut().set_items(items);
351
                }
352
            });
353
        }
354
    }) as Box<dyn FnMut(_)>);
355
    input
356
        .add_event_listener_with_callback("focus", focus_callback.as_ref().unchecked_ref())
357
        .unwrap();
358
    focus_callback.forget();
359

            
360
    let ac_blur = Rc::clone(&ac);
361
    let blur_callback = Closure::wrap(Box::new(move |_: web_sys::FocusEvent| {
362
        let window = web_sys::window().unwrap();
363
        let ac_delayed = Rc::clone(&ac_blur);
364
        let delayed_close = Closure::once(Box::new(move || {
365
            ac_delayed.borrow_mut().close();
366
        }) as Box<dyn FnOnce()>);
367
        window
368
            .set_timeout_with_callback_and_timeout_and_arguments_0(
369
                delayed_close.as_ref().unchecked_ref(),
370
                150,
371
            )
372
            .unwrap();
373
        delayed_close.forget();
374
    }) as Box<dyn FnMut(_)>);
375
    input
376
        .add_event_listener_with_callback("blur", blur_callback.as_ref().unchecked_ref())
377
        .unwrap();
378
    blur_callback.forget();
379

            
380
    let ac_keydown = Rc::clone(&ac);
381
    let keydown_callback = Closure::wrap(Box::new(move |e: KeyboardEvent| {
382
        let mut ac = ac_keydown.borrow_mut();
383
        match e.key().as_str() {
384
            "ArrowDown" => {
385
                e.prevent_default();
386
                ac.move_selection(1);
387
            }
388
            "ArrowUp" => {
389
                e.prevent_default();
390
                ac.move_selection(-1);
391
            }
392
            "Enter" if ac.is_open && ac.selected_index.is_some() => {
393
                e.prevent_default();
394
                ac.select_current();
395
            }
396
            "Escape" => {
397
                ac.close();
398
            }
399
            _ => {}
400
        }
401
    }) as Box<dyn FnMut(_)>);
402
    input
403
        .add_event_listener_with_callback("keydown", keydown_callback.as_ref().unchecked_ref())
404
        .unwrap();
405
    keydown_callback.forget();
406

            
407
    // Pointer-event tap-to-select that still lets the (max-height,
408
    // overflow-y) results list be touch-scrolled. The old
409
    // `mousedown`(prevent-default) + `click`(select) pair worked on
410
    // desktop but never selected on touch: the synthesized `mousedown`
411
    // couldn't cancel the input blur, so the 150ms delayed close fired
412
    // before the `click` landed.
413
    //
414
    // `pointerdown` (primary button only) records the pressed item and
415
    // `prevent_default`s to keep the input focused — suppressing the
416
    // blur and its delayed close. A `pointermove` past a small slop is a
417
    // scroll drag and cancels the pending pick; `pointerup` commits it;
418
    // `pointercancel` (fired when the browser takes over for scrolling)
419
    // discards it. Selecting on `pointerdown` instead would close the
420
    // list on the first contact of a scroll gesture.
421
    let pending: Rc<RefCell<Option<(i32, i32, usize)>>> = Rc::new(RefCell::new(None));
422

            
423
    let down_pending = Rc::clone(&pending);
424
    let pointerdown_callback = Closure::wrap(Box::new(move |e: web_sys::MouseEvent| {
425
        if e.button() != 0 {
426
            return;
427
        }
428
        let Some(idx) = item_index_at(&e) else {
429
            return;
430
        };
431
        e.prevent_default();
432
        *down_pending.borrow_mut() = Some((e.client_x(), e.client_y(), idx));
433
    }) as Box<dyn FnMut(_)>);
434
    container
435
        .add_event_listener_with_callback(
436
            "pointerdown",
437
            pointerdown_callback.as_ref().unchecked_ref(),
438
        )
439
        .unwrap();
440
    pointerdown_callback.forget();
441

            
442
    let move_pending = Rc::clone(&pending);
443
    let pointermove_callback = Closure::wrap(Box::new(move |e: web_sys::MouseEvent| {
444
        let moved = move_pending
445
            .borrow()
446
            .is_some_and(|(sx, sy, _)| (e.client_x() - sx).abs() + (e.client_y() - sy).abs() > 8);
447
        if moved {
448
            *move_pending.borrow_mut() = None;
449
        }
450
    }) as Box<dyn FnMut(_)>);
451
    container
452
        .add_event_listener_with_callback(
453
            "pointermove",
454
            pointermove_callback.as_ref().unchecked_ref(),
455
        )
456
        .unwrap();
457
    pointermove_callback.forget();
458

            
459
    let ac_select = Rc::clone(&ac);
460
    let up_pending = Rc::clone(&pending);
461
    let pointerup_callback = Closure::wrap(Box::new(move |_: web_sys::MouseEvent| {
462
        if let Some((_, _, idx)) = up_pending.borrow_mut().take() {
463
            ac_select.borrow_mut().select(idx);
464
        }
465
    }) as Box<dyn FnMut(_)>);
466
    container
467
        .add_event_listener_with_callback("pointerup", pointerup_callback.as_ref().unchecked_ref())
468
        .unwrap();
469
    pointerup_callback.forget();
470

            
471
    let cancel_pending = Rc::clone(&pending);
472
    let pointercancel_callback = Closure::wrap(Box::new(move |_: web_sys::Event| {
473
        *cancel_pending.borrow_mut() = None;
474
    }) as Box<dyn FnMut(_)>);
475
    container
476
        .add_event_listener_with_callback(
477
            "pointercancel",
478
            pointercancel_callback.as_ref().unchecked_ref(),
479
        )
480
        .unwrap();
481
    pointercancel_callback.forget();
482

            
483
    // Skip initial fetch if there's a dependency - fetch on focus instead
484
    if config.dependency_input.is_none() {
485
        let ac_fetch = Rc::clone(&ac);
486
        let config_fetch = Rc::clone(&config);
487
        let cache_fetch = Rc::clone(&cache);
488
        wasm_bindgen_futures::spawn_local(async move {
489
            let cache_key = build_cache_key(&config_fetch.fetch_url, None);
490

            
491
            if config_fetch.enable_cache
492
                && let Some(cached) = cache_fetch.borrow().get(&cache_key)
493
            {
494
                ac_fetch.borrow_mut().set_items(cached.clone());
495
                return;
496
            }
497

            
498
            if let Ok(items) = fetch_items::<T>(&config_fetch, "").await {
499
                if config_fetch.enable_cache {
500
                    cache_fetch.borrow_mut().insert(cache_key, items.clone());
501
                }
502
                ac_fetch.borrow_mut().set_items(items);
503
            }
504
        });
505
    }
506
}
507

            
508
async fn fetch_items<T: Suggestion>(
509
    config: &AutocompleteConfig,
510
    query: &str,
511
) -> Result<Vec<T>, JsValue> {
512
    let window = web_sys::window().ok_or("no window")?;
513
    let opts = web_sys::RequestInit::new();
514

            
515
    let url = match config.fetch_method {
516
        FetchMethod::Get => {
517
            opts.set_method("GET");
518
            config.fetch_url.clone()
519
        }
520
        FetchMethod::Post => {
521
            opts.set_method("POST");
522
            if let Some(body_builder) = config.post_body_builder {
523
                let body = body_builder(query);
524
                opts.set_body(&JsValue::from_str(&body));
525
            }
526
            config.fetch_url.clone()
527
        }
528
    };
529

            
530
    let request = web_sys::Request::new_with_str_and_init(&url, &opts)?;
531
    request.headers().set("Accept", "application/json")?;
532
    if matches!(config.fetch_method, FetchMethod::Post) {
533
        request.headers().set("Content-Type", "application/json")?;
534
    }
535

            
536
    let resp_value =
537
        wasm_bindgen_futures::JsFuture::from(window.fetch_with_request(&request)).await?;
538
    let resp: web_sys::Response = resp_value.dyn_into()?;
539

            
540
    let json = wasm_bindgen_futures::JsFuture::from(resp.json()?).await?;
541
    let items: Vec<T> = serde_wasm_bindgen::from_value(json)?;
542

            
543
    Ok(items)
544
}
545

            
546
#[cfg(test)]
547
mod tests {
548
    use super::*;
549

            
550
    #[test]
551
1
    fn cache_key_without_dependency() {
552
1
        let key = build_cache_key("/api/accounts", None);
553
1
        assert_eq!(key, "/api/accounts");
554
1
    }
555

            
556
    #[test]
557
1
    fn cache_key_with_dependency() {
558
1
        let key = build_cache_key("/api/tags/values", Some("category"));
559
1
        assert_eq!(key, "/api/tags/values?dep=category");
560
1
    }
561

            
562
    #[test]
563
1
    fn config_get_creates_get_request() {
564
1
        let config = AutocompleteConfig::get("/api/test");
565
1
        assert!(matches!(config.fetch_method, FetchMethod::Get));
566
1
        assert_eq!(config.fetch_url, "/api/test");
567
1
        assert!(config.enable_cache);
568
1
        assert!(config.post_body_builder.is_none());
569
1
    }
570

            
571
    #[test]
572
1
    fn config_post_creates_post_request() {
573
1
        let config = AutocompleteConfig::post("/api/search", |q| format!("{{\"q\":\"{q}\"}}"));
574
1
        assert!(matches!(config.fetch_method, FetchMethod::Post));
575
1
        assert_eq!(config.fetch_url, "/api/search");
576
1
        assert!(config.post_body_builder.is_some());
577

            
578
1
        let body = (config.post_body_builder.unwrap())("test");
579
1
        assert_eq!(body, r#"{"q":"test"}"#);
580
1
    }
581

            
582
    #[test]
583
1
    fn config_with_cache_disabled() {
584
1
        let config = AutocompleteConfig::get("/api/test").with_cache(false);
585
1
        assert!(!config.enable_cache);
586
1
    }
587
}