213 lines
7.9 KiB
Rust
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>
|
|
}
|
|
}
|