Radio Group
A set of checkable buttons where only one can be checked at a time.
Default
A basic radio group with three options.
Sizes
Radio buttons come in three sizes.
Small
Default
Large
Horizontal Layout
Radio group can be displayed horizontally.
Disabled
Radio buttons can be disabled individually or as a group.
With Description
Radio options with additional description text. Click anywhere on the card to select.
Form Example
A typical form with a radio group for selecting notification preferences.
FreeRadio Group
Single-choice form groups.
Install with the Proa CLI
$ proa ui add radio_groupInstalls the reviewed component source, dependencies, shared support files, and required legal notices.
CLI and registry setup →recipe.rs
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Radio Group recipe — typed root axes and named compound slots.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Size of each radio item.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "size")]
pub enum RadioGroupSize {
Sm,
#[default]
Default,
Lg,
}
impl RadioGroupSize {
pub const fn as_str(self) -> &'static str {
match self {
Radio Group source
Browse and copy the reviewed source included with this Free component.
controlled.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! rsjs-controlled radio group pilot.
use proa_core::{DataLoader, WebContext, WebRenderSync, WriteBuf, WriteError};
use rsjs::Handler;
use super::{RadioGroupOrientation, RadioGroupSize};
/// A single option for [`ControlledRadioGroup`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, rsjs::ClientProps)]
pub struct ControlledRadioOption {
pub value: &'static str,
pub label: &'static str,
pub id: &'static str,
pub disabled: bool,
}
impl ControlledRadioOption {
pub const fn new(value: &'static str, label: &'static str, id: &'static str) -> Self {
Self {
value,
label,
id,
disabled: false,
}
}
pub const fn disabled(value: &'static str, label: &'static str, id: &'static str) -> Self {
Self {
value,
label,
id,
disabled: true,
}
}
}
/// Minimal controlled radio group for proving rsjs composition over repeated
/// form controls.
///
/// This intentionally stays narrower than [`super::RadioGroup`]. It exercises
/// parent-owned selected state, repeated radio inputs, loop-local labels/ids,
/// and typed `event.current_value()` callback payloads.
pub struct ControlledRadioGroup {
pub name: &'static str,
pub value: String,
pub options: &'static [ControlledRadioOption],
pub orientation: RadioGroupOrientation,
pub size: RadioGroupSize,
pub class: Option<&'static str>,
pub on_change: Handler<String>,
}
impl ControlledRadioGroup {
pub fn new(
name: &'static str,
value: String,
options: &'static [ControlledRadioOption],
on_change: Handler<String>,
) -> Self {
Self {
name,
value,
options,
orientation: RadioGroupOrientation::Vertical,
size: RadioGroupSize::Default,
class: None,
on_change,
}
}
}
#[rsjs::rsjs(component, client)]
impl<B: WriteBuf, L: DataLoader> WebRenderSync<L, B> for ControlledRadioGroup {
fn render(self, cx: &mut WebContext<L, B>) -> Result<(), WriteError> {
let value = rsjs::signal(self.value);
let style = ::proa_ui::components::radio_group::RADIO_GROUP_RECIPE.resolve(
::proa_ui::components::radio_group::RadioGroupRecipeProps {
orientation: Some(self.orientation),
size: Some(self.size),
},
);
let orientation = style.orientation().as_str();
let size = style.size().as_str();
proa_macros::html_sync! {
<div
data_controlled_radio_group
data_radio_group
data_scope={::proa_ui::components::radio_group::RADIO_GROUP_ANATOMY.scope()}
data_part="root"
data_slot="radio-group"
role="radiogroup"
data_name={rsjs::server_attr(self.name)}
data_value={value.get()}
data_orientation={orientation}
data_size={size}
aria_orientation={orientation}
class={rsjs::server_attr(proa_macros::text!("{} {}", style, self.class))}
>
{for option in self.options { proa_macros::html_sync! {
<label data_controlled_radio_option data_scope={::proa_ui::components::radio_group::RADIO_GROUP_ANATOMY.scope()} data_part="option" data_slot="radio-group-option" for={option.id} class={::proa_ui::components::radio_group::RADIO_GROUP_RECIPE.option}>
<input type="radio" data_scope={::proa_ui::components::radio_group::RADIO_GROUP_ANATOMY.scope()} data_part="item" data_slot="radio-group-item" data_state={if value.get() == option.value { "checked" } else { "unchecked" }} data_checked={value.get() == option.value} data_disabled={if option.disabled { "true" } else { "false" }} data_size={size} id={option.id} name={rsjs::server_attr(self.name)} value={option.value} checked={value.get() == option.value} disabled={option.disabled} class={::proa_ui::components::radio_group::RADIO_GROUP_RECIPE.item} on_change={|event: rsjs::ChangeEvent<rsjs::elem::Input>| self.on_change.call(event.current_value())} />
<span>{option.label}</span>
</label>
} }}
</div>
}
.render(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
const OPTIONS: [ControlledRadioOption; 3] = [
ControlledRadioOption::new("pickup", "Pickup", "delivery-pickup"),
ControlledRadioOption::new("courier", "Courier", "delivery-courier"),
ControlledRadioOption::disabled("drone", "Drone", "delivery-drone"),
];
#[test]
fn controlled_radio_group_renders() {
let html = proa_core::render_to_string(ControlledRadioGroup {
name: "delivery",
value: "courier".to_string(),
options: &OPTIONS,
orientation: RadioGroupOrientation::Vertical,
size: RadioGroupSize::Default,
class: Some("extra-radio"),
on_change: rsjs::noop_handler(),
});
assert!(html.contains("data-controlled-radio-group"));
assert!(html.contains("data-radio-group"));
assert!(html.contains("data-slot=\"radio-group\""));
assert!(html.contains("role=\"radiogroup\""));
assert!(html.contains("data-orientation=\"vertical\""));
assert!(html.contains("aria-orientation=\"vertical\""));
assert!(html.contains("data-size=\"default\""));
assert!(html.contains("data-value=\"courier\""));
assert!(html.contains("--radio-group-item-size:1rem"));
assert!(html.contains("data-slot=\"radio-group-option\""));
assert!(html.contains("data-slot=\"radio-group-item\""));
assert_eq!(
html.matches(r#"data-scope="radio-group""#).count(),
7,
"{html}"
);
for part in ["root", "option", "item"] {
assert!(
::proa_ui::components::radio_group::RADIO_GROUP_ANATOMY.contains(part),
"undeclared anatomy part {part}"
);
assert!(
html.contains(&format!(r#"data-part="{part}""#)),
"missing {part}: {html}"
);
}
assert!(html.contains("data-state=\"checked\""));
assert!(html.contains("data-state=\"unchecked\""));
assert!(html.contains("data-checked=\"true\""));
assert!(html.contains("data-disabled=\"true\""), "{html}");
assert!(html.contains("name=\"delivery\""));
assert!(html.contains("value=\"courier\""));
assert!(html.contains("checked"));
assert!(html.contains("disabled"));
assert!(html.contains("Pickup"));
assert!(html.contains("Courier"));
assert!(html.contains("extra-radio"));
}
#[test]
fn controlled_radio_group_propagates_recipe_axes_from_root() {
let html = proa_core::render_to_string(ControlledRadioGroup {
name: "delivery",
value: "pickup".to_string(),
options: &OPTIONS,
orientation: RadioGroupOrientation::Horizontal,
size: RadioGroupSize::Lg,
class: None,
on_change: rsjs::noop_handler(),
});
assert!(html.contains(r#"data-orientation="horizontal""#));
assert!(html.contains(r#"data-size="lg""#));
assert!(html.contains(RadioGroupOrientation::Horizontal.classes()));
assert!(html.contains("--radio-group-item-size:1.25rem"));
}
}
md.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Markdown rendering for the radio group component.
#[cfg(feature = "rsjs")]
use super::ControlledRadioGroup;
use super::{RadioGroup, RadioGroupItem};
use crate::components::md_support::{block_boundary, line_boundary};
use proa_core::WebContext;
use proa_core::{MdRenderSync, WriteBuf, WriteError};
use proa_macros::md_sync;
/// RadioGroup renders its items as a bullet list followed by a blank line.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for RadioGroup<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
self.children.render_md(cx)?;
block_boundary(cx)?;
Ok(())
}
}
/// RadioGroupItem renders as a list item with the selected option marked:
/// `- (•) value` when checked, `- ( ) value` otherwise.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf> MdRenderSync<Loader, B> for RadioGroupItem {
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
let label = self.aria_label.unwrap_or(self.value);
// Fresh line, not a blank-line boundary: consecutive items in a
// group must stay adjacent or the list would be split.
line_boundary(cx)?;
cx.write_md_list_indent()?;
if self.checked {
md_sync! { "- (•) " { label } "\n" }.render_md(cx)
} else {
md_sync! { "- ( ) " { label } "\n" }.render_md(cx)
}
}
}
/// ControlledRadioGroup renders each option as a list item with the
/// selected option marked.
#[cfg(feature = "rsjs")]
impl<Loader: ::proa_core::DataLoader, B: WriteBuf> MdRenderSync<Loader, B>
for ControlledRadioGroup
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
for option in self.options {
cx.write_md_list_indent()?;
if self.value == option.value {
md_sync! { "- (•) " { option.label } "\n" }.render_md(cx)?;
} else {
md_sync! { "- ( ) " { option.label } "\n" }.render_md(cx)?;
}
}
block_boundary(cx)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn radio_item_unchecked() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
RadioGroupItem::new("free", "plan")
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "- ( ) free\n");
}
#[test]
fn radio_item_checked_uses_aria_label() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
RadioGroupItem {
aria_label: Some("Pro plan"),
..RadioGroupItem::checked("pro", "plan")
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "- (•) Pro plan\n");
}
#[test]
fn radio_group_renders_items_as_list() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
RadioGroup::new(
"plan",
(
RadioGroupItem::new("free", "plan"),
RadioGroupItem::checked("pro", "plan"),
),
)
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("- ( ) free\n"), "output: {output}");
assert!(output.contains("- (•) pro\n"), "output: {output}");
}
/// A RadioGroup composed after mid-line prose must be blank-line
/// separated from the prose, while its consecutive items stay adjacent
/// (no blank line between them, or the list would be split).
#[test]
fn radio_group_after_prose_separates_but_items_stay_adjacent() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
cx.write_md_prepared_static(b"intro prose").unwrap();
RadioGroup::new(
"plan",
(
RadioGroupItem::new("free", "plan"),
RadioGroupItem::checked("pro", "plan"),
),
)
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(
output.contains("intro prose\n\n- ( ) free"),
"group must be blank-line separated from prose, output: {output}"
);
assert!(
output.contains("- ( ) free\n- (•) pro\n"),
"consecutive items must have no blank line between them, output: {output}"
);
}
#[cfg(feature = "rsjs")]
#[test]
fn controlled_radio_group_marks_selected() {
use super::super::{ControlledRadioOption, RadioGroupOrientation, RadioGroupSize};
const OPTIONS: [ControlledRadioOption; 2] = [
ControlledRadioOption::new("pickup", "Pickup", "d-pickup"),
ControlledRadioOption::new("courier", "Courier", "d-courier"),
];
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
ControlledRadioGroup {
name: "delivery",
value: "courier".into(),
options: &OPTIONS,
orientation: RadioGroupOrientation::Vertical,
size: RadioGroupSize::Default,
class: None,
on_change: rsjs::noop_handler(),
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("- ( ) Pickup\n"), "output: {output}");
assert!(output.contains("- (•) Courier\n"), "output: {output}");
}
}
mod.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
#![allow(clippy::module_inception)]
//! RadioGroup component - a set of radio buttons where only one can be selected
//!
//! The RadioGroup component provides a styled set of radio buttons with
//! support for form integration via proa_ir.
use crate::Anatomy;
#[cfg(feature = "rsjs")]
mod controlled;
mod md;
mod radio_group;
mod recipe;
/// Stable DOM parts shared by static and controlled radio groups.
pub const RADIO_GROUP_ANATOMY: Anatomy = Anatomy::new("radio-group", &["root", "option", "item"]);
// Re-export radio group types
#[cfg(feature = "rsjs")]
pub use controlled::{ControlledRadioGroup, ControlledRadioOption};
pub use radio_group::{RadioGroup, RadioGroupItem};
// Re-export recipe types
pub use recipe::{
radio_group_classes, radio_group_recipe, radio_item_classes, radio_item_classes_with_size,
RadioGroupItemStyle, RadioGroupOrientation, RadioGroupRecipe, RadioGroupRecipeProps,
RadioGroupSize, RadioGroupStyle, RADIO_GROUP_RECIPE,
};
#[cfg(test)]
mod anatomy_tests {
use super::*;
#[test]
fn anatomy_declares_every_public_part() {
assert_eq!(RADIO_GROUP_ANATOMY.scope(), "radio-group");
assert_eq!(RADIO_GROUP_ANATOMY.parts(), &["root", "option", "item"]);
}
}
radio_group.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! RadioGroup component implementation
//!
//! A set of radio buttons where only one can be selected at a time.
//! Supports form integration with proa_ir for reactive state management.
use super::recipe::{
RadioGroupOrientation, RadioGroupRecipeProps, RadioGroupSize, RADIO_GROUP_RECIPE,
};
use super::RADIO_GROUP_ANATOMY;
use crate::free_web::{binding_to_js_attr, JsonAttr};
#[cfg(test)]
use proa_core::ctx::Ctx;
use proa_core::{WebContext, WebRenderSync, WriteBuf};
use proa_ir::{FieldRegistration, ReactiveAttr};
use proa_macros::html_sync;
/// RadioGroup container component
///
/// Wraps a set of [`RadioGroupItem`] components and owns group-level state.
///
/// The root renders as a disabled-capable `fieldset`, so `disabled` is
/// inherited by every native radio input without each item repeating it.
/// Native radio `name` attributes still have to be present on each item; use
/// [`super::ControlledRadioGroup`] when the root must construct and own the
/// complete option collection.
///
/// # Example
///
/// ```ignore
/// use proa_ui::{RadioGroup, RadioGroupItem};
/// use proa_macros::html_sync;
///
/// html_sync! {
/// {RadioGroup::new(
/// "plan",
/// html_sync! {
/// <div class="flex items-center space-x-2">
/// {RadioGroupItem { id: Some("r1"), ..RadioGroupItem::new("free", "plan") }}
/// <label for="r1">"Free"</label>
/// </div>
/// <div class="flex items-center space-x-2">
/// {RadioGroupItem { id: Some("r2"), ..RadioGroupItem::new("pro", "plan") }}
/// <label for="r2">"Pro"</label>
/// </div>
/// }
/// )}
/// }.render(cx);
/// ```
pub struct RadioGroup<C> {
/// The name attribute for all radio inputs in the group
pub name: &'static str,
/// The currently selected value (for SSR)
pub value: Option<&'static str>,
/// Whether the entire group is disabled
pub disabled: ReactiveAttr<bool>,
/// Whether the group is required
pub required: bool,
/// Form registration data (from form.register_*)
pub registration: Option<FieldRegistration>,
/// Orientation: vertical (default) or horizontal
pub orientation: RadioGroupOrientation,
/// Additional CSS classes
pub class: Option<&'static str>,
/// ARIA label for the group
pub aria_label: Option<&'static str>,
/// Children (RadioGroupItem components)
pub children: C,
}
impl<C> RadioGroup<C> {
/// Create a new radio group with the given name
pub const fn new(name: &'static str, children: C) -> Self {
Self {
name,
value: None,
disabled: ReactiveAttr::static_value(false),
required: false,
registration: None,
orientation: RadioGroupOrientation::Vertical,
class: None,
aria_label: None,
children,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for RadioGroup<C>
{
#[inline(always)]
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let style = RADIO_GROUP_RECIPE.resolve(RadioGroupRecipeProps {
orientation: Some(self.orientation),
size: None,
});
let data_field = self.registration.as_ref().map(|reg| reg.data_field);
let name_attr = self
.registration
.as_ref()
.map(|reg| reg.name)
.unwrap_or(self.name);
let rules_attr = JsonAttr(self.registration.as_ref().map(|reg| reg.rules.as_slice()));
let validate_on = self.registration.as_ref().map(|reg| match reg.validate_on {
proa_ir::ValidationTrigger::Blur => "blur",
proa_ir::ValidationTrigger::Input => "input",
proa_ir::ValidationTrigger::Change => "change",
proa_ir::ValidationTrigger::Submit => "submit",
});
let is_disabled = self.disabled.default;
let disabled_binding_string = binding_to_js_attr(&self.disabled.binding);
let disabled_binding = disabled_binding_string.as_deref();
html_sync! {
<fieldset
data-radio-group
role="radiogroup"
class={proa_macros::text!("{} {}", style, self.class)}
data-scope={RADIO_GROUP_ANATOMY.scope()}
data-part="root"
data-slot="radio-group"
data-name={name_attr}
data-value={self.value}
data-field={data_field}
data-rules={rules_attr}
data-validate-on={validate_on}
data-orientation={style.orientation().as_str()}
data-size={style.size().as_str()}
data-bind-disabled={disabled_binding}
disabled={is_disabled}
aria-label={self.aria_label}
aria-orientation={style.orientation().as_str()}
aria-required={self.required}
>
{self.children}
</fieldset>
}
.render(cx)
}
}
/// RadioGroupItem component - individual radio button
///
/// Must be used within a [`RadioGroup`]. Because this is a native radio input,
/// its `name` remains explicit and should match the group name.
///
/// # Example
///
/// ```ignore
/// use proa_ui::RadioGroupItem;
///
/// RadioGroupItem {
/// id: Some("option-1"),
/// checked: true,
/// ..RadioGroupItem::new("option1", "plan")
/// }
/// ```
pub struct RadioGroupItem {
/// The value of this radio item
pub value: &'static str,
/// The name attribute (should match the group name)
pub name: &'static str,
/// Whether this item is checked (for SSR)
pub checked: bool,
/// Whether this item is disabled
pub disabled: ReactiveAttr<bool>,
/// Size variant
pub size: Option<RadioGroupSize>,
/// Additional CSS classes
pub class: Option<&'static str>,
/// ID for the input element
pub id: Option<&'static str>,
/// ARIA label
pub aria_label: Option<&'static str>,
/// ARIA describedby
pub aria_describedby: Option<&'static str>,
/// ARIA invalid state
pub aria_invalid: ReactiveAttr<bool>,
}
impl RadioGroupItem {
/// Create a new radio item with the given value
pub const fn new(value: &'static str, name: &'static str) -> Self {
Self {
value,
name,
checked: false,
disabled: ReactiveAttr::static_value(false),
size: None,
class: None,
id: None,
aria_label: None,
aria_describedby: None,
aria_invalid: ReactiveAttr::static_value(false),
}
}
/// Create a checked radio item
pub const fn checked(value: &'static str, name: &'static str) -> Self {
Self {
value,
name,
checked: true,
disabled: ReactiveAttr::static_value(false),
size: None,
class: None,
id: None,
aria_label: None,
aria_describedby: None,
aria_invalid: ReactiveAttr::static_value(false),
}
}
}
impl<B: WriteBuf, L: ::proa_core::DataLoader> WebRenderSync<L, B> for RadioGroupItem {
#[inline(always)]
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let style = RADIO_GROUP_RECIPE.resolve_item(self.size);
let is_disabled = self.disabled.default;
let is_aria_invalid = self.aria_invalid.default;
let disabled_binding_string = binding_to_js_attr(&self.disabled.binding);
let aria_invalid_binding_string = binding_to_js_attr(&self.aria_invalid.binding);
let disabled_binding = disabled_binding_string.as_deref();
let aria_invalid_binding = aria_invalid_binding_string.as_deref();
html_sync! {
<input
type="radio"
class={proa_macros::text!("{} {}", style, self.class)}
id={self.id}
name={self.name}
value={self.value}
checked={self.checked}
disabled={is_disabled}
aria-label={self.aria_label}
aria-describedby={self.aria_describedby}
aria-invalid={is_aria_invalid}
data-scope={RADIO_GROUP_ANATOMY.scope()}
data-part="item"
data-slot="radio-group-item"
data-size={style.size().as_str()}
data-bind-disabled={disabled_binding}
data-bind-aria-invalid={aria_invalid_binding}
/>
}
.render(cx)
}
}
/// Convert a Binding to a JavaScript expression string
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn radio_group_renders_with_defaults() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
RadioGroup::new(
"plan",
html_sync! {
<div>"Test"</div>
},
)
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("role=\"radiogroup\""));
assert!(html.contains("data-slot=\"radio-group\""));
assert!(html.contains("data-name=\"plan\""));
assert!(html.contains("data-orientation=\"vertical\""));
assert!(html.contains("aria-orientation=\"vertical\""));
assert!(html.contains("data-size=\"default\""));
assert!(html.contains("<fieldset"));
assert!(html.contains("--radio-group-item-size:1rem"));
}
#[test]
fn radio_group_item_renders_with_defaults() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
RadioGroupItem::new("option1", "plan")
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("<input"));
assert!(html.contains("type=\"radio\""));
assert!(html.contains("name=\"plan\""));
assert!(html.contains("value=\"option1\""));
assert!(html.contains("data-slot=\"radio-group-item\""));
assert!(html.contains("data-size=\"default\""));
assert!(html.contains("--radio-group-item-size:1rem"));
}
#[test]
fn radio_group_item_renders_checked() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
RadioGroupItem::checked("option1", "plan")
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(
html.contains(" checked ") || html.contains(" checked>") || html.contains(" checked/")
);
}
#[test]
fn radio_group_item_renders_disabled() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
RadioGroupItem {
disabled: true.into(),
..RadioGroupItem::new("option1", "plan")
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains(" disabled"));
}
#[test]
fn radio_group_horizontal_orientation() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
RadioGroup {
orientation: RadioGroupOrientation::Horizontal,
..RadioGroup::new(
"plan",
html_sync! {
<div>"Test"</div>
},
)
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("flex"));
assert!(html.contains("data-orientation=\"horizontal\""));
assert!(html.contains("aria-orientation=\"horizontal\""));
}
#[test]
fn group_disabled_state_is_owned_by_native_fieldset() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
RadioGroup {
disabled: true.into(),
..RadioGroup::new("plan", RadioGroupItem::new("option1", "plan"))
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.starts_with("<fieldset"));
assert!(html.contains(" disabled"));
assert!(html.contains("type=\"radio\""));
}
#[test]
fn radio_group_with_form_registration() {
use proa_ir::ValidationTrigger;
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
let registration = FieldRegistration {
name: "subscription_plan",
data_field: "subscriptionPlan",
rules: vec![proa_ir::ValidationRule::Required {
message: "Please select a plan",
}],
validate_on: ValidationTrigger::Change,
};
RadioGroup {
registration: Some(registration),
..RadioGroup::new(
"plan",
html_sync! {
<div>"Test"</div>
},
)
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-name=\"subscription_plan\""));
assert!(html.contains("data-field=\"subscriptionPlan\""));
assert!(html.contains("data-validate-on=\"change\""));
assert!(html.contains("data-rules=\""));
}
#[test]
fn radio_group_size_is_reasonable() {
assert!(std::mem::size_of::<RadioGroupItem>() < 512);
}
#[test]
fn radio_item_fuses_custom_class_and_size_style() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
RadioGroupItem {
size: Some(RadioGroupSize::Lg),
class: Some("custom-radio"),
..RadioGroupItem::new("option1", "plan")
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-size=\"lg\""));
assert!(html.contains("--radio-group-item-size:1.25rem"));
assert!(html.contains("custom-radio"));
}
#[test]
fn static_markup_matches_public_anatomy() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
RadioGroup::new("plan", RadioGroupItem::new("pro", "plan"))
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert_eq!(
html.matches(r#"data-scope="radio-group""#).count(),
2,
"{html}"
);
for part in ["root", "item"] {
assert!(RADIO_GROUP_ANATOMY.contains(part));
assert!(
html.contains(&format!(r#"data-part="{part}""#)),
"missing {part}: {html}"
);
}
assert!(html.contains(r#"data-slot="radio-group""#));
assert!(html.contains(r#"data-slot="radio-group-item""#));
}
}
recipe.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Radio Group recipe — typed root axes and named compound slots.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Size of each radio item.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "size")]
pub enum RadioGroupSize {
Sm,
#[default]
Default,
Lg,
}
impl RadioGroupSize {
pub const fn as_str(self) -> &'static str {
match self {
Self::Sm => "sm",
Self::Default => "default",
Self::Lg => "lg",
}
}
/// Compatibility size utility for callers of the pre-recipe API.
pub const fn classes(self) -> &'static str {
match self {
Self::Sm => "size-3.5",
Self::Default => "size-4",
Self::Lg => "size-5",
}
}
/// A CSS variable lets a controlled group resolve size once at its root.
const fn variable_classes(self) -> &'static str {
match self {
Self::Sm => "[--radio-group-item-size:0.875rem]",
Self::Default => "[--radio-group-item-size:1rem]",
Self::Lg => "[--radio-group-item-size:1.25rem]",
}
}
}
/// Orientation of the radio group.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "orientation")]
pub enum RadioGroupOrientation {
#[default]
Vertical,
Horizontal,
}
impl RadioGroupOrientation {
pub const fn as_str(self) -> &'static str {
match self {
Self::Vertical => "vertical",
Self::Horizontal => "horizontal",
}
}
pub const fn classes(self) -> &'static str {
match self {
Self::Vertical => RADIO_GROUP_VERTICAL,
Self::Horizontal => RADIO_GROUP_HORIZONTAL,
}
}
}
/// Optional author inputs accepted by [`RadioGroupRecipe::resolve`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct RadioGroupRecipeProps {
pub orientation: Option<RadioGroupOrientation>,
pub size: Option<RadioGroupSize>,
}
/// Named classes for the Radio Group root, item, and controlled option.
#[derive(Debug, Clone, Copy)]
pub struct RadioGroupRecipe {
pub root: &'static str,
pub item: &'static str,
pub option: &'static str,
orientation_default: RadioGroupOrientation,
size_default: RadioGroupSize,
}
impl RadioGroupRecipe {
pub const fn resolve(&self, props: RadioGroupRecipeProps) -> RadioGroupStyle {
let orientation = match props.orientation {
Some(orientation) => orientation,
None => self.orientation_default,
};
let size = match props.size {
Some(size) => size,
None => self.size_default,
};
RadioGroupStyle {
root: self.root,
orientation,
size,
}
}
pub const fn resolve_item(&self, size: Option<RadioGroupSize>) -> RadioGroupItemStyle {
let size = match size {
Some(size) => size,
None => self.size_default,
};
RadioGroupItemStyle {
item: self.item,
size,
}
}
const fn legacy_group_classes(&self, orientation: RadioGroupOrientation) -> &'static str {
orientation.classes()
}
}
/// Fully resolved, zero-allocation Radio Group root style.
#[must_use]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RadioGroupStyle {
root: &'static str,
orientation: RadioGroupOrientation,
size: RadioGroupSize,
}
impl RadioGroupStyle {
pub const fn orientation(self) -> RadioGroupOrientation {
self.orientation
}
pub const fn size(self) -> RadioGroupSize {
self.size
}
}
impl AttrValue for RadioGroupStyle {
#[inline(always)]
fn should_render(&self) -> bool {
true
}
#[inline(always)]
fn render_attr_value<B: WriteBuf, L: DataLoader>(
&self,
cx: &mut WebContext<L, B>,
) -> Result<(), WriteError> {
if !self.root.is_empty() {
cx.out.extend_static(self.root.as_bytes())?;
cx.out.extend_static(b" ")?;
}
cx.out
.extend_static(self.orientation.classes().as_bytes())?;
cx.out.extend_static(b" ")?;
cx.out
.extend_static(self.size.variable_classes().as_bytes())
}
}
/// Fully resolved, zero-allocation standalone item style.
#[must_use]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RadioGroupItemStyle {
item: &'static str,
size: RadioGroupSize,
}
impl RadioGroupItemStyle {
pub const fn size(self) -> RadioGroupSize {
self.size
}
}
impl AttrValue for RadioGroupItemStyle {
#[inline(always)]
fn should_render(&self) -> bool {
true
}
#[inline(always)]
fn render_attr_value<B: WriteBuf, L: DataLoader>(
&self,
cx: &mut WebContext<L, B>,
) -> Result<(), WriteError> {
cx.out.extend_static(self.item.as_bytes())?;
cx.out.extend_static(b" ")?;
cx.out
.extend_static(self.size.variable_classes().as_bytes())
}
}
pub const RADIO_GROUP_ROOT: &str = "";
pub const RADIO_GROUP_VERTICAL: &str = tw_join!("grid", "gap-3");
pub const RADIO_GROUP_HORIZONTAL: &str =
tw_join!("flex", "flex-row", "flex-wrap", "items-center", "gap-4");
pub const RADIO_GROUP_ITEM: &str = tw_join!(
"aspect-square",
"size-[var(--radio-group-item-size)]",
"shrink-0",
"rounded-full",
"border",
"border-input",
"shadow-xs",
"appearance-none",
"cursor-pointer",
"focus-visible:outline-hidden",
"focus-visible:ring-[3px]",
"focus-visible:ring-ring/50",
"focus-visible:border-ring",
"disabled:cursor-not-allowed",
"disabled:opacity-50",
"aria-invalid:border-destructive",
"aria-invalid:ring-destructive/20",
"dark:aria-invalid:ring-destructive/40",
"checked:border-primary",
"checked:bg-primary",
"bg-center",
"bg-no-repeat"
);
pub const RADIO_GROUP_OPTION: &str = tw_join!("inline-flex", "items-center", "gap-2");
/// Global named-slot Radio Group recipe.
pub const RADIO_GROUP_RECIPE: RadioGroupRecipe = RadioGroupRecipe {
root: RADIO_GROUP_ROOT,
item: RADIO_GROUP_ITEM,
option: RADIO_GROUP_OPTION,
orientation_default: RadioGroupOrientation::Vertical,
size_default: RadioGroupSize::Default,
};
pub const fn radio_group_recipe() -> &'static RadioGroupRecipe {
&RADIO_GROUP_RECIPE
}
impl crate::variant_spec::ComponentSpec for RadioGroupRecipe {
const NAME: &'static str = "RadioGroup";
fn base_classes(&self) -> &'static str {
self.root
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
vec![
crate::variant_spec::variant_metadata::<RadioGroupOrientation>(),
crate::variant_spec::variant_metadata::<RadioGroupSize>(),
]
}
fn anatomy(&self) -> Option<crate::Anatomy> {
Some(super::RADIO_GROUP_ANATOMY)
}
}
// Public compatibility helpers delegate to the canonical recipe.
pub const fn radio_group_classes(orientation: RadioGroupOrientation) -> &'static str {
RADIO_GROUP_RECIPE.legacy_group_classes(orientation)
}
pub const fn radio_item_classes() -> &'static str {
RADIO_GROUP_RECIPE.item
}
pub fn radio_item_classes_with_size(size: RadioGroupSize) -> proa_core::ClassList<3> {
proa_core::ClassList::new()
.add(RADIO_GROUP_RECIPE.item)
.add(size.variable_classes())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recipe_exposes_named_slots() {
let recipe = radio_group_recipe();
assert_eq!(recipe.root, "");
assert!(recipe.item.contains("radio-group-item-size"));
assert!(recipe.option.contains("inline-flex"));
}
#[test]
fn recipe_resolves_root_defaults_and_overrides() {
let default = radio_group_recipe().resolve(RadioGroupRecipeProps::default());
assert_eq!(default.orientation(), RadioGroupOrientation::Vertical);
assert_eq!(default.size(), RadioGroupSize::Default);
let custom = radio_group_recipe().resolve(RadioGroupRecipeProps {
orientation: Some(RadioGroupOrientation::Horizontal),
size: Some(RadioGroupSize::Lg),
});
assert_eq!(custom.orientation(), RadioGroupOrientation::Horizontal);
assert_eq!(custom.size(), RadioGroupSize::Lg);
assert!(custom.orientation().classes().contains("flex-row"));
assert!(custom.size().variable_classes().contains("1.25rem"));
}
#[test]
fn item_style_resolves_its_local_size() {
let style = radio_group_recipe().resolve_item(Some(RadioGroupSize::Sm));
assert_eq!(style.size(), RadioGroupSize::Sm);
assert!(style.size().variable_classes().contains("0.875rem"));
}
#[test]
fn compatibility_helpers_delegate_to_recipe() {
assert_eq!(
radio_group_classes(RadioGroupOrientation::Vertical),
RadioGroupOrientation::Vertical.classes()
);
assert_eq!(radio_item_classes(), RADIO_GROUP_RECIPE.item);
assert_eq!(RadioGroupSize::Sm.classes(), "size-3.5");
assert_eq!(RadioGroupSize::Default.classes(), "size-4");
assert_eq!(RadioGroupSize::Lg.classes(), "size-5");
}
#[test]
fn component_metadata_uses_root_axes_and_canonical_anatomy() {
use crate::variant_spec::ComponentSpec;
let variants = RADIO_GROUP_RECIPE.variant_metadata();
assert_eq!(variants.len(), 2);
assert_eq!(variants[0].name, "orientation");
assert_eq!(variants[1].name, "size");
assert_eq!(
RADIO_GROUP_RECIPE.anatomy(),
Some(super::super::RADIO_GROUP_ANATOMY)
);
}
}