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/vanillaPin @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
- Apple
- Banana
- Cherry
FRUITS = [
{ value: "apple", label: "Apple" },
{ value: "banana", label: "Banana" },
{ value: "cherry", label: "Cherry" },
]
render Components::UI::Select.new(
name: "fruit",
placeholder: "Choose a fruit",
items: FRUITS,
class: "w-72"
) do
render Components::UI::SelectTrigger.new(aria_label: "Choose a fruit") do
render Components::UI::SelectValue.new
end
render Components::UI::SelectContent.new do
FRUITS.each do |fruit|
render Components::UI::SelectItem.new(value: fruit[:value]) { fruit[:label] }
end
end
end
Disabled
Pass disabled: true to disable the whole control, or mark a single entry in items: with disabled: true to skip just that option.
- Free
- Pro
- Team
- Free
- Pro
- Team
PLANS = [
{ value: "free", label: "Free" },
{ value: "pro", label: "Pro" },
{ value: "team", label: "Team", disabled: true },
]
# A single disabled option
render Components::UI::Select.new(
name: "plan",
placeholder: "Choose a plan",
items: PLANS,
class: "w-72"
) do
render Components::UI::SelectTrigger.new(aria_label: "Choose a plan") do
render Components::UI::SelectValue.new
end
render Components::UI::SelectContent.new do
PLANS.each do |plan|
render Components::UI::SelectItem.new(value: plan[:value]) { plan[:label] }
end
end
end
# The whole control disabled
render Components::UI::Select.new(
name: "plan_locked",
placeholder: "Unavailable",
items: PLANS,
disabled: true,
class: "w-72"
) do
render Components::UI::SelectTrigger.new(aria_label: "Plan (unavailable)") do
render Components::UI::SelectValue.new
end
render Components::UI::SelectContent.new do
PLANS.each do |plan|
render Components::UI::SelectItem.new(value: plan[:value]) { plan[:label] }
end
end
end
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.