chattyness/crates/chattyness-user-ui/src/components/context_menu.rs

213 lines
7.9 KiB
Rust

//! Generic context menu component for right-click menus.
//!
//! Provides a reusable popup menu with:
//! - Fixed positioning at mouse coordinates
//! - Viewport edge detection to prevent off-screen rendering
//! - Click-outside-to-close behavior
//! - Escape key to close
//! - Dark theme styling
//! - ARIA attributes for accessibility
use leptos::prelude::*;
/// A menu item in the context menu.
#[derive(Clone)]
pub struct ContextMenuItem {
/// The label displayed for this menu item.
pub label: String,
/// The action identifier passed to the on_select callback.
pub action: String,
}
/// Context menu component that displays at a specific position.
///
/// Props:
/// - `open`: Whether the menu is currently visible
/// - `position`: The (x, y) position in client coordinates where the menu should appear
/// - `header`: Optional header text displayed at the top (e.g., username)
/// - `items`: The menu items to display
/// - `on_select`: Callback when a menu item is selected, receives the action string
/// - `on_close`: Callback when the menu should close (click outside, escape, etc.)
#[component]
pub fn ContextMenu(
/// Whether the menu is visible.
#[prop(into)]
open: Signal<bool>,
/// Position (x, y) in client coordinates.
#[prop(into)]
position: Signal<(f64, f64)>,
/// Optional header text (e.g., username) displayed above the menu items.
#[prop(optional, into)]
header: Option<Signal<Option<String>>>,
/// Menu items to display.
#[prop(into)]
items: Signal<Vec<ContextMenuItem>>,
/// Called when an item is selected with the action string.
#[prop(into)]
on_select: Callback<String>,
/// Called when the menu should close.
#[prop(into)]
on_close: Callback<()>,
) -> impl IntoView {
let menu_ref = NodeRef::<leptos::html::Div>::new();
// Calculate adjusted position to stay within viewport
let adjusted_style = move || {
let (x, y) = position.get();
let menu_width = 150.0; // min-w-[150px]
let menu_height = 100.0; // approximate
#[cfg(feature = "hydrate")]
let (viewport_width, viewport_height) = {
let window = web_sys::window().unwrap();
(
window.inner_width().unwrap().as_f64().unwrap_or(800.0),
window.inner_height().unwrap().as_f64().unwrap_or(600.0),
)
};
#[cfg(not(feature = "hydrate"))]
let (viewport_width, viewport_height) = (800.0, 600.0);
// Adjust position to keep menu within viewport
let adjusted_x = if x + menu_width > viewport_width {
(x - menu_width).max(0.0)
} else {
x
};
let adjusted_y = if y + menu_height > viewport_height {
(y - menu_height).max(0.0)
} else {
y
};
format!(
"position: fixed; left: {}px; top: {}px; z-index: 40;",
adjusted_x, adjusted_y
)
};
// Click outside handler - use Effect with cleanup to properly remove handlers
#[cfg(feature = "hydrate")]
{
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::{JsCast, closure::Closure};
// Store closures so we can remove them on cleanup
let mousedown_closure: Rc<RefCell<Option<Closure<dyn Fn(web_sys::MouseEvent)>>>> =
Rc::new(RefCell::new(None));
let keydown_closure: Rc<RefCell<Option<Closure<dyn Fn(web_sys::KeyboardEvent)>>>> =
Rc::new(RefCell::new(None));
let mousedown_closure_clone = mousedown_closure.clone();
let keydown_closure_clone = keydown_closure.clone();
Effect::new(move |_| {
let window = web_sys::window().unwrap();
// Clean up previous handlers first
if let Some(old_handler) = mousedown_closure_clone.borrow_mut().take() {
let _ = window.remove_event_listener_with_callback(
"mousedown",
old_handler.as_ref().unchecked_ref(),
);
}
if let Some(old_handler) = keydown_closure_clone.borrow_mut().take() {
let _ = window.remove_event_listener_with_callback(
"keydown",
old_handler.as_ref().unchecked_ref(),
);
}
// Only add handlers when menu is open
if !open.get() {
return;
}
let Some(menu_el) = menu_ref.get() else {
return;
};
let on_close = on_close.clone();
let menu_el: web_sys::HtmlElement = menu_el.into();
let menu_el_clone = menu_el.clone();
// Mousedown handler for click-outside detection
let handler =
Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
if let Some(target) = ev.target() {
if let Ok(target_el) = target.dyn_into::<web_sys::Node>() {
if !menu_el_clone.contains(Some(&target_el)) {
on_close.run(());
}
}
}
});
let _ = window
.add_event_listener_with_callback("mousedown", handler.as_ref().unchecked_ref());
*mousedown_closure_clone.borrow_mut() = Some(handler);
// Escape key handler
let on_close_esc = on_close.clone();
let keydown_handler = Closure::<dyn Fn(web_sys::KeyboardEvent)>::new(
move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Escape" {
on_close_esc.run(());
ev.prevent_default();
}
},
);
let _ = window.add_event_listener_with_callback(
"keydown",
keydown_handler.as_ref().unchecked_ref(),
);
*keydown_closure_clone.borrow_mut() = Some(keydown_handler);
});
}
view! {
<Show when=move || open.get()>
<div
node_ref=menu_ref
class="bg-gray-800/95 backdrop-blur-sm rounded-lg shadow-lg border border-gray-700 py-1 min-w-[150px]"
style=adjusted_style
role="menu"
aria-label="Context menu"
>
// Header with username and divider
{move || {
header.and_then(|h| h.get()).map(|header_text| {
view! {
<div class="px-4 py-2 text-center">
<div class="text-sm font-semibold text-white">{header_text}</div>
</div>
<hr class="border-gray-600 mx-2" />
}
})
}}
<For
each=move || items.get()
key=|item| item.action.clone()
children=move |item| {
let action = item.action.clone();
let on_select = on_select.clone();
let on_close = on_close.clone();
view! {
<button
class="w-full text-left px-4 py-2 text-sm text-gray-200 hover:bg-gray-700 hover:text-white focus:bg-gray-700 focus:text-white focus:outline-none transition-colors"
role="menuitem"
on:click=move |_| {
on_select.run(action.clone());
on_close.run(());
}
>
{item.label}
</button>
}
}
/>
</div>
</Show>
}
}