Context Menu
A menu of actions revealed by right-clicking an element.
Installation
Included in poetry-ui — available as
poetry_context_menu
the moment you've installed Poetry,
with no per-component step. To own the source and edit it, copy it into your app:
bin/rails g poetry:add context-menu
Default
<%= poetry_context_menu do |menu| %>
<% menu.with_trigger(tag: :div, class: "flex h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed text-sm select-none") { "Right-click here" } %>
<% menu.with_item(inset: true, shortcut: "⌘[") { "Back" } %>
<% menu.with_item(inset: true, disabled: true, shortcut: "⌘]") { "Forward" } %>
<% menu.with_item(inset: true, shortcut: "⌘R") { "Reload" } %>
<% menu.with_sub do |sub| %>
<% sub.with_trigger(inset: true) { "More Tools" } %>
<% sub.with_item(shortcut: "⇧⌘S") { "Save Page As..." } %>
<% sub.with_item { "Create Shortcut..." } %>
<% sub.with_item { "Name Window..." } %>
<% sub.with_separator %>
<% sub.with_item { "Developer Tools" } %>
<% end %>
<% menu.with_separator %>
<% menu.with_checkbox_item(checked: true, close_on_select: false, shortcut: "⌘⇧B") { "Show Bookmarks Bar" } %>
<% menu.with_checkbox_item(checked: false, close_on_select: false) { "Show Full URLs" } %>
<% menu.with_separator %>
<% menu.with_radio_group(value: "pedro") do |group| %>
<% group.with_radio_item(value: "pedro") { "Pedro Duarte" } %>
<% group.with_radio_item(value: "colm") { "Colm Tuite" } %>
<% end %>
<% end %>
Checkboxes
<%# checked: renders the check indicator; close_on_select: false keeps the
menu open so several preferences can be toggled in one visit. %>
<%= poetry_context_menu do |menu| %>
<% menu.with_trigger(tag: :div, class: "flex h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed text-sm select-none") { "Right-click here" } %>
<% menu.with_checkbox_item(checked: true, close_on_select: false) { "Show Bookmarks Bar" } %>
<% menu.with_checkbox_item(close_on_select: false) { "Show Full URLs" } %>
<% menu.with_checkbox_item(checked: true, close_on_select: false) { "Show Developer Tools" } %>
<% end %>
Destructive
<%= poetry_context_menu(label: "File actions") do |menu| %>
<% menu.with_trigger(tag: :div, class: "flex h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed text-sm select-none") { "quarterly-report.pdf" } %>
<% menu.with_item { "Open" } %>
<% menu.with_item { "Rename" } %>
<% menu.with_separator %>
<% menu.with_item(variant: :destructive, shortcut: "⌘⌫") { "Delete" } %>
<% end %>
Disabled surface
<%# disabled: true stands the handlers down — right-clicking shows the browser-native context menu. %>
<%= poetry_context_menu(disabled: true) do |menu| %>
<% menu.with_trigger(tag: :div, class: "flex h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed text-sm select-none") { "Native menu here (disabled)" } %>
<% menu.with_item { "Unreachable" } %>
<% end %>
Focusable surface
<%# focusable_surface: true puts the surface in the tab order — Shift+F10 or
the Menu key opens it (Windows/Linux). macOS has no context-menu key:
there, the path is VoiceOver's VO+Shift+M (Ctrl+Option+Shift+M) on the
focused surface. The browser synthesizes the contextmenu event either
way — poetry binds no key itself. %>
<%= poetry_context_menu(focusable_surface: true, label: "Row actions") do |menu| %>
<% menu.with_trigger(tag: :div, class: "flex h-[150px] w-[300px] flex-col items-center justify-center gap-1 rounded-md border border-dashed text-sm select-none") do %>
<span>Focusable row</span>
<span class="text-xs text-muted-foreground">Shift+F10 · VoiceOver VO+Shift+M</span>
<% end %>
<% menu.with_item { "Rename" } %>
<% menu.with_item { "Duplicate" } %>
<% end %>
Groups
<%# with_label heads a run of items; with_group scopes a set of related
actions, and with_separator rules between the groups. %>
<%= poetry_context_menu(label: "Document actions") do |menu| %>
<% menu.with_trigger(tag: :div, class: "flex h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed text-sm select-none") { "Right-click the document" } %>
<% menu.with_label { "Edit" } %>
<% menu.with_group do |group| %>
<% group.with_item(shortcut: "⌘X") { "Cut" } %>
<% group.with_item(shortcut: "⌘C") { "Copy" } %>
<% group.with_item(shortcut: "⌘V") { "Paste" } %>
<% end %>
<% menu.with_separator %>
<% menu.with_label { "Organize" } %>
<% menu.with_group do |group| %>
<% group.with_item(shortcut: "⌘D") { "Duplicate" } %>
<% group.with_item { "Move to folder..." } %>
<% group.with_item { "Add to collection" } %>
<% end %>
<% end %>
Radio
<%# Each with_radio_group scopes ONE single-select value; a with_label
heads each run. close_on_select: false shows the dot move in place. %>
<%= poetry_context_menu do |menu| %>
<% menu.with_trigger(tag: :div, class: "flex h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed text-sm select-none") { "Right-click here" } %>
<% menu.with_label { "People" } %>
<% menu.with_radio_group(value: "pedro") do |group| %>
<% group.with_radio_item(value: "pedro", close_on_select: false) { "Pedro Duarte" } %>
<% group.with_radio_item(value: "colm", close_on_select: false) { "Colm Tuite" } %>
<% end %>
<% menu.with_separator %>
<% menu.with_label { "Theme" } %>
<% menu.with_radio_group(value: "light") do |group| %>
<% group.with_radio_item(value: "light", close_on_select: false) { "Light" } %>
<% group.with_radio_item(value: "dark", close_on_select: false) { "Dark" } %>
<% group.with_radio_item(value: "system", close_on_select: false) { "System" } %>
<% end %>
<% end %>
Sides
<%# side: picks which side of the pointer the menu opens toward
(default :right). Collisions still flip it near viewport edges. %>
<div class="grid w-full max-w-sm grid-cols-2 gap-4">
<%= poetry_context_menu(side: :top) do |menu| %>
<% menu.with_trigger(tag: :div, class: "flex aspect-video w-full items-center justify-center rounded-md border border-dashed text-sm select-none") { "Right-click (top)" } %>
<% menu.with_item { "Back" } %>
<% menu.with_item { "Forward" } %>
<% menu.with_item { "Reload" } %>
<% end %>
<%= poetry_context_menu(side: :right) do |menu| %>
<% menu.with_trigger(tag: :div, class: "flex aspect-video w-full items-center justify-center rounded-md border border-dashed text-sm select-none") { "Right-click (right)" } %>
<% menu.with_item { "Back" } %>
<% menu.with_item { "Forward" } %>
<% menu.with_item { "Reload" } %>
<% end %>
<%= poetry_context_menu(side: :bottom) do |menu| %>
<% menu.with_trigger(tag: :div, class: "flex aspect-video w-full items-center justify-center rounded-md border border-dashed text-sm select-none") { "Right-click (bottom)" } %>
<% menu.with_item { "Back" } %>
<% menu.with_item { "Forward" } %>
<% menu.with_item { "Reload" } %>
<% end %>
<%= poetry_context_menu(side: :left) do |menu| %>
<% menu.with_trigger(tag: :div, class: "flex aspect-video w-full items-center justify-center rounded-md border border-dashed text-sm select-none") { "Right-click (left)" } %>
<% menu.with_item { "Back" } %>
<% menu.with_item { "Forward" } %>
<% menu.with_item { "Reload" } %>
<% end %>
</div>
Submenu
<%# with_sub nests a full menu level - its own trigger row, items,
separators, and variants - and submenus recurse. %>
<%= poetry_context_menu do |menu| %>
<% menu.with_trigger(tag: :div, class: "flex h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed text-sm select-none") { "Right-click here" } %>
<% menu.with_group do |group| %>
<% group.with_item(shortcut: "⌘C") { "Copy" } %>
<% group.with_item(shortcut: "⌘X") { "Cut" } %>
<% end %>
<% menu.with_sub do |sub| %>
<% sub.with_trigger { "More Tools" } %>
<% sub.with_item { "Save Page..." } %>
<% sub.with_item { "Create Shortcut..." } %>
<% sub.with_item { "Name Window..." } %>
<% sub.with_separator %>
<% sub.with_item { "Developer Tools" } %>
<% sub.with_separator %>
<% sub.with_item(variant: :destructive) { "Delete" } %>
<% end %>
<% end %>
With icon
<%# Item content is a plain block, so a leading poetry_icon sits inline with
the label - the item's flex row aligns and gaps them, and sizes the glyph. %>
<%= poetry_context_menu(label: "Message actions") do |menu| %>
<% menu.with_trigger(tag: :div, class: "flex h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed text-sm select-none") { "Right-click the message" } %>
<% menu.with_item(shortcut: "⌘R") do %>
<%= poetry_icon(name: :reply) %>
Reply
<% end %>
<% menu.with_item(shortcut: "⌘S") do %>
<%= poetry_icon(name: :star) %>
Add star
<% end %>
<% menu.with_item(shortcut: "⌘E") do %>
<%= poetry_icon(name: :archive) %>
Archive
<% end %>
<% menu.with_separator %>
<% menu.with_item(variant: :destructive, shortcut: "⌘⌫") do %>
<%= poetry_icon(name: :"trash-2") %>
Delete
<% end %>
<% end %>
API
Poetry::Ui::ContextMenu::Component — options are
constructor keywords (the poetry_* helper forwards them);
slots are composed inside the block. Generated from the gem's source documentation.
| Option | Type | Details | Description |
|---|---|---|---|
| dir: | Symbol | one of: ltr, rtl; |
Writing-direction override (ltr/rtl) stamped on the root. |
| disabled: | Boolean | defaults to false |
Inerts the surface - no gesture opens the menu. |
| focusable_surface: | Boolean | defaults to false |
Puts the surface in the tab order and advertises Shift+F10. |
| label: | String | The menu's accessible name (localized fallback when omitted). | |
| long_press_delay: | Integer | defaults to 700 |
Touch long-press duration in ms before the menu opens. |
| loop: | Boolean | defaults to false |
Wraps arrow-key movement past either end of the menu. |
| modal: | Boolean | defaults to true |
Traps focus in the open menu; false keeps the page interactive. |
| open: | Boolean | defaults to false |
Server-renders the menu open (rare - context menus normally open from the gesture). |
| side: | Symbol | one of: top, right, bottom, left; defaults to :right |
Which side of the pointer the menu opens toward; collisions may still flip it. |
Slots
| Writer | Description |
|---|---|
| with_trigger | The right-click/long-press SURFACE: wraps arbitrary content (a card, a row, a region); polymorphic tag: (default :span, set tag: :div to wrap block content). NOT a button: no role, no aria-haspopup, no tabindex by default. The inline -webkit-touch-callout suppresses the iOS callout so long-press can run (iOS never fires contextmenu; the timer is the only touch path there). |
Styling
Every part carries a stable data-slot attribute — target
[data-slot=…] from your own CSS to restyle it. State rides
data attributes on the parts below. This contract is verified against rendered DOM in CI.
| Part | Description |
|---|---|
| [data-slot=context-menu] | Root wrapper hosting the context-menu + menu + popper controllers around the surface and content |
| [data-slot=context-menu-trigger] | The right-click/long-press SURFACE wrapping the logical object - not a widget: no role, no aria-haspopup |
| [data-slot=context-menu-content] | The role=menu popup panel - anchored at the pointer via popper's virtual-anchor mode; open state and animation ride here |
| [data-slot=context-menu-group] | role=group semantic grouping between separators |
| [data-slot=context-menu-label] | Non-interactive heading for a run of items |
| [data-slot=context-menu-item] | One role=menuitem action row |
| [data-slot=context-menu-checkbox-item] | A role=menuitemcheckbox toggle row |
| [data-slot=context-menu-radio-group] | role=group scoping one single-select value |
| [data-slot=context-menu-radio-item] | A role=menuitemradio row inside a radio group |
| [data-slot=context-menu-checkbox-item-indicator] | The check glyph inside checkbox items (aria-hidden; the item carries the checked state) |
| [data-slot=context-menu-radio-item-indicator] | The circle glyph inside radio items (aria-hidden; the item carries the checked state) |
| [data-slot=context-menu-separator] | role=separator rule between groups |
| [data-slot=context-menu-shortcut] | The trailing keybinding HINT - aria-hidden, never binds the key |
| [data-slot=context-menu-sub] | A submenu scope - hosts its own popper around the sub trigger/content pair |
| [data-slot=context-menu-sub-trigger] | The role=menuitem row opening its submenu |
| [data-slot=context-menu-sub-content] | The nested role=menu panel - its own popper content on the same presence machinery |
State attributes
| Part | Attribute | Condition | Values |
|---|---|---|---|
| context-menu-trigger | data-popup-open | the menu is open (absence is the closed state - no aria-expanded on a role-less surface) | — |
| context-menu-trigger | data-disabled | the surface is inert (disabled: true) | — |
| context-menu-content | data-open | menu is open (presence flips the pair at runtime) | — |
| context-menu-content | data-closed | menu is closed or animating out (the server-rendered state) | — |
| context-menu-content | data-side | the placement side (the side: option, default right; popper re-writes it after collision flips) | top · right · bottom · left |
| context-menu-content | data-align | the alignment (forced start initially; popper re-resolves it) | start · center · end |
| context-menu-label | data-inset | indented to align with checkbox/radio item text (inset: true) | — |
| context-menu-item | data-variant | default or destructive (the danger treatment) | — |
| context-menu-item | data-inset | indented to align with checkbox/radio item text (inset: true) | — |
| context-menu-item | data-disabled | item is disabled (always written together with aria-disabled) | — |
| context-menu-checkbox-item | data-checked | checked (the controller re-writes the pair with aria-checked on activation) | — |
| context-menu-checkbox-item | data-unchecked | unchecked | — |
| context-menu-checkbox-item | data-disabled | item is disabled (always written together with aria-disabled) | — |
| context-menu-checkbox-item | data-close-on-select | per-item override of the menu's close-on-select default ("false" keeps the menu open) | — |
| context-menu-radio-group | data-value | the selected radio value (the controller re-writes it on change) | — |
| context-menu-radio-item | data-checked | the selected radio (the controller re-writes the pair with aria-checked) | — |
| context-menu-radio-item | data-unchecked | not selected | — |
| context-menu-radio-item | data-value | the radio's value | — |
| context-menu-radio-item | data-disabled | item is disabled (always written together with aria-disabled) | — |
| context-menu-sub-trigger | data-popup-open | its submenu is open (written with aria-expanded; absence is the closed state) | — |
| context-menu-sub-trigger | data-inset | indented to align with checkbox/radio item text (inset: true) | — |
| context-menu-sub-content | data-open | submenu is open (presence flips the pair at runtime) | — |
| context-menu-sub-content | data-closed | submenu is closed (the server-rendered state) | — |
| context-menu-sub-content | data-side | the placement side (right/left by direction; popper resolves it at runtime) | top · right · bottom · left |
| context-menu-sub-content | data-align | the alignment against the sub-trigger (popper resolves it at runtime) | start · center · end |
CSS variables
| Part | Variable | Description |
|---|---|---|
| context-menu-content | --transform-origin | popper's anchor-facing animation origin |
| context-menu-content | --available-width | popper: viewport space left for the panel (post-flip) |
| context-menu-content | --available-height | popper: viewport space left for the panel (post-flip) |
| context-menu-content | --anchor-width | popper: the anchor rect's measured width |
| context-menu-content | --anchor-height | popper: the anchor rect's measured height |
| context-menu-sub-content | --transform-origin | popper's anchor-facing animation origin |
| context-menu-sub-content | --available-width | popper: viewport space left for the panel (post-flip) |
| context-menu-sub-content | --available-height | popper: viewport space left for the panel (post-flip) |
| context-menu-sub-content | --anchor-width | popper: the sub-trigger's measured width |
| context-menu-sub-content | --anchor-height | popper: the sub-trigger's measured height |
Wiring
The Stimulus surface each element carries — declared in the component, verified against rendered DOM in CI. Bare actions fire on the element's default event.
| Element | Controller | Wiring |
|---|---|---|
| root | poetry--core--context-menu | registers · value long_press_delay · value disabled |
| root | poetry--core--menu | registers · value open · value modal · value loop |
| root | poetry--core--popper | registers · value side · value align · value side_offset · value avoid_collisions |
| trigger | poetry--core--context-menu | open on contextmenu · pressStart on pointerdown · pressCancel on pointermove/pointerup/pointercancel |
| trigger | poetry--core--popper | target anchor |
| content | poetry--core--popper | target content |
| item | poetry--core--menu | activate on click |
| sub_trigger | poetry--core--menu | subEnter on pointerenter · subLeave on pointerleave · openSub on click |
| sub_trigger | poetry--core--popper | target anchor |