Lines
14.7 %
Functions
4.63 %
Branches
100 %
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use web_sys::{Element, HtmlElement, HtmlInputElement, KeyboardEvent};
use super::Suggestion;
use crate::data_attrs::AutocompleteAttr;
/// HTTP method for fetching autocomplete data.
#[derive(Debug, Clone, Copy, Default)]
pub enum FetchMethod {
#[default]
Get,
Post,
}
/// Callback type for post-selection actions.
pub type OnSelectCallback = Rc<dyn Fn(&HtmlInputElement, &str, &str)>;
/// Configuration for autocomplete behavior.
pub struct AutocompleteConfig {
pub fetch_url: String,
pub fetch_method: FetchMethod,
pub post_body_builder: Option<fn(&str) -> String>,
pub enable_cache: bool,
pub dependency_input: Option<HtmlInputElement>,
pub on_select: Option<OnSelectCallback>,
impl AutocompleteConfig {
#[must_use]
pub fn get(url: impl Into<String>) -> Self {
Self {
fetch_url: url.into(),
fetch_method: FetchMethod::Get,
post_body_builder: None,
enable_cache: true,
dependency_input: None,
on_select: None,
pub fn post(url: impl Into<String>, body_builder: fn(&str) -> String) -> Self {
fetch_method: FetchMethod::Post,
post_body_builder: Some(body_builder),
pub fn with_cache(mut self, enable: bool) -> Self {
self.enable_cache = enable;
self
pub fn with_dependency(mut self, input: HtmlInputElement) -> Self {
self.dependency_input = Some(input);
pub fn with_on_select(mut self, callback: OnSelectCallback) -> Self {
self.on_select = Some(callback);
/// Builds a cache key from URL and optional dependency value.
pub fn build_cache_key(url: &str, dependency_value: Option<&str>) -> String {
match dependency_value {
Some(dep) => format!("{url}?dep={dep}"),
None => url.to_string(),
pub struct Autocomplete<T: Suggestion> {
input: HtmlInputElement,
hidden_primary: HtmlInputElement,
hidden_secondary: Option<HtmlInputElement>,
container: Element,
items: Vec<T>,
filtered_indices: Vec<usize>,
selected_index: Option<usize>,
is_open: bool,
on_select: Option<OnSelectCallback>,
impl<T: Suggestion> Autocomplete<T> {
pub fn new(
) -> Self {
input,
hidden_primary,
hidden_secondary,
container,
items: Vec::new(),
filtered_indices: Vec::new(),
selected_index: None,
is_open: false,
pub fn set_on_select(&mut self, callback: OnSelectCallback) {
pub fn set_items(&mut self, items: Vec<T>) {
self.items = items;
self.filter(&self.input.value());
pub fn filter(&mut self, query: &str) {
self.filtered_indices = self
.items
.iter()
.enumerate()
.filter(|(_, item)| query.is_empty() || item.matches(query))
.map(|(i, _)| i)
.collect();
self.selected_index = if self.filtered_indices.is_empty() {
None
} else {
Some(0)
};
self.render_suggestions();
pub fn select(&mut self, index: usize) {
if let Some(&item_idx) = self.filtered_indices.get(index)
&& let Some(item) = self.items.get(item_idx)
{
let display_text = item.display_text().to_string();
let id = item.get_id().to_string();
let secondary = item.get_secondary_value().unwrap_or("").to_string();
self.input.set_value(&display_text);
self.hidden_primary.set_value(&id);
if let Some(ref secondary_input) = self.hidden_secondary {
secondary_input.set_value(&secondary);
self.trigger_input_event(&self.input);
self.trigger_change_event(&self.hidden_primary);
self.trigger_change_event(secondary_input);
if let Some(ref callback) = self.on_select {
callback(&self.input, &id, &secondary);
self.close();
pub fn open(&mut self) {
self.is_open = true;
self.show_container();
pub fn close(&mut self) {
self.is_open = false;
self.hide_container();
self.container.set_inner_html("");
fn show_container(&self) {
if let Ok(el) = self.container.clone().dyn_into::<HtmlElement>() {
let _ = el.style().set_property("display", "block");
fn hide_container(&self) {
let _ = el.style().set_property("display", "none");
pub fn move_selection(&mut self, delta: i32) {
if self.filtered_indices.is_empty() {
return;
let len = self.filtered_indices.len();
let current = self.selected_index.unwrap_or(0) as i32;
let new_idx = (current + delta).rem_euclid(len as i32) as usize;
self.selected_index = Some(new_idx);
pub fn select_current(&mut self) {
if let Some(idx) = self.selected_index {
self.select(idx);
fn render_suggestions(&mut self) {
if !self.is_open {
let mut html = String::new();
for (display_idx, &item_idx) in self.filtered_indices.iter().enumerate() {
if let Some(item) = self.items.get(item_idx) {
let selected_class = if self.selected_index == Some(display_idx) {
" selected"
""
html.push_str(&format!(
r#"<div class="autocomplete-item{}" {}="{}">{}</div>"#,
selected_class,
AutocompleteAttr::Index.as_str(),
display_idx,
html_escape(item.display_text())
));
if html.is_empty() && !self.input.value().is_empty() {
html = r#"<div class="no-results">No matches found</div>"#.to_string();
self.container.set_inner_html(&html);
fn trigger_input_event(&self, element: &HtmlInputElement) {
let init = web_sys::EventInit::new();
init.set_bubbles(true);
if let Ok(event) = web_sys::Event::new_with_event_init_dict("input", &init) {
let _ = element.dispatch_event(&event);
fn trigger_change_event(&self, element: &HtmlInputElement) {
if let Ok(event) = web_sys::Event::new_with_event_init_dict("change", &init) {
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
pub fn attach_autocomplete<T: Suggestion>(
ac: Rc<RefCell<Autocomplete<T>>>,
config: AutocompleteConfig,
) {
let input = ac.borrow().input.clone();
let container = ac.borrow().container.clone();
if let Some(ref callback) = config.on_select {
ac.borrow_mut().set_on_select(Rc::clone(callback));
let config = Rc::new(config);
let cache: Rc<RefCell<HashMap<String, Vec<T>>>> = Rc::new(RefCell::new(HashMap::new()));
let ac_input = Rc::clone(&ac);
let input_callback = Closure::wrap(Box::new(move |_: web_sys::InputEvent| {
// Use try_borrow_mut to skip if already borrowed (e.g., during programmatic selection)
let Ok(mut ac) = ac_input.try_borrow_mut() else {
let query = ac.input.value();
ac.open();
ac.filter(&query);
}) as Box<dyn FnMut(_)>);
input
.add_event_listener_with_callback("input", input_callback.as_ref().unchecked_ref())
.unwrap();
input_callback.forget();
let ac_focus = Rc::clone(&ac);
let config_focus = Rc::clone(&config);
let cache_focus = Rc::clone(&cache);
let focus_callback = Closure::wrap(Box::new(move |_: web_sys::FocusEvent| {
ac_focus.borrow_mut().open();
if let Some(ref dep_input) = config_focus.dependency_input {
let dep_value = dep_input.value();
if dep_value.is_empty() {
let ac_fetch = Rc::clone(&ac_focus);
let config_fetch = Rc::clone(&config_focus);
let cache_fetch = Rc::clone(&cache_focus);
let dep_value_clone = dep_value.clone();
wasm_bindgen_futures::spawn_local(async move {
let url = format!("{}?name={}", config_fetch.fetch_url, dep_value_clone);
let cache_key = build_cache_key(&config_fetch.fetch_url, Some(&dep_value_clone));
if config_fetch.enable_cache
&& let Some(cached) = cache_fetch.borrow().get(&cache_key)
ac_fetch.borrow_mut().set_items(cached.clone());
let fetch_config = AutocompleteConfig {
fetch_url: url,
fetch_method: config_fetch.fetch_method,
post_body_builder: config_fetch.post_body_builder,
enable_cache: config_fetch.enable_cache,
if let Ok(items) = fetch_items(&fetch_config, "").await {
if config_fetch.enable_cache {
cache_fetch.borrow_mut().insert(cache_key, items.clone());
ac_fetch.borrow_mut().set_items(items);
});
.add_event_listener_with_callback("focus", focus_callback.as_ref().unchecked_ref())
focus_callback.forget();
let ac_blur = Rc::clone(&ac);
let blur_callback = Closure::wrap(Box::new(move |_: web_sys::FocusEvent| {
let window = web_sys::window().unwrap();
let ac_delayed = Rc::clone(&ac_blur);
let delayed_close = Closure::once(Box::new(move || {
ac_delayed.borrow_mut().close();
}) as Box<dyn FnOnce()>);
window
.set_timeout_with_callback_and_timeout_and_arguments_0(
delayed_close.as_ref().unchecked_ref(),
150,
)
delayed_close.forget();
.add_event_listener_with_callback("blur", blur_callback.as_ref().unchecked_ref())
blur_callback.forget();
let ac_keydown = Rc::clone(&ac);
let keydown_callback = Closure::wrap(Box::new(move |e: KeyboardEvent| {
let mut ac = ac_keydown.borrow_mut();
match e.key().as_str() {
"ArrowDown" => {
e.prevent_default();
ac.move_selection(1);
"ArrowUp" => {
ac.move_selection(-1);
"Enter" => {
if ac.is_open && ac.selected_index.is_some() {
ac.select_current();
"Escape" => {
ac.close();
_ => {}
.add_event_listener_with_callback("keydown", keydown_callback.as_ref().unchecked_ref())
keydown_callback.forget();
// Prevent blur when clicking on suggestions (important for mobile Safari)
let mousedown_callback = Closure::wrap(Box::new(move |e: web_sys::MouseEvent| {
container
.add_event_listener_with_callback("mousedown", mousedown_callback.as_ref().unchecked_ref())
mousedown_callback.forget();
let ac_click = Rc::clone(&ac);
let click_callback = Closure::wrap(Box::new(move |e: web_sys::MouseEvent| {
if let Some(target) = e.target()
&& let Ok(el) = target.dyn_into::<HtmlElement>()
&& el.class_list().contains("autocomplete-item")
&& let Some(idx_str) = el.get_attribute(AutocompleteAttr::Index.as_str())
&& let Ok(idx) = idx_str.parse::<usize>()
ac_click.borrow_mut().select(idx);
.add_event_listener_with_callback("click", click_callback.as_ref().unchecked_ref())
click_callback.forget();
// Skip initial fetch if there's a dependency - fetch on focus instead
if config.dependency_input.is_none() {
let ac_fetch = Rc::clone(&ac);
let config_fetch = Rc::clone(&config);
let cache_fetch = Rc::clone(&cache);
let cache_key = build_cache_key(&config_fetch.fetch_url, None);
if let Ok(items) = fetch_items::<T>(&config_fetch, "").await {
async fn fetch_items<T: Suggestion>(
config: &AutocompleteConfig,
query: &str,
) -> Result<Vec<T>, JsValue> {
let window = web_sys::window().ok_or("no window")?;
let opts = web_sys::RequestInit::new();
let url = match config.fetch_method {
FetchMethod::Get => {
opts.set_method("GET");
config.fetch_url.clone()
FetchMethod::Post => {
opts.set_method("POST");
if let Some(body_builder) = config.post_body_builder {
let body = body_builder(query);
opts.set_body(&JsValue::from_str(&body));
let request = web_sys::Request::new_with_str_and_init(&url, &opts)?;
request.headers().set("Accept", "application/json")?;
if matches!(config.fetch_method, FetchMethod::Post) {
request.headers().set("Content-Type", "application/json")?;
let resp_value =
wasm_bindgen_futures::JsFuture::from(window.fetch_with_request(&request)).await?;
let resp: web_sys::Response = resp_value.dyn_into()?;
let json = wasm_bindgen_futures::JsFuture::from(resp.json()?).await?;
let items: Vec<T> = serde_wasm_bindgen::from_value(json)?;
Ok(items)
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_key_without_dependency() {
let key = build_cache_key("/api/accounts", None);
assert_eq!(key, "/api/accounts");
fn cache_key_with_dependency() {
let key = build_cache_key("/api/tags/values", Some("category"));
assert_eq!(key, "/api/tags/values?dep=category");
fn config_get_creates_get_request() {
let config = AutocompleteConfig::get("/api/test");
assert!(matches!(config.fetch_method, FetchMethod::Get));
assert_eq!(config.fetch_url, "/api/test");
assert!(config.enable_cache);
assert!(config.post_body_builder.is_none());
fn config_post_creates_post_request() {
let config = AutocompleteConfig::post("/api/search", |q| format!("{{\"q\":\"{q}\"}}"));
assert!(matches!(config.fetch_method, FetchMethod::Post));
assert_eq!(config.fetch_url, "/api/search");
assert!(config.post_body_builder.is_some());
let body = (config.post_body_builder.unwrap())("test");
assert_eq!(body, r#"{"q":"test"}"#);
fn config_with_cache_disabled() {
let config = AutocompleteConfig::get("/api/test").with_cache(false);
assert!(!config.enable_cache);