Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

### Added

- Add `GPUI.UI.Overlay.context_menu/1`: a right click on its content opens its
`:item` slots as a compact menu at the pointer (styled after Zed's context
menus, with keyboard navigation), and `phx-select` receives the chosen value.

## 0.2.0 - 2026-09-08

### Breaking changes
Expand Down
2 changes: 1 addition & 1 deletion apps/gpui/lib/gpui/template.ex
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ defmodule GPUI.Template do
end
end

defp component_builder("ui_" <> name) when name in ~w(popover tooltip dialog dropdown_menu),
defp component_builder("ui_" <> name) when name in ~w(popover tooltip dialog dropdown_menu context_menu),
do: "<GPUI.UI.Overlay.#{name}>"

defp component_builder("ui_" <> name)
Expand Down
34 changes: 34 additions & 0 deletions apps/gpui/test/gpui/template/components_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,40 @@ defmodule GPUI.Template.ComponentsTest do
end
end

test "context menus wrap content and compile item slots after it" do
assert %GPUI.Element{
type: :ui_context_menu,
attrs: attrs,
children: [
%GPUI.Element{type: :div},
%GPUI.Element{type: :ui_dropdown_menu_item, attrs: move},
%GPUI.Element{type: :ui_dropdown_menu_item, attrs: pin}
]
} =
~GPUI"""
<Overlay.context_menu id="row-menu" phx-select="row_action">
<div phx-click="open-row">Row</div>
<:item value="move">Move…</:item>
<:item value="pin" checked={true}>Pinned</:item>
</Overlay.context_menu>
"""

assert attrs[:id] == "row-menu"
assert attrs[:"phx-select"] == "row_action"
assert move[:value] == "move" and move[:label] == "Move…"
assert pin[:checked]

item = %GPUI.Template.Component.Slot{attrs: [value: "same"], children: ["Item"]}

assert_raise ArgumentError, ~r/at least one :item/, fn ->
Overlay.context_menu(%{id: "menu", children: []})
end

assert_raise ArgumentError, ~r/values must be unique/, fn ->
Overlay.context_menu(%{id: "menu", item: [item, item], children: []})
end
end

test "popover uses explicit trigger and content slots" do
assert %GPUI.Element{
type: :ui_popover,
Expand Down
14 changes: 14 additions & 0 deletions apps/gpui_components/lib/gpui/components/schema_declarations.ex
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,20 @@ defmodule GPUI.Components.Schema.Declarations do
stateful: true,
tag: :ui_dropdown_menu
},
%{
__struct__: GPUI.Schema.Component,
attrs: [id: :required_string],
children: true,
events: [select: :"phx-select"],
extension: nil,
kind: :context_menu_component,
public_hidden_attrs: [],
public_required_attrs: [],
public_slots: [item: :one_or_more],
required_events: [],
stateful: false,
tag: :ui_context_menu
},
%{
__struct__: GPUI.Schema.Component,
attrs: [],
Expand Down
42 changes: 42 additions & 0 deletions apps/gpui_components/lib/gpui/ui/overlay.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ defmodule GPUI.UI.Overlay do
tooltip_options: :ui_tooltip,
dialog_options: :ui_dialog,
dropdown_menu_options: :ui_dropdown_menu,
context_menu_options: :ui_context_menu,
popover_options: :ui_popover
)

Expand Down Expand Up @@ -158,6 +159,47 @@ defmodule GPUI.UI.Overlay do
}
end

@doc """
Builds a context menu around its content, with one or more `:item` slots.

A right click on the content opens a compact menu of the items at the
pointer, and `phx-select` receives the chosen item's value. The pointer and
the arrow keys move one highlight; Enter chooses, and Escape or a click
outside closes the menu. Items take the same attributes as `dropdown_menu/1`
items: disabled items can't be chosen, and checked items show a check mark.
Primary clicks still reach the content.

#{ComponentsSchema.component_options_doc(:ui_context_menu)}
"""
@spec context_menu(context_menu_options()) :: Element.t()
def context_menu(assigns) when is_map(assigns) do
assigns =
assigns
|> ComponentsSchema.apply_defaults(:ui_context_menu)
|> ComponentsSchema.validate_component_assigns!(:ui_context_menu, [:item])

items = Map.get(assigns, :item, [])
if items == [], do: raise(ArgumentError, "context_menu requires at least one :item slot")

values = Enum.map(items, &dropdown_menu_item_value!/1)

if Enum.uniq(values) != values do
raise ArgumentError, "context_menu item values must be unique"
end

id = component_id!(assigns, :ui_context_menu)

%Element{
type: :ui_context_menu,
attrs:
assigns
|> Map.drop([:children, :item])
|> Map.put(:id, id)
|> Map.to_list(),
children: Map.get(assigns, :children, []) ++ Enum.map(items, &dropdown_menu_item!/1)
}
end

@doc """
Builds a controlled popover with one `:trigger` and one `:content` slot.

Expand Down
36 changes: 36 additions & 0 deletions apps/gpui_components/native/src/generated/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ pub enum GeneratedComponentKind {
DialogTriggerComponent,
DialogContentComponent,
DropdownMenuComponent,
ContextMenuComponent,
DropdownMenuTriggerComponent,
DropdownMenuItemComponent,
CheckboxComponent,
Expand Down Expand Up @@ -2685,6 +2686,28 @@ pub(crate) fn decode_generated_dropdown_menu_component<'a>(
#[derive(Clone, Debug)]
#[cfg(feature = "real-gpui")]
#[allow(dead_code)]
pub(crate) struct ContextMenuComponentNode {
pub(crate) style: StyleAttrs,
pub(crate) id: String,
pub(crate) children: Vec<ElementNode>,
pub(crate) select: Option<String>,
}
#[cfg(feature = "real-gpui")]
#[allow(clippy::redundant_field_names)]
#[allow(clippy::useless_vec)]
pub(crate) fn decode_generated_context_menu_component<'a>(
term: Term<'a>,
) -> NifResult<ContextMenuComponentNode> {
Ok(ContextMenuComponentNode {
style: decode_style(term)?,
id: component_required_string_attr(term, atoms::id())?,
children: decode_children(term)?,
select: component_string_attr(term, atoms::phx_select())?,
})
}
#[derive(Clone, Debug)]
#[cfg(feature = "real-gpui")]
#[allow(dead_code)]
pub(crate) struct DropdownMenuTriggerComponentNode {
pub(crate) style: StyleAttrs,
pub(crate) children: Vec<ElementNode>,
Expand Down Expand Up @@ -3852,6 +3875,7 @@ pub(crate) enum ElementNode {
DialogTriggerComponent(DialogTriggerComponentNode),
DialogContentComponent(DialogContentComponentNode),
DropdownMenuComponent(DropdownMenuComponentNode),
ContextMenuComponent(ContextMenuComponentNode),
DropdownMenuTriggerComponent(DropdownMenuTriggerComponentNode),
DropdownMenuItemComponent(DropdownMenuItemComponentNode),
CheckboxComponent(CheckboxComponentNode),
Expand Down Expand Up @@ -3917,6 +3941,7 @@ pub enum GeneratedElementTag {
UiDialogTrigger,
UiDialogContent,
UiDropdownMenu,
UiContextMenu,
UiDropdownMenuTrigger,
UiDropdownMenuItem,
UiCheckbox,
Expand Down Expand Up @@ -3981,6 +4006,7 @@ pub fn decode_generated_element_tag(tag: &str) -> GeneratedElementTag {
"ui_dialog_trigger" => GeneratedElementTag::UiDialogTrigger,
"ui_dialog_content" => GeneratedElementTag::UiDialogContent,
"ui_dropdown_menu" => GeneratedElementTag::UiDropdownMenu,
"ui_context_menu" => GeneratedElementTag::UiContextMenu,
"ui_dropdown_menu_trigger" => GeneratedElementTag::UiDropdownMenuTrigger,
"ui_dropdown_menu_item" => GeneratedElementTag::UiDropdownMenuItem,
"ui_checkbox" => GeneratedElementTag::UiCheckbox,
Expand Down Expand Up @@ -4058,6 +4084,9 @@ pub fn generated_component_kind(tag: GeneratedElementTag) -> GeneratedComponentK
GeneratedElementTag::UiDropdownMenu => {
GeneratedComponentKind::DropdownMenuComponent
}
GeneratedElementTag::UiContextMenu => {
GeneratedComponentKind::ContextMenuComponent
}
GeneratedElementTag::UiDropdownMenuTrigger => {
GeneratedComponentKind::DropdownMenuTriggerComponent
}
Expand Down Expand Up @@ -4195,6 +4224,10 @@ pub(crate) fn decode_generated_element_node<'a>(
decode_generated_dropdown_menu_component(term)
.map(|node| ElementNode::DropdownMenuComponent(node))
}
GeneratedComponentKind::ContextMenuComponent => {
decode_generated_context_menu_component(term)
.map(|node| ElementNode::ContextMenuComponent(node))
}
GeneratedComponentKind::DropdownMenuTriggerComponent => {
decode_generated_dropdown_menu_trigger_component(term)
.map(|node| ElementNode::DropdownMenuTriggerComponent(node))
Expand Down Expand Up @@ -4381,6 +4414,9 @@ pub(crate) fn render_generated_component_node(
ElementNode::DropdownMenuComponent(node) => {
element::component::overlay::dropdown::render(node, context)
}
ElementNode::ContextMenuComponent(node) => {
element::component::context_menu::render(node, context)
}
ElementNode::DropdownMenuTriggerComponent(node) => {
element::component::overlay::render_dropdown_menu_trigger(node, context)
}
Expand Down
2 changes: 2 additions & 0 deletions apps/gpui_native/native/src/element/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ pub(crate) mod accordion;
#[cfg(feature = "components")]
pub(crate) mod code_viewer;
#[cfg(feature = "components")]
pub(crate) mod context_menu;
#[cfg(feature = "components")]
pub(crate) mod controls;
#[cfg(feature = "components")]
pub(crate) mod data_table;
Expand Down
Loading