Menubar
A horizontal bar of menus, like a desktop application menu.
Installation
Included in poetry-ui — available as
poetry_menubar
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 menubar
Default
<%= poetry_menubar(label: "Application menu") do |bar| %>
<% bar.with_menu do |menu| %>
<% menu.with_trigger { "File" } %>
<% menu.with_item(shortcut: "⌘T") { "New Tab" } %>
<% menu.with_item(shortcut: "⌘N") { "New Window" } %>
<% menu.with_item(disabled: true) { "New Incognito Window" } %>
<% menu.with_separator %>
<% menu.with_sub do |sub| %>
<% sub.with_trigger { "Share" } %>
<% sub.with_item { "Email link" } %>
<% sub.with_item { "Messages" } %>
<% sub.with_item { "Notes" } %>
<% end %>
<% menu.with_separator %>
<% menu.with_item(shortcut: "⌘P") { "Print..." } %>
<% end %>
<% bar.with_menu do |menu| %>
<% menu.with_trigger { "Edit" } %>
<% menu.with_item(shortcut: "⌘Z") { "Undo" } %>
<% menu.with_item(shortcut: "⇧⌘Z") { "Redo" } %>
<% menu.with_separator %>
<% menu.with_sub do |sub| %>
<% sub.with_trigger { "Find" } %>
<% sub.with_item { "Search the web" } %>
<% sub.with_separator %>
<% sub.with_item { "Find..." } %>
<% sub.with_item { "Find Next" } %>
<% sub.with_item { "Find Previous" } %>
<% end %>
<% menu.with_separator %>
<% menu.with_item { "Cut" } %>
<% menu.with_item { "Copy" } %>
<% menu.with_item { "Paste" } %>
<% end %>
<% bar.with_menu do |menu| %>
<% menu.with_trigger { "View" } %>
<% menu.with_checkbox_item(close_on_select: false) { "Always Show Bookmarks Bar" } %>
<% menu.with_checkbox_item(checked: true, close_on_select: false) { "Always Show Full URLs" } %>
<% menu.with_separator %>
<% menu.with_item(inset: true, shortcut: "⌘R") { "Reload" } %>
<% menu.with_item(inset: true, disabled: true, shortcut: "⇧⌘R") { "Force Reload" } %>
<% menu.with_separator %>
<% menu.with_item(inset: true) { "Toggle Fullscreen" } %>
<% menu.with_separator %>
<% menu.with_item(inset: true) { "Hide Sidebar" } %>
<% end %>
<% bar.with_menu do |menu| %>
<% menu.with_trigger { "Profiles" } %>
<% menu.with_radio_group(value: "benoit") do |group| %>
<% group.with_radio_item(value: "andy") { "Andy" } %>
<% group.with_radio_item(value: "benoit") { "Benoit" } %>
<% group.with_radio_item(value: "luis") { "Luis" } %>
<% end %>
<% menu.with_separator %>
<% menu.with_item(inset: true) { "Edit..." } %>
<% menu.with_separator %>
<% menu.with_item(inset: true) { "Add Profile..." } %>
<% end %>
<% end %>
Checkbox
<%# Upstream menubar-checkbox: checked state rides the item -
close_on_select: false keeps the menu open while toggling. %>
<%= poetry_menubar(label: "View menu", class: "w-72") do |bar| %>
<% bar.with_menu do |menu| %>
<% menu.with_trigger { "View" } %>
<% menu.with_checkbox_item(close_on_select: false) { "Always Show Bookmarks Bar" } %>
<% menu.with_checkbox_item(checked: true, close_on_select: false) { "Always Show Full URLs" } %>
<% menu.with_separator %>
<% menu.with_item(inset: true, shortcut: "⌘R") { "Reload" } %>
<% menu.with_item(inset: true, disabled: true, shortcut: "⇧⌘R") { "Force Reload" } %>
<% end %>
<% bar.with_menu do |menu| %>
<% menu.with_trigger { "Format" } %>
<% menu.with_checkbox_item(checked: true, close_on_select: false) { "Strikethrough" } %>
<% menu.with_checkbox_item(close_on_select: false) { "Code" } %>
<% menu.with_checkbox_item(close_on_select: false) { "Superscript" } %>
<% end %>
<% end %>
Disabled menu
<%# disabled: true skips the menu in roving focus - the bar keeps one tab stop. %>
<%= poetry_menubar(label: "Editor menu") do |bar| %>
<% bar.with_menu do |menu| %>
<% menu.with_trigger { "File" } %>
<% menu.with_item { "New" } %>
<% end %>
<% bar.with_menu(disabled: true) do |menu| %>
<% menu.with_trigger { "Team" } %>
<% menu.with_item { "Invite" } %>
<% end %>
<% bar.with_menu do |menu| %>
<% menu.with_trigger { "Help" } %>
<% menu.with_item { "About" } %>
<% end %>
<% end %>
Icons
<%# Upstream menubar-icons: icons lead the label (poetry_icon inside
the item block); the destructive variant colors item and glyph.
Upstream's MenubarGroup is a semantic wrapper only - the menu
content is already the single group here. %>
<%= poetry_menubar(label: "Document menu", class: "w-72") do |bar| %>
<% bar.with_menu do |menu| %>
<% menu.with_trigger { "File" } %>
<% menu.with_item(shortcut: "⌘N") do %>
<%= poetry_icon(name: :file) %>New File
<% end %>
<% menu.with_item do %>
<%= poetry_icon(name: :folder) %>Open Folder
<% end %>
<% menu.with_separator %>
<% menu.with_item(shortcut: "⌘S") do %>
<%= poetry_icon(name: :save) %>Save
<% end %>
<% end %>
<% bar.with_menu do |menu| %>
<% menu.with_trigger { "More" } %>
<% menu.with_item do %>
<%= poetry_icon(name: :settings) %>Settings
<% end %>
<% menu.with_item do %>
<%= poetry_icon(name: :"circle-question-mark") %>Help
<% end %>
<% menu.with_separator %>
<% menu.with_item(variant: :destructive) do %>
<%= poetry_icon(name: :trash) %>Delete
<% end %>
<% end %>
<% end %>
Radio
<%# Upstream menubar-radio: one selected value per group, server-
rendered via value:. %>
<%= poetry_menubar(label: "Profile menu", class: "w-72") do |bar| %>
<% bar.with_menu do |menu| %>
<% menu.with_trigger { "Profiles" } %>
<% menu.with_radio_group(value: "benoit") do |group| %>
<% group.with_radio_item(value: "andy") { "Andy" } %>
<% group.with_radio_item(value: "benoit") { "Benoit" } %>
<% group.with_radio_item(value: "luis") { "Luis" } %>
<% end %>
<% menu.with_separator %>
<% menu.with_item(inset: true) { "Edit..." } %>
<% menu.with_item(inset: true) { "Add Profile..." } %>
<% end %>
<% bar.with_menu do |menu| %>
<% menu.with_trigger { "Theme" } %>
<% menu.with_radio_group(value: "system") do |group| %>
<% group.with_radio_item(value: "light") { "Light" } %>
<% group.with_radio_item(value: "dark") { "Dark" } %>
<% group.with_radio_item(value: "system") { "System" } %>
<% end %>
<% end %>
<% end %>
Submenu
<%# Upstream menubar-submenu: with_sub nests a flyout - the sub
trigger carries the chevron, items land in the sub content. %>
<%= poetry_menubar(label: "File menu", class: "w-72") do |bar| %>
<% bar.with_menu do |menu| %>
<% menu.with_trigger { "File" } %>
<% menu.with_sub do |sub| %>
<% sub.with_trigger { "Share" } %>
<% sub.with_item { "Email link" } %>
<% sub.with_item { "Messages" } %>
<% sub.with_item { "Notes" } %>
<% end %>
<% menu.with_separator %>
<% menu.with_item(shortcut: "⌘P") { "Print..." } %>
<% end %>
<% bar.with_menu do |menu| %>
<% menu.with_trigger { "Edit" } %>
<% menu.with_item(shortcut: "⌘Z") { "Undo" } %>
<% menu.with_item(shortcut: "⇧⌘Z") { "Redo" } %>
<% menu.with_separator %>
<% menu.with_sub do |sub| %>
<% sub.with_trigger { "Find" } %>
<% sub.with_item { "Find..." } %>
<% sub.with_item { "Find Next" } %>
<% sub.with_item { "Find Previous" } %>
<% end %>
<% menu.with_separator %>
<% menu.with_item { "Cut" } %>
<% menu.with_item { "Copy" } %>
<% menu.with_item { "Paste" } %>
<% end %>
<% end %>
API
Poetry::Ui::Menubar::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; |
The reading direction; :rtl flips arrow-key movement and submenu sides. |
| label: | String | required | The bar's accessible name - a page may hold more than one menubar. |
| loop: | Boolean | defaults to false |
Wraps arrow-key movement past either end of the bar. |
| value: | String | Server-renders the menu with this value open (values default to \"menu-<position>\"). |
Slots
| Writer | Description |
|---|---|
| with_menu | The top-level menus. Each takes with_trigger (the menu button) plus the family item slots (with_item, with_checkbox_item, with_radio_group, with_sub, with_separator, ...); value: defaults to the menu's position. |
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=menubar] | The role=menubar bar - one horizontal roving tab stop across the triggers |
| [data-slot=menubar-menu] | One logical menu - a display:contents wrapper hosting the trigger + content pair's menu and popper controllers |
| [data-slot=menubar-trigger] | The top-level menu button - a role=menuitem INSIDE the bar |
| [data-slot=menubar-content] | The role=menu popup panel - positioning, animation, and the open state ride here |
| [data-slot=menubar-group] | role=group semantic grouping between separators |
| [data-slot=menubar-label] | Non-interactive heading for a run of items |
| [data-slot=menubar-item] | One role=menuitem action row |
| [data-slot=menubar-checkbox-item] | A role=menuitemcheckbox toggle row |
| [data-slot=menubar-radio-group] | role=group scoping one single-select value |
| [data-slot=menubar-radio-item] | A role=menuitemradio row inside a radio group |
| [data-slot=menubar-checkbox-item-indicator] | The check glyph slot inside checkbox items - aria-hidden; the item's aria-checked/data-checked pair carries state |
| [data-slot=menubar-radio-item-indicator] | The circle glyph slot inside radio items - aria-hidden; the item's aria-checked/data-checked pair carries state |
| [data-slot=menubar-separator] | role=separator rule between groups |
| [data-slot=menubar-shortcut] | The trailing keybinding HINT - aria-hidden, never binds the key |
| [data-slot=menubar-sub] | A submenu scope - hosts its own popper around the sub trigger/content pair |
| [data-slot=menubar-sub-trigger] | The role=menuitem row opening its submenu |
| [data-slot=menubar-sub-content] | The nested role=menu panel - its own popper content on the same presence machinery |
State attributes
| Part | Attribute | Condition | Values |
|---|---|---|---|
| menubar | data-open | some menu is open (value present; the coordinator flips the pair) | — |
| menubar | data-closed | no menu is open | — |
| menubar-trigger | data-value | the menu's value - the coordinator's open/close key | — |
| menubar-trigger | data-popup-open | its menu is open (written with aria-expanded; absence is the closed state) | — |
| menubar-trigger | data-disabled | trigger is disabled (written together with the disabled property) | — |
| menubar-content | data-open | menu is open (presence flips the pair at runtime) | — |
| menubar-content | data-closed | menu is closed or animating out (the server-rendered state) | — |
| menubar-content | data-side | the placement side (bottom initially; popper re-writes it after collision flips) | top · right · bottom · left |
| menubar-content | data-align | the alignment against the trigger (start initially; popper re-resolves it) | start · center · end |
| menubar-label | data-inset | indented to align with checkbox/radio item text (inset: true) | — |
| menubar-item | data-variant | default or destructive (the danger treatment) | — |
| menubar-item | data-inset | indented to align with checkbox/radio item text (inset: true) | — |
| menubar-item | data-disabled | item is disabled (always written together with aria-disabled) | — |
| menubar-checkbox-item | data-checked | checked (the controller re-writes the pair with aria-checked on activation) | — |
| menubar-checkbox-item | data-unchecked | unchecked | — |
| menubar-checkbox-item | data-disabled | item is disabled (always written together with aria-disabled) | — |
| menubar-checkbox-item | data-close-on-select | per-item override of the menu's close-on-select default ("false" keeps the menu open) | — |
| menubar-radio-group | data-value | the selected radio value (the controller re-writes it on change) | — |
| menubar-radio-item | data-checked | the selected radio (the controller re-writes the pair with aria-checked) | — |
| menubar-radio-item | data-unchecked | not selected | — |
| menubar-radio-item | data-value | the radio's value | — |
| menubar-radio-item | data-disabled | item is disabled (always written together with aria-disabled) | — |
| menubar-sub-trigger | data-popup-open | its submenu is open (written with aria-expanded; absence is the closed state) | — |
| menubar-sub-trigger | data-inset | indented to align with checkbox/radio item text (inset: true) | — |
| menubar-sub-content | data-open | submenu is open (presence flips the pair at runtime) | — |
| menubar-sub-content | data-closed | submenu is closed (the server-rendered state) | — |
| menubar-sub-content | data-side | the placement side (right/left by direction; popper resolves it at runtime) | top · right · bottom · left |
| menubar-sub-content | data-align | the alignment against the sub-trigger (popper resolves it at runtime) | start · center · end |
CSS variables
| Part | Variable | Description |
|---|---|---|
| menubar-content | --transform-origin | popper's anchor-facing animation origin |
| menubar-content | --available-width | popper: viewport space left for the panel (post-flip) |
| menubar-content | --available-height | popper: viewport space left for the panel (post-flip) |
| menubar-content | --anchor-width | popper: the trigger's measured width |
| menubar-content | --anchor-height | popper: the trigger's measured height |
| menubar-sub-content | --transform-origin | popper's anchor-facing animation origin |
| menubar-sub-content | --available-width | popper: viewport space left for the panel (post-flip) |
| menubar-sub-content | --available-height | popper: viewport space left for the panel (post-flip) |
| menubar-sub-content | --anchor-width | popper: the sub-trigger's measured width |
| menubar-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--menubar | registers · value value · value loop · slideAdjacent on poetry:menu:edge-navigate · onMenuClosed on poetry:menu:closed |
| root | poetry--core--roving-focus | registers · value orientation · value manage_tabindex · value loop · keydown on keydown |
| menu_wrapper | poetry--core--menu | registers · value open · value modal |
| menu_wrapper | poetry--core--popper | registers · value side · value align · value side_offset · value align_offset · value avoid_collisions |
| trigger | poetry--core--menubar | toggle on pointerdown · hoverSlide on pointerenter · 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 |