Dropdown Menu
A menu of actions or options triggered by a button.
Installation
Included in poetry-ui — available as
poetry_dropdown_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 dropdown-menu
Default
<%= poetry_dropdown_menu(align: :start) do |menu| %>
<% menu.with_trigger(variant: :outline) { "Open" } %>
<% menu.with_label { "My Account" } %>
<% menu.with_group do |group| %>
<% group.with_item(shortcut: "⇧⌘P") { "Profile" } %>
<% group.with_item(shortcut: "⌘B") { "Billing" } %>
<% group.with_item(shortcut: "⌘S") { "Settings" } %>
<% end %>
<% menu.with_separator %>
<% menu.with_sub do |sub| %>
<% sub.with_trigger { "Invite users" } %>
<% sub.with_item { "Email" } %>
<% sub.with_item { "Message" } %>
<% sub.with_sub do |nested| %>
<% nested.with_trigger { "More..." } %>
<% nested.with_item { "Import from CSV" } %>
<% end %>
<% end %>
<% menu.with_separator %>
<% menu.with_item(disabled: true) { "API" } %>
<% menu.with_item(variant: :destructive, shortcut: "⇧⌘Q") { "Log out" } %>
<% end %>
Avatar
<%# The avatar composes into the trigger; an icon-sized Button needs label: for its
accessible name. (Same inline SVG stand-in photo as the Avatar page.) %>
<% image = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIxIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjNjM2NmYxIi8+PHN0b3Agb2Zmc2V0PSIuNTUiIHN0b3AtY29sb3I9IiM4YjVjZjYiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNlYzQ4OTkiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIGZpbGw9InVybCgjZykiLz48Y2lyY2xlIGN4PSIzMiIgY3k9IjI1IiByPSIxMCIgZmlsbD0iI2ZmZiIgb3BhY2l0eT0iLjkiLz48cGF0aCBkPSJNMTIgNjRjMi0xMyAxMC0xOSAyMC0xOXMxOCA2IDIwIDE5eiIgZmlsbD0iI2ZmZiIgb3BhY2l0eT0iLjkiLz48L3N2Zz4=" %>
<%= poetry_dropdown_menu(align: :end) do |menu| %>
<% menu.with_trigger(variant: :ghost, size: :icon, label: "Open account menu", class: "rounded-full") do %>
<%= poetry_avatar(src: image, label: "Nadia Whitfield") { "NW" } %>
<% end %>
<% menu.with_group do |group| %>
<% group.with_item do %><%= poetry_icon(name: :"badge-check") %>Account<% end %>
<% group.with_item do %><%= poetry_icon(name: :"credit-card") %>Billing<% end %>
<% group.with_item do %><%= poetry_icon(name: :bell) %>Notifications<% end %>
<% end %>
<% menu.with_separator %>
<% menu.with_item do %><%= poetry_icon(name: :"log-out") %>Sign out<% end %>
<% end %>
Checkbox with icons
<%# Checkbox items keep their left-side check indicator; a labeled icon follows it. %>
<%= poetry_dropdown_menu(align: :start) do |menu| %>
<% menu.with_trigger(variant: :outline) { "Notifications" } %>
<% menu.with_label { "Notify me about" } %>
<% menu.with_separator %>
<% menu.with_checkbox_item(checked: true, close_on_select: false) do %><%= poetry_icon(name: :inbox) %>Mentions<% end %>
<% menu.with_checkbox_item(checked: false, close_on_select: false) do %><%= poetry_icon(name: :calendar) %>Reminders<% end %>
<% menu.with_checkbox_item(checked: true, close_on_select: false) do %><%= poetry_icon(name: :star) %>Highlights<% end %>
<% end %>
Checkboxes
<%# close_on_select: false keeps the menu open while toggling. %>
<%= poetry_dropdown_menu(align: :start) do |menu| %>
<% menu.with_trigger(variant: :outline) { "View" } %>
<% menu.with_label { "Appearance" } %>
<% menu.with_separator %>
<% menu.with_checkbox_item(checked: true, close_on_select: false) { "Status Bar" } %>
<% menu.with_checkbox_item(checked: false, close_on_select: false, disabled: true) { "Activity Bar" } %>
<% menu.with_checkbox_item(checked: false, close_on_select: false) { "Panel" } %>
<% end %>
Complex
<%# Every menu surface in one place: labeled groups, shortcuts, checkbox toggles,
a radio theme picker, and submenus nested two deep. close_on_select: false
keeps toggle rows from dismissing the menu. %>
<%= poetry_dropdown_menu(align: :start) do |menu| %>
<% menu.with_trigger(variant: :outline) { "Complex Menu" } %>
<% menu.with_group do |file_menu| %>
<% file_menu.with_label { "File" } %>
<% file_menu.with_item(shortcut: "⌘N") do %><%= poetry_icon(name: :file) %>New File<% end %>
<% file_menu.with_item(shortcut: "⇧⌘N") do %><%= poetry_icon(name: :folder) %>New Folder<% end %>
<% file_menu.with_sub do |recent| %>
<% recent.with_trigger do %><%= poetry_icon(name: :"folder-open") %>Open Recent<% end %>
<% recent.with_label { "Recent Projects" } %>
<% recent.with_item do %><%= poetry_icon(name: :"file-code") %>Project Alpha<% end %>
<% recent.with_item do %><%= poetry_icon(name: :"file-code") %>Project Beta<% end %>
<% recent.with_sub do |more| %>
<% more.with_trigger do %><%= poetry_icon(name: :ellipsis) %>More Projects<% end %>
<% more.with_item do %><%= poetry_icon(name: :"file-code") %>Project Gamma<% end %>
<% more.with_item do %><%= poetry_icon(name: :"file-code") %>Project Delta<% end %>
<% end %>
<% recent.with_separator %>
<% recent.with_item do %><%= poetry_icon(name: :"folder-search") %>Browse...<% end %>
<% end %>
<% file_menu.with_separator %>
<% file_menu.with_item(shortcut: "⌘S") do %><%= poetry_icon(name: :save) %>Save<% end %>
<% file_menu.with_item(shortcut: "⇧⌘E") do %><%= poetry_icon(name: :download) %>Export<% end %>
<% end %>
<% menu.with_separator %>
<% menu.with_group do |view_menu| %>
<% view_menu.with_label { "View" } %>
<% view_menu.with_checkbox_item(checked: true, close_on_select: false) do %><%= poetry_icon(name: :eye) %>Show Sidebar<% end %>
<% view_menu.with_checkbox_item(checked: false, close_on_select: false) do %><%= poetry_icon(name: :"panels-top-left") %>Show Status Bar<% end %>
<% view_menu.with_sub do |theme| %>
<% theme.with_trigger do %><%= poetry_icon(name: :palette) %>Theme<% end %>
<% theme.with_label { "Appearance" } %>
<% theme.with_radio_group(value: "light") do |appearance| %>
<% appearance.with_radio_item(value: "light") do %><%= poetry_icon(name: :sun) %>Light<% end %>
<% appearance.with_radio_item(value: "dark") do %><%= poetry_icon(name: :moon) %>Dark<% end %>
<% appearance.with_radio_item(value: "system") do %><%= poetry_icon(name: :monitor) %>System<% end %>
<% end %>
<% end %>
<% end %>
<% menu.with_separator %>
<% menu.with_group do |account| %>
<% account.with_label { "Account" } %>
<% account.with_item(shortcut: "⇧⌘P") do %><%= poetry_icon(name: :user) %>Profile<% end %>
<% account.with_item do %><%= poetry_icon(name: :"credit-card") %>Billing<% end %>
<% account.with_sub do |settings| %>
<% settings.with_trigger do %><%= poetry_icon(name: :settings) %>Settings<% end %>
<% settings.with_label { "Preferences" } %>
<% settings.with_item do %><%= poetry_icon(name: :keyboard) %>Keyboard Shortcuts<% end %>
<% settings.with_item do %><%= poetry_icon(name: :languages) %>Language<% end %>
<% settings.with_sub do |alerts| %>
<% alerts.with_trigger do %><%= poetry_icon(name: :bell) %>Notifications<% end %>
<% alerts.with_label { "Notification Types" } %>
<% alerts.with_checkbox_item(checked: true, close_on_select: false) do %><%= poetry_icon(name: :bell) %>Push Notifications<% end %>
<% alerts.with_checkbox_item(checked: true, close_on_select: false) do %><%= poetry_icon(name: :mail) %>Email Notifications<% end %>
<% end %>
<% settings.with_separator %>
<% settings.with_item do %><%= poetry_icon(name: :shield) %>Privacy & Security<% end %>
<% end %>
<% end %>
<% menu.with_separator %>
<% menu.with_group do |help| %>
<% help.with_item do %><%= poetry_icon(name: :"circle-question-mark") %>Help & Support<% end %>
<% help.with_item do %><%= poetry_icon(name: :"file-text") %>Documentation<% end %>
<% end %>
<% menu.with_separator %>
<% menu.with_item(variant: :destructive, shortcut: "⇧⌘Q") do %><%= poetry_icon(name: :"log-out") %>Sign Out<% end %>
<% end %>
Inset and disabled
<%# inset: true aligns flush items with the checkbox/radio gutter. %>
<%= poetry_dropdown_menu do |menu| %>
<% menu.with_trigger(variant: :outline) { "Edit" } %>
<% menu.with_label(inset: true) { "Actions" } %>
<% menu.with_item(inset: true) { "Undo" } %>
<% menu.with_item(inset: true, disabled: true) { "Redo" } %>
<% menu.with_separator %>
<% menu.with_checkbox_item(checked: true, close_on_select: false) { "Autosave" } %>
<% end %>
Radio group
<%# One checked value per radio group; values must be unique. %>
<%= poetry_dropdown_menu(align: :start) do |menu| %>
<% menu.with_trigger(variant: :outline) { "Open" } %>
<% menu.with_label { "Panel Position" } %>
<% menu.with_separator %>
<% menu.with_radio_group(value: "bottom") do |group| %>
<% group.with_radio_item(value: "top") { "Top" } %>
<% group.with_radio_item(value: "bottom") { "Bottom" } %>
<% group.with_radio_item(value: "right") { "Right" } %>
<% end %>
<% end %>
Radio with icons
<%# Radio items carry a leading icon; the single-select circle indicator stays on the left. %>
<%= poetry_dropdown_menu(align: :start) do |menu| %>
<% menu.with_trigger(variant: :outline) { "Sort by" } %>
<% menu.with_label { "Sort order" } %>
<% menu.with_separator %>
<% menu.with_radio_group(value: "recent") do |group| %>
<% group.with_radio_item(value: "recent") do %><%= poetry_icon(name: :calendar) %>Most recent<% end %>
<% group.with_radio_item(value: "name") do %><%= poetry_icon(name: :"arrow-right") %>Name<% end %>
<% group.with_radio_item(value: "size") do %><%= poetry_icon(name: :file) %>File size<% end %>
<% end %>
<% end %>
With icons
<%# A leading icon sits inside the item block; the theme layer sets the svg size and gap. %>
<%= poetry_dropdown_menu(align: :start) do |menu| %>
<% menu.with_trigger(variant: :outline) { "Actions" } %>
<% menu.with_label { "Document" } %>
<% menu.with_separator %>
<% menu.with_group do |group| %>
<% group.with_item(shortcut: "⌘C") do %><%= poetry_icon(name: :copy) %>Duplicate<% end %>
<% group.with_item(shortcut: "⌘D") do %><%= poetry_icon(name: :download) %>Download<% end %>
<% group.with_item do %><%= poetry_icon(name: :bookmark) %>Add to favorites<% end %>
<% end %>
<% menu.with_separator %>
<% menu.with_item(variant: :destructive, shortcut: "⌫") do %><%= poetry_icon(name: :"trash-2") %>Delete<% end %>
<% end %>
API
Poetry::Ui::DropdownMenu::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 |
|---|---|---|---|
| align: | Symbol | one of: start, center, end; defaults to :center |
The menu's alignment against the trigger's edge. |
| align_offset: | Integer | defaults to 0 |
Pixel shift along the alignment edge. |
| avoid_collisions: | Boolean | defaults to true |
Flips/shifts placement to keep the menu inside the viewport. |
| dir: | Symbol | one of: ltr, rtl; |
Reading direction; :rtl flips submenu sides and indicators. |
| disabled: | Boolean | defaults to false |
Disables the menu trigger button. |
| loop: | Boolean | defaults to false |
Arrow-key navigation wraps from the last item back to the first. |
| modal: | Boolean | defaults to true |
While open, pointer interaction outside the menu is blocked; false keeps the rest of the page interactive. |
| open: | Boolean | defaults to false |
Renders the menu already open on page load. |
| side: | Symbol | one of: top, right, bottom, left; defaults to :bottom |
Which side of the trigger the menu opens on (flips on collision). |
| side_offset: | Integer | defaults to 4 |
Gap in pixels between the trigger and the menu. |
Slots
| Writer | Description |
|---|---|
| with_trigger | The menu button - a poetry Button (options forward to it, e.g. variant: :outline). The slot owns the aria-haspopup/expanded/ controls wiring regardless of the composed content, so composition cannot drop the aria. |
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=dropdown-menu] | Root wrapper hosting the menu + popper controllers around the trigger and content |
| [data-slot=dropdown-menu-content] | The role=menu popup panel - positioning, animation, and the open state ride here |
| [data-slot=dropdown-menu-group] | role=group semantic grouping between separators |
| [data-slot=dropdown-menu-label] | Non-interactive heading for a run of items |
| [data-slot=dropdown-menu-item] | One role=menuitem action row |
| [data-slot=dropdown-menu-checkbox-item] | A role=menuitemcheckbox toggle row |
| [data-slot=dropdown-menu-radio-group] | role=group scoping one single-select value |
| [data-slot=dropdown-menu-radio-item] | A role=menuitemradio row inside a radio group |
| [data-slot=dropdown-menu-checkbox-item-indicator] | The check glyph inside checkbox items (aria-hidden; the item carries the checked state) |
| [data-slot=dropdown-menu-radio-item-indicator] | The circle glyph inside radio items (aria-hidden; the item carries the checked state) |
| [data-slot=dropdown-menu-separator] | role=separator rule between groups |
| [data-slot=dropdown-menu-shortcut] | The trailing keybinding HINT - aria-hidden, never binds the key |
| [data-slot=dropdown-menu-sub] | A submenu scope - hosts its own popper around the sub trigger/content pair |
| [data-slot=dropdown-menu-sub-trigger] | The role=menuitem row opening its submenu |
| [data-slot=dropdown-menu-sub-content] | The nested role=menu panel - its own popper content on the same presence machinery |
State attributes
| Part | Attribute | Condition | Values |
|---|---|---|---|
| dropdown-menu-content | data-open | menu is open (presence flips the pair at runtime) | — |
| dropdown-menu-content | data-closed | menu is closed or animating out (the server-rendered state) | — |
| dropdown-menu-content | data-side | the placement side (popper re-writes it after collision flips) | top · right · bottom · left |
| dropdown-menu-content | data-align | the alignment against the trigger (popper re-resolves it) | start · center · end |
| dropdown-menu-label | data-inset | indented to align with checkbox/radio item text (inset: true) | — |
| dropdown-menu-item | data-variant | default or destructive (the danger treatment) | — |
| dropdown-menu-item | data-inset | indented to align with checkbox/radio item text (inset: true) | — |
| dropdown-menu-item | data-disabled | item is disabled (always written together with aria-disabled) | — |
| dropdown-menu-checkbox-item | data-checked | checked (the controller re-writes the pair with aria-checked on activation) | — |
| dropdown-menu-checkbox-item | data-unchecked | unchecked | — |
| dropdown-menu-checkbox-item | data-disabled | item is disabled (always written together with aria-disabled) | — |
| dropdown-menu-checkbox-item | data-close-on-select | per-item override of the menu's close-on-select default ("false" keeps the menu open) | — |
| dropdown-menu-radio-group | data-value | the selected radio value (the controller re-writes it on change) | — |
| dropdown-menu-radio-item | data-checked | the selected radio (the controller re-writes the pair with aria-checked) | — |
| dropdown-menu-radio-item | data-unchecked | not selected | — |
| dropdown-menu-radio-item | data-value | the radio's value | — |
| dropdown-menu-radio-item | data-disabled | item is disabled (always written together with aria-disabled) | — |
| dropdown-menu-sub-trigger | data-popup-open | its submenu is open (written with aria-expanded; absence is the closed state) | — |
| dropdown-menu-sub-trigger | data-inset | indented to align with checkbox/radio item text (inset: true) | — |
| dropdown-menu-sub-content | data-open | submenu is open (presence flips the pair at runtime) | — |
| dropdown-menu-sub-content | data-closed | submenu is closed (the server-rendered state) | — |
| dropdown-menu-sub-content | data-side | the placement side (right/left by direction; popper resolves it at runtime) | top · right · bottom · left |
| dropdown-menu-sub-content | data-align | the alignment against the sub-trigger (popper resolves it at runtime) | start · center · end |
CSS variables
| Part | Variable | Description |
|---|---|---|
| dropdown-menu-content | --transform-origin | popper's anchor-facing animation origin |
| dropdown-menu-content | --available-width | popper: viewport space left for the panel (post-flip) |
| dropdown-menu-content | --available-height | popper: viewport space left for the panel (post-flip) |
| dropdown-menu-content | --anchor-width | popper: the trigger's measured width |
| dropdown-menu-content | --anchor-height | popper: the trigger's measured height |
| dropdown-menu-sub-content | --transform-origin | popper's anchor-facing animation origin |
| dropdown-menu-sub-content | --available-width | popper: viewport space left for the panel (post-flip) |
| dropdown-menu-sub-content | --available-height | popper: viewport space left for the panel (post-flip) |
| dropdown-menu-sub-content | --anchor-width | popper: the sub-trigger's measured width |
| dropdown-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--menu | registers · value open · value modal · value loop |
| root | poetry--core--popper | registers · value side · value align · value side_offset · value align_offset · value avoid_collisions |
| trigger | poetry--core--menu | toggle on click · triggerKeydown on keydown |
| 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 |