Skip to main content

nightshade_api/web/
floating.rs

1//! Floating overlay components: popovers, dropdowns, comboboxes, and dialogs that anchor
2//! to a trigger and reposition to stay within the viewport.
3
4use leptos::html;
5use leptos::prelude::*;
6
7use crate::web::base::Overlay;
8
9/// The preferred side of the anchor on which to place a floating element. Placement may
10/// flip to the opposite side when there is not enough room.
11#[derive(Clone, Copy, PartialEq, Eq)]
12pub enum Side {
13    Top,
14    Bottom,
15    Left,
16    Right,
17}
18
19/// Alignment of a floating element along the anchor's cross axis: to its start, centered,
20/// or to its end.
21#[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/// A floating panel anchored to a `trigger` element, toggled by the `open` signal and by
109/// clicking the trigger. It repositions on scroll and resize to stay in the viewport,
110/// flipping `side` and shifting as needed, and dismisses on outside click via `on_dismiss`.
111#[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/// A button labelled `label` that opens a [`Popover`] containing a menu of `children`.
207/// The menu closes when any item is clicked.
208#[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/// A selectable option for [`Combobox`], pairing a stored `value` with a display `label`.
244#[derive(Clone)]
245pub struct ComboOption {
246    /// The value emitted when this option is chosen.
247    pub value: String,
248    /// The text shown to the user.
249    pub label: String,
250}
251
252impl ComboOption {
253    /// Creates a [`ComboOption`] from a value and its display label.
254    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/// A filterable dropdown selector: the trigger shows the label of the current `value` (or
263/// `placeholder`), and the panel offers a text filter plus a keyboard-navigable list of
264/// `options`. Emits the chosen option's value through `on_select`.
265#[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/// A modal dialog with a title, body `children`, and cancel/confirm buttons. Toggled by
402/// the `open` signal; both buttons close it and run the optional `on_cancel`/`on_confirm`
403/// callbacks. The confirm button uses danger styling when `danger` is set.
404#[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}