diff --git a/CHANGELOG.md b/CHANGELOG.md index 5920c7b..ce25e44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/gpui/lib/gpui/template.ex b/apps/gpui/lib/gpui/template.ex index 8f2fb34..8853d57 100644 --- a/apps/gpui/lib/gpui/template.ex +++ b/apps/gpui/lib/gpui/template.ex @@ -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: "" defp component_builder("ui_" <> name) diff --git a/apps/gpui/test/gpui/template/components_test.exs b/apps/gpui/test/gpui/template/components_test.exs index fcedeae..2ffda8c 100644 --- a/apps/gpui/test/gpui/template/components_test.exs +++ b/apps/gpui/test/gpui/template/components_test.exs @@ -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""" + +
Row
+ <:item value="move">Move… + <:item value="pin" checked={true}>Pinned +
+ """ + + 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, diff --git a/apps/gpui_components/lib/gpui/components/schema_declarations.ex b/apps/gpui_components/lib/gpui/components/schema_declarations.ex index 69db4a4..a471134 100644 --- a/apps/gpui_components/lib/gpui/components/schema_declarations.ex +++ b/apps/gpui_components/lib/gpui/components/schema_declarations.ex @@ -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: [], diff --git a/apps/gpui_components/lib/gpui/ui/overlay.ex b/apps/gpui_components/lib/gpui/ui/overlay.ex index c9fcca5..dda397b 100644 --- a/apps/gpui_components/lib/gpui/ui/overlay.ex +++ b/apps/gpui_components/lib/gpui/ui/overlay.ex @@ -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 ) @@ -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. diff --git a/apps/gpui_components/native/src/generated/schema.rs b/apps/gpui_components/native/src/generated/schema.rs index bea7448..0e39f95 100644 --- a/apps/gpui_components/native/src/generated/schema.rs +++ b/apps/gpui_components/native/src/generated/schema.rs @@ -25,6 +25,7 @@ pub enum GeneratedComponentKind { DialogTriggerComponent, DialogContentComponent, DropdownMenuComponent, + ContextMenuComponent, DropdownMenuTriggerComponent, DropdownMenuItemComponent, CheckboxComponent, @@ -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, + pub(crate) select: Option, +} +#[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 { + 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, @@ -3852,6 +3875,7 @@ pub(crate) enum ElementNode { DialogTriggerComponent(DialogTriggerComponentNode), DialogContentComponent(DialogContentComponentNode), DropdownMenuComponent(DropdownMenuComponentNode), + ContextMenuComponent(ContextMenuComponentNode), DropdownMenuTriggerComponent(DropdownMenuTriggerComponentNode), DropdownMenuItemComponent(DropdownMenuItemComponentNode), CheckboxComponent(CheckboxComponentNode), @@ -3917,6 +3941,7 @@ pub enum GeneratedElementTag { UiDialogTrigger, UiDialogContent, UiDropdownMenu, + UiContextMenu, UiDropdownMenuTrigger, UiDropdownMenuItem, UiCheckbox, @@ -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, @@ -4058,6 +4084,9 @@ pub fn generated_component_kind(tag: GeneratedElementTag) -> GeneratedComponentK GeneratedElementTag::UiDropdownMenu => { GeneratedComponentKind::DropdownMenuComponent } + GeneratedElementTag::UiContextMenu => { + GeneratedComponentKind::ContextMenuComponent + } GeneratedElementTag::UiDropdownMenuTrigger => { GeneratedComponentKind::DropdownMenuTriggerComponent } @@ -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)) @@ -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) } diff --git a/apps/gpui_native/native/src/element/component.rs b/apps/gpui_native/native/src/element/component.rs index 7cb1c88..d9c25f7 100644 --- a/apps/gpui_native/native/src/element/component.rs +++ b/apps/gpui_native/native/src/element/component.rs @@ -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; diff --git a/apps/gpui_native/native/src/element/component/context_menu.rs b/apps/gpui_native/native/src/element/component/context_menu.rs new file mode 100644 index 0000000..ba86188 --- /dev/null +++ b/apps/gpui_native/native/src/element/component/context_menu.rs @@ -0,0 +1,378 @@ +use crate::{gpui, ContextMenuComponentNode, ElementRenderContext}; + +/// A secondary click on the content opens a menu at the pointer, drawn after +/// Zed's context menus: compact rows, a light shadow, and one highlight that +/// follows both the pointer and the arrow keys. Choosing an item reports its +/// value through `phx-select`. Primary clicks still reach the content. +#[cfg(feature = "components")] +pub(crate) fn render( + node: ContextMenuComponentNode, + context: &mut ElementRenderContext<'_, '_>, +) -> gpui::AnyElement { + use crate::element::apply_generated_render_styles; + use crate::{push_event, ElementNode, EventValue, InputKind, NativeEvent}; + use gpui::prelude::FluentBuilder; + use gpui::{ + anchored, deferred, div, hsla, point, px, BoxShadow, InteractiveElement, IntoElement, + MouseButton, ParentElement, Role, StatefulInteractiveElement, Styled, + }; + use gpui_component::{ActiveTheme, Icon, IconName}; + use std::rc::Rc; + + let mut items = Vec::new(); + let mut content = Vec::new(); + for child in node.children { + match child { + ElementNode::DropdownMenuItemComponent(item) => items.push(item), + other => content.push(other.render(context)), + } + } + + let menu = + context + .window + .use_keyed_state(format!("{}-context-menu", node.id), context.cx, |_, cx| { + MenuState { + position: None, + highlighted: None, + focus: cx.focus_handle(), + } + }); + let MenuState { + position, + highlighted, + focus, + } = menu.read(context.cx).clone(); + + let runtime = context.runtime.clone(); + let window_id = context.window_id; + let select = node.select; + let values = items + .iter() + .map(|item| item.value.clone()) + .collect::>(); + let enabled = items.iter().map(|item| !item.disabled).collect::>(); + + // Choosing closes the menu before reporting the value. + let choose: Rc = { + let menu = menu.clone(); + Rc::new(move |index, cx| { + menu.update(cx, |menu, cx| menu.close(cx)); + if let Some(event) = select.clone() { + let _ = push_event( + &runtime, + NativeEvent::Input { + kind: InputKind::Select, + window_id, + event, + value: Some(EventValue::String(values[index].clone())), + }, + ); + } + }) + }; + + let mut element = apply_generated_render_styles(div(), node.style) + .id(node.id.clone()) + .children(content) + .on_mouse_down(MouseButton::Right, { + let menu = menu.clone(); + let focus = focus.clone(); + move |event, window, cx| { + menu.update(cx, |menu, cx| { + menu.position = Some(event.position); + menu.highlighted = None; + cx.notify(); + }); + focus.focus(window, cx); + cx.stop_propagation(); + } + }); + + if let Some(position) = position { + let theme = context.cx.theme(); + let highlight = theme.secondary_hover; + let any_checked = items.iter().any(|item| item.checked); + + let rows = items + .into_iter() + .enumerate() + .map(|(index, item)| { + let row = div() + .id(("context-menu-item", index)) + .role(Role::MenuItem) + .flex() + .items_center() + .gap(px(6.)) + .h(px(22.)) + .px(px(6.)) + .rounded(px(4.)) + .when(any_checked, |row| { + row.child(div().w(px(14.)).when(item.checked, |slot| { + slot.child(Icon::new(IconName::Check).size(px(14.))) + })) + }) + .child(item.label); + + let row = if item.disabled { + row.text_color(theme.muted_foreground) + } else { + let menu = menu.clone(); + let choose = choose.clone(); + row.when(highlighted == Some(index), |row| row.bg(highlight)) + .on_hover(move |hovered, _window, cx| { + menu.update(cx, |menu, cx| { + if *hovered && menu.highlighted != Some(index) { + menu.highlighted = Some(index); + cx.notify(); + } + }); + }) + .on_click(move |_event, _window, cx| choose(index, cx)) + }; + + div().px(px(4.)).child(row) + }) + .collect::>(); + + let panel = div() + .id(format!("{}-context-menu-panel", node.id)) + .role(Role::Menu) + .track_focus(&focus) + .occlude() + .flex() + .flex_col() + .min_w(px(180.)) + .py(px(4.)) + .bg(theme.popover) + .text_color(theme.popover_foreground) + .text_size(px(13.)) + .line_height(px(20.)) + .border_1() + .border_color(theme.border) + .rounded(px(8.)) + .shadow(vec![ + BoxShadow { + color: hsla(0., 0., 0., 0.12), + offset: point(px(0.), px(2.)), + blur_radius: px(3.), + spread_radius: px(0.), + inset: false, + }, + BoxShadow { + color: hsla(0., 0., 0., 0.04), + offset: point(px(0.), px(1.)), + blur_radius: px(0.), + spread_radius: px(0.), + inset: false, + }, + ]) + .on_mouse_down_out({ + let menu = menu.clone(); + move |_event, _window, cx| menu.update(cx, |menu, cx| menu.close(cx)) + }) + .on_key_down({ + let menu = menu.clone(); + move |event, _window, cx| { + let key = event.keystroke.key.as_str(); + let highlighted = menu.read(cx).highlighted; + match key { + "escape" => menu.update(cx, |menu, cx| menu.close(cx)), + "enter" | "space" => { + if let Some(index) = highlighted { + choose(index, cx); + } + } + "up" | "down" => { + let next = step(&enabled, highlighted, key == "down"); + menu.update(cx, |menu, cx| { + menu.highlighted = next; + cx.notify(); + }); + } + _ => return, + } + cx.stop_propagation(); + } + }) + .children(rows); + + element = element.child( + deferred( + anchored() + .position(position) + .snap_to_window_with_margin(px(8.)) + .child(panel), + ) + .with_priority(1), + ); + } + + element.into_any_element() +} + +#[cfg(feature = "components")] +#[derive(Clone)] +struct MenuState { + position: Option>, + highlighted: Option, + focus: gpui::FocusHandle, +} + +#[cfg(feature = "components")] +impl MenuState { + fn close(&mut self, cx: &mut gpui::Context) { + if self.position.take().is_some() { + self.highlighted = None; + cx.notify(); + } + } +} + +/// The next enabled item from `current`, wrapping around; the first (or last) +/// enabled item when nothing is highlighted yet. +#[cfg(feature = "components")] +fn step(enabled: &[bool], current: Option, forward: bool) -> Option { + let count = enabled.len(); + let start = match (current, forward) { + (Some(index), true) => index + 1, + (Some(index), false) => index + count - 1, + (None, true) => 0, + (None, false) => count.checked_sub(1)?, + }; + (0..count) + .map(|offset| { + if forward { + (start + offset) % count + } else { + (start + count - offset) % count + } + }) + .find(|&index| enabled[index]) +} + +#[cfg(not(feature = "components"))] +pub(crate) fn render( + node: ContextMenuComponentNode, + context: &mut ElementRenderContext<'_, '_>, +) -> gpui::AnyElement { + super::render_component_fallback(node.style, None, node.children, context) +} + +#[cfg(all(test, feature = "components"))] +mod tests { + use crate::gpui; + use gpui::{point, px, size, IntoElement, Modifiers, MouseButton, VisualTestContext}; + + fn row_with_menu() -> crate::ElementNode { + let item = |value: &str, disabled| { + crate::ElementNode::DropdownMenuItemComponent(crate::DropdownMenuItemComponentNode { + style: crate::StyleAttrs::default(), + value: value.to_string(), + label: value.to_string(), + disabled, + checked: false, + }) + }; + let mut style = crate::StyleAttrs::default(); + style.width = Some(px(200.0).into()); + style.height = Some(px(40.0).into()); + crate::ElementNode::ContextMenuComponent(crate::ContextMenuComponentNode { + style, + id: "row".to_string(), + children: vec![item("move", false), item("off", true), item("pin", false)], + select: Some("picked".to_string()), + }) + } + + fn open(cx: &mut gpui::TestAppContext) -> (crate::SharedRuntime, &mut VisualTestContext) { + cx.update(gpui_component::init); + let runtime = std::sync::Arc::new(crate::runtime::RuntimeState::new()); + let state = std::sync::Arc::new(crate::WindowState::new(row_with_menu(), Vec::new())); + let runtime_for_view = runtime.clone(); + let (view, cx) = cx.add_window_view(move |_window, _cx| { + crate::ElixirRoot::new(state, runtime_for_view, 7, false, false) + }); + cx.draw( + point(px(0.0), px(0.0)), + size(px(400.0), px(300.0)), + |_window, _cx| view.clone().into_any_element(), + ); + let at = point(px(20.0), px(20.0)); + cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::default()); + cx.simulate_mouse_up(at, MouseButton::Right, Modifiers::default()); + redraw(cx); + (runtime, cx) + } + + fn redraw(cx: &mut VisualTestContext) { + cx.update(|window, cx| window.draw(cx).clear(cx)); + } + + // Rows start below the panel's border and padding, 22 pixels apart. + fn item(index: usize) -> gpui::Point { + point(px(60.0), px(20.0 + 5.0 + 11.0 + 22.0 * index as f32)) + } + + fn picked(runtime: &crate::SharedRuntime) -> Vec { + runtime + .events + .lock() + .unwrap() + .iter() + .filter_map(|event| match event { + crate::NativeEvent::Input { + event, + value: Some(crate::EventValue::String(value)), + .. + } if event == "picked" => Some(value.clone()), + _ => None, + }) + .collect() + } + + #[gpui::test] + fn right_click_opens_the_menu_at_the_pointer_and_a_click_chooses( + cx: &mut gpui::TestAppContext, + ) { + let (runtime, cx) = open(cx); + cx.simulate_click(item(2), Modifiers::default()); + assert_eq!(picked(&runtime), vec!["pin"]); + + // Choosing closed the menu. + redraw(cx); + cx.simulate_click(item(0), Modifiers::default()); + assert_eq!(picked(&runtime), vec!["pin"]); + } + + #[gpui::test] + fn disabled_items_ignore_clicks_and_arrow_keys(cx: &mut gpui::TestAppContext) { + let (runtime, cx) = open(cx); + cx.simulate_click(item(1), Modifiers::default()); + assert!(picked(&runtime).is_empty()); + + cx.simulate_keystrokes("down down enter"); + assert_eq!(picked(&runtime), vec!["pin"]); + } + + #[gpui::test] + fn escape_closes_without_choosing(cx: &mut gpui::TestAppContext) { + let (runtime, cx) = open(cx); + cx.simulate_keystrokes("down escape"); + redraw(cx); + cx.simulate_click(item(0), Modifiers::default()); + assert!(picked(&runtime).is_empty()); + } + + #[test] + fn steps_wrap_and_skip_disabled_items() { + let enabled = [true, false, true]; + assert_eq!(super::step(&enabled, None, true), Some(0)); + assert_eq!(super::step(&enabled, None, false), Some(2)); + assert_eq!(super::step(&enabled, Some(0), true), Some(2)); + assert_eq!(super::step(&enabled, Some(2), true), Some(0)); + assert_eq!(super::step(&enabled, Some(0), false), Some(2)); + assert_eq!(super::step(&[false], None, true), None); + assert_eq!(super::step(&[], None, true), None); + } +} diff --git a/apps/gpui_native/native/src/generated/schema.rs b/apps/gpui_native/native/src/generated/schema.rs index bea7448..0e39f95 100644 --- a/apps/gpui_native/native/src/generated/schema.rs +++ b/apps/gpui_native/native/src/generated/schema.rs @@ -25,6 +25,7 @@ pub enum GeneratedComponentKind { DialogTriggerComponent, DialogContentComponent, DropdownMenuComponent, + ContextMenuComponent, DropdownMenuTriggerComponent, DropdownMenuItemComponent, CheckboxComponent, @@ -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, + pub(crate) select: Option, +} +#[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 { + 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, @@ -3852,6 +3875,7 @@ pub(crate) enum ElementNode { DialogTriggerComponent(DialogTriggerComponentNode), DialogContentComponent(DialogContentComponentNode), DropdownMenuComponent(DropdownMenuComponentNode), + ContextMenuComponent(ContextMenuComponentNode), DropdownMenuTriggerComponent(DropdownMenuTriggerComponentNode), DropdownMenuItemComponent(DropdownMenuItemComponentNode), CheckboxComponent(CheckboxComponentNode), @@ -3917,6 +3941,7 @@ pub enum GeneratedElementTag { UiDialogTrigger, UiDialogContent, UiDropdownMenu, + UiContextMenu, UiDropdownMenuTrigger, UiDropdownMenuItem, UiCheckbox, @@ -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, @@ -4058,6 +4084,9 @@ pub fn generated_component_kind(tag: GeneratedElementTag) -> GeneratedComponentK GeneratedElementTag::UiDropdownMenu => { GeneratedComponentKind::DropdownMenuComponent } + GeneratedElementTag::UiContextMenu => { + GeneratedComponentKind::ContextMenuComponent + } GeneratedElementTag::UiDropdownMenuTrigger => { GeneratedComponentKind::DropdownMenuTriggerComponent } @@ -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)) @@ -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) }