tallyweb_components/
select.rs

1use super::CloseOverlays;
2use fuzzy_sort::*;
3use leptos::*;
4
5#[derive(Debug, Clone, Default, PartialEq, Eq)]
6pub struct SelectOption {
7    name: String,
8    value: String,
9}
10
11impl Sortable for SelectOption {
12    fn as_str(&self) -> &str {
13        &self.name
14    }
15}
16
17impl From<(String, String)> for SelectOption {
18    fn from(value: (String, String)) -> Self {
19        Self {
20            name: value.0,
21            value: value.1,
22        }
23    }
24}
25
26impl From<(&str, &str)> for SelectOption {
27    fn from(value: (&str, &str)) -> Self {
28        Self {
29            name: value.0.to_string(),
30            value: value.1.to_string(),
31        }
32    }
33}
34
35#[component]
36pub fn Select(
37    #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>,
38    #[prop(into)] options: Vec<SelectOption>,
39    #[prop(into)] selected: MaybeSignal<SelectOption>,
40) -> impl IntoView {
41    let attrs = store_value(attrs);
42    let hidden_select_ref = create_node_ref::<html::Input>();
43    let show_custom = create_rw_signal(false);
44    let selection = create_rw_signal(SelectOption::default());
45    let options = store_value(options);
46
47    create_isomorphic_effect(move |_| {
48        selection.set(selected.get());
49    });
50
51    let options_view = options()
52        .into_iter()
53        .map(move |option| {
54            view! {
55                <option
56                    value=option.value.clone()
57                    selected=move || selection().value == option.value
58                >
59                    {option.name}
60                </option>
61            }
62        })
63        .collect_view();
64
65    create_effect(move |_| {
66        show_custom.set(true);
67        if let Some(node) = hidden_select_ref.get() {
68            selection.set(
69                options()
70                    .into_iter()
71                    .find_map(|o| (o.value == node.value()).then_some(o))
72                    .unwrap_or_default(),
73            );
74        }
75    });
76
77    create_effect(move |_| {
78        if let Some(node) = hidden_select_ref.get() {
79            node.set_value(&selection().value)
80        }
81    });
82
83    view! {
84        <Show
85            when=show_custom
86            fallback=move || {
87                view! { <select {..attrs()}>{options_view.clone()}</select> }
88            }
89        >
90
91            <SelectOver options=options() selection />
92            <input {..attrs()} type="hidden" node_ref=hidden_select_ref />
93        </Show>
94    }
95}
96
97#[component]
98pub fn SelectOver(
99    #[prop(into)] options: Vec<SelectOption>,
100    selection: RwSignal<SelectOption>,
101) -> impl IntoView {
102    let options = store_value(options);
103    let show_options = create_rw_signal(false);
104
105    let toggle_show = move |ev: ev::MouseEvent| {
106        ev.stop_propagation();
107        show_options.update(|s| *s = !*s)
108    };
109
110    let on_option = move |val| {
111        selection.set(val);
112        show_options.set(false);
113    };
114
115    let toggle_style = move || if show_options() { "rotate(180deg)" } else { "" };
116
117    let options_list_ref = create_node_ref::<html::Div>();
118
119    let max_height = create_rw_signal(None::<String>);
120
121    create_effect(move |_| {
122        if let Some(node) = options_list_ref() {
123            request_animation_frame(move || {
124                let y = node.get_bounding_client_rect().top();
125                let screen_height = window()
126                    .inner_height()
127                    .ok()
128                    .and_then(|js_val| js_val.as_f64())
129                    .unwrap_or(1080.0);
130                max_height.set(Some(format!("{}px", screen_height - y)))
131            })
132        }
133    });
134
135    let key_input = create_rw_signal(None::<String>);
136    let options_memo = create_memo(move |_| {
137        if let Some(i) = key_input() {
138            let sorter = SimpleMatch::new(i);
139            let mut mut_options = options();
140            mut_options.sort_by(sorter.sort());
141            mut_options
142        } else {
143            options()
144        }
145    });
146
147    let selected_bg = move |idx: usize, option: SelectOption| {
148        if key_input().is_some() && idx == 0
149            || key_input().is_none() && option.value == selection().value
150        {
151            "var(--accent, #3584E4)"
152        } else {
153            ""
154        }
155    };
156
157    let key_listener = window_event_listener(ev::keydown, move |ev| {
158        if !show_options() {
159            return;
160        }
161
162        match ev.key().as_str() {
163            "Backspace" => key_input.set({
164                if key_input().is_some_and(|i| i.len() > 1) {
165                    let i = key_input().unwrap();
166                    Some(i[0..i.len() - 1].to_string())
167                } else {
168                    None
169                }
170            }),
171            " " if key_input().is_none() => {}
172            "Enter" if key_input().is_some() => {
173                selection.set(options_memo.get_untracked()[0].clone());
174                key_input.set(None);
175            }
176            "Escape" => {
177                key_input.set(None);
178                show_options.set(false);
179            }
180            k if k.len() == 1 => {
181                ev.stop_propagation();
182                ev.prevent_default();
183                key_input.set(Some(key_input().unwrap_or_default() + k))
184            }
185            _ => {}
186        }
187    });
188
189    if let Some(close_signal) = use_context::<RwSignal<CloseOverlays>>() {
190        create_effect(move |_| {
191            close_signal.track();
192            show_options.set(false);
193        });
194    } else {
195        logging::warn!("No `close overlay` signal available");
196    }
197
198    on_cleanup(|| key_listener.remove());
199
200    let get_label = move || key_input().unwrap_or(selection().name);
201
202    view! {
203        <style>
204            r#"select-options {
205                scrollbar-width: thin;
206                scrollbar-color: rgba(0, 0, 0, 0.32) transparent;
207            }"#
208        </style>
209        <custom-select>
210            <div node_ref=options_list_ref>
211                <select-view style:display="flex">
212                    <label
213                        style:align-content="center"
214                        style:width="100%"
215                        on:click=|ev| ev.stop_propagation()
216                        for="dropdown-button"
217                    >
218                        <Show
219                            when=move || key_input().is_some()
220                            fallback=move || view! { <span>{selection().name}</span> }
221                        >
222                            {get_label}
223                        </Show>
224                    </label>
225                    <button
226                        type="button"
227                        id="dropdown-button"
228                        on:click=toggle_show
229                        style:height="40px"
230                        style:width="40px"
231                    >
232                        <img
233                            src="/icons/dropdown.svg"
234                            width="24px"
235                            height="24px"
236                            style:transform=toggle_style
237                        />
238                    </button>
239                </select-view>
240                <Show when=show_options>
241                    <select-options style:display="block" style:max-height=max_height>
242
243                        {options_memo()
244                            .into_iter()
245                            .enumerate()
246                            .map(move |(idx, option)| {
247                                let option = store_value(option);
248                                view! {
249                                    <select-option
250                                        on:click=move |_| on_option(option())
251                                        style:display="block"
252                                        style:background=move || selected_bg(idx, option())
253                                    >
254                                        {option().name}
255                                    </select-option>
256                                }
257                            })
258                            .collect_view()}
259
260                    </select-options>
261                </Show>
262            </div>
263        </custom-select>
264    }
265}