use yew::prelude::*;
use wasm_bindgen::closure::Closure;
use wasm_bindgen::JsCast;
use web_sys::{Document, Node, ScrollBehavior, ScrollIntoViewOptions, ScrollLogicalPosition};
use crate::component::form::FormControlValidation;
pub mod soption;
pub mod filter;
pub use soption::*;
pub use filter::*;
#[derive(Properties, PartialEq)]
pub struct SearchableSelectProps {
pub options: Vec<SOption>,
pub title: Option<AttrValue>,
pub placeholder: AttrValue,
pub onselectchange: Callback<(AttrValue, bool)>,
#[prop_or_else(filter_icase)]
pub filter: FilterFn,
#[prop_or_default]
pub keep_open: bool,
#[prop_or_default]
pub class: Classes,
#[prop_or_default]
pub disabled: bool,
#[prop_or_default]
pub label: AttrValue,
#[prop_or_default]
pub id: AttrValue,
#[prop_or(FormControlValidation::None)]
pub validation: FormControlValidation,
#[prop_or_default]
pub noautoscroll: bool,
}
#[function_component]
pub fn SearchableSelect(props: &SearchableSelectProps) -> Html {
if props.label != "" && props.id == "" {
panic!("When a label is provided, an id is required");
}
let is_open = use_state(|| false);
let search_text = use_state(|| AttrValue::from(""));
let container_ref = use_node_ref();
let input_ref = use_node_ref();
let active_ref = use_node_ref();
let active_index = use_state(|| 0);
let filtered_options = filter_by_group(
&props.options, (*search_text).clone(), props.filter.clone()
);
let (
prev_active, current_active,
next_active, current_active_index
) = get_actives(&filtered_options, *active_index);
let on_toggle_dropdown = {
if props.disabled {
Callback::from(|_| {})
} else {
let is_open = is_open.clone();
let active_index = active_index.clone();
let search_text = search_text.clone();
let first_selected = props.options.iter().position(|option| option.selected);
Callback::from(move |_| {
let new_open = !*is_open;
is_open.set(new_open);
if new_open {
active_index.set(first_selected.unwrap_or(0));
search_text.set(AttrValue::from(""));
}
})
}
};
{
let is_open = is_open.clone();
let active_index = active_index.clone();
let input_ref = input_ref.clone();
let active_ref = active_ref.clone();
let noautoscroll = props.noautoscroll;
use_effect_with(
(*is_open, *active_index),
move |(is_open, _)| {
if *is_open {
if let Some(input) = input_ref.cast::<web_sys::HtmlInputElement>() {
input.focus().unwrap();
}
if !noautoscroll {
if let Some(element) = active_ref.cast::<web_sys::Element>() {
let scroll_options = ScrollIntoViewOptions::new();
scroll_options.set_block(ScrollLogicalPosition::Nearest);
scroll_options.set_behavior(ScrollBehavior::Auto);
element.scroll_into_view_with_scroll_into_view_options(&scroll_options);
}
}
}
}
)
};
let on_search_input = {
let search_text = search_text.clone();
let active_index = active_index.clone();
Callback::from(move |e: InputEvent| {
if let Some(input_elem) = e.target_dyn_into::<web_sys::HtmlInputElement>() {
search_text.set(input_elem.value().into());
active_index.set(0);
}
})
};
let on_key_press = {
let active_index = active_index.clone();
let is_open = is_open.clone();
let selectchange = props.onselectchange.clone();
let (active_value, active_selected) = if let Some(index) = current_active_index {
let (_, option) = filtered_options.get(index).unwrap();
(option.value.clone(), option.selected)
} else {
(AttrValue::from(""), false)
};
let keep_open = props.keep_open;
Callback::from(move |e: KeyboardEvent| {
let active_index = active_index.clone();
match e.key().as_str() {
"Enter" => {
if !keep_open {
is_open.set(false);
}
selectchange.emit((active_value.clone(), !active_selected));
e.prevent_default();
}
"ArrowUp" => {
if let Some(index) = prev_active { active_index.set(index) };
e.prevent_default();
}
"ArrowDown" => {
if let Some(index) = next_active { active_index.set(index) };
e.prevent_default();
},
"Escape" => {
is_open.set(false);
e.prevent_default();
}
_ => {}
}
})
};
let onselectchange = {
let is_open = is_open.clone();
let selectchange = props.onselectchange.clone();
let keep_open = props.keep_open;
Callback::from(move |(value, selected)| {
if !keep_open {
is_open.set(false);
}
selectchange.emit((value, selected));
})
};
{
let is_open = is_open.clone();
let container_ref = container_ref.clone();
use_effect_with(
is_open, move |is_open| {
let is_open = is_open.clone();
let click_closure = {
let container_ref = container_ref.clone();
let is_open = is_open.clone();
Closure::<dyn FnMut(_)>::new(move |event: web_sys::Event| {
if *is_open {
if let Some(target) = event.target() {
if let Some(container) = container_ref.cast::<Node>() {
if let Ok(target_node) = target.dyn_into::<Node>() {
if !container.contains(Some(&target_node)) {
is_open.set(false);
}
}
}
}
}
})
};
let document: Document = gloo_utils::document();
if *is_open {
document
.add_event_listener_with_callback("mousedown", click_closure.as_ref().unchecked_ref())
.unwrap();
}
let closure_js = (*is_open).then_some(click_closure.into_js_value());
let document = document.clone();
move || {
if let Some(closure_js) = closure_js {
if let Some(func) = closure_js.dyn_ref::<js_sys::Function>() {
let _ = document.remove_event_listener_with_callback("mousedown", func);
}
}
}
}
)
};
let (validation, validation_class) = match props.validation.clone() {
FormControlValidation::None => (None, None),
FormControlValidation::Valid(None) => (None, Some("is-valid")),
FormControlValidation::Valid(Some(text)) => (Some(html! {
<div class="valid-feedback"> { text.clone() }</div>
}), Some("is-valid")),
FormControlValidation::Invalid(text) => (Some(html! {
<div class="invalid-feedback"> { text.clone() }</div>
}), Some("is-invalid")),
};
let label = (props.label != "").then_some(html! {
<label for={ props.id.clone() } class={ "form-label" }>{ props.label.clone() }</label>
});
html! {
<div ref={container_ref} class={ classes!("searchable_select", "position-relative", props.class.clone()) }>
{ label }
<div class="input-group" onclick={on_toggle_dropdown.clone()}>
<input
id={ props.id.clone() }
type="text"
class={ classes!("form-control", validation_class) }
value={ props.title.clone().unwrap_or("".into()) }
placeholder={props.placeholder.clone()}
disabled={ props.disabled }
readonly={true}
/>
<button
class="btn btn-outline-secondary dropdown-toggle"
type="button"
disabled={ props.disabled }
onclick={on_toggle_dropdown}
>
{ "" }
</button>
if ! *is_open {
{ validation }
}
</div>
if *is_open {
<div
class="position-absolute mt-1 border bg-white w-100 rounded"
style="z-index: 9999;"
>
<div class="p-2">
<input
ref={input_ref}
autofocus={true}
type="text"
class="form-control"
placeholder="Search..."
oninput={on_search_input}
onkeydown={on_key_press}
/>
</div>
<div class="list-group list-group-flush" style="max-height: 200px; overflow-y: auto;">
{
filtered_options.iter().map(|(i, option)| {
let active = Some(*i) == current_active;
html!{
<SOptionComp
key={*i}
node_ref={if active { Some(active_ref.clone()) } else { None }}
attrs={(*option).clone()}
onselectchange={onselectchange.clone()}
{active}
/>
}
}).collect::<Html>()
}
</div>
</div>
}
</div>
}
}
fn get_actives(options: &Vec<(usize, &SOption)>, current_index: usize)
-> (Option<usize>, Option<usize>, Option<usize>, Option<usize>)
{
let iter = options.iter().filter(
|(_, option)| !option.disabled && !option.header
);
let index = iter.clone().position(|(i, _)| *i >= current_index);
let indexes: Vec<usize> = iter.map(|(i, _)| *i).collect();
let current = index.and_then(|index| indexes.get(index).copied() );
let prev = index.and_then(|index| if index > 0 { indexes.get(index - 1).copied() } else { None } );
let next = index.and_then(|index| if index < indexes.len() - 1 { indexes.get(index + 1).copied() } else { None });
let index = current.and_then(|current| options.iter().position(|(i, _)| *i == current));
(prev, current, next, index)
}
#[cfg(test)]
mod test {
use super::*;
#[test]
pub fn test_get_actives() {
assert_eq!(
get_actives(&vec![], 0),
(None, None, None, None)
);
assert_eq!(
get_actives(&vec![], 1),
(None, None, None, None)
);
let items = vec![
SOption { title: "Item A0".into(), header: false, ..SOption::default() }, SOption { title: "Group 1".into(), header: true, ..SOption::default() }, SOption { title: "Item B0".into(), header: false, ..SOption::default() }, SOption { title: "Item B1".into(), header: false, ..SOption::default() }, SOption { title: "Group 2".into(), header: true, ..SOption::default() }, SOption { title: "Item C0".into(), header: false, ..SOption::default() }, ];
let options: Vec<(usize, &SOption)> = items.iter().enumerate().map(
|(i, option)| (i*2, option))
.collect();
assert_eq!(
get_actives(&options, 0),
(None, Some(0), Some(4), Some(0))
);
assert_eq!(
get_actives(&options, 1),
(Some(0), Some(4), Some(6), Some(2))
);
assert_eq!(
get_actives(&options, 6),
(Some(4), Some(6), Some(10), Some(3))
);
assert_eq!(
get_actives(&options, 10),
(Some(6), Some(10), None, Some(5))
);
assert_eq!(
get_actives(&options, 11),
(None, None, None, None)
);
}
}