use leptos::callback::Callback;
use leptos::prelude::{
Callable, ClassAttribute, CustomAttribute, ElementChild, Get, GlobalAttributes, IntoAny,
IntoView, Signal, component, view,
};
#[cfg(target_arch = "wasm32")]
use leptos::wasm_bindgen::JsCast;
#[cfg(target_arch = "wasm32")]
use leptos::wasm_bindgen::JsValue;
#[cfg(target_arch = "wasm32")]
use leptos::wasm_bindgen::closure::Closure;
#[cfg(target_arch = "wasm32")]
use leptos::web_sys::Element;
#[cfg(target_arch = "wasm32")]
use js_sys::JSON;
use crate::util::TestAttr;
#[component]
pub fn AutoComplete(
id: String,
#[prop(optional)]
max_items: Option<u32>,
#[prop(optional)]
items: Option<Vec<String>>,
_on_update: Callback<String>,
_on_remove: Callback<String>,
#[prop(optional, into)]
current_selector: Signal<String>,
#[prop(optional, into)]
placeholder: Signal<String>,
#[prop(optional, into)]
classes: Signal<String>,
#[prop(optional, into)]
_case_sensitive: Signal<bool>,
#[prop(optional, into)]
data_item_text: Signal<String>,
#[prop(optional, into)]
data_item_value: Signal<String>,
#[prop(optional, into)]
_url_for_fetch: Signal<String>,
#[prop(optional, into)]
_auth_header: Signal<String>,
#[prop(optional, into)]
test_attr: Option<TestAttr>,
) -> impl IntoView {
let _max_items_value = max_items.unwrap_or(10);
let input_class = {
let classes = classes.clone();
move || {
let extra = classes.get();
if extra.trim().is_empty() {
"input".to_string()
} else {
format!("input {}", extra)
}
}
};
let static_mode = items.as_ref().map(|v| !v.is_empty()).unwrap_or(false)
&& data_item_text.get().trim().is_empty()
&& data_item_value.get().trim().is_empty();
let (data_testid, data_cy) = match &test_attr {
Some(attr) if attr.key == "data-testid" => (Some(attr.value.clone()), None),
Some(attr) if attr.key == "data-cy" => (None, Some(attr.value.clone())),
_ => (None, None),
};
let body = if static_mode {
let options_view = {
let items = items.clone().unwrap_or_default();
let current = current_selector.get();
view! {
<>
{items.into_iter().map(|item| {
let selected = item == current;
view! {
<option value=item.clone() selected=selected>{item.clone()}</option>
}.into_any()
}).collect::<Vec<_>>()}
</>
}
.into_any()
};
view! {
<div
class=move || {
let extra = classes.get();
if extra.trim().is_empty() { "select".to_string() } else { format!("select {}", extra) }
}
attr:data-testid=move || data_testid.clone()
attr:data-cy=move || data_cy.clone()
>
<select
id=id.clone()
data-type="tags"
data-placeholder=placeholder.get()
>
{options_view}
</select>
</div>
}
.into_any()
} else if !data_item_text.get().trim().is_empty() && !data_item_value.get().trim().is_empty() {
let value_json = {
let current = current_selector.get();
if current.trim().is_empty() {
"{}".to_string()
} else {
format!("{{\"{}\":\"{}\"}}", data_item_value.get(), current)
}
};
view! {
<input
id=id.clone()
r#type="text"
class=move || input_class()
data-item-text=data_item_text.get()
data-item-value=data_item_value.get()
data-placeholder=placeholder.get()
value=value_json
attr:data-testid=move || data_testid.clone()
attr:data-cy=move || data_cy.clone()
/>
}
.into_any()
} else {
view! {
<input
id=id.clone()
r#type="text"
class=move || input_class()
data-placeholder=placeholder.get()
value=current_selector.get()
attr:data-testid=move || data_testid.clone()
attr:data-cy=move || data_cy.clone()
/>
}
.into_any()
};
#[cfg(target_arch = "wasm32")]
{
let id_for_js = id.clone();
let max_items = _max_items_value;
let current_selector = current_selector.clone();
let case_sensitive = _case_sensitive.clone();
let url_for_fetch = _url_for_fetch.clone();
let auth_header = _auth_header.clone();
let data_item_value = data_item_value.clone();
leptos::prelude::Effect::new(move |_| {
let document = leptos::prelude::document();
if let Some(element) = document.get_element_by_id(&id_for_js) {
let cb = {
let on_update = _on_update.clone();
let on_remove = _on_remove.clone();
Closure::wrap(Box::new(move |json: JsValue| {
let Some(s) = json.as_string() else {
return;
};
let Ok(obj) = JSON::parse(&s) else {
return;
};
let op = js_sys::Reflect::get(&obj, &JsValue::from_str("op"))
.ok()
.and_then(|v| v.as_string())
.unwrap_or_default();
let value = js_sys::Reflect::get(&obj, &JsValue::from_str("value"))
.ok()
.and_then(|v| v.as_string())
.unwrap_or_default();
if value.trim().is_empty() {
return;
}
if op == "add" {
on_update.run(value);
} else if op == "remove" {
on_remove.run(value);
}
}) as Box<dyn FnMut(JsValue)>)
};
let url = url_for_fetch.get();
if url.trim().is_empty() {
setup_static_autocomplete(
&element.unchecked_into::<Element>(),
cb.as_ref(),
&JsValue::from(max_items),
&JsValue::from(case_sensitive.get()),
);
} else {
setup_dynamic_autocomplete(
&element.unchecked_into::<Element>(),
cb.as_ref(),
&JsValue::from(max_items),
&JsValue::from(url),
&JsValue::from(auth_header.get()),
&JsValue::from(case_sensitive.get()),
&JsValue::from(data_item_value.get()),
&JsValue::from(current_selector.get()),
);
}
cb.forget();
}
});
let id_cleanup = id.clone();
leptos::prelude::on_cleanup(move || {
detach_autocomplete(&JsValue::from(id_cleanup.as_str()));
});
}
view! { {body} }
}
#[cfg(target_arch = "wasm32")]
#[leptos::wasm_bindgen::prelude::wasm_bindgen(inline_js = r#"
let init = new Map();
export function setup_dynamic_autocomplete(element, callback, max_tags, url_for_fetch, auth_header, case_sensitive, data_item_value, initial_value) {
if (!init.has(element.id)) {
let autocompleteInstance = BulmaTagsInput.attach(element, {
maxTags: Number(max_tags) || 10,
caseSensitive: !!case_sensitive,
source: async function(value) {
return await fetch(String(url_for_fetch) + value, {
headers: auth_header ? { 'Authorization': String(auth_header) } : {}
})
.then(function(response) {
if (response.status !== 200) {
throw new Error('Failed to fetch data');
}
return response.json();
});
},
});
let autocomplete = autocompleteInstance[0];
autocomplete.on('after.add', function(tag) {
callback('{"op":"add","value":"'+tag.item[data_item_value]+'"}');
});
autocomplete.on('after.remove', function(tag) {
callback('{"op":"remove","value":"'+tag[data_item_value]+'"}');
});
if (String(initial_value).length > 0) {
autocomplete.add('{"'+data_item_value+'":"'+String(initial_value)+'"}');
}
init.set(element.id, autocomplete);
}
}
export function setup_static_autocomplete(element, callback, max_tags, case_sensitive) {
if (!init.has(element.id)) {
let autocompleteInstance = BulmaTagsInput.attach(element, {
maxTags: Number(max_tags) || 10,
caseSensitive: !!case_sensitive,
});
let autocomplete = autocompleteInstance[0];
autocomplete.on('after.add', function(tag) {
if (tag.item && tag.item.value) {
callback('{"op":"add","value":"'+tag.item.value+'"}');
} else if (tag.value) {
callback('{"op":"add","value":"'+tag.value+'"}');
} else {
callback('{"op":"add","value":"'+tag.item+'"}');
}
});
autocomplete.on('after.remove', function(tag) {
if (tag.item && tag.item.value) {
callback('{"op":"remove","value":"'+tag.item.value+'"}');
} else if (tag.value) {
callback('{"op":"remove","value":"'+tag.value+'"}');
} else {
callback('{"op":"remove","value":"'+tag+'"}');
}
});
init.set(element.id, autocomplete);
}
}
export function detach_autocomplete(id) {
init.delete(String(id));
}
"#)]
#[allow(improper_ctypes, improper_ctypes_definitions)]
extern "C" {
fn setup_dynamic_autocomplete(
element: &Element,
callback: &JsValue,
max_tags: &JsValue,
url_to_fetch: &JsValue,
auth_header: &JsValue,
case_sensitive: &JsValue,
data_item_value: &JsValue,
initial_value: &JsValue,
);
fn setup_static_autocomplete(
element: &Element,
callback: &JsValue,
max_tags: &JsValue,
case_sensitive: &JsValue,
);
fn detach_autocomplete(id: &JsValue);
}
#[cfg(test)]
mod tests {
use super::*;
use leptos::prelude::RenderHtml;
fn noop() -> Callback<String> {
Callback::new(|_v: String| {})
}
#[test]
fn renders_static_select_when_items_provided() {
let html = view! {
<AutoComplete
id="ac1".to_string()
items=vec!["A".to_string(), "B".to_string()]
placeholder="Choose"
_on_update=noop()
_on_remove=noop()
/>
}
.to_html();
assert!(
html.contains(r#"data-type="tags""#),
"expected tags select; got: {}",
html
);
assert!(html.contains("<option"), "expected options; got: {}", html);
}
#[test]
fn renders_dynamic_input_with_data_attrs() {
let html = view! {
<AutoComplete
id="ac2".to_string()
data_item_text="name"
data_item_value="name"
_url_for_fetch="/api?q="
_on_update=noop()
_on_remove=noop()
/>
}
.to_html();
assert!(
html.contains(r#"data-item-text="name""#),
"expected data-item-text; got: {}",
html
);
assert!(
html.contains(r#"class="input""#),
"expected input class; got: {}",
html
);
}
#[test]
fn renders_plain_input_otherwise() {
let html = view! {
<AutoComplete
id="ac3".to_string()
placeholder="Type..."
_on_update=noop()
_on_remove=noop()
/>
}
.to_html();
assert!(
html.contains(r#"class="input""#) && html.contains(r#"id="ac3""#),
"expected plain input; got: {}",
html
);
}
}
#[cfg(all(test, target_arch = "wasm32"))]
mod wasm_tests {
use super::*;
use crate::util::TestAttr;
use leptos::prelude::*;
use wasm_bindgen_test::*;
fn noop() -> Callback<String> {
Callback::new(|_v: String| {})
}
wasm_bindgen_test_configure!(run_in_browser);
#[wasm_bindgen_test]
fn autocomplete_renders_test_attr_static_mode() {
let html = view! {
<AutoComplete
id="ac1".to_string()
items=vec!["A".to_string(), "B".to_string()]
placeholder="Choose"
_on_update=noop()
_on_remove=noop()
test_attr=TestAttr::test_id("autocomplete-test")
/>
}
.to_html();
assert!(
html.contains(r#"data-testid="autocomplete-test""#),
"expected data-testid attribute; got: {}",
html
);
}
#[wasm_bindgen_test]
fn autocomplete_no_test_attr_when_not_provided() {
let html = view! {
<AutoComplete
id="ac1".to_string()
items=vec!["A".to_string(), "B".to_string()]
placeholder="Choose"
_on_update=noop()
_on_remove=noop()
/>
}
.to_html();
assert!(
!html.contains("data-testid") && !html.contains("data-cy"),
"expected no test attribute; got: {}",
html
);
}
}