use leptos::{ev, html::*, prelude::*};
use leptos_router::hooks::use_location;
#[derive(Clone, PartialEq, Debug)]
#[allow(dead_code)]
pub enum Position {
Top,
Bottom,
}
#[component]
pub fn Popover(
#[prop(optional)] children: Option<ChildrenFn>,
#[prop(into)] display_item: ViewFn,
#[prop(default = Position::Bottom, optional)] position: Position,
#[prop(into, optional)] style_ext: String,
#[prop(into)] showing: RwSignal<bool>,
) -> impl IntoView {
let (children, _set_children) = signal(children);
let trigger_ref = NodeRef::<Div>::new();
let align = RwSignal::new((
"left-1/2 -translate-x-1/2".to_string(),
"left-1/2 -translate-x-1/2".to_string(),
));
let location = use_location();
let onclick_toggle_handler = move |_| {
showing.update(|val| *val = !*val);
};
let position_class = StoredValue::new(match position {
Position::Top => "bottom-full mb-2",
Position::Bottom => "top-full mt-2",
});
let arrow_class = StoredValue::new(match position {
Position::Top => "-bottom-[10px] rotate-180",
Position::Bottom => "-top-[10px]",
});
let style_ext = StoredValue::new(style_ext);
Effect::new(move |_| {
let _ = location.pathname.get();
showing.set(false);
});
let recalculate = StoredValue::new(move || {
if let Some(trigger) = trigger_ref.get_untracked() {
let rect = trigger.get_bounding_client_rect();
if let Some(window) = web_sys::window() {
let vw = window
.inner_width()
.unwrap_or_default()
.as_f64()
.unwrap_or(375.0);
let (popover_align, arrow_align) = if rect.left() < vw / 3.0 {
("left-0".to_string(), "left-4 translate-x-0".to_string())
} else if rect.right() > (vw * 2.0 / 3.0) {
("right-0".to_string(), "right-4 translate-x-0".to_string())
} else {
(
"left-1/2 -translate-x-1/2".to_string(),
"left-1/2 -translate-x-1/2".to_string(),
)
};
align.set((popover_align, arrow_align));
};
}
});
Effect::new(move |_| {
if showing.get() {
request_animation_frame(move || recalculate.get_value()());
} else {
align.set((
"left-1/2 -translate-x-1/2".to_string(),
"left-1/2 -translate-x-1/2".to_string(),
));
}
});
let window_resize_listener = window_event_listener(ev::resize, move |_| {
recalculate.get_value()();
});
on_cleanup(move || {
window_resize_listener.remove(); });
view! {
<div class="relative">
<div node_ref=trigger_ref on:click=onclick_toggle_handler class="cursor-pointer">
{display_item.run()}
</div>
<Show when=move || showing.get() fallback=|| ()>
<div
on:click=onclick_toggle_handler
class="fixed inset-0 z-20 bg-transparent"
></div>
<div
class=move || format!(
"absolute {} {} z-30
w-max min-w-32 max-w-[calc(100vw-1rem)]
bg-contrast-white border border-light-gray
shadow-lg text-sm rounded-[5px] {}",
align.get().0,
position_class.get_value(),
style_ext.get_value()
)
>
<div
class=move || format!(
"absolute {} {}",
align.get().1,
arrow_class.get_value()
)
>
<div class="w-[20px] h-[20px] bg-contrast-white border-l border-t border-light-gray rotate-45"></div>
</div>
<div class="relative z-10 bg-contrast-white rounded-[5px]">
{move || children.get().map(|child| child())}
</div>
</div>
</Show>
</div>
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn position_eq() {
assert_eq!(Position::Top, Position::Top);
assert_ne!(Position::Top, Position::Bottom);
}
#[test]
fn position_clone() {
assert_eq!(Position::Bottom.clone(), Position::Bottom);
}
fn position_class(position: &Position) -> &'static str {
match position {
Position::Top => "bottom-full mb-2",
Position::Bottom => "top-full mt-2",
}
}
fn arrow_class(position: &Position) -> &'static str {
match position {
Position::Top => "-bottom-[10px] rotate-180",
Position::Bottom => "-top-[10px]",
}
}
#[test]
fn top_position_class() {
assert_eq!(position_class(&Position::Top), "bottom-full mb-2");
}
#[test]
fn bottom_position_class() {
assert_eq!(position_class(&Position::Bottom), "top-full mt-2");
}
#[test]
fn top_arrow_class() {
assert_eq!(arrow_class(&Position::Top), "-bottom-[10px] rotate-180");
}
#[test]
fn bottom_arrow_class() {
assert_eq!(arrow_class(&Position::Bottom), "-top-[10px]");
}
fn resolve_alignment(left: f64, right: f64, vw: f64) -> (&'static str, &'static str) {
if left < vw / 3.0 {
("left-0", "left-4 translate-x-0")
} else if right > vw * 2.0 / 3.0 {
("right-0", "right-4 translate-x-0")
} else {
("left-1/2 -translate-x-1/2", "left-1/2 -translate-x-1/2")
}
}
#[test]
fn near_left_edge_aligns_left() {
let (popover, arrow) = resolve_alignment(10.0, 200.0, 375.0);
assert_eq!(popover, "left-0");
assert_eq!(arrow, "left-4 translate-x-0");
}
#[test]
fn near_right_edge_aligns_right() {
let (popover, arrow) = resolve_alignment(300.0, 370.0, 375.0);
assert_eq!(popover, "right-0");
assert_eq!(arrow, "right-4 translate-x-0");
}
#[test]
fn centered_aligns_center() {
let (popover, arrow) = resolve_alignment(150.0, 250.0, 375.0);
assert_eq!(popover, "left-1/2 -translate-x-1/2");
assert_eq!(arrow, "left-1/2 -translate-x-1/2");
}
#[test]
fn toggle_opens_when_closed() {
let owner = Owner::new();
owner.with(|| {
let showing = RwSignal::new(false);
showing.update(|v| *v = !*v);
assert!(showing.get());
});
}
#[test]
fn toggle_closes_when_open() {
let owner = Owner::new();
owner.with(|| {
let showing = RwSignal::new(true);
showing.update(|v| *v = !*v);
assert!(!showing.get());
});
}
#[test]
fn route_change_closes_popover() {
let owner = Owner::new();
owner.with(|| {
let showing = RwSignal::new(true);
showing.set(false);
assert!(!showing.get());
});
}
#[test]
fn alignment_resets_when_closed() {
let owner = Owner::new();
owner.with(|| {
let align = RwSignal::new(("left-0".to_string(), "left-4 translate-x-0".to_string()));
let showing = RwSignal::new(false);
if !showing.get() {
align.set((
"left-1/2 -translate-x-1/2".to_string(),
"left-1/2 -translate-x-1/2".to_string(),
));
}
assert_eq!(align.get().0, "left-1/2 -translate-x-1/2");
assert_eq!(align.get().1, "left-1/2 -translate-x-1/2");
});
}
}