ComponentsThemes
Palette
Default
Stone
Rose
Blue
Green
Violet
Yellow
Orange
6

← Components

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/vanilla

Pin @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

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.

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".