Command
A command palette for fast, keyboard-driven search and actions.
Installation
Included in poetry-ui — available as
poetry_command
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 command
Default
<%= poetry_command(placeholder: "Type a command or search...",
"aria-label": "Command palette",
class: "w-full max-w-md rounded-lg border shadow-md") do |command| %>
<% command.with_group(heading: "Suggestions") do |group| %>
<% group.with_item(value: "calendar", keywords: %w[schedule dates]) { "Calendar" } %>
<% group.with_item(value: "emoji") { "Search Emoji" } %>
<% group.with_item(value: "calculator", disabled: true) { "Calculator" } %>
<% end %>
<% command.with_separator %>
<% command.with_group(heading: "Settings") do |group| %>
<% group.with_item(value: "profile", shortcut: "⌘P") { "Profile" } %>
<% group.with_item(value: "billing", shortcut: "⌘B") { "Billing" } %>
<% group.with_item(value: "settings", shortcut: "⌘S") { "Settings" } %>
<% end %>
<% end %>
Close button
<%# The X is off by default - the palette is keyboard-first (Esc, the backdrop,
or picking an item closes it) - and appears on its own with dismissible: false.
show_close_button: true forces it; it seats in the input row. %>
<%= poetry_command_dialog(placeholder: "Type a command or search...", show_close_button: true) do |palette| %>
<% palette.with_trigger(variant: :outline) { "Open with a close button" } %>
<% palette.with_group(heading: "Suggestions") do |group| %>
<% group.with_item(value: "calendar") { "Calendar" } %>
<% group.with_item(value: "emoji") { "Search Emoji" } %>
<% end %>
<% palette.with_group(heading: "Settings") do |group| %>
<% group.with_item(value: "profile", shortcut: "⌘P") { "Profile" } %>
<% group.with_item(value: "settings", shortcut: "⌘S") { "Settings" } %>
<% end %>
<% end %>
Dialog
<%= poetry_command_dialog(placeholder: "Type a command or search...") do |palette| %>
<% palette.with_trigger(variant: :outline) { "Open command palette" } %>
<% palette.with_group(heading: "Suggestions") do |group| %>
<% group.with_item(value: "calendar") { "Calendar" } %>
<% group.with_item(value: "emoji") { "Search Emoji" } %>
<% end %>
<% palette.with_separator %>
<% palette.with_group(heading: "Settings") do |group| %>
<% group.with_item(value: "profile", shortcut: "⌘P") { "Profile" } %>
<% group.with_item(value: "settings", shortcut: "⌘S") { "Settings" } %>
<% end %>
<% end %>
Initial value
<%= poetry_command(value: "billing", "aria-label": "Settings",
class: "w-full max-w-md rounded-lg border shadow-md") do |command| %>
<% command.with_item(value: "profile") { "Profile" } %>
<% command.with_item(value: "billing") { "Billing" } %>
<% command.with_item(value: "notifications") { "Notifications" } %>
<% end %>
Scrollable
<%= poetry_command(placeholder: "Search timezones…",
"aria-label": "Timezones",
class: "w-full max-w-md rounded-lg border shadow-md " \
"[&_[data-slot=command-list]]:max-h-64") do |command| %>
<% command.with_empty { "No timezone matches that search." } %>
<% command.with_group(heading: "Timezones") do |group| %>
<% group.with_item(value: "auckland") { "Auckland" } %>
<% group.with_item(value: "bangkok") { "Bangkok" } %>
<% group.with_item(value: "berlin") { "Berlin" } %>
<% group.with_item(value: "cairo") { "Cairo" } %>
<% group.with_item(value: "chicago") { "Chicago" } %>
<% group.with_item(value: "denver") { "Denver" } %>
<% group.with_item(value: "dubai") { "Dubai" } %>
<% group.with_item(value: "helsinki") { "Helsinki" } %>
<% group.with_item(value: "istanbul") { "Istanbul" } %>
<% group.with_item(value: "karachi") { "Karachi" } %>
<% group.with_item(value: "lagos") { "Lagos" } %>
<% group.with_item(value: "lisbon") { "Lisbon" } %>
<% group.with_item(value: "london") { "London" } %>
<% group.with_item(value: "madrid") { "Madrid" } %>
<% group.with_item(value: "mumbai") { "Mumbai" } %>
<% group.with_item(value: "nairobi") { "Nairobi" } %>
<% group.with_item(value: "oslo") { "Oslo" } %>
<% group.with_item(value: "paris") { "Paris" } %>
<% group.with_item(value: "reykjavik") { "Reykjavik" } %>
<% group.with_item(value: "santiago") { "Santiago" } %>
<% group.with_item(value: "seoul") { "Seoul" } %>
<% group.with_item(value: "singapore") { "Singapore" } %>
<% group.with_item(value: "sydney") { "Sydney" } %>
<% group.with_item(value: "tokyo") { "Tokyo" } %>
<% group.with_item(value: "toronto") { "Toronto" } %>
<% end %>
<% end %>
With icons
<%= poetry_command(placeholder: "Type a command or search...",
"aria-label": "Workspace commands",
class: "w-full max-w-md rounded-lg border shadow-md") do |command| %>
<% command.with_group(heading: "Create") do |group| %>
<% group.with_item(value: "new-file", keywords: %w[document page]) do %>
<%= poetry_icon(name: :"file-plus") %><span>New file</span>
<% end %>
<% group.with_item(value: "new-folder", keywords: %w[directory group]) do %>
<%= poetry_icon(name: :"folder-plus") %><span>New folder</span>
<% end %>
<% group.with_item(value: "upload") do %>
<%= poetry_icon(name: :upload) %><span>Upload assets</span>
<% end %>
<% end %>
<% command.with_separator %>
<% command.with_group(heading: "Account") do |group| %>
<% group.with_item(value: "profile", shortcut: "⌘P") do %>
<%= poetry_icon(name: :user) %><span>View profile</span>
<% end %>
<% group.with_item(value: "invite") do %>
<%= poetry_icon(name: :"user-plus") %><span>Invite a teammate</span>
<% end %>
<% group.with_item(value: "sign-out") do %>
<%= poetry_icon(name: :"log-out") %><span>Sign out</span>
<% end %>
<% end %>
<% end %>
Zero match
<%= poetry_command("aria-label": "Jump to", placeholder: "Search pages…",
class: "w-full max-w-md rounded-lg border shadow-md") do |command| %>
<% command.with_empty { "Nothing matches - try fewer letters." } %>
<% command.with_item(value: "dashboard") { "Dashboard" } %>
<% command.with_item(value: "reports") { "Reports" } %>
<% command.with_group(heading: "Recent", always_render: true) do |group| %>
<% group.with_item(value: "readme", always_render: true) { "README.md" } %>
<% end %>
<% end %>
API
Poetry::Ui::Command::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 |
|---|---|---|---|
| disabled: | Boolean | defaults to false |
Disables the filter input. |
| filter: | Boolean | defaults to true |
Client-side filtering; false leaves the list server-driven. |
| id: | String | The base DOM id; the input, list, and item ids derive from it. | |
| list_label: | String | defaults to -> { I18n.t("poetry.command.list_label") } |
The listbox's accessible name. |
| loop: | Boolean | defaults to false |
Wraps arrow-key highlight movement past either end of the list. |
| placeholder: | String | The filter input's placeholder text. | |
| value: | String | Seats the initial highlight on the item with this value. |
Slots
| Writer | Description |
|---|---|
| with_empty | Custom zero-results content (defaults to t('poetry.command.empty')). |
| with_group | The item UNION: item | group (heading + items) | separator - one ordered collection (interleaving preserved; items and groups are part COMPONENTS so id assignment follows render/DOM order). |
| with_item | The item UNION: item | group (heading + items) | separator - one ordered collection (interleaving preserved; items and groups are part COMPONENTS so id assignment follows render/DOM order). |
| with_loading | Custom pending content (a spinner); the HOST toggles visibility (Turbo frame events) - Command renders the part, never sets it. |
| with_separator | The item UNION: item | group (heading + items) | separator - one ordered collection (interleaving preserved; items and groups are part COMPONENTS so id assignment follows render/DOM order). |
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=command] | Root of the palette - the input row over the listbox, carrying the engine controller |
| [data-slot=command-input-wrapper] | The input row - search icon + filter input above the list |
| [data-slot=command-search-icon] | Decorative search glyph beside the input |
| [data-slot=command-input] | The role=combobox filter input - real focus stays pinned here for the whole session; the highlight rides aria-activedescendant |
| [data-slot=command-list] | The role=listbox holding empty/loading/items - the input's aria-controls target |
| [data-slot=command-empty] | Zero-matches message - rendered hidden; the controller unhides it when the filter pass leaves no visible items |
| [data-slot=command-loading] | Pending affordance (role=status) - rendered hidden; the HOST toggles it (Turbo frame events), Command never does |
| [data-slot=command-group] | role=group labelled by its heading - hidden by the controller when every member item is filtered out |
| [data-slot=command-group-heading] | The group heading - styled, no ARIA role (the group points at it via aria-labelledby) |
| [data-slot=command-item] | One role=option action row - highlight, filtering, and disablement ride here (never aria-selected in a bare Command) |
| [data-slot=command-item-text] | The item's label span - the filter/typematch text source (shortcuts and icons excluded) |
| [data-slot=command-shortcut] | Presentational keyboard hint - excluded from the filter text; Command never binds the hinted key |
| [data-slot=command-separator] | Decorative divider (aria-hidden) - hidden by the controller whenever the query is non-empty |
| [data-slot=command-status] | The sr-only polite result-count live region - the controller writes the debounced count from the localized templates |
State attributes
| Part | Attribute | Condition | Values |
|---|---|---|---|
| command-group | data-always-render | always_render: is set - the group survives every filter pass | — |
| command-item | data-value | always - the item's unique value (its server-stable id follows registration order) | — |
| command-item | data-highlighted | the item holds the highlight (bare; the controller twin-writes it with the input's aria-activedescendant) | — |
| command-item | data-disabled | disabled: is set (aria-disabled rides along) | — |
| command-item | data-keywords | keywords: given - extra filter terms beyond the label | — |
| command-item | data-always-render | always_render: is set - the item survives every filter pass | — |
| command-item | data-hidden | the filter scored the item zero (the controller pairs it with hidden; never rendered server-side) | — |
| command-status | data-zero | always - the localized zero-results template | — |
| command-status | data-one | always - the localized one-result template | — |
| command-status | data-other | always - the localized many-results template (a literal count placeholder the controller interpolates) | — |
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--command | registers · value filter · value loop |
| input | poetry--core--command | filterInput on input · keydown on keydown |
| item | poetry--core--command | activate on click · pointerHighlight on pointermove |