leptix-context-menu 0.1.0

Leptix Context Menu component — a menu triggered by right-click, extending the Menu primitive.
Documentation
use leptix_core::compose_refs::use_composed_refs;
use leptix_core::dismissable_layer::use_dismissable_layer;
use leptix_core::focus_scope::use_focus_scope;
use leptix_core::id::use_id;
use leptix_core::presence::use_presence;
use leptix_core::primitive::Primitive;
use leptos::{context::Provider, ev::KeyboardEvent, html, prelude::*};
use leptos_node_ref::AnyNodeRef;

#[derive(Clone, Debug)]
struct ContextMenuContextValue {
    open: RwSignal<bool>,
    content_id: String,
    position_x: RwSignal<f64>,
    position_y: RwSignal<f64>,
}

#[component]
pub fn ContextMenu(
    #[prop(into, optional)] on_open_change: Option<Callback<bool>>,
    children: TypedChildrenFn<impl IntoView + 'static>,
) -> impl IntoView {
    let children = StoredValue::new(children.into_inner());
    let open = RwSignal::new(false);
    let base_id = use_id(None).get();

    Effect::new(move |_| {
        if let Some(cb) = on_open_change {
            cb.run(open.get());
        }
    });

    let ctx = ContextMenuContextValue {
        open,
        content_id: format!("{}-ctx", base_id),
        position_x: RwSignal::new(0.0),
        position_y: RwSignal::new(0.0),
    };

    view! {
        <Provider value=ctx>
            {children.with_value(|c| c())}
        </Provider>
    }
}

#[component]
pub fn ContextMenuTrigger(
    #[prop(into, optional)] disabled: MaybeProp<bool>,
    #[prop(into, optional)] as_child: MaybeProp<bool>,
    #[prop(into, optional)] node_ref: AnyNodeRef,
    children: TypedChildrenFn<impl IntoView + 'static>,
) -> impl IntoView {
    let children = StoredValue::new(children.into_inner());
    let ctx = expect_context::<ContextMenuContextValue>();
    let disabled = Signal::derive(move || disabled.get().unwrap_or(false));

    view! {
        <Primitive
            element=html::span
            as_child=as_child
            node_ref=node_ref
            attr:data-state=move || if ctx.open.get() { "open" } else { "closed" }
            attr:data-disabled=move || disabled.get().then_some("")
            on:contextmenu=move |event: web_sys::MouseEvent| {
                if !disabled.get() {
                    event.prevent_default();
                    ctx.position_x.set(event.client_x() as f64);
                    ctx.position_y.set(event.client_y() as f64);
                    ctx.open.set(true);
                }
            }
        >
            {children.with_value(|c| c())}
        </Primitive>
    }
}

#[component]
pub fn ContextMenuPortal(children: TypedChildrenFn<impl IntoView + 'static>) -> impl IntoView {
    let children = StoredValue::new(children.into_inner());
    let ctx = expect_context::<ContextMenuContextValue>();
    view! { <Show when=move || ctx.open.get()>{children.with_value(|c| c())}</Show> }
}

#[component]
pub fn ContextMenuContent(
    #[prop(into, optional)] as_child: MaybeProp<bool>,
    #[prop(into, optional)] node_ref: AnyNodeRef,
    children: TypedChildrenFn<impl IntoView + 'static>,
) -> impl IntoView {
    let children = StoredValue::new(children.into_inner());
    let ctx = expect_context::<ContextMenuContextValue>();
    let open = Signal::derive(move || ctx.open.get());
    let presence = use_presence(open);

    let focus_ref = use_focus_scope(Signal::derive(|| true), Signal::derive(|| true), None, None);
    let dismiss_ref = use_dismissable_layer(
        None,
        None,
        None,
        None,
        Some(Callback::new(move |()| ctx.open.set(false))),
        Signal::derive(move || !ctx.open.get()),
    );
    let refs = use_composed_refs(vec![node_ref, presence.node_ref, focus_ref, dismiss_ref]);

    view! {
        <Show when=move || presence.is_present.get()>
            <Primitive
                element=html::div
                as_child=as_child
                node_ref=refs
                attr:id=ctx.content_id.clone()
                attr:role="menu"
                attr:data-state=move || if ctx.open.get() { "open" } else { "closed" }
                attr:tabindex="-1"
                attr:style=move || format!("position:fixed;left:{}px;top:{}px;", ctx.position_x.get(), ctx.position_y.get())
                on:keydown=move |event: KeyboardEvent| {
                    if event.key() == "ArrowDown" { event.prevent_default(); focus_menu_item(&event, true); }
                    else if event.key() == "ArrowUp" { event.prevent_default(); focus_menu_item(&event, false); }
                }
            >
                {children.with_value(|c| c())}
            </Primitive>
        </Show>
    }
}

// Simple item components that use ContextMenuContextValue directly
// (cannot re-export from dropdown-menu because it expects MenuContextValue)

#[component]
pub fn ContextMenuItem(
    #[prop(into, optional)] disabled: MaybeProp<bool>,
    #[prop(into, optional)] on_select: Option<Callback<()>>,
    #[prop(into, optional)] as_child: MaybeProp<bool>,
    #[prop(into, optional)] node_ref: AnyNodeRef,
    children: TypedChildrenFn<impl IntoView + 'static>,
) -> impl IntoView {
    let children = StoredValue::new(children.into_inner());
    let ctx = expect_context::<ContextMenuContextValue>();
    let disabled = Signal::derive(move || disabled.get().unwrap_or(false));

    view! {
        <Primitive element=html::div as_child=as_child node_ref=node_ref
            attr:role="menuitem"
            attr:data-disabled=move || disabled.get().then_some("")
            attr:tabindex="-1"
            on:click=move |_| {
                if !disabled.get() {
                    if let Some(cb) = on_select { cb.run(()); }
                    ctx.open.set(false);
                }
            }
            on:keydown=move |event: KeyboardEvent| {
                if matches!(event.key().as_str(), "Enter" | " ") && !disabled.get() {
                    event.prevent_default();
                    if let Some(cb) = on_select { cb.run(()); }
                    ctx.open.set(false);
                }
            }
        >
            {children.with_value(|c| c())}
        </Primitive>
    }
}

#[component]
pub fn ContextMenuSeparator(
    #[prop(into, optional)] as_child: MaybeProp<bool>,
    #[prop(into, optional)] node_ref: AnyNodeRef,
) -> impl IntoView {
    view! {
        <Primitive element=html::div as_child=as_child node_ref=node_ref
            attr:role="separator"
            attr:aria-orientation="horizontal"
        >
            {""}
        </Primitive>
    }
}

#[component]
pub fn ContextMenuLabel(
    #[prop(into, optional)] as_child: MaybeProp<bool>,
    #[prop(into, optional)] node_ref: AnyNodeRef,
    children: TypedChildrenFn<impl IntoView + 'static>,
) -> impl IntoView {
    let children = StoredValue::new(children.into_inner());
    view! {
        <Primitive element=html::div as_child=as_child node_ref=node_ref>
            {children.with_value(|c| c())}
        </Primitive>
    }
}

#[component]
pub fn ContextMenuGroup(
    #[prop(into, optional)] as_child: MaybeProp<bool>,
    #[prop(into, optional)] node_ref: AnyNodeRef,
    children: TypedChildrenFn<impl IntoView + 'static>,
) -> impl IntoView {
    let children = StoredValue::new(children.into_inner());
    view! {
        <Primitive element=html::div as_child=as_child node_ref=node_ref attr:role="group">
            {children.with_value(|c| c())}
        </Primitive>
    }
}

fn focus_menu_item(event: &KeyboardEvent, forward: bool) {
    let Some(container) = event.current_target().and_then(|t| {
        use web_sys::wasm_bindgen::JsCast;
        t.dyn_into::<web_sys::Element>().ok()
    }) else {
        return;
    };
    let Ok(items) = container.query_selector_all("[role='menuitem']:not([data-disabled])") else {
        return;
    };
    let mut nodes = vec![];
    for i in 0..items.length() {
        if let Some(n) = items.item(i) {
            nodes.push(n);
        }
    }
    let active = web_sys::window()
        .and_then(|w| w.document())
        .and_then(|d| d.active_element());
    let idx = active.as_ref().and_then(|a| {
        nodes
            .iter()
            .position(|n| n == <web_sys::Element as AsRef<web_sys::Node>>::as_ref(a))
    });
    let next = if forward {
        idx.map(|i| i + 1).filter(|i| *i < nodes.len()).or(Some(0))
    } else {
        idx.and_then(|i| i.checked_sub(1))
            .or(Some(nodes.len().saturating_sub(1)))
    };
    if let Some(i) = next
        && let Some(n) = nodes.get(i)
    {
        use web_sys::wasm_bindgen::JsCast;
        if let Ok(el) = n.clone().dyn_into::<web_sys::HtmlElement>() {
            let _ = el.focus();
        }
    }
}