Button Group source
Inspect and copy the reviewed source included with this Free component.
Free source · 4 files
recipe.rs
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Button Group recipe — typed root appearance and named slot classes.
//!
//! The root resolves orientation once. Descendant slots read their static
//! classes directly from [`BUTTON_GROUP_RECIPE`], and the separator responds
//! to the root's semantic `data-orientation` through a named Tailwind group.
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
mod base {
use crate::tw_join;
pub const BUTTON_GROUP_ROOT: &str = tw_join!(
"group/button-group",
"flex",
"w-fit",
"items-stretch",
"[&>*]:focus-visible:z-10",
"[&>*]:focus-visible:relative",
"[&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit",
"[&>input]:flex-1",
"has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md",
"has-[>[data-slot=button-group]]:gap-2"
);
pub const BUTTON_GROUP_TEXT: &str = tw_join!(
"bg-muted",
"text-muted-foreground",
"flex",
"items-center",
"gap-2",
"rounded-md",
"border",
"border-input",
"px-3",
"text-sm",
"font-medium",
"[&_svg]:pointer-events-none",
"[&_svg:not([class*='size-'])]:size-4"
);
/// A separator is vertical in a horizontal group and horizontal in a
/// vertical group. The named-group selectors make that relationship
/// authoritative at the root instead of requiring every separator to
/// repeat an orientation prop.
pub const BUTTON_GROUP_SEPARATOR: &str = tw_join!(
"bg-input",
"relative",
"!m-0",
"self-stretch",
"w-px",
"h-auto",
"group-data-[orientation=horizontal]/button-group:w-px",
"group-data-[orientation=horizontal]/button-group:h-auto",
"group-data-[orientation=vertical]/button-group:w-auto",
"group-data-[orientation=vertical]/button-group:h-px"
);
}
mod orientations {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "orientation")]
pub enum ButtonGroupOrientation {
#[default]
Horizontal,
Vertical,
}
impl ButtonGroupOrientation {
pub const fn classes(self) -> &'static str {
match self {
Self::Horizontal => "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
Self::Vertical => "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
}
}
pub const fn data_value(self) -> &'static str {
match self {
Self::Horizontal => "horizontal",
Self::Vertical => "vertical",
}
}
}
}
pub use orientations::ButtonGroupOrientation;
/// Optional root inputs accepted by [`ButtonGroupRecipe::resolve`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ButtonGroupRecipeProps {
pub orientation: Option<ButtonGroupOrientation>,
}
/// Static named slots for the Button Group compound component.
#[derive(Debug, Clone, Copy)]
pub struct ButtonGroupRecipe {
pub root: &'static str,
pub text: &'static str,
pub separator: &'static str,
orientation_default: ButtonGroupOrientation,
}
impl ButtonGroupRecipe {
/// Resolve the effective root orientation without allocating class storage.
pub const fn resolve(
&'static self,
props: ButtonGroupRecipeProps,
) -> ResolvedButtonGroupRecipe {
let orientation = match props.orientation {
Some(orientation) => orientation,
None => self.orientation_default,
};
ResolvedButtonGroupRecipe {
recipe: self,
orientation,
}
}
}
/// Fully resolved, streamable Button Group root style.
#[must_use]
#[derive(Debug, Clone, Copy)]
pub struct ResolvedButtonGroupRecipe {
recipe: &'static ButtonGroupRecipe,
orientation: ButtonGroupOrientation,
}
impl ResolvedButtonGroupRecipe {
pub const fn orientation(self) -> ButtonGroupOrientation {
self.orientation
}
}
impl AttrValue for ResolvedButtonGroupRecipe {
#[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.recipe.root.as_bytes())?;
cx.out.extend_static(b" ")?;
cx.out.extend_static(self.orientation.classes().as_bytes())
}
}
/// Global, zero-initialization Button Group recipe.
pub const BUTTON_GROUP_RECIPE: ButtonGroupRecipe = ButtonGroupRecipe {
root: base::BUTTON_GROUP_ROOT,
text: base::BUTTON_GROUP_TEXT,
separator: base::BUTTON_GROUP_SEPARATOR,
orientation_default: ButtonGroupOrientation::Horizontal,
};
pub const fn button_group_recipe() -> &'static ButtonGroupRecipe {
&BUTTON_GROUP_RECIPE
}
/// Compatibility resolver for callers of the former helper.
///
/// New render code should call [`ButtonGroupRecipe::resolve`] directly.
pub const fn button_group_classes(
orientation: ButtonGroupOrientation,
) -> ResolvedButtonGroupRecipe {
BUTTON_GROUP_RECIPE.resolve(ButtonGroupRecipeProps {
orientation: Some(orientation),
})
}
/// Compatibility accessors for the former standalone class constants.
pub const fn button_group_text_classes() -> &'static str {
BUTTON_GROUP_RECIPE.text
}
pub const fn button_group_separator_classes() -> &'static str {
BUTTON_GROUP_RECIPE.separator
}
impl crate::variant_spec::ComponentSpec for ButtonGroupRecipe {
const NAME: &'static str = "ButtonGroup";
fn base_classes(&self) -> &'static str {
self.root
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
vec![crate::variant_spec::variant_metadata::<
ButtonGroupOrientation,
>()]
}
}
/// Export metadata for TypeScript code generation.
#[cfg(feature = "metadata-export")]
pub fn export_metadata() -> crate::variant_spec::ComponentMetadata {
crate::variant_spec::component_metadata(&BUTTON_GROUP_RECIPE)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recipe_exposes_named_slots() {
let recipe = button_group_recipe();
assert!(recipe.root.contains("group/button-group"));
assert!(recipe.text.contains("bg-muted"));
assert!(recipe
.separator
.contains("group-data-[orientation=vertical]"));
}
#[test]
fn recipe_resolves_defaults_and_overrides() {
let default = BUTTON_GROUP_RECIPE.resolve(ButtonGroupRecipeProps::default());
assert_eq!(default.orientation(), ButtonGroupOrientation::Horizontal);
let vertical = BUTTON_GROUP_RECIPE.resolve(ButtonGroupRecipeProps {
orientation: Some(ButtonGroupOrientation::Vertical),
});
assert_eq!(vertical.orientation(), ButtonGroupOrientation::Vertical);
assert!(vertical.orientation().classes().contains("flex-col"));
}
#[test]
fn separator_layout_is_owned_by_root_orientation() {
let separator = BUTTON_GROUP_RECIPE.separator;
assert!(separator.contains("group-data-[orientation=horizontal]/button-group:w-px"));
assert!(separator.contains("group-data-[orientation=vertical]/button-group:h-px"));
assert!(!separator
.split_ascii_whitespace()
.any(|class| class.starts_with("data-[orientation=")));
}
}
FreeButton Group
Grouped adjacent controls.
Install with the Proa CLI
proa ui add button_groupInstalls the reviewed component source, dependencies, shared support files, and required legal notices.
CLI and registry setup