ComponentsThemes
Palette
Default
Stone
Rose
Blue
Green
Violet
Yellow
Orange
6

← Components

Select

Accessible custom select with keyboard navigation, powered by Zag.js.

Installation

bin/rails g wabi:add select
bin/importmap pin @zag-js/select
bin/importmap pin @zag-js/vanilla

Pin @zag-js/select 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

Disabled

Pass disabled: true to disable the whole control, or mark a single entry in items: with disabled: true to skip just that option.

Source

app/components/ui/select.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 Select < Wabi::Base
      def initialize(id: nil, name: nil, value: nil, disabled: false, items: [], placeholder: "Select an option", portal: true, **attrs)
        @id          = id
        @name        = name
        @value       = value
        @disabled    = disabled
        @items       = items
        @placeholder = placeholder
        @portal      = portal
        @attrs       = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        div(
          id: @id,
          data: {
            controller: "wabi--select",
            "wabi--select-items-value":       @items.to_json,
            "wabi--select-name-value":        @name,
            "wabi--select-value-value":       @value,
            "wabi--select-disabled-value":    @disabled.to_s,
            "wabi--select-placeholder-value": @placeholder,
            "wabi--select-portal-value":      @portal.to_s,
          },
          class: merge_class("relative inline-block", user_class)
        ) do
          # Real <select> kept visually hidden for form submission and a11y.
          # Zag's getHiddenSelectProps only sets attributes, so we render the
          # <option> set ourselves (mirroring @items); the controller's render()
          # then syncs the selected value for form submission.
          select(
            name: @name,
            disabled: @disabled,
            data: { "wabi--select-target": "hiddenSelect" },
            class: "sr-only",
            tabindex: "-1",
            "aria-hidden": "true"
          ) do
            # Leading empty option: with no selection the form submits "" rather
            # than the native default (the first <option>).
            option(value: "", selected: @value.nil? || @value == "") { @placeholder }
            @items.each do |item|
              item = item.transform_keys(&:to_sym) if item.is_a?(Hash)
              option(value: item[:value], selected: item[:value].to_s == @value.to_s) { item[:label] }
            end
          end
          yield if block_given?
        end
      end
    end
  end
end

app/components/ui/select_trigger.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class SelectTrigger < Wabi::Base
      variants do
        base "flex h-10 w-full items-center justify-between 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"
      end

      def initialize(aria_label: nil, **attrs)
        @aria_label = aria_label
        @attrs      = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        button(
          type: "button",
          # OPTIONAL accessible name for the combobox trigger. The button's only
          # child is SelectValue, which is empty until a value is chosen, so axe
          # (button-name) flags it as unnamed. Set aria_label: to fix it.
          "aria-label": @aria_label,
          data: { "wabi--select-target": "trigger" },
          class: merge_class(tokens, user_class)
        ) do
          yield if block_given?
          span(
            "aria-hidden": "true",
            data: { "wabi--select-target": "indicator" },
            class: "ml-2 h-4 w-4 opacity-50"
          ) do
            raw(safe('<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>'))
          end
        end
      end
    end
  end
end

app/components/ui/select_content.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class SelectContent < Wabi::Base
      variants do
        base "z-50 max-h-96 min-w-[var(--reference-width,8rem)] overflow-hidden rounded-md border border-input " \
             "bg-popover text-popover-foreground shadow-md " \
             "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"
      end

      def initialize(**attrs)
        @attrs = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        div(
          data: { "wabi--select-target": "positioner" },
          class: "z-50"
        ) do
          div(
            data: { "wabi--select-target": "content" },
            "data-state": "closed",
            inert: true,
            class: merge_class(tokens, user_class)
          ) do
            ul(
              data: { "wabi--select-target": "list" },
              class: "p-1"
            ) do
              yield if block_given?
            end
          end
        end
      end
    end
  end
end

app/components/ui/select_item.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class SelectItem < Wabi::Base
      variants do
        base "relative flex w-full cursor-default select-none items-center rounded-sm " \
             "py-1.5 pl-8 pr-2 text-sm outline-none " \
             "data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground " \
             "data-[disabled]:pointer-events-none data-[disabled]:opacity-50 " \
             "data-[state=checked]:font-medium"
      end

      def initialize(value:, **attrs)
        @value = value
        @attrs = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        li(
          role: "option",
          data: {
            "wabi--select-target": "item",
            "wabi-value": @value,
          },
          class: merge_class(tokens, user_class)
        ) do
          # Item indicator (checkmark) — shown only on the selected item.
          span(
            data: { "wabi--select-target": "itemIndicator" },
            class: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center",
            hidden: true
          ) do
            raw(safe('<svg aria-hidden="true" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>'))
          end
          span(data: { "wabi--select-target": "itemText" }) do
            yield if block_given?
          end
        end
      end
    end
  end
end

app/components/ui/select_label.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class SelectLabel < Wabi::Base
      variants { base "py-1.5 pl-8 pr-2 text-sm font-semibold text-muted-foreground" }

      def initialize(**attrs)
        @attrs = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        # role="presentation" is required: the parent <ul> carries role="listbox"
        # (set by Zag at runtime), and a bare <li> is not a valid listbox child.
        # Permitted owned elements are option, group, or presentation/none.
        li(role: "presentation", class: merge_class(tokens, user_class)) do
          yield if block_given?
        end
      end
    end
  end
end

app/components/ui/select_value.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class SelectValue < Wabi::Base
      def initialize(**attrs)
        @attrs = attrs
      end

      def view_template
        user_class = @attrs.delete(:class)
        span(
          data: { "wabi--select-target": "valueText" },
          class: merge_class("pointer-events-none truncate", user_class)
        )
      end
    end
  end
end

Accessibility

  • combobox role; trigger announces selected value and "expanded/collapsed" state.
  • arrow keys navigate options; type-ahead jumps to matching items; Enter selects; Escape closes.
  • focus is trapped inside the listbox while open; first focus lands on selected option.
  • scroll lock prevents background scroll while the listbox is open.