Skip to main content

vertigo_forms/
select_search.rs

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