a context menu

open a small menu exactly where the user clicked, then dismiss it

by the end of this guide you will have a panel that opens a context menu at the exact pixel the user clicked. the menu names the button used, shows the click coordinates, and closes when the user clicks anywhere outside it. along the way you will learn how mouse events carry cursor position, how PositionIn converts that position into the right coordinate frame, and how a nullable field and a single Rebuild() call toggle the menu on and off.

hold the menu state

the menu is either open or closed. two nullable fields describe that: a position and the button that opened it. when both are null the menu is absent; when they are set the menu renders at that spot.

public class ContextMenuUI : GooPanel<Container>
{
    Vector2?      _menuAt;
    MouseButtons? _menuBtn;

    protected override Container Build()
    {
        return new Container
        {
            Position        = PositionMode.Relative,
            Width           = 400,
            Height          = 300,
            BackgroundColor = Color.White,
        };
    }
}

Position = PositionMode.Relative on the root is load-bearing. absolutely-positioned children are placed relative to their nearest positioned ancestor, so without it the menu would escape this container and land somewhere unexpected.

capture the click

add OnClick to the root container. the lambda receives a MousePanelEvent; call e.PositionIn(e.Target.Parent) to translate the cursor from the Target frame into the root's own frame, then store the result and rebuild.

protected override Container Build()
{
    var root = new Container
    {
        Position        = PositionMode.Relative,
        Width           = 400,
        Height          = 300,
        BackgroundColor = Color.White,
        OnClick = e =>
        {
            _menuAt  = e.PositionIn( e.Target.Parent );
            _menuBtn = e.MouseButton;
            Rebuild();
        },
    };

    return root;
}

e.Target.Parent is the root, which is the positioning ancestor for the menu. passing it to PositionIn converts the cursor coordinates into that frame.

render the menu

add a Menu helper method and call it from Build() when _menuAt is set. the menu is an absolutely-positioned column; Left and Top are set directly from the stored position.

protected override Container Build()
{
    var root = new Container
    {
        Position        = PositionMode.Relative,
        Width           = 400,
        Height          = 300,
        BackgroundColor = Color.White,
        OnClick = e =>
        {
            _menuAt  = e.PositionIn( e.Target.Parent );
            _menuBtn = e.MouseButton;
            Rebuild();
        },
    };

    if ( _menuAt is { } pos )
        root.Children.Add( Menu( pos, _menuBtn ) );

    return root;
}

Container Menu( Vector2 pos, MouseButtons? btn ) => new Container
{
    Key             = "menu",
    Position        = PositionMode.Absolute,
    Left            = pos.x,
    Top             = pos.y,
    Padding         = 12,
    BackgroundColor = Color.FromBytes( 240, 240, 240 ),
    BorderWidth     = Length.Pixels( 1 ),
    BorderColor     = Color.Black,
    FlexDirection   = FlexDirection.Column,
    Gap             = 8,
    SwallowClick    = true,
    Children =
    {
        new Text( $"clicked: {btn} at ({pos.x:F0}, {pos.y:F0})" ),
    },
};

the if ( _menuAt is { } pos ) pattern unpacks the nullable in one step: when _menuAt is null the menu is absent; when set, pos holds the unwrapped Vector2 and the menu appears at that spot.

SwallowClick = true stops the click from bubbling up to the root's OnClick. see events for how swallow and bubble interact.

dismiss on click outside

for "click outside to close" without reopening, add a dedicated backdrop container that clears state and sits behind the menu:

protected override Container Build()
{
    var root = new Container
    {
        Position        = PositionMode.Relative,
        Width           = 400,
        Height          = 300,
        BackgroundColor = Color.White,
    };

    if ( _menuAt is { } pos )
    {
        // full-size scrim catches outside clicks and closes the menu.
        root.Children.Add( new Container
        {
            Key             = "scrim",
            Position        = PositionMode.Absolute,
            Width           = Length.Percent( 100 ),
            Height          = Length.Percent( 100 ),
            OnClick = _ =>
            {
                _menuAt  = null;
                _menuBtn = null;
                Rebuild();
            },
        } );

        root.Children.Add( Menu( pos, _menuBtn ) );
    }
    else
    {
        // no scrim: clicks reach the root to open the menu.
        root.OnClick = e =>
        {
            _menuAt  = e.PositionIn( e.Target.Parent );
            _menuBtn = e.MouseButton;
            Rebuild();
        };
    }

    return root;
}

when the menu is closed, the root carries the open handler directly. when it is open, a full-size absolute scrim sits below the menu and owns the dismiss click; the root's own handler is absent. the scrim's OnClick sets both fields to null and calls Rebuild(), which drops the scrim and the menu from the next tree.

the complete class

here is everything together:

public class ContextMenuUI : GooPanel<Container>
{
    Vector2?      _menuAt;
    MouseButtons? _menuBtn;

    protected override Container Build()
    {
        var root = new Container
        {
            Position        = PositionMode.Relative,
            Width           = 400,
            Height          = 300,
            BackgroundColor = Color.White,
        };

        if ( _menuAt is { } pos )
        {
            root.Children.Add( new Container
            {
                Key             = "scrim",
                Position        = PositionMode.Absolute,
                Width           = Length.Percent( 100 ),
                Height          = Length.Percent( 100 ),
                OnClick = _ =>
                {
                    _menuAt  = null;
                    _menuBtn = null;
                    Rebuild();
                },
            } );

            root.Children.Add( Menu( pos, _menuBtn ) );
        }
        else
        {
            root.OnClick = e =>
            {
                _menuAt  = e.PositionIn( e.Target.Parent );
                _menuBtn = e.MouseButton;
                Rebuild();
            };
        }

        return root;
    }

    Container Menu( Vector2 pos, MouseButtons? btn ) => new Container
    {
        Key             = "menu",
        Position        = PositionMode.Absolute,
        Left            = pos.x,
        Top             = pos.y,
        Padding         = 12,
        BackgroundColor = Color.FromBytes( 240, 240, 240 ),
        BorderWidth     = Length.Pixels( 1 ),
        BorderColor     = Color.Black,
        FlexDirection   = FlexDirection.Column,
        Gap             = 8,
        SwallowClick    = true,
        Children =
        {
            new Text( $"clicked: {btn} at ({pos.x:F0}, {pos.y:F0})" ),
        },
    };
}

press play, click anywhere on the white card, and the menu appears under the cursor naming the button and coordinates. click outside the menu and it closes.

what just happened

three ideas carried this whole build:

the events reference covers SwallowClick and the bubble-trap problem in full.

see also