Combobox
A text input with an autocomplete popover for picking from a list.
Installation
Included in poetry-ui — available as
poetry_combobox
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 combobox
Default
<%= poetry_combobox(name: "framework", placeholder: "Select framework...",
search_placeholder: "Search framework...", "aria-label": "Framework") do |combobox| %>
<% combobox.with_item(value: "next.js") { "Next.js" } %>
<% combobox.with_item(value: "sveltekit") { "SvelteKit" } %>
<% combobox.with_item(value: "nuxt.js") { "Nuxt.js" } %>
<% combobox.with_item(value: "remix") { "Remix" } %>
<% combobox.with_item(value: "astro") { "Astro" } %>
<% end %>
Auto highlight
<%# Auto highlight is poetry's DEFAULT: the embedded command engine
re-seats the highlight on the best visible match on every keystroke
(Base UI needs the autoHighlight prop for this; cmdk always does it,
and poetry follows cmdk). Type "nu" and Enter commits Nuxt.js - no
arrow key needed. %>
<%= poetry_combobox(name: "framework", placeholder: "Select framework...",
search_placeholder: "Type to highlight the first match...",
"aria-label": "Framework") do |combobox| %>
<% combobox.with_item(value: "next.js") { "Next.js" } %>
<% combobox.with_item(value: "sveltekit") { "SvelteKit" } %>
<% combobox.with_item(value: "nuxt.js") { "Nuxt.js" } %>
<% combobox.with_item(value: "remix") { "Remix" } %>
<% combobox.with_item(value: "astro") { "Astro" } %>
<% end %>
Clear button
<%# show_clear: renders the trigger-side deselection X (Base UI's
showClear) - it swaps in over the chevrons while a value is committed,
and pressing it commits the blank value back through the native select
(single mode only; multiple has per-chip removal). %>
<%= poetry_combobox(name: "framework", value: "next.js", show_clear: true,
placeholder: "Select framework...",
search_placeholder: "Search framework...", "aria-label": "Framework") do |combobox| %>
<% combobox.with_item(value: "next.js") { "Next.js" } %>
<% combobox.with_item(value: "sveltekit") { "SvelteKit" } %>
<% combobox.with_item(value: "nuxt.js") { "Nuxt.js" } %>
<% combobox.with_item(value: "remix") { "Remix" } %>
<% combobox.with_item(value: "astro") { "Astro" } %>
<% end %>
Custom items
<%= poetry_combobox(name: "assignee", width: "w-72", placeholder: "Assign a teammate...",
search_placeholder: "Search teammates...", "aria-label": "Assignee") do |combobox| %>
<% [["amara", "Amara Okafor", "AO", "amara@example.com"],
["devin", "Devin Park", "DP", "devin@example.com"],
["priya", "Priya Nair", "PN", "priya@example.com"]].each do |value, name, initials, email| %>
<% combobox.with_item(value: value) do %>
<span class="flex items-center gap-2">
<span class="flex size-6 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
<%= initials %>
</span>
<span class="flex flex-col leading-tight">
<span class="text-sm"><%= name %></span>
<span class="text-xs text-muted-foreground"><%= email %></span>
</span>
</span>
<% end %>
<% end %>
<% end %>
Disabled
<%= poetry_combobox(name: "plan", value: "pro", disabled: true,
placeholder: "Select a plan...", "aria-label": "Plan") do |combobox| %>
<% combobox.with_item(value: "hobby") { "Hobby" } %>
<% combobox.with_item(value: "pro") { "Pro" } %>
<% combobox.with_item(value: "enterprise") { "Enterprise" } %>
<% end %>
Disabled options
<%= poetry_combobox(name: "plan", placeholder: "Select a plan...",
"aria-label": "Plan") do |combobox| %>
<% combobox.with_item(value: "hobby") { "Hobby" } %>
<% combobox.with_item(value: "pro") { "Pro" } %>
<% combobox.with_item(value: "enterprise", disabled: true) { "Enterprise (contact sales)" } %>
<% end %>
Grouped
<%= poetry_combobox(name: "stack", placeholder: "Select a tool...",
search_placeholder: "Search tools...", "aria-label": "Tool") do |combobox| %>
<% combobox.with_group(heading: "Frameworks") do |group| %>
<% group.with_item(value: "rails") { "Ruby on Rails" } %>
<% group.with_item(value: "hanami") { "Hanami" } %>
<% end %>
<% combobox.with_separator %>
<% combobox.with_group(heading: "Test runners") do |group| %>
<% group.with_item(value: "minitest") { "Minitest" } %>
<% group.with_item(value: "rspec") { "RSpec" } %>
<% end %>
<% end %>
Invalid
<%= poetry_combobox(name: "country", placeholder: "Select a country...",
search_placeholder: "Search countries...",
"aria-label": "Billing country", "aria-invalid": true) do |combobox| %>
<% combobox.with_item(value: "us") { "United States" } %>
<% combobox.with_item(value: "ca") { "Canada" } %>
<% combobox.with_item(value: "mx") { "Mexico" } %>
<% combobox.with_item(value: "br") { "Brazil" } %>
<% end %>
Multiple
<%= poetry_combobox(name: "frameworks", multiple: true, value: %w[sveltekit remix], "aria-label": "Frameworks",
placeholder: "Select frameworks...", class: "w-80") do |combobox| %>
<% [["next", "Next.js"], ["sveltekit", "SvelteKit"], ["remix", "Remix"],
["astro", "Astro"], ["nuxt", "Nuxt"]].each do |value, label| %>
<% combobox.with_item(value: value) { label } %>
<% end %>
<% end %>
Valued
<%= poetry_combobox(name: "framework", value: "sveltekit", placeholder: "Select framework...",
search_placeholder: "Search framework...", "aria-label": "Framework") do |combobox| %>
<% combobox.with_item(value: "next.js") { "Next.js" } %>
<% combobox.with_item(value: "sveltekit") { "SvelteKit" } %>
<% combobox.with_item(value: "nuxt.js") { "Nuxt.js" } %>
<% combobox.with_item(value: "remix") { "Remix" } %>
<% combobox.with_item(value: "astro") { "Astro" } %>
<% end %>
Wide
<%= poetry_combobox(name: "timezone", width: "w-80", placeholder: "Select a timezone...",
search_placeholder: "Search timezones...", "aria-label": "Timezone") do |combobox| %>
<% combobox.with_item(value: "est") { "Eastern Standard Time (EST)" } %>
<% combobox.with_item(value: "cet") { "Central European Time (CET)" } %>
<% combobox.with_item(value: "jst") { "Japan Standard Time (JST)" } %>
<% end %>
With icon
<%= poetry_combobox(name: "project", width: "w-64", placeholder: "Select a project...",
search_placeholder: "Search projects...", "aria-label": "Project") do |combobox| %>
<% combobox.with_trigger { poetry_icon(name: :folder, class: "size-4 text-muted-foreground") } %>
<% combobox.with_item(value: "atlas") { "Atlas" } %>
<% combobox.with_item(value: "beacon") { "Beacon" } %>
<% combobox.with_item(value: "cascade") { "Cascade" } %>
<% combobox.with_item(value: "delta") { "Delta" } %>
<% end %>
API
Poetry::Ui::Combobox::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 :start |
The popup's alignment along the trigger's edge. |
| align_offset: | Integer | defaults to 0 |
Skid in px along the aligned edge. |
| avoid_collisions: | Boolean | defaults to true |
Flips/shifts the popup to stay inside the viewport. |
| dir: | Symbol | one of: ltr, rtl; |
Writing-direction override (ltr/rtl) stamped on the root. |
| disabled: | Boolean | defaults to false |
Disables the trigger, the filter input, and the native select. |
| filter: | Boolean | defaults to true |
Forwarded to the embedded engine: false = server-driven options (the async Turbo-frame recipe). |
| id: | String | The trigger's DOM id - the Field label target; every other part id derives from it. | |
| loop: | Boolean | defaults to false |
Wraps arrow-key highlight movement past either end of the list. |
| modal: | Boolean | defaults to false |
DEFAULT FALSE - popover semantics (Tab-out closes, no scrim). true restores the focus-scope trap for dialog-critical pickers. |
| multiple: | Boolean | defaults to false |
Multi-select mode: value: becomes LIST-capable (single stays the scalar), the trigger is replaced by the chips field, the native <select multiple> posts name[], selection toggles without closing. |
| name: | String | The form field name on the native <select>; multiple: appends [] for you. | |
| open: | Boolean | defaults to false |
Server-renders the popup open. |
| placeholder: | String | Shown in the value display (multiple: in the inline input) while nothing is committed. | |
| required: | Boolean | defaults to false |
Forwards to the native <select> for constraint validation. |
| search_placeholder: | String | Placeholder for the popup's filter input (single mode). | |
| show_clear: | Boolean | defaults to false |
Single mode only: the trigger-side deselection X - swaps in over the chevrons while a value is committed and commits the blank value, so the cleared state serializes as \"\". |
| side: | Symbol | one of: top, right, bottom, left; defaults to :bottom |
The popup's preferred side of the trigger; collisions may flip it. |
| side_offset: | Integer | defaults to 4 |
Gap in px between the trigger and the popup. |
| value: | String | The committed value; with multiple:, an array of values. | |
| width: | String | The trigger width utility class; the popup ALWAYS tracks the trigger's measured width, so one knob sizes both surfaces. nil resolves to the dictionary's default (w-50). |
Slots
| Writer | Description |
|---|---|
| with_empty | Custom zero-results content (defaults to t('poetry.combobox.empty')). |
| with_group | The option UNION forwarded to the embedded command list: item | group (heading + items) | separator - one ordered collection (interleaving preserved; items and groups are part COMPONENTS so option registration follows render/DOM order). |
| with_item | The option UNION forwarded to the embedded command list: item | group (heading + items) | separator - one ordered collection (interleaving preserved; items and groups are part COMPONENTS so option registration follows render/DOM order). |
| with_loading | Custom pending content (a spinner); the HOST toggles visibility (Turbo frame events) - the part renders hidden (Command parity). |
| with_separator | The option UNION forwarded to the embedded command list: item | group (heading + items) | separator - one ordered collection (interleaving preserved; items and groups are part COMPONENTS so option registration follows render/DOM order). |
| with_trigger | Optional custom trigger content rendered BEFORE the value span (rare); the component owns role=combobox + the aria wiring + the chevrons regardless, so composition cannot drop the contract. |
Methods
| Method | Description |
|---|---|
| #webmcp_tool_definition(definition) | The rendered instance knows its options (the option union renders into a capture before the root's attributes): the payload's schema lists the enabled values as the enum, each with its label as the title - an agent reads "Japan" and passes "jp" - so an unknown value is refused before dispatch instead of committing nothing. multiple: keeps the bare schema (its value is a list). |
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=combobox] | Root wrapper carrying both controllers (combobox + popper) and the optional dir attribute |
| [data-slot=combobox-native] | The visually-hidden native <select> - the serialization truth (Select's decision verbatim); plumbing, never styled or targeted |
| [data-slot=combobox-trigger] | The role=combobox button (the demo's outline Button) the field label reaches - value display and chevrons ride inside |
| [data-slot=combobox-value] | The value display span - the selected option's label, or the placeholder |
| [data-slot=combobox-chip-input] | The inline filter input beside the chips (multiple: only) - the one typing surface; the source's chip input |
| [data-slot=combobox-content] | The popper-positioned popup housing the embedded Command anatomy - open/closed and the resolved placement ride here |
| [data-slot=combobox-clear] | The show_clear: deselection X - a trigger sibling seated over the chevron slot; pressing it commits the blank value and returns focus to the trigger. Wears the html hidden attribute while no value is committed (the controller flips it on every commit; the chevron swap derives from that one flip in CSS) |
| [data-slot=combobox-command] | The embedded engine root - Command's anatomy rendered here against its own controller (composition at the markup contract) |
| [data-slot=combobox-input-wrapper] | The input row - search icon + filter input above the list |
| [data-slot=combobox-search-icon] | Decorative search glyph beside the input |
| [data-slot=combobox-input] | The filter input (role=combobox) - the typing session and aria-activedescendant live here. Single: in the popup with its own accessible name; multiple: INLINE in the chips frame (the input-inside layout), where the field label reaches it |
| [data-slot=combobox-list] | THE role=listbox - the aria-controls target of both combobox roles |
| [data-slot=combobox-empty] | Zero-matches message - rendered hidden; the engine unhides it when the filter pass leaves no visible items |
| [data-slot=combobox-group] | role=group labelled by its heading - hidden by the engine when every member item is filtered out |
| [data-slot=combobox-label] | The group heading - styled, no ARIA role (the group points at it via aria-labelledby) |
| [data-slot=combobox-item] | One role=option div wearing BOTH meanings - a Command item (filtering + highlight) AND Select's committed-value surface |
| [data-slot=combobox-item-text] | The option's label span - the filter/typematch text source |
| [data-slot=combobox-item-indicator] | The trailing committed-value check (ms-auto per the demo) - the parent item's data-selected absence hides it |
| [data-slot=combobox-separator] | Decorative divider (aria-hidden) - hidden by the engine whenever the query is non-empty |
| [data-slot=combobox-loading] | Pending affordance (role=status) - rendered hidden; the HOST unhides it around async refills |
| [data-slot=combobox-status] | The engine's sr-only polite result-count live region |
| [data-slot=combobox-chips] | The chips FIELD frame (multiple: only) - the popper anchor replacing the trigger; chips + the inline input flex-wrap inside, and role=toolbar rides it only while it holds >=1 chip |
| [data-slot=combobox-chip] | One committed value (multiple: only) - a div taking REAL focus (tabindex=-1, styled by :focus-visible; chips NEVER wear data-highlighted), named by its value text, holding the remove button |
| [data-slot=combobox-chip-remove] | The chip's native remove button (tabindex=-1, labelled 'Remove <label>') - a press removes the value and is never a chips-area press |
State attributes
| Part | Attribute | Condition | Values |
|---|---|---|---|
| combobox-trigger | data-placeholder | no option is committed (bare; the controller toggles it on every commit) | — |
| combobox-trigger | data-popup-open | the popup is open (bare while open, absent while closed - the controller flips it with the open state) | — |
| combobox-value | data-placeholder | placeholder: is given - carries the placeholder text so the controller can restore it | — |
| combobox-content | data-open | popup is open (the controller flips the pair at runtime) | — |
| combobox-content | data-closed | popup is closed or animating out (the server-rendered state) | — |
| combobox-content | data-chips | multiple: - chips mode; the popup's minimum width follows the chips field | — |
| combobox-content | data-side | the placement side - server-rendered from side:, rewritten to the resolved side by popper on open | top · right · bottom · left |
| combobox-content | data-align | the placement alignment - server-rendered from align:, rewritten by popper on open | start · center · end |
| combobox-input | data-popup-open | multiple: the popup is open (bare while open, absent while closed - the input carries the flip; single's trigger owns it) | — |
| combobox-item | data-value | always - the option's committable value (the native <option> twin) | — |
| combobox-item | data-selected | the option is committed (bare; absent while unselected - the controller twin-writes it with aria-selected) | — |
| combobox-item | data-highlighted | the item holds the activedescendant highlight (the server seeds it; the engine moves it with the input's aria-activedescendant) | — |
| combobox-item | data-disabled | disabled: is set (aria-disabled rides along) | — |
| combobox-item | data-hidden | the filter scored the item zero (the engine pairs it with hidden; never rendered server-side) | — |
| combobox-status | data-zero | always - the localized zero-results template | — |
| combobox-status | data-one | always - the localized one-result template | — |
| combobox-status | data-other | always - the localized many-results template (a literal count placeholder the controller interpolates) | — |
| combobox-chips | data-placeholder | the selection is empty (bare; the controller flips it on every commit - the toolbar role departs with it) | — |
| combobox-chips | data-disabled | disabled: is set - every chip mutation is gated | — |
| combobox-chips | data-remove-label | always - the localized chip-remove template (a literal label placeholder the controller interpolates for client-built chips) | — |
| combobox-chip | data-value | always - the chip's committed value (the native <option> twin) | — |
| combobox-chip | data-disabled | disabled: is set (chip focus is blocked entirely) | — |
CSS variables
| Part | Variable | Description |
|---|---|---|
| combobox-content | --transform-origin | popper - the animation origin matching the resolved placement |
| combobox-content | --available-width | popper - viewport space available to the popup post-flip |
| combobox-content | --available-height | popper - viewport space available to the popup post-flip |
| combobox-content | --anchor-width | popper - the trigger's measured width (the popup width tracks it - one knob, two surfaces) |
| combobox-content | --anchor-height | popper - the trigger's measured height |
Agent tools
What an agent may do to a rendered instance once the call opts in
(webmcp: "name") — declared beside the wiring, projected
to the registry, registered with the browser's document.modelContext
by poetry-agent.
| Tool | Parameters | Does | Dispatches |
|---|---|---|---|
| set_value mutating | value (string, required) | Select the option whose value matches; the schema lists the rendered options, and clear selects nothing. | poetry--core--combobox#setValue |
| clear mutating | — | Clear the current selection. | poetry--core--combobox#clear |
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--combobox | registers · value open · value value · value modal · value multiple (if multiple) |
| root | poetry--core--command (if multiple) | registers · value filter · value loop |
| root | poetry--core--popper | registers · value side · value align · value side_offset · value align_offset · value avoid_collisions |
| trigger | poetry--core--combobox | toggle on click · triggerKeydown on keydown |
| trigger | poetry--core--popper | target anchor |
| chips | poetry--core--combobox | chipsPointerdown on mousedown |
| chips | poetry--core--popper | target anchor |
| inline_input | poetry--core--command | filterInput on input · keydown on keydown |
| inline_input | poetry--core--combobox | inputKeydown on keydown |
| content | poetry--core--popper | target content |
| command_part | 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 |
| native | poetry--core--combobox | nativeChanged on change |
| clear | poetry--core--combobox | clear on click |
| chip | poetry--core--combobox | chipKeydown on keydown |
| chip_remove | poetry--core--combobox | removeChip on click |