## combobox (`poetry_combobox`)

A text input with an autocomplete popover for picking from a list.

Class: Poetry::Ui::Combobox::Component - BEM block `poetry-ui-combobox`.
Slot REQUIRED: with_item (at least one item) - a call without it raises.
- `align:` (symbol) - one of start|center|end, default "start" - The popup's alignment along the trigger's edge.
- `align_offset:` (integer) - default 0 - Skid in px along the aligned edge.
- `avoid_collisions:` (boolean) - default 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) - default false - Disables the trigger, the filter input, and the native select.
- `filter:` (boolean) - default 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) - default false - Wraps arrow-key highlight movement past either end of the list.
- `modal:` (boolean) - default false - DEFAULT FALSE - popover semantics (Tab-out closes, no scrim). true restores the focus-scope trap for dialog-critical pickers.
- `multiple:` (boolean) - default 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) - default false - Server-renders the popup open.
- `placeholder:` (string) - Shown in the value display (multiple: in the inline input) while nothing is committed.
- `required:` (boolean) - default false - Forwards to the native <select> for constraint validation.
- `search_placeholder:` (string) - Placeholder for the popup's filter input (single mode).
- `show_clear:` (boolean) - default 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, default "bottom" - The popup's preferred side of the trigger; collisions may flip it.
- `side_offset:` (integer) - default 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: 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.), empty (Custom zero-results content (defaults to t('poetry.combobox.empty')).), loading (Custom pending content (a spinner); the HOST toggles visibility (Turbo frame events) - the part renders hidden (Command parity).), items (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).; many; types item|group|separator - one with_<type> setter each, options as keywords).
- PART `combobox` - Root wrapper carrying both controllers (combobox + popper) and the optional dir attribute
- PART `combobox-native` - The visually-hidden native <select> - the serialization truth (Select's decision verbatim); plumbing, never styled or targeted
- PART `combobox-trigger` - The role=combobox button (the demo's outline Button) the field label reaches - value display and chevrons ride inside | states: data-placeholder (no option is committed (bare; the controller toggles it on every commit)); data-popup-open (the popup is open (bare while open, absent while closed - the controller flips it with the open state))
- PART `combobox-value` - The value display span - the selected option's label, or the placeholder | states: data-placeholder (placeholder: is given - carries the placeholder text so the controller can restore it)
- PART `combobox-chip-input` - The inline filter input beside the chips (multiple: only) - the one typing surface; the source's chip input
- PART `combobox-content` - The popper-positioned popup housing the embedded Command anatomy - open/closed and the resolved placement ride here | states: data-open (popup is open (the controller flips the pair at runtime)); data-closed (popup is closed or animating out (the server-rendered state)); data-chips (multiple: - chips mode; the popup's minimum width follows the chips field); data-side=top|right|bottom|left (the placement side - server-rendered from side:, rewritten to the resolved side by popper on open); data-align=start|center|end (the placement alignment - server-rendered from align:, rewritten by popper on open) | vars: --transform-origin (popper - the animation origin matching the resolved placement); --available-width (popper - viewport space available to the popup post-flip); --available-height (popper - viewport space available to the popup post-flip); --anchor-width (popper - the trigger's measured width (the popup width tracks it - one knob, two surfaces)); --anchor-height (popper - the trigger's measured height)
- PART `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)
- PART `combobox-command` - The embedded engine root - Command's anatomy rendered here against its own controller (composition at the markup contract)
- PART `combobox-input-wrapper` - The input row - search icon + filter input above the list
- PART `combobox-search-icon` - Decorative search glyph beside the input
- PART `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 | states: data-popup-open (multiple: the popup is open (bare while open, absent while closed - the input carries the flip; single's trigger owns it))
- PART `combobox-list` - THE role=listbox - the aria-controls target of both combobox roles
- PART `combobox-empty` - Zero-matches message - rendered hidden; the engine unhides it when the filter pass leaves no visible items
- PART `combobox-group` - role=group labelled by its heading - hidden by the engine when every member item is filtered out
- PART `combobox-label` - The group heading - styled, no ARIA role (the group points at it via aria-labelledby)
- PART `combobox-item` - One role=option div wearing BOTH meanings - a Command item (filtering + highlight) AND Select's committed-value surface | states: data-value (always - the option's committable value (the native <option> twin)); data-selected (the option is committed (bare; absent while unselected - the controller twin-writes it with aria-selected)); data-highlighted (the item holds the activedescendant highlight (the server seeds it; the engine moves it with the input's aria-activedescendant)); data-disabled (disabled: is set (aria-disabled rides along)); data-hidden (the filter scored the item zero (the engine pairs it with hidden; never rendered server-side))
- PART `combobox-item-text` - The option's label span - the filter/typematch text source
- PART `combobox-item-indicator` - The trailing committed-value check (ms-auto per the demo) - the parent item's data-selected absence hides it
- PART `combobox-separator` - Decorative divider (aria-hidden) - hidden by the engine whenever the query is non-empty
- PART `combobox-loading` - Pending affordance (role=status) - rendered hidden; the HOST unhides it around async refills
- PART `combobox-status` - The engine's sr-only polite result-count live region | states: data-zero (always - the localized zero-results template); data-one (always - the localized one-result template); data-other (always - the localized many-results template (a literal count placeholder the controller interpolates))
- PART `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 | states: data-placeholder (the selection is empty (bare; the controller flips it on every commit - the toolbar role departs with it)); data-disabled (disabled: is set - every chip mutation is gated); data-remove-label (always - the localized chip-remove template (a literal label placeholder the controller interpolates for client-built chips))
- PART `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 | states: data-value (always - the chip's committed value (the native <option> twin)); data-disabled (disabled: is set (chip focus is blocked entirely))
- PART `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
- WIRING root: `poetry--core--combobox` registers; values open, value, modal, multiple (if multiple) | `poetry--core--command` (if multiple) registers; values filter, loop | `poetry--core--popper` registers; values side, align, side_offset, align_offset, avoid_collisions
- WIRING trigger: `poetry--core--combobox` actions toggle on click, triggerKeydown on keydown | `poetry--core--popper` targets anchor
- WIRING chips: `poetry--core--combobox` actions chipsPointerdown on mousedown | `poetry--core--popper` targets anchor
- WIRING inline_input: `poetry--core--command` actions filterInput on input, keydown on keydown | `poetry--core--combobox` actions inputKeydown on keydown
- WIRING content: `poetry--core--popper` targets content
- WIRING command_part: `poetry--core--command` registers; values filter, loop
- WIRING input: `poetry--core--command` actions filterInput on input, keydown on keydown
- WIRING item: `poetry--core--command` actions activate on click, pointerHighlight on pointermove
- WIRING native: `poetry--core--combobox` actions nativeChanged on change
- WIRING clear: `poetry--core--combobox` actions clear on click
- WIRING chip: `poetry--core--combobox` actions chipKeydown on keydown
- WIRING chip_remove: `poetry--core--combobox` actions removeChip on click
- tool set_value (mutating; params: value (string, required)) - Select the option whose value matches; the schema lists the rendered options, and clear selects nothing. [opt in with webmcp: "name" on the call; dispatches poetry--core--combobox#setValue]
- tool clear (mutating) - Clear the current selection. [opt in with webmcp: "name" on the call; dispatches poetry--core--combobox#clear]
- RULE: Use poetry_combobox (f.poetry_combobox in forms) - never hand-wire Popover+Command+hidden-input; this component IS that wiring, with the form story done right.
- RULE: Combobox picks VALUES. Filter-then-ACT is bare Command; short known lists are Select; free text is Input.
- RULE: Every Combobox MUST be named (Field label via id: or aria-label) - a nameless bare combobox fails at render.
- RULE: NEVER write aria-selected from highlight logic (position is data-highlighted + aria-activedescendant); NEVER write the display without the native select first - the commit pipeline does all of it; agents patching DOM must too.
- RULE: Async options: filter: false + the Turbo-frame ?q= recipe - AND the frame must render the twin native <option> for every committable item (the recipe's one hard rule).
- RULE: multiple: true is the multi-select/chips mode: value: takes an ARRAY, the native <select multiple> posts name[] (the [] is appended for you), selection TOGGLES with the popup staying open, and chips replace the trigger - never fake multi with hidden inputs.
- RULE: Do not put interactive elements inside options (an option IS the interactive unit).
- RULE: Deselection in single mode is include_blank (a visible blank option) or show_clear: (the trigger-side X - single mode only), never a re-click toggle - committing the already-selected value closes without change. In multiple, re-committing IS the deselect gesture (chip-remove is its pointer twin).
