1use leptos::html;
5use leptos::prelude::*;
6
7use crate::web::base::Overlay;
8
9#[derive(Clone, Copy, PartialEq, Eq)]
12pub enum Side {
13 Top,
14 Bottom,
15 Left,
16 Right,
17}
18
19#[derive(Clone, Copy, PartialEq, Eq)]
22pub enum Align {
23 Start,
24 Center,
25 End,
26}
27
28#[derive(Clone, Copy)]
29struct Rect {
30 left: f64,
31 top: f64,
32 width: f64,
33 height: f64,
34}
35
36const MARGIN: f64 = 8.0;
37
38fn align_main(anchor_start: f64, anchor_size: f64, floating_size: f64, align: Align) -> f64 {
39 match align {
40 Align::Start => anchor_start,
41 Align::Center => anchor_start + (anchor_size - floating_size) / 2.0,
42 Align::End => anchor_start + anchor_size - floating_size,
43 }
44}
45
46fn resolve(
47 anchor: Rect,
48 floating: (f64, f64),
49 side: Side,
50 align: Align,
51 offset: f64,
52 viewport: (f64, f64),
53) -> (f64, f64) {
54 let (floating_width, floating_height) = floating;
55 let (viewport_width, viewport_height) = viewport;
56 let fits_below = anchor.top + anchor.height + offset + floating_height <= viewport_height;
57 let fits_above = anchor.top - offset - floating_height >= MARGIN;
58 let fits_right = anchor.left + anchor.width + offset + floating_width <= viewport_width;
59 let fits_left = anchor.left - offset - floating_width >= MARGIN;
60
61 let side = match side {
62 Side::Bottom if !fits_below && fits_above => Side::Top,
63 Side::Top if !fits_above && fits_below => Side::Bottom,
64 Side::Right if !fits_right && fits_left => Side::Left,
65 Side::Left if !fits_left && fits_right => Side::Right,
66 other => other,
67 };
68
69 let (left, top) = match side {
70 Side::Bottom => (
71 align_main(anchor.left, anchor.width, floating_width, align),
72 anchor.top + anchor.height + offset,
73 ),
74 Side::Top => (
75 align_main(anchor.left, anchor.width, floating_width, align),
76 anchor.top - offset - floating_height,
77 ),
78 Side::Right => (
79 anchor.left + anchor.width + offset,
80 align_main(anchor.top, anchor.height, floating_height, align),
81 ),
82 Side::Left => (
83 anchor.left - offset - floating_width,
84 align_main(anchor.top, anchor.height, floating_height, align),
85 ),
86 };
87
88 let max_left = (viewport_width - floating_width - MARGIN).max(MARGIN);
89 let max_top = (viewport_height - floating_height - MARGIN).max(MARGIN);
90 (left.clamp(MARGIN, max_left), top.clamp(MARGIN, max_top))
91}
92
93fn viewport() -> (f64, f64) {
94 let window = web_sys::window();
95 let width = window
96 .as_ref()
97 .and_then(|window| window.inner_width().ok())
98 .and_then(|value| value.as_f64())
99 .unwrap_or(1280.0);
100 let height = window
101 .as_ref()
102 .and_then(|window| window.inner_height().ok())
103 .and_then(|value| value.as_f64())
104 .unwrap_or(800.0);
105 (width, height)
106}
107
108#[component]
112pub fn Popover(
113 open: RwSignal<bool>,
114 #[prop(into)] trigger: ViewFn,
115 #[prop(default = Side::Bottom)] side: Side,
116 #[prop(default = Align::Start)] align: Align,
117 #[prop(default = 8.0)] offset: f64,
118 #[prop(optional)] on_dismiss: Option<Callback<()>>,
119 children: ChildrenFn,
120) -> impl IntoView {
121 let anchor_ref = NodeRef::<html::Span>::new();
122 let content_ref = NodeRef::<html::Div>::new();
123 let position = RwSignal::new((0.0_f64, 0.0_f64));
124 let children = StoredValue::new(children);
125
126 let reposition = move || {
127 if let (Some(anchor), Some(content)) = (anchor_ref.get(), content_ref.get()) {
128 let anchor_rect = anchor.get_bounding_client_rect();
129 let content_rect = content.get_bounding_client_rect();
130 let (viewport_width, viewport_height) = viewport();
131 let placed = resolve(
132 Rect {
133 left: anchor_rect.left(),
134 top: anchor_rect.top(),
135 width: anchor_rect.width(),
136 height: anchor_rect.height(),
137 },
138 (content_rect.width(), content_rect.height()),
139 side,
140 align,
141 offset,
142 (viewport_width, viewport_height),
143 );
144 position.set(placed);
145 }
146 };
147
148 Effect::new(move |_| {
149 if open.get() {
150 reposition();
151 }
152 });
153
154 let scroll_handle = window_event_listener(leptos::ev::scroll, move |_| {
155 if open.get_untracked() {
156 reposition();
157 }
158 });
159 let resize_handle = window_event_listener(leptos::ev::resize, move |_| {
160 if open.get_untracked() {
161 reposition();
162 }
163 });
164 on_cleanup(move || {
165 scroll_handle.remove();
166 resize_handle.remove();
167 });
168
169 let dismiss = move || {
170 open.set(false);
171 if let Some(callback) = on_dismiss {
172 callback.run(());
173 }
174 };
175
176 view! {
177 <span
178 class="nightshade-popover-anchor"
179 node_ref=anchor_ref
180 on:click=move |_| open.update(|value| *value = !*value)
181 >
182 {trigger.run()}
183 </span>
184 <Show when=move || open.get() fallback=|| ()>
185 <Overlay>
186 <div
187 class="nightshade-popover-catcher"
188 on:pointerdown=move |_| dismiss()
189 ></div>
190 <div
191 class="nightshade-popover"
192 node_ref=content_ref
193 style=move || {
194 let (left, top) = position.get();
195 format!("left:{left}px;top:{top}px")
196 }
197 on:pointerdown=|event| event.stop_propagation()
198 >
199 {children.with_value(|render| render())}
200 </div>
201 </Overlay>
202 </Show>
203 }
204}
205
206#[component]
209pub fn Dropdown(
210 #[prop(into)] label: String,
211 #[prop(into, optional)] class: String,
212 #[prop(default = Side::Bottom)] side: Side,
213 #[prop(default = Align::Start)] align: Align,
214 children: ChildrenFn,
215) -> impl IntoView {
216 let open = RwSignal::new(false);
217 let label = StoredValue::new(label);
218 let class = StoredValue::new(class);
219 let children = StoredValue::new(children);
220 view! {
221 <Popover
222 open=open
223 side=side
224 align=align
225 trigger=move || {
226 view! {
227 <button
228 class=format!("nightshade-button {}", class.get_value())
229 aria-haspopup="menu"
230 >
231 {label.get_value()}
232 </button>
233 }
234 }
235 >
236 <div class="nightshade-menu-list" role="menu" on:click=move |_| open.set(false)>
237 {children.with_value(|render| render())}
238 </div>
239 </Popover>
240 }
241}
242
243#[derive(Clone)]
245pub struct ComboOption {
246 pub value: String,
248 pub label: String,
250}
251
252impl ComboOption {
253 pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
255 Self {
256 value: value.into(),
257 label: label.into(),
258 }
259 }
260}
261
262#[component]
266pub fn Combobox(
267 #[prop(into)] value: Signal<String>,
268 options: Vec<ComboOption>,
269 on_select: Callback<String>,
270 #[prop(into, optional)] placeholder: String,
271) -> impl IntoView {
272 let open = RwSignal::new(false);
273 let query = RwSignal::new(String::new());
274 let active = RwSignal::new(0usize);
275 let options = StoredValue::new(options);
276 let placeholder = StoredValue::new(if placeholder.is_empty() {
277 "Select…".to_string()
278 } else {
279 placeholder
280 });
281
282 let selected_label = move || {
283 let current = value.get();
284 options.with_value(|list| {
285 list.iter()
286 .find(|option| option.value == current)
287 .map(|option| option.label.clone())
288 .unwrap_or_default()
289 })
290 };
291
292 let filtered = move || {
293 let needle = query.get().to_lowercase();
294 options.with_value(|list| {
295 list.iter()
296 .filter(|option| needle.is_empty() || option.label.to_lowercase().contains(&needle))
297 .cloned()
298 .collect::<Vec<_>>()
299 })
300 };
301
302 let choose = move |option: ComboOption| {
303 on_select.run(option.value);
304 open.set(false);
305 query.set(String::new());
306 };
307
308 let on_key = move |event: web_sys::KeyboardEvent| match event.key().as_str() {
309 "ArrowDown" => {
310 event.prevent_default();
311 let count = filtered().len();
312 if count > 0 {
313 active.update(|index| *index = (*index + 1) % count);
314 }
315 }
316 "ArrowUp" => {
317 event.prevent_default();
318 let count = filtered().len();
319 if count > 0 {
320 active.update(|index| *index = (*index + count - 1) % count);
321 }
322 }
323 "Enter" => {
324 event.prevent_default();
325 if let Some(option) = filtered().into_iter().nth(active.get()) {
326 choose(option);
327 }
328 }
329 "Escape" => open.set(false),
330 _ => {}
331 };
332
333 view! {
334 <Popover
335 open=open
336 align=Align::Start
337 trigger=move || {
338 view! {
339 <button class="nightshade-combobox-trigger" type="button">
340 <span class="nightshade-combobox-value">
341 {move || {
342 let label = selected_label();
343 if label.is_empty() { placeholder.get_value() } else { label }
344 }}
345 </span>
346 <span class="nightshade-combobox-caret">"\u{25be}"</span>
347 </button>
348 }
349 }
350 >
351 <div class="nightshade-combobox-panel">
352 <input
353 class="nightshade-combobox-input"
354 type="text"
355 placeholder="Filter…"
356 prop:value=move || query.get()
357 on:input=move |event| {
358 query.set(event_target_value(&event));
359 active.set(0);
360 }
361 on:keydown=on_key
362 />
363 <div class="nightshade-combobox-list" role="listbox">
364 {move || {
365 let rows = filtered();
366 if rows.is_empty() {
367 return view! {
368 <div class="nightshade-combobox-empty">"No matches"</div>
369 }
370 .into_any();
371 }
372 rows.into_iter()
373 .enumerate()
374 .map(|(index, option)| {
375 let is_active = move || active.get() == index;
376 let chosen = option.clone();
377 view! {
378 <div
379 class="nightshade-combobox-option"
380 class:active=is_active
381 role="option"
382 aria-selected=move || is_active().to_string()
383 on:pointerdown=move |event| {
384 event.prevent_default();
385 choose(chosen.clone());
386 }
387 >
388 {option.label}
389 </div>
390 }
391 })
392 .collect_view()
393 .into_any()
394 }}
395 </div>
396 </div>
397 </Popover>
398 }
399}
400
401#[component]
405pub fn Dialog(
406 open: RwSignal<bool>,
407 #[prop(into)] title: String,
408 #[prop(into, optional)] confirm_label: String,
409 #[prop(into, optional)] cancel_label: String,
410 #[prop(optional)] danger: bool,
411 #[prop(optional)] on_confirm: Option<Callback<()>>,
412 #[prop(optional)] on_cancel: Option<Callback<()>>,
413 children: ChildrenFn,
414) -> impl IntoView {
415 let title = StoredValue::new(title);
416 let children = StoredValue::new(children);
417 let confirm_label = if confirm_label.is_empty() {
418 "Confirm".to_string()
419 } else {
420 confirm_label
421 };
422 let cancel_label = if cancel_label.is_empty() {
423 "Cancel".to_string()
424 } else {
425 cancel_label
426 };
427 let cancel = move || {
428 open.set(false);
429 if let Some(callback) = on_cancel {
430 callback.run(());
431 }
432 };
433 let confirm = move || {
434 open.set(false);
435 if let Some(callback) = on_confirm {
436 callback.run(());
437 }
438 };
439 view! {
440 <crate::web::base::Modal open=open>
441 <div class="nightshade-dialog">
442 <div class="nightshade-dialog-title">{move || title.get_value()}</div>
443 <div class="nightshade-dialog-body">{children.with_value(|render| render())}</div>
444 <div class="nightshade-dialog-actions">
445 <button class="nightshade-button" on:click=move |_| cancel()>
446 {cancel_label.clone()}
447 </button>
448 <button
449 class=if danger { "nightshade-button danger" } else { "nightshade-button primary" }
450 on:click=move |_| confirm()
451 >
452 {confirm_label.clone()}
453 </button>
454 </div>
455 </div>
456 </crate::web::base::Modal>
457 }
458}
459
460#[cfg(test)]
461mod tests {
462 use super::{Align, Rect, Side, resolve};
463
464 fn anchor() -> Rect {
465 Rect {
466 left: 100.0,
467 top: 100.0,
468 width: 80.0,
469 height: 30.0,
470 }
471 }
472
473 #[test]
474 fn bottom_start_places_below_and_left_aligned() {
475 let (left, top) = resolve(
476 anchor(),
477 (120.0, 60.0),
478 Side::Bottom,
479 Align::Start,
480 8.0,
481 (1000.0, 1000.0),
482 );
483 assert_eq!(left, 100.0);
484 assert_eq!(top, 138.0);
485 }
486
487 #[test]
488 fn flips_to_top_when_no_room_below() {
489 let tall_anchor = Rect {
490 left: 100.0,
491 top: 950.0,
492 width: 80.0,
493 height: 30.0,
494 };
495 let (_, top) = resolve(
496 tall_anchor,
497 (120.0, 60.0),
498 Side::Bottom,
499 Align::Start,
500 8.0,
501 (1000.0, 1000.0),
502 );
503 assert!(top < 950.0);
504 }
505
506 #[test]
507 fn shifts_to_stay_within_viewport() {
508 let edge_anchor = Rect {
509 left: 960.0,
510 top: 100.0,
511 width: 80.0,
512 height: 30.0,
513 };
514 let (left, _) = resolve(
515 edge_anchor,
516 (200.0, 60.0),
517 Side::Bottom,
518 Align::Start,
519 8.0,
520 (1000.0, 1000.0),
521 );
522 assert!(left + 200.0 <= 1000.0);
523 }
524}