Radio Group source

Inspect and copy the reviewed source included with this Free component.

Free source · 5 files

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 {
            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)
        );
    }
}

Copy this source with the Free Proa UI components it imports; those component files remain available from their own source pages.

Licensed under MIT OR Apache-2.0. Third-party notices remain attached to the installed payload.

Open Markdown
FreeRadio Group

Single-choice form groups.

Install with the Proa CLI
proa ui add radio_group

Installs 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 {

Search

Type at least 2 characters

Customize

Make it yours

Palette

Radius

Overview
Components
Settings

Build your own

Themed Proa UI surface

Preview
Components52
RecipesTyped
customer-portal