Checkbox
A control that allows the user to toggle between checked and unchecked states.
States
Checkboxes support three states: unchecked, checked, and indeterminate.
Unchecked
Checked
Indeterminate
Colors
Checkboxes support different color variants for the checked state.
Primary
Destructive
Success
Icon Variants
Checkboxes support different icon variants for the checked state.
Check (default)
Cross
Minus
Dot
With Label
Checkboxes can be combined with labels for accessibility.
Sizes
Checkboxes come in three sizes.
Small
Default
Large
Disabled
Checkboxes can be disabled to prevent interaction.
Form Example
A typical form with multiple checkboxes.
Boolean form control with states.
$ proa ui add checkboxInstalls the reviewed component source, dependencies, shared support files, and required legal notices.
CLI and registry setup →// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND MIT AND (ISC AND MIT)
//! Checkbox recipe - type-safe styling system for Checkbox component
//!
//! Provides size variants, color variants, icon variants, and base styling for the Checkbox component.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Base styles for the checkbox (without color-specific checked state styling)
mod base {
use super::*;
pub const CHECKBOX_BASE: &str = tw_join!(
// Layout - peer for label styling
"peer",
// Size and shape
"size-4",
"shrink-0",
"rounded-[4px]",
Checkbox source
Browse and copy the reviewed source included with this Free component.
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND MIT
//! Checkbox component implementation
#![allow(unused_must_use)]
use super::recipe::{
CheckboxColor, CheckboxIcon, CheckboxRecipeProps, CheckboxSize, CHECKBOX_RECIPE,
};
use super::CHECKBOX_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;
/// Checkbox-specific name for the shared three-state value.
pub type CheckboxState = crate::CheckedState;
/// Type-safe Checkbox component.
///
/// A form checkbox with support for checked, unchecked, and indeterminate states.
/// Follows the shadcn/ui checkbox design with Tailwind CSS styling.
///
/// # Example
///
/// ```ignore
/// use proa_ui::{Checkbox, CheckboxSize, CheckboxState};
/// use proa_macros::html_sync;
///
/// // Basic usage
/// html_sync! {
/// <div class="flex items-center gap-2">
/// {Checkbox {
/// id: Some("terms"),
/// ..Checkbox::new()
/// }}
/// <label for="terms">"Accept terms and conditions"</label>
/// </div>
/// }.render(cx);
///
/// // Checked state
/// html_sync! {
/// <div>
/// {Checkbox {
/// state: CheckboxState::Checked,
/// ..Checkbox::new()
/// }}
/// </div>
/// }.render(cx);
/// ```
pub struct Checkbox {
pub size: Option<CheckboxSize>,
/// The color variant (default: Primary)
pub color: Option<CheckboxColor>,
/// The icon to display when checked (default: Check)
pub icon: Option<CheckboxIcon>,
/// The current state of the checkbox
pub state: CheckboxState,
/// The name attribute for form submission
pub name: Option<&'static str>,
/// The value attribute for form submission
pub value: Option<&'static str>,
/// Whether the checkbox is disabled
pub disabled: ReactiveAttr<bool>,
/// Whether the checkbox is required
pub required: bool,
pub aria_label: Option<&'static str>,
pub aria_describedby: Option<&'static str>,
pub aria_invalid: ReactiveAttr<bool>,
/// Field registration data (from form.register_*)
pub registration: Option<FieldRegistration>,
pub class: Option<&'static str>,
pub id: Option<&'static str>,
}
impl Checkbox {
/// Create a new checkbox with default settings
pub const fn new() -> Self {
Self {
size: None,
color: None,
icon: None,
state: CheckboxState::Unchecked,
name: None,
value: None,
disabled: ReactiveAttr::static_value(false),
required: false,
aria_label: None,
aria_describedby: None,
aria_invalid: ReactiveAttr::static_value(false),
registration: None,
class: None,
id: None,
}
}
/// Create a checked checkbox
pub const fn checked() -> Self {
Self {
size: None,
color: None,
icon: None,
state: CheckboxState::Checked,
name: None,
value: None,
disabled: ReactiveAttr::static_value(false),
required: false,
aria_label: None,
aria_describedby: None,
aria_invalid: ReactiveAttr::static_value(false),
registration: None,
class: None,
id: None,
}
}
}
impl Default for Checkbox {
fn default() -> Self {
Self::new()
}
}
impl<B: WriteBuf, L: ::proa_core::DataLoader> WebRenderSync<L, B> for Checkbox {
#[inline(always)]
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let style = CHECKBOX_RECIPE.resolve(CheckboxRecipeProps {
size: self.size,
color: self.color,
});
let icon = self.icon.unwrap_or_default();
let icon_style = icon.style();
let is_disabled = self.disabled.default;
let is_aria_invalid = self.aria_invalid.default;
let form_state = crate::FormControlState {
disabled: is_disabled,
invalid: is_aria_invalid,
required: self.required,
read_only: false,
};
let is_checked = self.state == CheckboxState::Checked;
let is_indeterminate = self.state == CheckboxState::Indeterminate;
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();
let registration = self.registration.as_ref();
let rules_attr = JsonAttr(registration.map(|reg| reg.rules.as_slice()));
let data_field = registration.map(|reg| reg.data_field);
let name_attr = registration.map(|reg| reg.name).or(self.name);
let validate_on = registration.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",
});
html_sync! {
<input
type="checkbox"
class={proa_macros::text!("{} {}", style, self.class)}
style={proa_core::style_ok(icon_style)}
id={self.id}
name={name_attr}
value={self.value}
checked={is_checked}
disabled={is_disabled}
required={self.required}
aria-label={self.aria_label}
aria-describedby={self.aria_describedby}
aria-checked={self.state.aria_checked()}
aria-disabled={form_state.aria_disabled()}
aria-invalid={form_state.aria_invalid()}
aria-required={form_state.aria_required()}
data-checkbox
data-scope={CHECKBOX_ANATOMY.scope()}
data-part="root"
data-slot="checkbox"
data-state={self.state}
data-disabled={form_state.data_disabled()}
data-invalid={form_state.data_invalid()}
data-required={form_state.data_required()}
data-size={style.size().as_str()}
data-color={style.color().as_str()}
data-icon={icon.as_str()}
data-indeterminate={is_indeterminate}
data-field={data_field}
data-rules={rules_attr}
data-validate-on={validate_on}
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 checkbox_renders_with_defaults() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Checkbox::new().render(&mut cx).unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("<input"));
assert!(html.contains("type=\"checkbox\""));
assert!(html.contains("data-slot=\"checkbox\""));
assert!(html.contains("data-size=\"default\""));
assert!(html.contains("data-color=\"primary\""));
assert!(html.contains("data-icon=\"check\""));
assert!(html.contains("data-scope=\"checkbox\""));
assert!(html.contains("data-part=\"root\""));
assert!(html.contains("data-state=\"unchecked\""));
assert!(html.contains("aria-checked=\"false\""));
assert!(
!html.contains(" checked ")
&& !html.contains(" checked>")
&& !html.contains(" checked/")
);
}
#[test]
fn checkbox_renders_checked_state() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Checkbox {
state: CheckboxState::Checked,
..Checkbox::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("<input"));
assert!(html.contains("type=\"checkbox\""));
assert!(
html.contains(" checked ") || html.contains(" checked>") || html.contains(" checked/")
);
assert!(html.contains("data-state=\"checked\""));
assert!(html.contains("aria-checked=\"true\""));
}
#[test]
fn checkbox_renders_indeterminate_state() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Checkbox {
state: CheckboxState::Indeterminate,
..Checkbox::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-indeterminate"));
assert!(html.contains("data-state=\"indeterminate\""));
assert!(html.contains("aria-checked=\"mixed\""));
assert!(
!html.contains(" checked ")
&& !html.contains(" checked>")
&& !html.contains(" checked/")
);
}
#[test]
fn checkbox_handles_disabled_state() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Checkbox {
disabled: true.into(),
..Checkbox::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains(" disabled"));
assert!(html.contains("data-disabled=\"true\""));
assert!(html.contains("aria-disabled=\"true\""));
}
#[test]
fn checkbox_renders_with_id_and_name() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Checkbox {
id: Some("my-checkbox"),
name: Some("agree"),
value: Some("yes"),
..Checkbox::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("id=\"my-checkbox\""));
assert!(html.contains("name=\"agree\""));
assert!(html.contains("value=\"yes\""));
}
#[test]
fn checkbox_renders_aria_attributes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Checkbox {
aria_label: Some("Accept terms"),
aria_describedby: Some("terms-help"),
required: true,
..Checkbox::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("aria-label=\"Accept terms\""));
assert!(html.contains("aria-describedby=\"terms-help\""));
assert!(html.contains(" required"));
}
#[test]
fn checkbox_renders_with_custom_class() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Checkbox {
class: Some("custom-checkbox"),
..Checkbox::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("custom-checkbox"));
}
#[test]
fn checkbox_size_is_reasonable() {
assert!(std::mem::size_of::<Checkbox>() < 512);
const _CHECKBOX: Checkbox = Checkbox::new();
}
#[test]
fn checkbox_with_reactive_disabled() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
let disabled = !ReactiveAttr::form_state(false, "isValid");
Checkbox {
disabled,
..Checkbox::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("disabled"));
assert!(html.contains("data-bind-disabled"));
assert!(html.contains("formState.isValid"));
}
#[test]
fn checkbox_with_form_registration() {
use proa_ir::ValidationTrigger;
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
let registration = FieldRegistration {
name: "agree_terms",
data_field: "agreeTerms",
rules: vec![proa_ir::ValidationRule::Required {
message: "You must agree to the terms",
}],
validate_on: ValidationTrigger::Change,
};
Checkbox {
registration: Some(registration),
state: CheckboxState::Unchecked,
..Checkbox::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("name=\"agree_terms\""));
assert!(html.contains("data-field=\"agreeTerms\""));
assert!(html.contains("data-rules=\""));
assert!(html.contains("You must agree to the terms"));
assert!(html.contains("data-validate-on=\"change\""));
}
#[test]
fn checkbox_registration_overrides_name() {
use proa_ir::ValidationTrigger;
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
let registration = FieldRegistration {
name: "registered_name",
data_field: "registeredName",
rules: vec![],
validate_on: ValidationTrigger::Blur,
};
Checkbox {
name: Some("manual_name"),
registration: Some(registration),
..Checkbox::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("name=\"registered_name\""));
assert!(!html.contains("manual_name"));
assert!(html.contains("data-validate-on=\"blur\""));
}
}
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! rsjs-controlled checkbox pilot.
use proa_core::{DataLoader, WebContext, WebRenderSync, WriteBuf, WriteError};
use rsjs::Handler;
use super::{CheckboxColor, CheckboxRecipeProps, CheckboxSize, CHECKBOX_ANATOMY, CHECKBOX_RECIPE};
/// Minimal controlled checkbox for proving rsjs component composition.
///
/// This stays narrower than [`super::Checkbox`]: it exercises the
/// `checked` + `on_change` shape without pulling the full form-registration API
/// through rsjs.
pub struct ControlledCheckbox {
pub checked: bool,
pub label: &'static str,
pub size: CheckboxSize,
pub color: CheckboxColor,
pub class: Option<&'static str>,
pub on_change: Handler<bool>,
}
impl ControlledCheckbox {
pub fn new(checked: bool, label: &'static str, on_change: Handler<bool>) -> Self {
Self {
checked,
label,
size: CheckboxSize::Default,
color: CheckboxColor::Primary,
class: None,
on_change,
}
}
}
#[rsjs::rsjs(component, client)]
impl<B: WriteBuf, L: DataLoader> WebRenderSync<L, B> for ControlledCheckbox {
fn render(self, cx: &mut WebContext<L, B>) -> Result<(), WriteError> {
let checked = rsjs::signal(self.checked);
let style = CHECKBOX_RECIPE.resolve(CheckboxRecipeProps {
size: Some(self.size),
color: Some(self.color),
});
let size = style.size().as_str();
let color = style.color().as_str();
proa_macros::html_sync! {
<label
data_controlled_checkbox
data_slot="controlled-checkbox"
class={CHECKBOX_RECIPE.controlled_root}
>
<input
type="checkbox"
data_checkbox
data_scope={CHECKBOX_ANATOMY.scope()}
data_part="root"
data_slot="checkbox"
data_state={if checked.get() { "checked" } else { "unchecked" }}
data_size={size}
data_color={color}
class={rsjs::server_attr(proa_macros::text!("{} {}", style, self.class))}
checked={checked.get()}
aria_checked={if checked.get() { "true" } else { "false" }}
on_change={|event: rsjs::ChangeEvent<rsjs::elem::Input>| self.on_change.call(event.current_checked())}
/>
<span>{self.label}</span>
</label>
}
.render(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn controlled_checkbox_renders() {
let html = proa_core::render_to_string(ControlledCheckbox {
checked: true,
label: "Accept terms",
size: CheckboxSize::Default,
color: CheckboxColor::Primary,
class: Some("extra-checkbox"),
on_change: rsjs::noop_handler(),
});
assert!(html.contains("data-controlled-checkbox"));
assert!(html.contains("data-slot=\"controlled-checkbox\""));
assert!(html.contains(r#"data-scope="checkbox""#));
assert!(html.contains(r#"data-part="root""#));
assert!(html.contains(r#"data-state="checked""#));
assert!(html.contains("Accept terms"));
assert!(html.contains("checked"));
assert!(html.contains("aria-checked=\"true\""));
assert!(html.contains("extra-checkbox"));
}
#[test]
fn controlled_checkbox_uses_resolved_recipe_axes() {
let html = proa_core::render_to_string(ControlledCheckbox {
checked: false,
label: "Ship updates",
size: CheckboxSize::Lg,
color: CheckboxColor::Success,
class: None,
on_change: rsjs::noop_handler(),
});
assert!(html.contains(r#"data-size="lg""#));
assert!(html.contains(r#"data-color="success""#));
assert!(html.contains(CheckboxSize::Lg.classes()));
assert!(html.contains(CheckboxColor::Success.classes()));
}
}
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Markdown rendering for the checkbox component.
#[cfg(feature = "rsjs")]
use super::ControlledCheckbox;
use super::{Checkbox, CheckboxState};
use crate::components::md_support::line_boundary;
use proa_core::WebContext;
use proa_core::{MdRenderSync, WriteBuf, WriteError};
use proa_macros::md_sync;
/// Checkbox renders as a task-list item: `- [x]` / `- [ ]` / `- [-]` for
/// indeterminate, followed by the aria-label when one is set.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf> MdRenderSync<Loader, B> for Checkbox {
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
// Fresh line (not a full blank-line boundary): a lone checkbox after
// prose needs its own line, but consecutive task items must stay
// adjacent with no blank line between them.
line_boundary(cx)?;
cx.write_md_list_indent()?;
match self.state {
CheckboxState::Checked => md_sync! { "- [x]" }.render_md(cx)?,
CheckboxState::Indeterminate => md_sync! { "- [-]" }.render_md(cx)?,
CheckboxState::Unchecked => md_sync! { "- [ ]" }.render_md(cx)?,
}
if let Some(label) = self.aria_label {
md_sync! { " " { label } }.render_md(cx)?;
}
md_sync! { "" "\n" }.render_md(cx)
}
}
/// ControlledCheckbox renders as a task-list item with its label.
#[cfg(feature = "rsjs")]
impl<Loader: ::proa_core::DataLoader, B: WriteBuf> MdRenderSync<Loader, B> for ControlledCheckbox {
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
line_boundary(cx)?;
cx.write_md_list_indent()?;
if self.checked {
md_sync! { "- [x] " { self.label } "\n" }.render_md(cx)
} else {
md_sync! { "- [ ] " { self.label } "\n" }.render_md(cx)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn checkbox_unchecked_renders_task_item() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Checkbox::new().render_md(&mut cx).unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "- [ ]\n");
}
#[test]
fn checkbox_checked_with_label() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Checkbox {
state: CheckboxState::Checked,
aria_label: Some("Accept terms"),
..Checkbox::new()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "- [x] Accept terms\n");
}
#[test]
fn checkbox_indeterminate_mark() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Checkbox {
state: CheckboxState::Indeterminate,
..Checkbox::new()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "- [-]\n");
}
/// A checkbox composed after mid-line prose must start its task item on
/// a fresh line, while consecutive checkboxes stay blank-line free.
#[test]
fn checkbox_after_prose_starts_on_fresh_line() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
cx.write_md_prepared_static(b"intro prose").unwrap();
Checkbox {
state: CheckboxState::Checked,
aria_label: Some("Accept terms"),
..Checkbox::new()
}
.render_md(&mut cx)
.unwrap();
Checkbox {
aria_label: Some("Subscribe"),
..Checkbox::new()
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(
output.contains("intro prose\n- [x] Accept terms"),
"output: {output}"
);
assert!(
output.contains("- [x] Accept terms\n- [ ] Subscribe\n"),
"output: {output}"
);
}
#[cfg(feature = "rsjs")]
#[test]
fn controlled_checkbox_renders_label() {
use super::super::{CheckboxColor, CheckboxSize};
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
ControlledCheckbox {
checked: true,
label: "Enable alerts",
size: CheckboxSize::Default,
color: CheckboxColor::Primary,
class: None,
on_change: rsjs::noop_handler(),
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "- [x] Enable alerts\n");
}
}
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
#![allow(clippy::module_inception)]
//! Checkbox component - form checkbox with tri-state support
//!
//! The Checkbox component provides a styled checkbox with support for
//! checked, unchecked, and indeterminate states.
use crate::Anatomy;
mod checkbox;
#[cfg(feature = "rsjs")]
mod controlled;
mod md;
mod recipe;
/// Stable DOM root exposed by the native Checkbox control.
pub const CHECKBOX_ANATOMY: Anatomy = Anatomy::new("checkbox", &["root"]);
// Re-export checkbox types
pub use checkbox::{Checkbox, CheckboxState};
#[cfg(feature = "rsjs")]
pub use controlled::ControlledCheckbox;
// Re-export recipe types
pub use recipe::{
checkbox_classes, checkbox_recipe, CheckboxColor, CheckboxIcon, CheckboxRecipe,
CheckboxRecipeProps, CheckboxSize, CheckboxStyle, CHECKBOX_RECIPE,
};
#[cfg(test)]
mod anatomy_tests {
use super::*;
#[test]
fn anatomy_declares_the_native_control_root() {
assert_eq!(CHECKBOX_ANATOMY.scope(), "checkbox");
assert_eq!(CHECKBOX_ANATOMY.parts(), &["root"]);
}
}
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND MIT AND (ISC AND MIT)
//! Checkbox recipe - type-safe styling system for Checkbox component
//!
//! Provides size variants, color variants, icon variants, and base styling for the Checkbox component.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Base styles for the checkbox (without color-specific checked state styling)
mod base {
use super::*;
pub const CHECKBOX_BASE: &str = tw_join!(
// Layout - peer for label styling
"peer",
// Size and shape
"size-4",
"shrink-0",
"rounded-[4px]",
// Border
"border border-input",
// Background
"bg-background",
// Shadow
"shadow-xs",
// Focus ring (shadcn style)
"focus-visible:outline-hidden",
"focus-visible:ring-[3px]",
"focus-visible:ring-ring/50",
"focus-visible:border-ring",
// Disabled state
"disabled:cursor-not-allowed",
"disabled:opacity-50",
// Aria invalid (error state)
"aria-invalid:border-destructive",
"aria-invalid:ring-destructive/20",
"dark:aria-invalid:ring-destructive/40",
// Appearance - hide default checkbox styling
"appearance-none",
// Cursor
"cursor-pointer",
// Background sizing for icons
"bg-[length:100%_100%]",
"bg-center",
"bg-no-repeat"
);
}
/// Color variants for the checkbox
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "color")]
pub enum CheckboxColor {
/// Primary color (default) - typically blue/indigo
#[default]
Primary,
/// Destructive/danger color - typically red
Destructive,
/// Success color - typically green
Success,
}
impl CheckboxColor {
/// Get the Tailwind classes for this color's checked state
pub const fn classes(self) -> &'static str {
match self {
CheckboxColor::Primary => "checked:bg-primary checked:border-primary",
CheckboxColor::Destructive => "checked:bg-destructive checked:border-destructive",
CheckboxColor::Success => "checked:bg-green-600 checked:border-green-600",
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Primary => "primary",
Self::Destructive => "destructive",
Self::Success => "success",
}
}
}
/// Icon variants for the checkbox checkmark
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum CheckboxIcon {
/// Standard checkmark (default)
#[default]
Check,
/// X/Cross mark
Cross,
/// Minus/dash (typically used for indeterminate, but can be used as checked icon)
Minus,
/// Circle/dot
Dot,
}
impl CheckboxIcon {
/// Get the CSS style string to set the --checkbox-icon custom property.
/// Returns a style like: --checkbox-icon: url('data:...');
/// Note: SVGs use black stroke/fill since they're used as mask-image,
/// and the actual color is controlled by --checkbox-icon-color CSS variable.
pub const fn style(self) -> Option<&'static str> {
match self {
// Default check icon - no style needed, CSS fallback handles it
CheckboxIcon::Check => None,
// Non-default icons need explicit style (use black for mask)
CheckboxIcon::Cross => Some("--checkbox-icon: url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 24 24%27 fill=%27none%27 stroke=%27black%27 stroke-width=%273%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27%3E%3Cpath d=%27M18 6 6 18%27/%3E%3Cpath d=%27M6 6 18 18%27/%3E%3C/svg%3E')"),
CheckboxIcon::Minus => Some("--checkbox-icon: url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 24 24%27 fill=%27none%27 stroke=%27black%27 stroke-width=%273%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27%3E%3Cpath d=%27M5 12h14%27/%3E%3C/svg%3E')"),
CheckboxIcon::Dot => Some("--checkbox-icon: url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 24 24%27%3E%3Ccircle cx=%2712%27 cy=%2712%27 r=%275%27 fill=%27black%27/%3E%3C/svg%3E')"),
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Check => "check",
Self::Cross => "cross",
Self::Minus => "minus",
Self::Dot => "dot",
}
}
}
/// Size variants for the checkbox
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "size")]
pub enum CheckboxSize {
Sm,
#[default]
Default,
Lg,
}
impl CheckboxSize {
/// Get the Tailwind classes for this size
pub const fn classes(self) -> &'static str {
match self {
CheckboxSize::Sm => "size-3.5",
CheckboxSize::Default => "size-4",
CheckboxSize::Lg => "size-5",
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Sm => "sm",
Self::Default => "default",
Self::Lg => "lg",
}
}
}
/// Optional inputs accepted by [`CheckboxRecipe::resolve`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct CheckboxRecipeProps {
pub size: Option<CheckboxSize>,
pub color: Option<CheckboxColor>,
}
/// Fully typed checkbox recipe with compile-time defaults.
#[derive(Debug, Clone, Copy)]
pub struct CheckboxRecipe {
base: &'static str,
/// Wrapper used by the rsjs-controlled checkbox surface.
pub controlled_root: &'static str,
size_default: CheckboxSize,
color_default: CheckboxColor,
}
pub const CHECKBOX_CONTROLLED_ROOT: &str = "inline-flex items-center gap-2";
/// Global checkbox recipe instance
pub const CHECKBOX_RECIPE: CheckboxRecipe = CheckboxRecipe {
base: base::CHECKBOX_BASE,
controlled_root: CHECKBOX_CONTROLLED_ROOT,
size_default: CheckboxSize::Default,
color_default: CheckboxColor::Primary,
};
impl CheckboxRecipe {
pub const fn resolve(&'static self, props: CheckboxRecipeProps) -> CheckboxStyle {
let size = match props.size {
Some(size) => size,
None => self.size_default,
};
let color = match props.color {
Some(color) => color,
None => self.color_default,
};
CheckboxStyle {
recipe: self,
size,
color,
}
}
}
/// Fully resolved, streamable Checkbox style.
#[must_use]
#[derive(Debug, Clone, Copy)]
pub struct CheckboxStyle {
recipe: &'static CheckboxRecipe,
size: CheckboxSize,
color: CheckboxColor,
}
impl CheckboxStyle {
pub const fn size(self) -> CheckboxSize {
self.size
}
pub const fn color(self) -> CheckboxColor {
self.color
}
}
impl AttrValue for CheckboxStyle {
#[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.base.as_bytes())?;
cx.out.extend_static(b" ")?;
cx.out.extend_static(self.size.classes().as_bytes())?;
cx.out.extend_static(b" ")?;
cx.out.extend_static(self.color.classes().as_bytes())
}
}
/// Get the global checkbox recipe
pub const fn checkbox_recipe() -> &'static CheckboxRecipe {
&CHECKBOX_RECIPE
}
/// Note: Icon variants are now handled via CSS custom properties (--checkbox-icon)
/// which are set via inline style in the component.
pub const fn checkbox_classes(size: CheckboxSize, color: CheckboxColor) -> CheckboxStyle {
CHECKBOX_RECIPE.resolve(CheckboxRecipeProps {
size: Some(size),
color: Some(color),
})
}
impl crate::variant_spec::ComponentSpec for CheckboxRecipe {
const NAME: &'static str = "Checkbox";
fn base_classes(&self) -> &'static str {
self.base
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
vec![
crate::variant_spec::variant_metadata::<CheckboxSize>(),
crate::variant_spec::variant_metadata::<CheckboxColor>(),
]
}
fn anatomy(&self) -> Option<crate::Anatomy> {
Some(super::CHECKBOX_ANATOMY)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn checkbox_recipe_generates_default() {
let style = checkbox_recipe().resolve(CheckboxRecipeProps::default());
assert!(checkbox_recipe().base.contains("peer"));
assert!(checkbox_recipe().base.contains("rounded-[4px]"));
assert!(checkbox_recipe().controlled_root.contains("inline-flex"));
assert_eq!(style.size(), CheckboxSize::Default);
assert_eq!(style.color(), CheckboxColor::Primary);
}
#[test]
fn checkbox_recipe_supports_sizes() {
for (size, expected) in [(CheckboxSize::Sm, "size-3.5"), (CheckboxSize::Lg, "size-5")] {
let style = checkbox_recipe().resolve(CheckboxRecipeProps {
size: Some(size),
color: None,
});
assert_eq!(style.size(), size);
assert!(style.size().classes().contains(expected));
}
}
#[test]
fn recipe_is_fully_const() {
const RECIPE: &CheckboxRecipe = &CHECKBOX_RECIPE;
const STYLE: CheckboxStyle = RECIPE.resolve(CheckboxRecipeProps {
size: Some(CheckboxSize::Lg),
color: Some(CheckboxColor::Success),
});
assert_eq!(STYLE.size(), CheckboxSize::Lg);
assert_eq!(STYLE.color(), CheckboxColor::Success);
}
}