//! 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, /// 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>>, /// Menu items to display. #[prop(into)] items: Signal>, /// Called when an item is selected with the action string. #[prop(into)] on_select: Callback, /// Called when the menu should close. #[prop(into)] on_close: Callback<()>, ) -> impl IntoView { let menu_ref = NodeRef::::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>>> = Rc::new(RefCell::new(None)); let keydown_closure: Rc>>> = 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::::new(move |ev: web_sys::MouseEvent| { if let Some(target) = ev.target() { if let Ok(target_el) = target.dyn_into::() { 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::::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! { } }