Combobox
Input with autocomplete dropdown. Static items only in v0.6.
Installation
bin/rails g wabi:add combobox
bin/importmap pin @zag-js/combobox
bin/importmap pin @zag-js/vanillaPin @zag-js/combobox and @zag-js/vanilla at version 1.41+ using the +esm jsdelivr URLs — bin/importmap pin only downloads the main entry and leaves submodule imports unresolved.
Example
- Ruby on Rails
- Django
- Phoenix
- Express
- FastAPI
items = [
{ value: "rails", label: "Ruby on Rails" },
{ value: "django", label: "Django" },
{ value: "phoenix", label: "Phoenix", disabled: true },
{ value: "express", label: "Express" },
{ value: "fastapi", label: "FastAPI" },
]
render Components::UI::Combobox.new(name: "framework", items: items, placeholder: "Pick a framework...") do
render Components::UI::ComboboxLabel.new { "Framework" }
render Components::UI::ComboboxControl.new do
render Components::UI::ComboboxInput.new
render Components::UI::ComboboxTrigger.new
end
render Components::UI::ComboboxPositioner.new do
render Components::UI::ComboboxContent.new do
items.each do |item|
render Components::UI::ComboboxItem.new(value: item[:value], disabled: item[:disabled]) { item[:label] }
end
end
end
end
Async (server-rendered items)
Pass url: to fetch server-rendered ComboboxItem fragments on each keystroke. The controller debounces input, aborts stale requests, and swaps items into the content.
Loading...
Couldn't load results. Try again.
render Components::UI::Combobox.new(
name: "framework_async",
items: [],
placeholder: "Search frameworks...",
url: "/docs/components/combobox/search",
param: "q",
debounce: 250,
min_length: 1,
) do
render Components::UI::ComboboxLabel.new { "Framework (async)" }
render Components::UI::ComboboxControl.new do
render Components::UI::ComboboxInput.new
render Components::UI::ComboboxTrigger.new
end
render Components::UI::ComboboxPositioner.new do
render Components::UI::ComboboxContent.new do
render Components::UI::ComboboxLoading.new { "Loading..." }
render Components::UI::ComboboxError.new { "Couldn't load results. Try again." }
end
end
end
Source
app/components/ui/combobox.rb
# frozen_string_literal: true
require "json"
require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
module Components
module UI
class Combobox < Wabi::Base
def initialize(name: nil, items:, value: nil, placeholder: "Select an option...", disabled: false, portal: true, url: nil, param: "q", debounce: 250, min_length: 1, **attrs)
@name = name
@items = items
@value = value
@placeholder = placeholder
@disabled = disabled
@portal = portal
@url = url
@param = param
@debounce = debounce
@min_length = min_length
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
data = {
controller: "wabi--combobox",
"wabi--combobox-name-value": @name,
"wabi--combobox-items-value": @items.to_json,
"wabi--combobox-value-value": @value,
"wabi--combobox-placeholder-value": @placeholder,
"wabi--combobox-disabled-value": @disabled.to_s,
"wabi--combobox-portal-value": @portal.to_s,
}
# Async keys emitted only when a URL is given (sync mode stays
# byte-identical). Symbol keys to match the base hash above.
# NOTE: these defaults (param "q", debounce 250, min_length 1) mirror
# the controller's static-value defaults — keep the two in sync.
if @url
data[:"wabi--combobox-url-value"] = @url
data[:"wabi--combobox-param-value"] = @param
data[:"wabi--combobox-debounce-value"] = @debounce.to_s
data[:"wabi--combobox-min-length-value"] = @min_length.to_s
end
div(
**@attrs,
data: data,
class: merge_class("relative", user_class)
) do
# Hidden input mirrors the selected VALUE for form submission. The
# visible <input> carries the label; without this Rails forms would
# receive "Ruby on Rails" instead of "rails".
input(
type: "hidden",
name: @name,
value: @value,
data: { "wabi--combobox-target": "hiddenInput" }
)
yield if block_given?
end
end
end
end
end
app/components/ui/combobox_label.rb
# frozen_string_literal: true
require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
module Components
module UI
class ComboboxLabel < Wabi::Base
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
label(
data: { "wabi--combobox-target": "label" },
class: merge_class("text-sm font-medium leading-none mb-2 block", user_class)
) do
yield if block_given?
end
end
end
end
end
app/components/ui/combobox_control.rb
# frozen_string_literal: true
require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
module Components
module UI
class ComboboxControl < Wabi::Base
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
div(
data: { "wabi--combobox-target": "control" },
class: merge_class("relative flex items-center", user_class)
) do
yield if block_given?
end
end
end
end
end
app/components/ui/combobox_input.rb
# frozen_string_literal: true
require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
module Components
module UI
class ComboboxInput < Wabi::Base
INPUT_CLASS = "flex h-10 w-full rounded-md border border-input bg-background " \
"px-3 py-2 text-sm ring-offset-background " \
"placeholder:text-muted-foreground " \
"focus-visible:outline-none focus-visible:ring-2 " \
"focus-visible:ring-ring focus-visible:ring-offset-2 " \
"disabled:cursor-not-allowed disabled:opacity-50"
def initialize(**attrs)
@attrs = attrs
end
def view_template
user_class = @attrs.delete(:class)
input(
type: "text",
data: { "wabi--combobox-target": "input" },
class: merge_class(INPUT_CLASS, user_class)
)
end
end
end
end
app/components/ui/combobox_trigger.rb
# frozen_string_literal: true
require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
module Components
module UI
class ComboboxTrigger < Wabi::Base
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
aria_label = @attrs.delete(:"aria-label") || @attrs.delete(:aria_label) || "Toggle options"
button(
type: "button",
"aria-label": aria_label,
data: { "wabi--combobox-target": "trigger" },
class: merge_class(
"absolute right-2 top-1/2 -translate-y-1/2 h-6 w-6 " \
"flex items-center justify-center opacity-50 hover:opacity-100",
user_class
)
) do
if block_given?
yield
else
# Default chevron-down SVG icon (inline, decorative — hidden from AT)
raw safe(<<~SVG)
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
aria-hidden="true" focusable="false"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
SVG
end
end
end
end
end
end
app/components/ui/combobox_positioner.rb
# frozen_string_literal: true
require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
module Components
module UI
class ComboboxPositioner < Wabi::Base
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
div(
data: { "wabi--combobox-target": "positioner" },
class: merge_class("z-50", user_class)
) do
yield if block_given?
end
end
end
end
end
app/components/ui/combobox_content.rb
# frozen_string_literal: true
require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
module Components
module UI
class ComboboxContent < Wabi::Base
variants do
base "z-50 max-h-96 min-w-[var(--reference-width,8rem)] overflow-y-auto rounded-md border border-input " \
"bg-popover text-popover-foreground shadow-md p-1 " \
"transition-opacity duration-150 ease-out motion-reduce:transition-none " \
"data-[state=open]:opacity-100 data-[state=closed]:opacity-0 " \
"data-[state=closed]:pointer-events-none list-none m-0"
end
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
ul(
data: { "wabi--combobox-target": "content" },
"data-state": "closed",
inert: true,
class: merge_class(tokens, user_class)
) do
yield if block_given?
end
end
end
end
end
app/components/ui/combobox_item.rb
# frozen_string_literal: true
require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
module Components
module UI
class ComboboxItem < Wabi::Base
ITEM_CLASS = "relative flex cursor-default select-none items-center rounded-sm py-1.5 px-2 " \
"text-sm outline-none " \
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground " \
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
def initialize(value:, disabled: false, **attrs)
@value = value
@disabled = disabled
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
# data-disabled emitted ONLY when @disabled is true. The Tailwind
# `data-[disabled]:*` variants match attribute PRESENCE regardless
# of value, so emitting `data-wabi-disabled="false"` would still trigger
# the disabled styling — keep it absent in the not-disabled case.
# When @disabled is true, the SSR attribute matches Zag's hydration
# output (`isItemDisabled` callback in the controller's collection).
item_data = {
"wabi--combobox-target": "item",
"wabi-value": @value,
}
item_data[:disabled] = "true" if @disabled
li(
data: item_data,
class: merge_class(ITEM_CLASS, user_class)
) do
yield if block_given?
end
end
end
end
end
app/components/ui/combobox_item_indicator.rb
# frozen_string_literal: true
require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
module Components
module UI
class ComboboxItemIndicator < Wabi::Base
def initialize(**attrs)
@attrs = attrs
end
def view_template
user_class = @attrs.delete(:class)
span(
hidden: true,
data: { "wabi--combobox-target": "itemIndicator" },
class: merge_class("absolute right-2 flex h-3.5 w-3.5 items-center justify-center", user_class)
)
end
end
end
end
app/components/ui/combobox_loading.rb
# frozen_string_literal: true
require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
module Components
module UI
class ComboboxLoading < Wabi::Base
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
# Always present in the DOM so the aria-live region is registered with
# the accessibility tree at page load. Visually hidden (sr-only) when
# inactive; the controller removes that class to reveal content.
div(
"aria-live": "polite",
"aria-atomic": "true",
data: { "wabi--combobox-target": "loading" },
class: merge_class("sr-only px-2 py-1.5 text-sm text-muted-foreground", user_class)
) do
yield if block_given?
end
end
end
end
end
app/components/ui/combobox_error.rb
# frozen_string_literal: true
require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
module Components
module UI
class ComboboxError < Wabi::Base
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
# Always present in the DOM so the aria-live region is registered with
# the accessibility tree at page load. Visually hidden (sr-only) when
# inactive; the controller removes that class to reveal content.
div(
"aria-live": "polite",
"aria-atomic": "true",
data: { "wabi--combobox-target": "error" },
class: merge_class("sr-only px-2 py-1.5 text-sm text-destructive", user_class)
) do
yield if block_given?
end
end
end
end
end
Accessibility
- role="combobox" with aria-expanded, aria-controls, aria-activedescendant managed by Zag.
- Arrow Down / Up move highlight through filtered items; Enter selects the highlighted item.
- Escape closes the listbox and clears the highlight; Home / End jump to first / last item.
- Typing filters the collection in real time via the configured itemToString predicate.
- Trigger button is exposed as a separate control with aria-haspopup="listbox".