Add the right-click ability on avatars
This commit is contained in:
parent
d1cbb3ba34
commit
0492043625
7 changed files with 438 additions and 2 deletions
165
crates/chattyness-user-ui/src/components/context_menu.rs
Normal file
165
crates/chattyness-user-ui/src/components/context_menu.rs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
//! 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
|
||||
/// - `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)>,
|
||||
/// 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
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use wasm_bindgen::{closure::Closure, JsCast};
|
||||
|
||||
Effect::new(move |_| {
|
||||
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();
|
||||
|
||||
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 = web_sys::window().unwrap();
|
||||
let _ = window.add_event_listener_with_callback("mousedown", handler.as_ref().unchecked_ref());
|
||||
|
||||
// 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());
|
||||
|
||||
// Store handlers to clean up (they get cleaned up when Effect reruns)
|
||||
handler.forget();
|
||||
keydown_handler.forget();
|
||||
});
|
||||
}
|
||||
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue