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                let mut filtered = inner_options
54                    .into_iter()
55                    .filter(|(_, opt_value)| {
56                        opt_value.to_string().to_lowercase().contains(&inner_filter)
57                    })
58                    .collect::<Vec<_>>();
59                filtered.sort_by_cached_key(|(_, opt_value)| opt_value.to_string());
60                filtered
61            } else {
62                vec![]
63            }
64        } else {
65            vec![]
66        }
67    });
68
69    let dropdown_css = |visible| {
70        let display_value = if visible { "block" } else { "none" };
71        css! {"
72            display: {display_value};
73            position: fixed;
74            background-color: white;
75            box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.4);
76            border: 1px black solid;
77        "}
78    };
79
80    // Render list
81    let list_deps = computed_tuple!(dropdown_opened, items, item_selected);
82    let list = bind!(
83        value,
84        filter,
85        dropdown_opened,
86        list_deps.render_value(move |(inner_dropdown_opened, inner_items, item_selected)| {
87            let item_css = |selected: bool| {
88                let bg_color = if selected { "#ccc" } else { "inherit" };
89
90                css! {"
91                        cursor: pointer;
92                        padding: 2px 4px;
93                        background-color: {bg_color};
94
95                        :hover {
96                            background-color: #ccc;
97                        };
98                    "}
99            };
100
101            let list = dom_element! {
102                <div css={dropdown_css(inner_dropdown_opened)} />
103            };
104
105            if inner_dropdown_opened {
106                for (opt_key, opt_value) in &inner_items {
107                    // Prevent on blur on input
108                    let on_mouse_down = || true;
109                    let on_click = bind!(value, filter, dropdown_opened, opt_key, |_| {
110                        value.set(opt_key.clone());
111                        filter.set(None);
112                        dropdown_opened.set(false);
113                    });
114                    list.add_child(dom! {
115                        <div
116                            id={opt_key.to_string()}
117                            css={item_css(item_selected.as_ref() == Some(opt_key))}
118                            {on_mouse_down} {on_click}
119                        >
120                            {opt_value.to_string()}
121                        </div>
122                    });
123                }
124            }
125
126            list.into()
127        })
128    );
129
130    // Render input
131    let input_deps = computed_tuple!(value, options);
132    let input = input_deps.render_value(move |(inner_value, options_inner)| {
133        let i = i.clone();
134        // Displayed value is filter, or value label if no filter typed in
135        let displayed_value = filter.to_computed().map(move |inner_filter| {
136            if let Some(inner_filter) = inner_filter {
137                inner_filter
138            } else {
139                options_inner
140                    .get(&inner_value)
141                    .map(|val| val.to_string())
142                    .unwrap_or_default()
143            }
144        });
145
146        let on_input = bind!(filter, dropdown_opened, |new_value: String| {
147            if new_value.len() >= params.min_chars {
148                dropdown_opened.set(true);
149            }
150            filter.set(Some(new_value));
151        });
152
153        let on_blur = bind!(dropdown_opened, || dropdown_opened.set(false));
154
155        // Make items selectable by keyboard arrows
156        let hook_key_down = bind!(
157            value,
158            item_selected,
159            filter,
160            options,
161            items,
162            dropdown_opened,
163            |key_down: KeyDownEvent| {
164                if key_down.key == "ArrowDown" || key_down.key == "ArrowUp" {
165                    transaction(|ctx| {
166                        if filter.get(ctx).is_some() {
167                            // Create iterator over dropdown, reversed if arrow up
168                            let mut items_iter = {
169                                let iter = items.get(ctx).into_iter();
170                                if key_down.key == "ArrowUp" {
171                                    Either::Left(iter.rev())
172                                } else {
173                                    Either::Right(iter)
174                                }
175                            }
176                            .peekable();
177
178                            // Save first element for eventual later use
179                            let first_key = items_iter.peek().map(|(key, _)| key).cloned();
180
181                            if let Some(inner_item_selected) = item_selected.get(ctx) {
182                                // If some item already selected, advance
183                                if let Some((next_key, _)) = items_iter
184                                    .skip_while(|(opt_key, _)| opt_key != &inner_item_selected)
185                                    .nth(1)
186                                {
187                                    item_selected.set(Some(next_key));
188                                } else {
189                                    // Not found, probably last value was filtered out, just set the first one
190                                    item_selected.set(first_key);
191                                }
192                            } else if let Some((opt_key, _)) = items_iter.next() {
193                                // If nothing selected just take first one
194                                item_selected.set(Some(opt_key));
195                            }
196                        }
197                    });
198                    true
199                } else if key_down.key == "Enter" {
200                    transaction(|ctx| {
201                        if let Some(item_selected) = item_selected.get(ctx) {
202                            // Close dropdown
203                            dropdown_opened.set(false);
204                            // Set input text to chosen item
205                            if let Some(opt_value) = options.get(ctx).get(&item_selected) {
206                                filter.set(Some(opt_value.to_string()));
207                            }
208                            // Set the value itself
209                            value.set(item_selected);
210                        }
211                    });
212                    true
213                } else {
214                    false
215                }
216            }
217        );
218
219        dom! {
220            <input
221                required="required"
222                title={&params.input_title}
223                value={displayed_value}
224                {on_input} {on_blur} {hook_key_down} {..i}
225            />
226        }
227    });
228
229    let dropdown_css = css! {"
230        position: relative;
231    "};
232
233    dom! {
234        <div css={dropdown_css} {..c}>
235            {input}
236            {list}
237        </div>
238    }
239}