Radio Group
A set of options where only one can be selected at a time.
Installation
Included in poetry-ui — available as
poetry_radio_group
the moment you've installed Poetry,
with no per-component step. To own the source and edit it, copy it into your app:
bin/rails g poetry:add radio-group
Default
<%= poetry_radio_group(name: "density", value: "comfortable", label: "Density") do |group| %>
<% group.with_item(value: "default", label: "Default") %>
<% group.with_item(value: "comfortable", label: "Comfortable") %>
<% group.with_item(value: "compact", label: "Compact") %>
<% end %>
Cards
<%# A choice-card recipe: caller classes turn each item row (a direct-child
div of the radiogroup) into a bordered, selectable card, and
has-[[aria-checked=true]] lights up the chosen card by reading the
button's own state. Same real API as the horizontal layout - only the
class: passthrough changes; the keyboard and hidden-radio form wiring
are untouched. %>
<%= poetry_radio_group(
name: "workspace_plan", value: "team", label: "Workspace plan",
class: "max-w-md [&>div]:items-start [&>div]:rounded-lg [&>div]:border [&>div]:p-4 " \
"[&>div]:cursor-pointer [&>div]:transition-colors " \
"[&>div]:has-[[aria-checked=true]]:border-primary " \
"[&>div]:has-[[aria-checked=true]]:bg-primary/5"
) do |group| %>
<% group.with_item(
value: "solo", class: "mt-0.5",
label: tag.span(class: "grid gap-1 leading-snug") {
safe_join([
tag.span("Solo", class: "text-sm font-medium"),
tag.span("$0 / month. One editor and unlimited public projects.",
class: "text-sm text-muted-foreground")
])
}
) %>
<% group.with_item(
value: "team", class: "mt-0.5",
label: tag.span(class: "grid gap-1 leading-snug") {
safe_join([
tag.span("Team", class: "text-sm font-medium"),
tag.span("$12 / editor / month. Shared workspaces and private projects.",
class: "text-sm text-muted-foreground")
])
}
) %>
<% group.with_item(
value: "enterprise", class: "mt-0.5",
label: tag.span(class: "grid gap-1 leading-snug") {
safe_join([
tag.span("Enterprise", class: "text-sm font-medium"),
tag.span("Custom pricing with SSO, audit logs, and a dedicated contact.",
class: "text-sm text-muted-foreground")
])
}
) %>
<% end %>
Disabled
<div class="flex flex-col gap-6">
<%# A disabled option mid-list: skipped by Tab AND filtered from the
arrow-key collection. %>
<%= poetry_radio_group(name: "tier", value: "free", label: "Tier") do |group| %>
<% group.with_item(value: "free", label: "Free") %>
<% group.with_item(value: "pro", label: "Pro (unavailable)", disabled: true) %>
<% group.with_item(value: "enterprise", label: "Enterprise") %>
<% end %>
<%# Root-level disabled: the locked-in choice still renders its dot,
but disabled hidden radios submit nothing. %>
<%= poetry_radio_group(name: "billing", value: "yearly", disabled: true, label: "Billing") do |group| %>
<% group.with_item(value: "monthly", label: "Monthly") %>
<% group.with_item(value: "yearly", label: "Yearly") %>
<% end %>
</div>
Fieldset
<%# The group inside a real <fieldset>: legend_variant: :label renders
the legend at label size and hint: describes the whole group. The
legend names the fieldset, not the inner radiogroup - label: gives
the group its own accessible name (same text). %>
<div class="w-full max-w-xs">
<%= poetry_fieldset(legend: "Subscription plan", legend_variant: :label,
hint: "Yearly and lifetime plans offer significant savings.") do %>
<%= poetry_radio_group(name: "plan", value: "monthly",
label: "Subscription plan") do |group| %>
<% group.with_item(value: "monthly", label: "Monthly ($9.99/month)") %>
<% group.with_item(value: "yearly", label: "Yearly ($99.99/year)") %>
<% group.with_item(value: "lifetime", label: "Lifetime ($299.99)") %>
<% end %>
<% end %>
</div>
Horizontal layout
<%# Horizontal layout is a caller class - the source has no orientation
styling. The keyboard stays both-axis (all four arrows). %>
<%= poetry_radio_group(name: "align", value: "left", label: "Alignment", class: "grid-flow-col") do |group| %>
<% group.with_item(value: "left", label: "Left") %>
<% group.with_item(value: "center", label: "Center") %>
<% group.with_item(value: "right", label: "Right") %>
<% end %>
In a field
You can change this anytime.
<%# Field-bound (the FormBuilder shape): group: true drops the label's
for= (the root is a role-bearing div) and control_attributes names
the group via aria-labelledby plus the id + aria-describedby hint
wiring. %>
<% field = Poetry::Ui::Field::Component.new(
id: "radio-subscription", label_text: "Subscription",
hint: "You can change this anytime.", required: true, group: true
) %>
<div class="w-80">
<%= render field do %>
<%= poetry_radio_group(name: "subscription", value: "monthly", required: true,
**field.control_attributes.transform_keys(&:to_sym)) do |group| %>
<% group.with_item(value: "monthly", label: "Monthly") %>
<% group.with_item(value: "yearly", label: "Yearly") %>
<% end %>
<% end %>
</div>
Invalid
<%# The field-level error treatment: invalid: sets aria-invalid on every
item (the destructive ring) - wired by Field/FormBuilder from model
errors. Nothing is checked, so nothing submits and presence
validation stays honest. %>
<%= poetry_radio_group(name: "plan", invalid: true, required: true, label: "Plan") do |group| %>
<% group.with_item(value: "monthly", label: "Monthly") %>
<% group.with_item(value: "yearly", label: "Yearly") %>
<% end %>
With description
<%# Each option pairs a title with a secondary line of description. The
item's label: accepts rich content, so the whole title+description stack
renders inside the <label> and a click anywhere on the copy checks the
option. self-start keeps the dot aligned to the first line rather than
centering it against the two-line block. %>
<div class="max-w-md">
<%= poetry_radio_group(name: "delivery", value: "standard", label: "Delivery speed") do |group| %>
<% group.with_item(
value: "standard", class: "mt-0.5 self-start",
label: tag.span(class: "grid gap-1 leading-snug") {
safe_join([
tag.span("Standard", class: "text-sm font-medium"),
tag.span("Arrives in 5-7 business days at no extra cost.",
class: "text-sm text-muted-foreground")
])
}
) %>
<% group.with_item(
value: "express", class: "mt-0.5 self-start",
label: tag.span(class: "grid gap-1 leading-snug") {
safe_join([
tag.span("Express", class: "text-sm font-medium"),
tag.span("Arrives in 2 business days for a flat $8 fee.",
class: "text-sm text-muted-foreground")
])
}
) %>
<% group.with_item(
value: "overnight", class: "mt-0.5 self-start",
label: tag.span(class: "grid gap-1 leading-snug") {
safe_join([
tag.span("Overnight", class: "text-sm font-medium"),
tag.span("Order by 6 PM and it lands on your doorstep tomorrow.",
class: "text-sm text-muted-foreground")
])
}
) %>
<% end %>
</div>
API
Poetry::Ui::RadioGroup::Component — options are
constructor keywords (the poetry_* helper forwards them);
slots are composed inside the block. Generated from the gem's source documentation.
| Option | Type | Details | Description |
|---|---|---|---|
| disabled: | Boolean | defaults to false |
Disables every item (root-level). |
| invalid: | Boolean | defaults to false |
aria-invalid on the items (the destructive ring) - set by Field/FormBuilder from model errors. |
| label: | String | The group accessible name -> aria-label (or wire aria-labelledby yourself) - REQUIRED: an unlabelled radiogroup fails the audit. | |
| loop: | Boolean | defaults to true |
Arrow-key navigation wraps at the ends. |
| name: | String | required | The shared form name for every hidden radio (FormBuilder derives object[method]). |
| orientation: | Symbol | one of: both, vertical, horizontal; defaults to :both |
Keyboard axis: :both allows all four arrows (the standard radio pattern); :vertical/:horizontal restrict the axis. No visual effect. |
| required: | Boolean | defaults to false |
aria-required on the ROOT only - never native required on the hidden inputs (constraint-validation focus would land on an aria-hidden input). |
| value: | String | The checked item's value; nil = nothing checked (pre-selection). |
Slots
| Writer | Description |
|---|---|
| with_item | One item per option: a real button[role=radio] carrying its own hidden native radio; label: renders the dot beside a paired Label. variant: :card renders the choice-card row instead - title (+ optional description:) inside a selectable bordered label, the radio pinned to the right. |
Styling
Every part carries a stable data-slot attribute — target
[data-slot=…] from your own CSS to restyle it. State rides
data attributes on the parts below. This contract is verified against rendered DOM in CI.
| Part | Description |
|---|---|
| [data-slot=radio-group] | The role=radiogroup root - one Tab stop; items (and their label-pairing rows) render as direct children |
| [data-slot=radio-group-item] | A button[role=radio] per item - carries its own hidden native radio as a sibling |
| [data-slot=radio-group-indicator] | The theme-sized centering box holding the checked dot (the dot itself is the themed .cn-radio-group-indicator-icon span) - hidden (the native attribute, toggled by the controller) while unchecked |
| [data-slot=radio-group-card] | The choice-card row (variant: :card) - a <label> for= the radio button, so the whole card toggles; the checked treatments key on data-checked inside it |
| [data-slot=radio-group-card-content] | Text column of a choice-card item (variant: :card) - title and description stack inside the card label |
| [data-slot=radio-group-card-title] | The choice card's title line (the item label:) |
| [data-slot=radio-group-card-description] | Muted copy under the choice card's title (description:) |
State attributes
| Part | Attribute | Condition | Values |
|---|---|---|---|
| radio-group | data-disabled | disabled: is set on the root - every item disables with it | — |
| radio-group-item | data-checked | the checked item (the controller writes the pair and aria-checked together on every item) | — |
| radio-group-item | data-unchecked | every other item | — |
| radio-group-item | data-disabled | the item (or the whole group) is disabled - also the roving-focus collection filter | — |
| radio-group-item | data-value | always - the item's value (keys the checked-value machine) | — |
Wiring
The Stimulus surface each element carries — declared in the component, verified against rendered DOM in CI. Bare actions fire on the element's default event.
| Element | Controller | Wiring |
|---|---|---|
| root | poetry--core--radio-group | registers · value value (if value?) · entryCheck on poetry--core--roving-focus:entry |
| root | poetry--core--roving-focus | registers · value orientation · value loop · keydown on keydown |
| item | poetry--core--radio-group | check on click |
| input | poetry--core--radio-group | target input |