vertigo_forms/
select_search.rs

1use either::Either;
2use std::{collections::HashMap, hash::Hash};
3use vertigo::{
4    Computed, DomNode, KeyDownEvent, Value, bind, computed_tuple, css, dom, dom_element,
5    transaction,
6};
7
8/// Input that searches for entered query in provided item list, based on `HashMap<K, V>`.
9pub struct SelectSearch<K, V>
10where
11    K: Clone,
12    V: Clone,
13{
14    /// Currently selected value
15    pub value: Value<K>,
16    /// List of possible values
17    pub options: Computed<HashMap<K, V>>,
18    /// Component behavior/display parameters
19    pub params: SelectSearchParams,
20}
21
22pub struct SelectSearchParams {
23    /// Minimum number of letters to open dropdown
24    pub min_chars: usize,
25    pub input_title: String,
26}
27
28impl Default for SelectSearchParams {
29    fn default() -> Self {
30        Self {
31            min_chars: 3,
32            input_title: "Enter phrase".to_string(),
33        }
34    }
35}
36
37impl<K, V> SelectSearch<K, V>
38where
39    K: Clone + ToString + PartialEq + Eq + Hash + 'static,
40    V: Clone + ToString + PartialEq + 'static,
41{
42    pub fn into_component(self) -> Self {
43        self
44    }
45
46    pub fn mount(self) -> DomNode {
47        let Self {
48            value,
49            options,
50            params,
51        } = self;
52
53        // Filter currently typed by user
54        let filter = Value::<Option<String>>::default();
55        // Toggle for dropdown visibility
56        let dropdown_opened = Value::<bool>::default();
57        // Item selected in dropdown using keyboard
58        let item_selected = Value::<Option<K>>::default();
59
60        // Items list for dropdown display
61        let items = computed_tuple!(options, filter).map(move |(inner_options, inner_filter)| {
62            if let Some(inner_filter) = inner_filter {
63                let inner_filter = inner_filter.to_lowercase();
64                if inner_filter.len() >= params.min_chars {
65                    // Filter options
66                    inner_options
67                        .into_iter()
68                        .filter(|(_, opt_value)| {
69                            opt_value.to_string().to_lowercase().contains(&inner_filter)
70                        })
71                        .collect::<Vec<_>>()
72                } else {
73                    vec![]
74                }
75            } else {
76                vec![]
77            }
78        });
79
80        let dropdown_css = |visible| {
81            let display_value = if visible { "block" } else { "none" };
82            css! {"
83                display: {display_value};
84                position: absolute;
85                background-color: white;
86                box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.4);
87                border: 1px black solid;
88                z-index: 1;
89            "}
90        };
91
92        // Render list
93        let list_deps = computed_tuple!(dropdown_opened, items, item_selected);
94        let list = bind!(
95            value,
96            filter,
97            dropdown_opened,
98            list_deps.render_value(move |(inner_dropdown_opened, inner_items, item_selected)| {
99                let item_css = |selected: bool| {
100                    let bg_color = if selected { "#ccc" } else { "inherit" };
101
102                    css! {"
103                            cursor: pointer;
104                            padding: 2px 4px;
105                            background-color: {bg_color};
106
107                            :hover {
108                                background-color: #ccc;
109                            };
110                        "}
111                };
112
113                let list = dom_element! {
114                    <div css={dropdown_css(inner_dropdown_opened)} />
115                };
116
117                if inner_dropdown_opened {
118                    for (opt_key, opt_value) in &inner_items {
119                        // Prevent on blur on input
120                        let on_mouse_down = || true;
121                        let on_click = bind!(value, filter, dropdown_opened, opt_key, |_| {
122                            value.set(opt_key.clone());
123                            filter.set(None);
124                            dropdown_opened.set(false);
125                        });
126                        list.add_child(dom! {
127                            <div
128                                id={opt_key.to_string()}
129                                css={item_css(item_selected.as_ref() == Some(opt_key))}
130                                {on_mouse_down} {on_click}
131                            >
132                                {opt_value.to_string()}
133                            </div>
134                        });
135                    }
136                }
137
138                list.into()
139            })
140        );
141
142        // Render input
143        let input_deps = computed_tuple!(value, options);
144        let input = input_deps.render_value(move |(inner_value, options_inner)| {
145            // Displayed value is filter, or value label if no filter typed in
146            let displayed_value = filter.to_computed().map(move |inner_filter| {
147                if let Some(inner_filter) = inner_filter {
148                    inner_filter
149                } else {
150                    options_inner
151                        .get(&inner_value)
152                        .map(|val| val.to_string())
153                        .unwrap_or_default()
154                }
155            });
156
157            let on_input = bind!(filter, dropdown_opened, |new_value: String| {
158                if new_value.len() >= params.min_chars {
159                    dropdown_opened.set(true);
160                }
161                filter.set(Some(new_value));
162            });
163
164            let on_blur = bind!(dropdown_opened, || dropdown_opened.set(false));
165
166            // Make items selectable by keyboard arrows
167            let hook_key_down = bind!(
168                value,
169                item_selected,
170                filter,
171                options,
172                items,
173                dropdown_opened,
174                |key_down: KeyDownEvent| {
175                    if key_down.key == "ArrowDown" || key_down.key == "ArrowUp" {
176                        transaction(|ctx| {
177                            if filter.get(ctx).is_some() {
178                                // Create iterator over dropdown, reversed if arrow up
179                                let mut items_iter = {
180                                    let iter = items.get(ctx).into_iter();
181                                    if key_down.key == "ArrowUp" {
182                                        Either::Left(iter.rev())
183                                    } else {
184                                        Either::Right(iter)
185                                    }
186                                }
187                                .peekable();
188
189                                // Save first element for eventual later use
190                                let first_key = items_iter.peek().map(|(key, _)| key).cloned();
191
192                                if let Some(inner_item_selected) = item_selected.get(ctx) {
193                                    // If some item already selected, advance
194                                    if let Some((next_key, _)) = items_iter
195                                        .skip_while(|(opt_key, _)| opt_key != &inner_item_selected)
196                                        .nth(1)
197                                    {
198                                        item_selected.set(Some(next_key));
199                                    } else {
200                                        // Not found, probably last value was filtered out, just set the first one
201                                        item_selected.set(first_key);
202                                    }
203                                } else if let Some((opt_key, _)) = items_iter.next() {
204                                    // If nothing selected just take first one
205                                    item_selected.set(Some(opt_key));
206                                }
207                            }
208                        });
209                        true
210                    } else if key_down.key == "Enter" {
211                        transaction(|ctx| {
212                            if let Some(item_selected) = item_selected.get(ctx) {
213                                // Close dropdown
214                                dropdown_opened.set(false);
215                                // Set input text to chosen item
216                                if let Some(opt_value) = options.get(ctx).get(&item_selected) {
217                                    filter.set(Some(opt_value.to_string()));
218                                }
219                                // Set the value itself
220                                value.set(item_selected);
221                            }
222                        });
223                        true
224                    } else {
225                        false
226                    }
227                }
228            );
229
230            dom! {
231                <input
232                    required="required"
233                    title={&params.input_title}
234                    value={displayed_value}
235                    {on_input} {on_blur} {hook_key_down}
236                />
237            }
238        });
239
240        let dropdown_css = css! {"
241            position: relative;
242        "};
243
244        dom! {
245            <div css={dropdown_css}>
246                {input}
247                {list}
248            </div>
249        }
250    }
251}