1use dioxus::prelude::*;
6
7pub const INPUT_CLASS: &str = "w-full px-4 py-2 border border-paper-border rounded-2xl bg-paper-entry text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200";
9
10pub const INPUT_INLINE_CLASS: &str = "flex-1 min-w-0 px-4 py-2 border border-paper-border rounded-2xl bg-paper-entry text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200";
13
14pub const BUTTON_PRIMARY_CLASS: &str = "w-full py-2.5 px-4 bg-paper-accent text-white font-medium rounded-full hover:brightness-110 active:scale-[0.98] transition-all duration-200 cursor-pointer";
16
17static FORM_SELECT_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
19
20pub const FORM_SELECT_COMPACT_CLASS: &str = "inline-flex w-auto cursor-pointer select-none text-left text-sm pl-3 pr-8 py-1 border border-paper-border rounded-lg bg-paper-theme text-paper-primary focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200";
23
24#[allow(dead_code)] fn should_flip(
29 trigger_top: f64,
30 trigger_bottom: f64,
31 viewport_height: f64,
32 panel_height: f64,
33) -> bool {
34 const MARGIN: f64 = 14.0;
36 let below = viewport_height - trigger_bottom;
37 let above = trigger_top;
38 below < panel_height + MARGIN && above > below
39}
40
41fn wrap_index(cur: usize, delta: i32, len: usize) -> usize {
43 if len == 0 {
44 return 0;
45 }
46 (cur as i32 + delta).rem_euclid(len as i32) as usize
47}
48
49#[cfg(target_arch = "wasm32")]
51fn measure_flip(trigger_id: &str, option_count: usize) -> bool {
52 const ROW_HEIGHT: f64 = 44.0;
54 const PANEL_CHROME: f64 = 14.0;
56 const PANEL_MAX: f64 = 254.0;
58
59 let Some(window) = web_sys::window() else {
60 return false;
61 };
62 let Some(document) = window.document() else {
63 return false;
64 };
65 let Some(el) = document.get_element_by_id(trigger_id) else {
66 return false;
67 };
68 let rect = el.get_bounding_client_rect();
69 let viewport = window
70 .inner_height()
71 .ok()
72 .and_then(|v| v.as_f64())
73 .unwrap_or(800.0);
74 let panel_height = ((option_count as f64) * ROW_HEIGHT + PANEL_CHROME).min(PANEL_MAX);
75 should_flip(rect.top(), rect.bottom(), viewport, panel_height)
76}
77
78#[cfg(target_arch = "wasm32")]
80fn scroll_option_into_view(element_id: &str) {
81 let Some(document) = web_sys::window().and_then(|w| w.document()) else {
82 return;
83 };
84 let Some(el) = document.get_element_by_id(element_id) else {
85 return;
86 };
87 let opts = web_sys::ScrollIntoViewOptions::new();
88 opts.set_block(web_sys::ScrollLogicalPosition::Nearest);
89 el.scroll_into_view_with_scroll_into_view_options(&opts);
90}
91
92#[component]
110pub fn FormSelect<T: Clone + PartialEq + 'static>(
111 id: Option<String>,
112 value: T,
113 options: Vec<(T, &'static str)>,
114 onchange: EventHandler<T>,
115 #[props(default)]
118 trigger_class: Option<&'static str>,
119) -> Element {
120 const TRIGGER_CLASS: &str = "w-full block cursor-pointer truncate select-none text-left pl-4 pr-10 py-2 border border-paper-border rounded-2xl bg-paper-entry text-paper-primary focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200";
129 const PANEL_CLASS: &str = "absolute left-1/2 z-50 w-max min-w-full max-w-[calc(100vw_-_2rem)] [transform:translateX(-50%)] max-h-60 overflow-y-auto rounded-2xl border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] p-1.5 shadow-lg animate-select-enter";
130
131 let trigger_cls = trigger_class.unwrap_or(TRIGGER_CLASS);
132
133 let id_prefix = use_hook(|| FORM_SELECT_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst));
134
135 let selected = options.iter().position(|(v, _)| *v == value).unwrap_or(0);
137 let selected_label = options.get(selected).map(|(_, l)| *l).unwrap_or_default();
138 let len = options.len();
139
140 let mut open = use_signal(|| false);
141 let mut active = use_signal(|| selected);
142 #[allow(unused_mut)]
144 let mut flip_up = use_signal(|| false);
145
146 let options_for_keys = options.clone();
148
149 let trigger_id = id.unwrap_or_else(|| format!("form-select-{id_prefix}"));
151 #[cfg(target_arch = "wasm32")]
153 let trigger_id_click = trigger_id.clone();
154 #[cfg(target_arch = "wasm32")]
155 let trigger_id_keys = trigger_id.clone();
156
157 use_effect(move || {
159 if open() {
160 #[cfg(target_arch = "wasm32")]
161 {
162 let idx = active();
163 scroll_option_into_view(&format!("form-select-{id_prefix}-opt-{idx}"));
164 }
165 }
166 });
167
168 let active_idx = active();
170 let rows: Vec<(usize, T, &'static str, &'static str, &'static str)> = options
171 .iter()
172 .enumerate()
173 .map(|(i, (v, l))| {
174 let highlight = if i == active_idx {
175 "bg-[var(--color-paper-accent-soft)]"
176 } else {
177 ""
178 };
179 let text = if i == selected {
180 "text-paper-accent"
181 } else {
182 "text-[var(--color-paper-primary)]"
183 };
184 (i, v.clone(), *l, highlight, text)
185 })
186 .collect();
187
188 let chevron_rotate = if open() { "rotate-180" } else { "" };
189 let placement_cls = if flip_up() {
190 "bottom-full mb-1.5 origin-bottom"
191 } else {
192 "top-full mt-1.5 origin-top"
193 };
194 let active_descendant = open().then(|| {
195 let idx = active();
196 format!("form-select-{id_prefix}-opt-{idx}")
197 });
198
199 rsx! {
200 div { class: "relative",
201 button {
202 id: "{trigger_id}",
203 r#type: "button",
204 class: "{trigger_cls}",
205 aria_haspopup: "listbox",
206 aria_expanded: "{open()}",
207 aria_activedescendant: active_descendant,
208 onclick: move |_| {
209 if !open() {
212 #[cfg(target_arch = "wasm32")]
213 flip_up.set(measure_flip(&trigger_id_click, len));
214 active.set(selected);
215 open.set(true);
216 }
217 },
218 onkeydown: move |e| {
219 let key = e.key();
220 let is_space = matches!(&key, Key::Character(s) if s == " ");
221 if !open() {
222 if key == Key::ArrowDown || key == Key::ArrowUp || key == Key::Enter
223 || is_space
224 {
225 e.prevent_default();
226 #[cfg(target_arch = "wasm32")]
227 flip_up.set(measure_flip(&trigger_id_keys, len));
228 active.set(selected);
229 open.set(true);
230 }
231 return;
232 }
233 if key == Key::ArrowDown {
234 e.prevent_default();
235 active.set(wrap_index(active(), 1, len));
236 } else if key == Key::ArrowUp {
237 e.prevent_default();
238 active.set(wrap_index(active(), -1, len));
239 } else if key == Key::Home { e.prevent_default();
241 active.set(0);
242 } else if key == Key::End {
243 e.prevent_default();
244 active.set(len.saturating_sub(1));
245 } else if key == Key::Enter || is_space {
246 e.prevent_default();
247 if let Some((v, _)) = options_for_keys.get(active()) {
248 onchange.call(v.clone());
249 }
250 open.set(false);
251 } else if key == Key::Escape {
252 e.prevent_default();
253 open.set(false);
254 } else if key == Key::Tab {
255 open.set(false);
256 }
257 },
258 "{selected_label}"
259 svg {
261 class: "pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-paper-secondary transition-transform duration-200 {chevron_rotate}",
262 view_box: "0 0 24 24",
263 fill: "none",
264 stroke: "currentColor",
265 stroke_width: "2",
266 path {
267 stroke_linecap: "round",
268 stroke_linejoin: "round",
269 d: "M6 9l6 6 6-6",
270 }
271 }
272 }
273
274 if open() {
275 div {
277 class: "fixed inset-0 z-40",
278 onclick: move |_| open.set(false),
279 }
280 ul {
281 class: "{PANEL_CLASS} {placement_cls}",
282 role: "listbox",
283 aria_labelledby: "{trigger_id}",
284 for (i, opt_value, opt_label, highlight_cls, text_cls) in rows {
285 li {
286 id: "form-select-{id_prefix}-opt-{i}",
287 class: "flex items-center justify-between gap-2 px-3 py-2.5 rounded-xl cursor-pointer select-none transition-colors hover:bg-[var(--color-paper-accent-soft)] {text_cls} {highlight_cls}",
288 role: "option",
289 aria_selected: "{i == selected}",
290 onmousedown: move |e| e.prevent_default(),
292 onclick: move |_| {
293 onchange.call(opt_value.clone());
294 open.set(false);
295 },
296 onmouseenter: move |_| active.set(i),
297 span { class: "truncate", "{opt_label}" }
298 if i == selected {
299 svg {
300 class: "w-4 h-4 flex-shrink-0",
301 view_box: "0 0 24 24",
302 fill: "none",
303 stroke: "currentColor",
304 stroke_width: "2",
305 path {
306 stroke_linecap: "round",
307 stroke_linejoin: "round",
308 d: "M20 6L9 17l-5-5",
309 }
310 }
311 }
312 }
313 }
314 }
315 }
316 }
317 }
318}
319
320#[component]
334pub fn FormInput(
335 id: Option<String>,
336 r#type: &'static str,
337 placeholder: &'static str,
338 value: String,
339 #[props(default)] disabled: bool,
340 oninput: EventHandler<String>,
341 #[props(default)] onkeydown: Option<EventHandler<KeyboardEvent>>,
342 #[props(default)] class: Option<&'static str>,
343 #[props(default)] mono: bool,
344) -> Element {
345 let base = class.unwrap_or(INPUT_CLASS);
346 let mono_class = if mono { " font-mono" } else { "" };
347 let disabled_class = if disabled {
348 " opacity-60 cursor-not-allowed"
349 } else {
350 ""
351 };
352 rsx! {
353 input {
354 id: id.unwrap_or_default(),
355 class: "{base}{mono_class}{disabled_class}",
356 r#type: "{r#type}",
357 placeholder: "{placeholder}",
358 value: "{value}",
359 disabled,
360 oninput: move |e| oninput.call(e.value()),
361 onkeydown: move |e| {
362 if let Some(ref handler) = onkeydown {
363 handler.call(e);
364 }
365 },
366 }
367 }
368}
369
370#[component]
376pub fn FormLabel(label: &'static str, html_for: Option<String>) -> Element {
377 rsx! {
378 label {
379 class: "block text-sm font-medium text-paper-secondary mb-1",
380 r#for: html_for.unwrap_or_default(),
381 "{label}"
382 }
383 }
384}
385
386#[component]
392pub fn AlertBox(message: String, variant: &'static str) -> Element {
393 let (bg_class, text_class) = match variant {
394 "error" => (
395 "bg-red-100 dark:bg-red-900/30",
396 "text-red-700 dark:text-red-300",
397 ),
398 "success" => (
399 "bg-green-100 dark:bg-green-900/30",
400 "text-green-700 dark:text-green-300",
401 ),
402 _ => ("bg-paper-code-bg", "text-paper-secondary"),
403 };
404 rsx! {
405 div { class: "mb-4 p-3 {bg_class} {text_class} rounded-lg text-center", "{message}" }
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::{should_flip, wrap_index};
412
413 #[test]
414 fn wrap_index_cycles_both_directions() {
415 assert_eq!(wrap_index(0, 1, 3), 1);
416 assert_eq!(wrap_index(2, 1, 3), 0); assert_eq!(wrap_index(0, -1, 3), 2); assert_eq!(wrap_index(1, -1, 3), 0);
419 }
420
421 #[test]
422 fn wrap_index_empty_is_zero() {
423 assert_eq!(wrap_index(5, 1, 0), 0); }
425
426 #[test]
427 fn should_flip_only_when_below_insufficient_and_above_wider() {
428 assert!(!should_flip(100.0, 140.0, 800.0, 200.0));
430 assert!(should_flip(600.0, 640.0, 800.0, 200.0));
432 assert!(!should_flip(30.0, 70.0, 260.0, 200.0));
434 assert!(!should_flip(300.0, 340.0, 554.0, 200.0));
436 }
437}