tallyweb_components/
select.rs1use 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}