RadioGroup
Group of radio inputs with keyboard navigation.
Installation
bin/rails g wabi:add radio_group
bin/importmap pin @zag-js/radio-group
bin/importmap pin @zag-js/vanillaPin @zag-js/radio-group 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
render Components::UI::RadioGroup.new(name: "plan", value: "pro") do
render Components::UI::RadioGroupItem.new(value: "free") { "Free" }
render Components::UI::RadioGroupItem.new(value: "pro") { "Pro" }
render Components::UI::RadioGroupItem.new(value: "enterprise", disabled: true) { "Enterprise" }
end
Source
app/components/ui/radio_group.rb
# frozen_string_literal: true
require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
module Components
module UI
class RadioGroup < Wabi::Base
# +aria_label:+ provides an accessible name for the radiogroup landmark.
# Callers should supply a concise string describing the group, e.g.
# <tt>aria_label: "Subscription plan"</tt>. Alternatively pass
# <tt>aria_labelledby: "some-heading-id"</tt> via **attrs to reference
# an existing heading; callers must supply at least one of the two.
def initialize(name: nil, value: nil, disabled: false, aria_label: nil, **attrs)
@name = name
@value = value
@disabled = disabled
@aria_label = aria_label
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
div(
**@attrs,
role: "radiogroup",
aria: { label: @aria_label },
data: {
controller: "wabi--radio-group",
"wabi--radio-group-name-value": @name,
"wabi--radio-group-value-value": @value,
"wabi--radio-group-disabled-value": @disabled.to_s,
},
class: merge_class("grid gap-2", user_class),
&block
)
end
end
end
end
app/components/ui/radio_group_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 RadioGroupItem < Wabi::Base
# w-fit keeps the clickable label sized to its content — without it, as a
# flex/grid item the <label> stretches full-width and the empty space beside
# the text becomes a click target that selects the radio.
ITEM_CLASS = "inline-flex w-fit items-center gap-2 cursor-pointer " \
"data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50"
CONTROL_CLASS = "aspect-square h-4 w-4 rounded-full border border-primary " \
"text-primary shadow ring-offset-background " \
"focus-visible:outline-none focus-visible:ring-2 " \
"focus-visible:ring-ring focus-visible:ring-offset-2 " \
"data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 " \
"flex items-center justify-center"
def initialize(value:, disabled: false, **attrs)
@value = value
@disabled = disabled
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
label(
data: {
"wabi--radio-group-target": "item",
"wabi-value": @value,
"wabi-disabled": @disabled.to_s,
},
class: merge_class(ITEM_CLASS, user_class)
) do
# Hidden input for form submission (Zag fills in checked + name via getHiddenInputProps).
input(
type: "radio",
value: @value,
data: { "wabi--radio-group-target": "hiddenInput", "wabi-value": @value },
class: "sr-only"
)
# The control (radio dot container)
span(
data: { "wabi--radio-group-target": "itemControl", "wabi-value": @value },
class: CONTROL_CLASS
) do
render Components::UI::RadioGroupIndicator.new
end
# The text label
span(data: { "wabi--radio-group-target": "itemText", "wabi-value": @value }) do
yield if block_given?
end
end
end
end
end
end
app/components/ui/radio_group_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 RadioGroupIndicator < Wabi::Base
def initialize(**attrs)
@attrs = attrs
end
def view_template
user_class = @attrs.delete(:class)
span(
data: { "wabi--radio-group-target": "itemIndicator" },
"data-state": "unchecked",
class: merge_class(
"h-2.5 w-2.5 rounded-full bg-primary " \
"data-[state=checked]:opacity-100 data-[state=unchecked]:opacity-0 " \
"transition-opacity motion-reduce:transition-none",
user_class
)
)
end
end
end
end
Accessibility
- role="radiogroup" wrapper + role="radio" per item.
- Arrow keys move focus + selection. Space selects the focused item.
- When inside a <label>, clicking the label toggles the radio.
- Hidden <input type="radio"> per item for form submission.